mirror of
https://origin.cursor.com/mrdevmx/panels.git
synced 2026-10-09 18:43:18 +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>
121 lines
4.2 KiB
Vue
121 lines
4.2 KiB
Vue
<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>
|