panels-origin/web-panel/components/WorkProgramGrid.vue
Alberto Martinez 496273b41c feat: Programa de obra por proyecto
<!-- 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>&nbsp;<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>&nbsp;</div>
2026-09-08 04:51:09 +00:00

121 lines
4.2 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="wp-grid-wrap">
<DataTable :value="rows" scrollable scroll-height="flex" table-style="min-width: 48rem" size="small">
<Column frozen :header="rowLabel" style="min-width: 14rem">
<template #body="{ data }">
<div class="wp-row-label">
<span class="identifier">{{ data.code || "—" }}</span>
<span class="wp-desc">{{ shortDesc(data.label) }}</span>
</div>
</template>
</Column>
<Column v-for="p in periods" :key="p.id" :header="p.label" style="width: 7rem">
<template #body="{ data }">
<div v-if="mode === 'concept'" class="wp-cell" :class="{ active: cellActive(data, p.id) }">
<InputNumber
:model-value="cellStart(data, p.id)"
:min="0"
:max="100"
:min-fraction-digits="0"
:max-fraction-digits="2"
class="wp-mini"
@update:model-value="(v: number | null) => patchConcept(data, p.id, v ?? 0, cellEnd(data, p.id))"
/>
<span class="wp-sep">–</span>
<InputNumber
:model-value="cellEnd(data, p.id)"
:min="0"
:max="100"
:min-fraction-digits="0"
:max-fraction-digits="2"
class="wp-mini"
@update:model-value="(v: number | null) => patchConcept(data, p.id, cellStart(data, p.id), v ?? 0)"
/>
</div>
<div v-else class="wp-cell" :class="{ active: cellAmount(data, p.id) > 0 }">
<InputNumber
:model-value="cellAmount(data, p.id)"
mode="currency"
currency="MXN"
locale="es-MX"
:min="0"
class="w-full"
@update:model-value="(v: number | null) => patchPartida(data, p.id, v ?? 0)"
/>
</div>
</template>
</Column>
</DataTable>
</div>
</template>
<script setup lang="ts">
type Period = { id: number; period_index: number; label: string };
type GridRow = {
id: number;
code: string;
label: string;
periods: { period_id: number; start_pct?: number; end_pct?: number; amount?: number }[];
};
const props = defineProps<{
mode: "concept" | "partida";
periods: Period[];
rows: GridRow[];
projectId: number;
}>();
const emit = defineEmits<{ updated: [] }>();
const { api } = useApi();
const rowLabel = computed(() => (props.mode === "concept" ? "Concepto" : "Partida"));
function shortDesc(v: string) {
const t = String(v || "").trim();
return t.length > 48 ? `${t.slice(0, 45)}…` : t;
}
function periodData(row: GridRow, periodId: number) {
return row.periods.find((p) => p.period_id === periodId);
}
function cellStart(row: GridRow, periodId: number) {
return Number(periodData(row, periodId)?.start_pct ?? 0);
}
function cellEnd(row: GridRow, periodId: number) {
return Number(periodData(row, periodId)?.end_pct ?? 0);
}
function cellAmount(row: GridRow, periodId: number) {
return Number(periodData(row, periodId)?.amount ?? 0);
}
function cellActive(row: GridRow, periodId: number) {
return cellStart(row, periodId) > 0 || cellEnd(row, periodId) > 0;
}
async function patchConcept(row: GridRow, periodId: number, start: number, end: number) {
await api(`/v1/projects/${props.projectId}/work-program/concepts/${row.id}/periods/${periodId}`, {
method: "PATCH",
json: { start_pct: start, end_pct: end },
});
emit("updated");
}
async function patchPartida(row: GridRow, periodId: number, amount: number) {
await api(`/v1/projects/${props.projectId}/work-program/partidas/${row.id}/periods/${periodId}`, {
method: "PATCH",
json: { amount },
});
emit("updated");
}
</script>
<style scoped>
.wp-row-label { display: flex; flex-direction: column; gap: 0.15rem; }
.wp-desc { font-size: 0.75rem; color: var(--text-color-secondary); }
.wp-cell { padding: 0.15rem; border-radius: 4px; }
.wp-cell.active { background: color-mix(in srgb, var(--primary-color) 12%, transparent); }
.wp-cell { display: flex; align-items: center; gap: 0.15rem; }
.wp-mini { width: 3rem; }
.wp-sep { opacity: 0.5; font-size: 0.7rem; }
.wp-grid-wrap { flex: 1; min-height: 0; display: flex; flex-direction: column; }
</style>