panels-origin/web-panel/pages/programa-obra.vue
Cursor Agent 40703245af
feat: Gantt como vista principal del programa de obra
El Excel por partida no llena Conceptos (por eso se veían 0–0).
Gantt pasa a ser la primera pestaña, con barras de Sem primera a última
según erogaciones, y encabezado alineado por semana.

Co-authored-by: alberto.martinez <alberto.martinez@mrdev.mx>
2026-09-08 22:07:16 +00:00

384 lines
14 KiB
Vue

<template>
<div class="programa-page">
<DeskTableBar v-model="tableQ" placeholder="Buscar concepto o partida">
<Chip :label="projectName" icon="pi pi-building" />
<Chip v-if="program" :label="programStatus" icon="pi pi-calendar" />
<Chip v-if="curve.length" :label="`${periods.length} semanas`" icon="pi pi-clock" />
</DeskTableBar>
<Message v-if="!currentProject" severity="info" :closable="false">Seleccione una obra en el árbol.</Message>
<Message v-else-if="!hasBudget" severity="warn" :closable="false">
Esta obra no tiene presupuesto. Cárguelo primero en Presupuesto.
<Button label="Ir a Presupuesto" class="mt-2" size="small" @click="goPresupuesto" />
</Message>
<Message v-else-if="!hasDates" severity="warn" :closable="false">
Defina fecha de inicio y término en la ficha del proyecto.
<Button label="Ir a Proyectos" class="mt-2" size="small" @click="navigateTo('/proyectos')" />
</Message>
<Message v-else-if="!program" severity="info" :closable="false">
Presupuesto listo. Cree el programa importando Excel Neodata, generándolo automáticamente o en blanco.
<div class="flex flex-wrap gap-2 mt-3">
<Button label="Importar Excel" icon="pi pi-upload" @click="showImport = true" />
<Button label="Generar automático" icon="pi pi-bolt" outlined @click="showGenerate = true" />
<Button label="Crear vacío" icon="pi pi-plus" outlined :loading="creating" @click="createEmpty" />
</div>
</Message>
<template v-else>
<TabView v-model:active-index="activeTab" class="programa-tabs">
<TabPanel header="Gantt">
<div class="gantt-toolbar mb-2">
<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"
:chapters="ganttChapters"
:drill-chapter-id="ganttChapterId"
:drill-concepts="ganttConcepts"
/>
</TabPanel>
<TabPanel header="Partidas">
<WorkProgramGrid
mode="partida"
:periods="periods"
:rows="partidaRows"
:project-id="loadedProjectId"
@updated="reload"
/>
<div v-if="curve.length" class="curve-row mt-3">
<div v-for="c in curve" :key="c.period_index" class="curve-cell">
<span class="curve-label">{{ c.label }}</span>
<strong>{{ money(c.amount) }}</strong>
<span class="curve-pct">{{ pct(c.pct_period) }} · acum {{ pct(c.pct_cumulative) }}</span>
</div>
</div>
</TabPanel>
<TabPanel header="Conceptos">
<Message v-if="partidaOnly" severity="info" :closable="false" class="mb-2">
Este programa se importó por partida (erogaciones). Las barras de concepto quedan en 0.
El Gantt y la pestaña Partidas muestran las semanas programadas.
</Message>
<WorkProgramGrid
mode="concept"
:periods="periods"
:rows="conceptRows"
:project-id="loadedProjectId"
@updated="reload"
/>
</TabPanel>
<TabPanel v-if="showCostTab" header="Programado vs costos">
<WorkProgramCostCompare :by-period="vsCostPeriods" :by-chapter="vsCostChapters" />
</TabPanel>
</TabView>
</template>
<WorkProgramImportDialog
v-model:visible="showImport"
:project-id="loadedProjectId"
:project-name="projectName"
@imported="reload"
/>
<WorkProgramGenerateDialog
v-model:visible="showGenerate"
:project-id="loadedProjectId"
@generated="reload"
/>
</div>
</template>
<script setup lang="ts">
import WorkProgramGrid from "~/components/WorkProgramGrid.vue";
import WorkProgramGantt from "~/components/WorkProgramGantt.vue";
import WorkProgramCostCompare from "~/components/WorkProgramCostCompare.vue";
import WorkProgramImportDialog from "~/components/WorkProgramImportDialog.vue";
import WorkProgramGenerateDialog from "~/components/WorkProgramGenerateDialog.vue";
type Project = {
id: number;
name: string;
code: string;
start_date?: string | null;
end_date?: string | null;
budget_count?: number;
};
type Period = { id: number; period_index: number; label: string; start_date?: string; end_date?: string };
type CurvePoint = { period_index: number; label: string; amount: number; pct_period: number; pct_cumulative: number };
const desk = useDesktop();
const route = useRoute();
const { api, download } = useApi();
const toast = useToast();
const perms = usePermissions();
const showCostTab = computed(() => perms.can("cost_control.view"));
const projects = ref<Project[]>([]);
const loadedProjectId = ref(0);
const program = ref<Record<string, unknown> | null>(null);
const periods = ref<Period[]>([]);
const curve = ref<CurvePoint[]>([]);
const conceptItems = ref<Record<string, unknown>[]>([]);
const partidaChapters = ref<Record<string, unknown>[]>([]);
const vsCostPeriods = ref<Record<string, unknown>[]>([]);
const vsCostChapters = ref<Record<string, unknown>[]>([]);
const activeTab = ref(0);
const showImport = ref(false);
const showGenerate = ref(false);
const creating = ref(false);
const ganttChapterId = ref<number | null>(null);
const { tableQ, matches } = useTableSearch();
const currentProject = computed(() => projects.value.find((p) => p.id === loadedProjectId.value) || null);
const projectName = computed(() => currentProject.value?.name || "Sin proyecto");
const hasBudget = computed(() => Number(currentProject.value?.budget_count || 0) > 0);
const hasDates = computed(() => !!(currentProject.value?.start_date && currentProject.value?.end_date));
const programStatus = computed(() => {
const s = String(program.value?.status || "draft");
return s === "active" ? "Programa activo" : "Borrador";
});
const conceptRows = computed(() =>
conceptItems.value
.filter((i) => matches(i.code, i.description, i.chapter_name))
.map((i) => ({
id: Number(i.id),
code: String(i.code || ""),
label: String(i.description || ""),
periods: (i.periods as { period_id: number; start_pct?: number; end_pct?: number }[]) || [],
})),
);
const partidaRows = computed(() =>
partidaChapters.value
.filter((c) => matches(c.code, c.name))
.map((c) => ({
id: Number(c.id),
code: String(c.code || ""),
label: String(c.name || ""),
periods: (c.periods as { period_id: number; amount?: number }[]) || [],
})),
);
const ganttChapterOptions = computed(() =>
partidaChapters.value.map((c) => ({
value: Number(c.id),
label: [c.code, c.name].filter(Boolean).join(" "),
})),
);
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(() =>
partidaChapters.value
.filter((c) => matches(c.code, c.name))
.map((c) => ({
id: Number(c.id),
code: String(c.code || ""),
name: String(c.name || ""),
periods: ((c.periods as { period_id: number; period_index: number; amount: number }[]) || []),
})),
);
const ganttConcepts = computed(() =>
conceptItems.value.map((c) => ({
id: Number(c.id),
code: String(c.code || ""),
description: String(c.description || ""),
chapter_id: c.chapter_id != null ? Number(c.chapter_id) : null,
periods: ((c.periods as { period_index: number; start_pct: number; end_pct: number }[]) || []),
})),
);
function money(v: number) {
return new Intl.NumberFormat("es-MX", { style: "currency", currency: "MXN" }).format(Number(v || 0));
}
function pct(v: number) {
return `${(Number(v || 0) * 100).toFixed(1)}%`;
}
function goPresupuesto() {
navigateTo({ path: "/presupuesto", query: { project: loadedProjectId.value } });
}
async function loadProjects() {
const res = await api<{ projects: Project[] }>("/v1/projects");
projects.value = res.projects;
}
async function loadProgram(id: number) {
try {
const wp = await api<{ program: Record<string, unknown> | null; periods: Period[]; curve: CurvePoint[] }>(
`/v1/projects/${id}/work-program`,
);
program.value = wp.program;
periods.value = wp.periods || [];
curve.value = wp.curve || [];
if (wp.program) {
const [concepts, partidas, vs] = await Promise.all([
api<{ items: Record<string, unknown>[] }>(`/v1/projects/${id}/work-program/concepts`),
api<{ chapters: Record<string, unknown>[] }>(`/v1/projects/${id}/work-program/partidas`),
showCostTab.value
? api<{ by_period: Record<string, unknown>[]; by_chapter: Record<string, unknown>[] }>(
`/v1/projects/${id}/work-program/vs-cost`,
).catch(() => ({ by_period: [], by_chapter: [] }))
: Promise.resolve({ by_period: [], by_chapter: [] }),
]);
conceptItems.value = concepts.items || [];
partidaChapters.value = partidas.chapters || [];
vsCostPeriods.value = vs.by_period || [];
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 {
conceptItems.value = [];
partidaChapters.value = [];
vsCostPeriods.value = [];
vsCostChapters.value = [];
}
} catch (err) {
toast.add({ severity: "error", summary: err instanceof Error ? err.message : "Error al cargar programa", life: 3500 });
}
}
async function reload() {
if (loadedProjectId.value) await loadProgram(loadedProjectId.value);
syncChrome();
}
async function createEmpty() {
creating.value = true;
try {
await api(`/v1/projects/${loadedProjectId.value}/work-program`, { method: "POST" });
await reload();
} catch (err) {
toast.add({ severity: "error", summary: err instanceof Error ? err.message : "No se pudo crear", life: 3000 });
} finally {
creating.value = false;
}
}
async function recalculate() {
await api(`/v1/projects/${loadedProjectId.value}/work-program/recalculate-partidas`, { method: "POST" });
toast.add({ severity: "success", summary: "Partidas recalculadas desde conceptos", life: 2000 });
await reload();
}
async function exportExcel(kind: "concepto" | "partida") {
const name = kind === "partida" ? "programa-partidas.xlsx" : "programa-conceptos.xlsx";
await download(`/v1/projects/${loadedProjectId.value}/work-program/export?kind=${kind}`, name);
}
function syncChrome() {
const project = currentProject.value;
desk.configure({
tab: "Programa de obra",
treeTitle: "Obras",
showTree: true,
showDetail: false,
treeKey: desk.treeKey.value || (project ? `p:${project.id}` : ""),
countLabel: program.value ? `${periods.value.length} semanas` : "",
tree: projects.value.map((item) => ({
key: `p:${item.id}`,
label: item.name,
icon: "pi pi-building",
data: { count: item.id === project?.id ? conceptItems.value.length : 0 },
})),
ribbon: [
{
key: "programa",
label: "Programa",
items: [
{ key: "import", label: "Importar", icon: "pi pi-upload", disabled: !project || !hasBudget.value, command: () => { showImport.value = true; } },
{ key: "generate", label: "Generar", icon: "pi pi-bolt", disabled: !project || !hasBudget.value, command: () => { showGenerate.value = true; } },
{ key: "recalc", label: "Recalcular partidas", icon: "pi pi-calculator", disabled: !program.value, command: recalculate },
],
},
{
key: "datos",
label: "Datos",
items: [
{ key: "exp-c", label: "Export conceptos", icon: "pi pi-download", disabled: !program.value, command: () => exportExcel("concepto") },
{ key: "exp-p", label: "Export partidas", icon: "pi pi-download", disabled: !program.value, command: () => exportExcel("partida") },
{ key: "refresh", label: "Actualizar", icon: "pi pi-refresh", disabled: !project, command: reload },
],
},
],
});
}
watch(
() => desk.treeKey.value,
async (key) => {
if (key?.startsWith("p:")) {
const id = Number(key.split(":")[1]);
if (id && id !== loadedProjectId.value) {
loadedProjectId.value = id;
await loadProgram(id);
syncChrome();
}
}
},
);
onMounted(async () => {
await loadProjects();
const fromQuery = Number(route.query.project || 0);
const initial = fromQuery || projects.value[0]?.id;
if (initial) {
loadedProjectId.value = initial;
desk.treeKey.value = `p:${initial}`;
await loadProgram(initial);
}
syncChrome();
});
watch([program, periods, conceptItems], syncChrome);
</script>
<style scoped>
.programa-page { display: flex; flex-direction: column; height: 100%; min-height: 0; }
.programa-tabs { flex: 1; min-height: 0; display: flex; flex-direction: column; }
.programa-tabs :deep(.p-tabview-panels) { flex: 1; min-height: 0; overflow: auto; }
.curve-row { display: flex; flex-wrap: wrap; gap: 0.5rem; }
.curve-cell {
flex: 1;
min-width: 7rem;
padding: 0.5rem;
border: 1px solid var(--surface-border);
border-radius: 6px;
display: flex;
flex-direction: column;
font-size: 0.8rem;
}
.curve-label { color: var(--text-color-secondary); font-size: 0.7rem; }
.curve-pct { font-size: 0.65rem; color: var(--text-color-secondary); }
.gantt-toolbar { display: flex; gap: 0.5rem; }
.gantt-select { min-width: 16rem; }
</style>