mirror of
https://origin.cursor.com/mrdevmx/panels.git
synced 2026-10-09 18:23:22 +00:00
Pre-computed merge for change #24
This commit is contained in:
commit
4e8e1318f2
4 changed files with 240 additions and 92 deletions
|
|
@ -1,28 +1,39 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="gantt-panel">
|
<div class="gantt-panel">
|
||||||
<div class="gantt-header">
|
<Message v-if="!rows.length" severity="info" :closable="false">
|
||||||
<span v-for="p in periods" :key="p.id" class="gantt-col-head">{{ p.label }}</span>
|
{{ emptyHint }}
|
||||||
|
</Message>
|
||||||
|
<div v-else class="gantt" :style="{ '--weeks': String(periods.length || 1) }">
|
||||||
|
<div class="gantt-head">
|
||||||
|
<div class="gantt-label-col">{{ drillChapterId ? "Concepto" : "Partida" }}</div>
|
||||||
|
<div class="gantt-scale">
|
||||||
|
<span v-for="p in periods" :key="p.id" class="gantt-week">{{ p.label }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-for="row in bars" :key="row.id" class="gantt-row">
|
</div>
|
||||||
<div class="gantt-row-label">
|
<div v-for="row in rows" :key="row.id" class="gantt-row">
|
||||||
|
<div class="gantt-label-col">
|
||||||
<span class="identifier">{{ row.code }}</span>
|
<span class="identifier">{{ row.code }}</span>
|
||||||
<span>{{ row.label }}</span>
|
<span class="gantt-name">{{ row.label }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="gantt-track">
|
<div class="gantt-scale gantt-track">
|
||||||
|
<span v-for="p in periods" :key="`t-${p.id}`" class="gantt-tick" />
|
||||||
<div
|
<div
|
||||||
v-for="seg in row.segments"
|
v-if="row.span"
|
||||||
:key="`${row.id}-${seg.period_index}`"
|
|
||||||
class="gantt-bar"
|
class="gantt-bar"
|
||||||
:style="barStyle(seg)"
|
:style="{ left: `${row.span.leftPct}%`, width: `${row.span.widthPct}%` }"
|
||||||
:title="`${row.code} · ${seg.label}`"
|
:title="row.span.title"
|
||||||
/>
|
>
|
||||||
|
<span v-if="row.span.widthPct > 12" class="gantt-bar-text">{{ row.span.caption }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Message v-if="!bars.length" severity="info" :closable="false">Sin partidas programadas.</Message>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { ganttSpan, periodHasAmount, periodHasConceptBar } from "~/utils/workProgramGantt";
|
||||||
|
|
||||||
type Period = { id: number; period_index: number; label: string; start_date?: string; end_date?: string };
|
type Period = { id: number; period_index: number; label: string; start_date?: string; end_date?: string };
|
||||||
type ChapterRow = {
|
type ChapterRow = {
|
||||||
id: number;
|
id: number;
|
||||||
|
|
@ -34,65 +45,136 @@ type ChapterRow = {
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
periods: Period[];
|
periods: Period[];
|
||||||
chapters: ChapterRow[];
|
chapters: ChapterRow[];
|
||||||
drillConcepts?: { id: number; code: string; description: string; chapter_id: number | null; periods: { period_index: number; start_pct: number; end_pct: number }[] }[];
|
drillConcepts?: {
|
||||||
|
id: number;
|
||||||
|
code: string;
|
||||||
|
description: string;
|
||||||
|
chapter_id: number | null;
|
||||||
|
periods: { period_index: number; start_pct: number; end_pct: number }[];
|
||||||
|
}[];
|
||||||
drillChapterId?: number | null;
|
drillChapterId?: number | null;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const periodCount = computed(() => props.periods.length || 1);
|
const money = new Intl.NumberFormat("es-MX", { style: "currency", currency: "MXN", maximumFractionDigits: 0 });
|
||||||
|
|
||||||
const bars = computed(() => {
|
const emptyHint = computed(() => {
|
||||||
|
if (props.drillChapterId) {
|
||||||
|
return "Esta partida no tiene barras de concepto. El Excel por partida programa montos semanales; véalas en Gantt (todas las partidas) o en la pestaña Partidas.";
|
||||||
|
}
|
||||||
|
return "Sin partidas con erogación. Importe el Excel por partida o capture montos en Partidas.";
|
||||||
|
});
|
||||||
|
|
||||||
|
const rows = computed(() => {
|
||||||
if (props.drillChapterId && props.drillConcepts?.length) {
|
if (props.drillChapterId && props.drillConcepts?.length) {
|
||||||
return props.drillConcepts
|
return props.drillConcepts
|
||||||
.filter((c) => c.chapter_id === props.drillChapterId)
|
.filter((c) => c.chapter_id === props.drillChapterId)
|
||||||
.map((c) => ({
|
.map((c) => {
|
||||||
|
const active = props.periods
|
||||||
|
.map((p) => {
|
||||||
|
const bar = periodHasConceptBar(c.periods, p);
|
||||||
|
return bar ? p.period_index : null;
|
||||||
|
})
|
||||||
|
.filter((n): n is number => n != null);
|
||||||
|
const span = ganttSpan(active, props.periods.length);
|
||||||
|
return {
|
||||||
id: c.id,
|
id: c.id,
|
||||||
code: c.code,
|
code: c.code,
|
||||||
label: c.description,
|
label: c.description,
|
||||||
segments: props.periods
|
span: span
|
||||||
.map((p) => {
|
? {
|
||||||
const slot = c.periods.find((x) => x.period_index === p.period_index);
|
...span,
|
||||||
if (!slot || (slot.start_pct === 0 && slot.end_pct === 0)) return null;
|
caption: `${span.first}–${span.last}`,
|
||||||
return { period_index: p.period_index, label: p.label, start_pct: slot.start_pct, end_pct: slot.end_pct };
|
title: `${c.code} · Sem ${span.first} a ${span.last}`,
|
||||||
})
|
|
||||||
.filter(Boolean) as { period_index: number; label: string; start_pct: number; end_pct: number }[],
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
return props.chapters.map((ch) => ({
|
: null,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((row) => row.span);
|
||||||
|
}
|
||||||
|
|
||||||
|
return props.chapters
|
||||||
|
.map((ch) => {
|
||||||
|
const weeks = props.periods
|
||||||
|
.map((p) => ({ p, amount: periodHasAmount(ch.periods, p) }))
|
||||||
|
.filter((x) => x.amount > 0);
|
||||||
|
const span = ganttSpan(weeks.map((x) => x.p.period_index), props.periods.length);
|
||||||
|
const total = weeks.reduce((s, x) => s + x.amount, 0);
|
||||||
|
return {
|
||||||
id: ch.id,
|
id: ch.id,
|
||||||
code: ch.code,
|
code: ch.code,
|
||||||
label: ch.name,
|
label: ch.name,
|
||||||
segments: props.periods
|
span: span
|
||||||
.map((p) => {
|
? {
|
||||||
const slot = ch.periods.find((x) => x.period_index === p.period_index);
|
...span,
|
||||||
if (!slot || Number(slot.amount) <= 0) return null;
|
caption: money.format(total),
|
||||||
return { period_index: p.period_index, label: p.label, start_pct: 0, end_pct: 100 };
|
title: `${ch.code} ${ch.name} · Sem ${span.first}–${span.last} · ${money.format(total)}`,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
};
|
||||||
})
|
})
|
||||||
.filter(Boolean) as { period_index: number; label: string; start_pct: number; end_pct: number }[],
|
.filter((row) => row.span);
|
||||||
}));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
function barStyle(seg: { period_index: number; start_pct: number; end_pct: number }) {
|
|
||||||
const n = periodCount.value;
|
|
||||||
const colW = 100 / n;
|
|
||||||
const left = (seg.period_index - 1) * colW + (seg.start_pct / 100) * colW;
|
|
||||||
const width = Math.max(1, ((seg.end_pct - seg.start_pct) / 100) * colW);
|
|
||||||
return { left: `${left}%`, width: `${width}%` };
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.gantt-panel { display: flex; flex-direction: column; gap: 0.35rem; padding: 0.5rem 0; }
|
.gantt-panel { display: flex; flex-direction: column; gap: 0.5rem; min-height: 0; }
|
||||||
.gantt-header { display: grid; grid-template-columns: 12rem 1fr; gap: 0.5rem; font-size: 0.7rem; color: var(--text-color-secondary); }
|
.gantt { display: flex; flex-direction: column; gap: 0.35rem; overflow: auto; }
|
||||||
.gantt-col-head { text-align: center; }
|
.gantt-head,
|
||||||
.gantt-row { display: grid; grid-template-columns: 12rem 1fr; gap: 0.5rem; align-items: center; min-height: 2rem; }
|
.gantt-row {
|
||||||
.gantt-row-label { display: flex; flex-direction: column; font-size: 0.8rem; overflow: hidden; }
|
display: grid;
|
||||||
.gantt-track { position: relative; height: 1.25rem; background: var(--surface-100); border-radius: 4px; }
|
grid-template-columns: minmax(11rem, 16rem) minmax(24rem, 1fr);
|
||||||
|
gap: 0.75rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.gantt-label-col {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
.gantt-name {
|
||||||
|
color: var(--text-color-secondary);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.gantt-scale {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(var(--weeks), minmax(0, 1fr));
|
||||||
|
position: relative;
|
||||||
|
min-height: 1.6rem;
|
||||||
|
}
|
||||||
|
.gantt-week {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--text-color-secondary);
|
||||||
|
}
|
||||||
|
.gantt-track {
|
||||||
|
background: var(--surface-100);
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.gantt-tick {
|
||||||
|
border-right: 1px solid color-mix(in srgb, var(--surface-border) 70%, transparent);
|
||||||
|
}
|
||||||
|
.gantt-tick:last-child { border-right: none; }
|
||||||
.gantt-bar {
|
.gantt-bar {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 2px;
|
top: 4px;
|
||||||
bottom: 2px;
|
bottom: 4px;
|
||||||
background: var(--primary-color);
|
background: var(--primary-color);
|
||||||
border-radius: 3px;
|
border-radius: 4px;
|
||||||
opacity: 0.85;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 4px;
|
||||||
|
}
|
||||||
|
.gantt-bar-text {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
color: var(--primary-color-text, #fff);
|
||||||
|
padding: 0 0.35rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -82,7 +82,7 @@ type Preview = {
|
||||||
};
|
};
|
||||||
|
|
||||||
const props = defineProps<{ visible: boolean; projectId: number; projectName: string }>();
|
const props = defineProps<{ visible: boolean; projectId: number; projectName: string }>();
|
||||||
const emit = defineEmits<{ "update:visible": [boolean]; imported: [] }>();
|
const emit = defineEmits<{ "update:visible": [boolean]; imported: [{ format: string }] }>();
|
||||||
|
|
||||||
const { api } = useApi();
|
const { api } = useApi();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
|
|
@ -144,7 +144,7 @@ async function confirm() {
|
||||||
fd.append("file", pending.value);
|
fd.append("file", pending.value);
|
||||||
await api(`/v1/projects/${props.projectId}/work-program/import?create_chapters=true`, { method: "POST", body: fd });
|
await api(`/v1/projects/${props.projectId}/work-program/import?create_chapters=true`, { method: "POST", body: fd });
|
||||||
toast.add({ severity: "success", summary: "Programa importado", life: 2500 });
|
toast.add({ severity: "success", summary: "Programa importado", life: 2500 });
|
||||||
emit("imported");
|
emit("imported", { format: preview.value.format });
|
||||||
visibleModel.value = false;
|
visibleModel.value = false;
|
||||||
preview.value = null;
|
preview.value = null;
|
||||||
pending.value = null;
|
pending.value = null;
|
||||||
|
|
|
||||||
|
|
@ -29,13 +29,23 @@
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<TabView v-model:active-index="activeTab" class="programa-tabs">
|
<TabView v-model:active-index="activeTab" class="programa-tabs">
|
||||||
<TabPanel header="Conceptos">
|
<TabPanel header="Gantt">
|
||||||
<WorkProgramGrid
|
<div class="gantt-toolbar mb-2">
|
||||||
mode="concept"
|
<Select
|
||||||
|
v-model="ganttChapterId"
|
||||||
|
:options="ganttChapterOptions"
|
||||||
|
option-label="label"
|
||||||
|
option-value="value"
|
||||||
|
placeholder="Todas las partidas"
|
||||||
|
show-clear
|
||||||
|
class="gantt-select"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<WorkProgramGantt
|
||||||
:periods="periods"
|
:periods="periods"
|
||||||
:rows="conceptRows"
|
:chapters="ganttChapters"
|
||||||
:project-id="loadedProjectId"
|
:drill-chapter-id="ganttChapterId"
|
||||||
@updated="reload"
|
:drill-concepts="ganttConcepts"
|
||||||
/>
|
/>
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
<TabPanel header="Partidas">
|
<TabPanel header="Partidas">
|
||||||
|
|
@ -54,23 +64,17 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
<TabPanel header="Gantt">
|
<TabPanel header="Conceptos">
|
||||||
<div class="gantt-toolbar mb-2">
|
<Message v-if="partidaOnly" severity="info" :closable="false" class="mb-2">
|
||||||
<Select
|
Este programa se importó por partida (erogaciones). Las barras de concepto quedan en 0.
|
||||||
v-model="ganttChapterId"
|
El Gantt y la pestaña Partidas muestran las semanas programadas.
|
||||||
:options="ganttChapterOptions"
|
</Message>
|
||||||
option-label="label"
|
<WorkProgramGrid
|
||||||
option-value="value"
|
mode="concept"
|
||||||
placeholder="Todas las partidas"
|
|
||||||
show-clear
|
|
||||||
class="gantt-select"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<WorkProgramGantt
|
|
||||||
:periods="periods"
|
:periods="periods"
|
||||||
:chapters="ganttChapters"
|
:rows="conceptRows"
|
||||||
:drill-chapter-id="ganttChapterId"
|
:project-id="loadedProjectId"
|
||||||
:drill-concepts="ganttConcepts"
|
@updated="reload"
|
||||||
/>
|
/>
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
<TabPanel v-if="showCostTab" header="Programado vs costos">
|
<TabPanel v-if="showCostTab" header="Programado vs costos">
|
||||||
|
|
@ -172,8 +176,22 @@ const ganttChapterOptions = computed(() =>
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const partidaOnly = computed(() => {
|
||||||
|
const hasPartida = partidaChapters.value.some((c) =>
|
||||||
|
((c.periods as { amount?: number }[]) || []).some((p) => Number(p.amount) > 0),
|
||||||
|
);
|
||||||
|
const hasConcept = conceptItems.value.some((c) =>
|
||||||
|
((c.periods as { start_pct?: number; end_pct?: number }[]) || []).some(
|
||||||
|
(p) => Number(p.start_pct) > 0 || Number(p.end_pct) > 0,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return hasPartida && !hasConcept;
|
||||||
|
});
|
||||||
|
|
||||||
const ganttChapters = computed(() =>
|
const ganttChapters = computed(() =>
|
||||||
partidaChapters.value.map((c) => ({
|
partidaChapters.value
|
||||||
|
.filter((c) => matches(c.code, c.name))
|
||||||
|
.map((c) => ({
|
||||||
id: Number(c.id),
|
id: Number(c.id),
|
||||||
code: String(c.code || ""),
|
code: String(c.code || ""),
|
||||||
name: String(c.name || ""),
|
name: String(c.name || ""),
|
||||||
|
|
@ -219,14 +237,25 @@ async function loadProgram(id: number) {
|
||||||
const [concepts, partidas, vs] = await Promise.all([
|
const [concepts, partidas, vs] = await Promise.all([
|
||||||
api<{ items: Record<string, unknown>[] }>(`/v1/projects/${id}/work-program/concepts`),
|
api<{ items: Record<string, unknown>[] }>(`/v1/projects/${id}/work-program/concepts`),
|
||||||
api<{ chapters: Record<string, unknown>[] }>(`/v1/projects/${id}/work-program/partidas`),
|
api<{ chapters: Record<string, unknown>[] }>(`/v1/projects/${id}/work-program/partidas`),
|
||||||
api<{ by_period: Record<string, unknown>[]; by_chapter: Record<string, unknown>[] }>(
|
showCostTab.value
|
||||||
|
? api<{ by_period: Record<string, unknown>[]; by_chapter: Record<string, unknown>[] }>(
|
||||||
`/v1/projects/${id}/work-program/vs-cost`,
|
`/v1/projects/${id}/work-program/vs-cost`,
|
||||||
),
|
).catch(() => ({ by_period: [], by_chapter: [] }))
|
||||||
|
: Promise.resolve({ by_period: [], by_chapter: [] }),
|
||||||
]);
|
]);
|
||||||
conceptItems.value = concepts.items || [];
|
conceptItems.value = concepts.items || [];
|
||||||
partidaChapters.value = partidas.chapters || [];
|
partidaChapters.value = partidas.chapters || [];
|
||||||
vsCostPeriods.value = vs.by_period || [];
|
vsCostPeriods.value = vs.by_period || [];
|
||||||
vsCostChapters.value = vs.by_chapter || [];
|
vsCostChapters.value = vs.by_chapter || [];
|
||||||
|
const hasPartida = (partidas.chapters || []).some((c) =>
|
||||||
|
((c.periods as { amount?: number }[]) || []).some((p) => Number(p.amount) > 0),
|
||||||
|
);
|
||||||
|
const hasConcept = (concepts.items || []).some((c) =>
|
||||||
|
((c.periods as { start_pct?: number; end_pct?: number }[]) || []).some(
|
||||||
|
(p) => Number(p.start_pct) > 0 || Number(p.end_pct) > 0,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (hasPartida && !hasConcept) activeTab.value = 0;
|
||||||
} else {
|
} else {
|
||||||
conceptItems.value = [];
|
conceptItems.value = [];
|
||||||
partidaChapters.value = [];
|
partidaChapters.value = [];
|
||||||
|
|
|
||||||
37
web-panel/utils/workProgramGantt.ts
Normal file
37
web-panel/utils/workProgramGantt.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
/** period_index is 1-based (Sem 1 … Sem N). */
|
||||||
|
export function ganttSpan(
|
||||||
|
activeIndexes: number[],
|
||||||
|
periodCount: number,
|
||||||
|
): { leftPct: number; widthPct: number; first: number; last: number } | null {
|
||||||
|
const n = Number(periodCount) || 0;
|
||||||
|
const idxs = activeIndexes.filter((i) => Number.isFinite(i) && i >= 1 && (n <= 0 || i <= n));
|
||||||
|
if (!idxs.length || n <= 0) return null;
|
||||||
|
const first = Math.min(...idxs);
|
||||||
|
const last = Math.max(...idxs);
|
||||||
|
return {
|
||||||
|
first,
|
||||||
|
last,
|
||||||
|
leftPct: ((first - 1) / n) * 100,
|
||||||
|
widthPct: ((last - first + 1) / n) * 100,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function periodHasAmount(
|
||||||
|
periods: { period_id?: number; period_index?: number; amount?: number }[],
|
||||||
|
week: { id: number; period_index: number },
|
||||||
|
): number {
|
||||||
|
const slot = periods.find((x) => x.period_index === week.period_index || x.period_id === week.id);
|
||||||
|
return Number(slot?.amount || 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function periodHasConceptBar(
|
||||||
|
periods: { period_index?: number; start_pct?: number; end_pct?: number }[],
|
||||||
|
week: { period_index: number },
|
||||||
|
): { start_pct: number; end_pct: number } | null {
|
||||||
|
const slot = periods.find((x) => x.period_index === week.period_index);
|
||||||
|
if (!slot) return null;
|
||||||
|
const start = Number(slot.start_pct || 0);
|
||||||
|
const end = Number(slot.end_pct || 0);
|
||||||
|
if (start === 0 && end === 0) return null;
|
||||||
|
return { start_pct: start, end_pct: Math.max(start, end) };
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue