mirror of
https://origin.cursor.com/mrdevmx/panels.git
synced 2026-10-09 20:23:17 +00:00
<!-- CURSOR_AGENT_PR_BODY_BEGIN --> ## Summary Implementa el módulo **Programa de obra** por proyecto, vinculado al presupuesto ya existente. ### Flujo 1. Cargar presupuesto (requisito) 2. Definir fechas de inicio/término en el proyecto 3. Crear programa: **importar Excel Neodata/Opus**, **generar automático** o **crear vacío** 4. Editar en cuadrícula (conceptos con `Barra=` y partidas con erogaciones semanales) 5. Visualizar Gantt resumido y comparativo **programado vs ejecutado** ### Backend - Tablas: `work_programs`, `work_program_periods`, `work_program_concept_slots`, `work_program_partida_amounts`, `work_program_progress` - RPC: list/get, replace (import), generate (wizard), update celdas, recalculate partidas, vs-cost - Parser Excel para formatos **por concepto** (`Barra=`) y **por partida** (montos semanales) - Tests con fixtures reales del usuario ### Frontend - Nueva página `/programa-obra` con pestañas: Conceptos, Partidas, Gantt, Programado vs costos - Diálogos de importación y generación automática - Menú principal, enlace en ficha de proyecto, banner en presupuesto ### Permisos Reutiliza `manage_budget` en MVP. ## Test plan - [x] `deno test api/work_program_excel_test.ts` (5 tests) - [x] `deno check` API modules - [x] `npm run build` web-panel - [ ] Migrar DB (`029`, `030`) en entorno con Postgres - [ ] Importar Excel por concepto y por partida en obra con presupuesto - [ ] Generar automático con estrategia keyword - [ ] Recalcular partidas desde conceptos - [ ] Verificar tab Programado vs costos con gastos registrados <!-- CURSOR_AGENT_PR_BODY_END --> <div><a href="https://cursor.com/agents/bc-3985fe74-d26b-4506-bd4d-f9ad57374141?cursor_ref=pr_footer&cursor_cta=open_in_web"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/assets/images/open-in-web-dark.png"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/assets/images/open-in-web-light.png"><img alt="Open in Web" width="114" height="28" src="https://cursor.com/assets/images/open-in-web-dark.png"></picture></a> <a href="https://cursor.com/background-agent?bcId=bc-3985fe74-d26b-4506-bd4d-f9ad57374141&cursor_ref=pr_footer&cursor_cta=open_in_cursor"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/assets/images/open-in-cursor-dark.png"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/assets/images/open-in-cursor-light.png"><img alt="Open in Cursor" width="131" height="28" src="https://cursor.com/assets/images/open-in-cursor-dark.png"></picture></a> </div>
353 lines
13 KiB
Vue
353 lines
13 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="Conceptos">
|
|
<WorkProgramGrid
|
|
mode="concept"
|
|
:periods="periods"
|
|
:rows="conceptRows"
|
|
:project-id="loadedProjectId"
|
|
@updated="reload"
|
|
/>
|
|
</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="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="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 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 ganttChapters = computed(() =>
|
|
partidaChapters.value.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`),
|
|
api<{ by_period: Record<string, unknown>[]; by_chapter: Record<string, unknown>[] }>(
|
|
`/v1/projects/${id}/work-program/vs-cost`,
|
|
),
|
|
]);
|
|
conceptItems.value = concepts.items || [];
|
|
partidaChapters.value = partidas.chapters || [];
|
|
vsCostPeriods.value = vs.by_period || [];
|
|
vsCostChapters.value = vs.by_chapter || [];
|
|
} 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>
|