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>
98 lines
3.7 KiB
Vue
98 lines
3.7 KiB
Vue
<template>
|
|
<div class="gantt-panel">
|
|
<div class="gantt-header">
|
|
<span v-for="p in periods" :key="p.id" class="gantt-col-head">{{ p.label }}</span>
|
|
</div>
|
|
<div v-for="row in bars" :key="row.id" class="gantt-row">
|
|
<div class="gantt-row-label">
|
|
<span class="identifier">{{ row.code }}</span>
|
|
<span>{{ row.label }}</span>
|
|
</div>
|
|
<div class="gantt-track">
|
|
<div
|
|
v-for="seg in row.segments"
|
|
:key="`${row.id}-${seg.period_index}`"
|
|
class="gantt-bar"
|
|
:style="barStyle(seg)"
|
|
:title="`${row.code} · ${seg.label}`"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<Message v-if="!bars.length" severity="info" :closable="false">Sin partidas programadas.</Message>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
type Period = { id: number; period_index: number; label: string; start_date?: string; end_date?: string };
|
|
type ChapterRow = {
|
|
id: number;
|
|
code: string;
|
|
name: string;
|
|
periods: { period_id: number; period_index: number; amount: number }[];
|
|
};
|
|
|
|
const props = defineProps<{
|
|
periods: Period[];
|
|
chapters: ChapterRow[];
|
|
drillConcepts?: { id: number; code: string; description: string; chapter_id: number | null; periods: { period_index: number; start_pct: number; end_pct: number }[] }[];
|
|
drillChapterId?: number | null;
|
|
}>();
|
|
|
|
const periodCount = computed(() => props.periods.length || 1);
|
|
|
|
const bars = computed(() => {
|
|
if (props.drillChapterId && props.drillConcepts?.length) {
|
|
return props.drillConcepts
|
|
.filter((c) => c.chapter_id === props.drillChapterId)
|
|
.map((c) => ({
|
|
id: c.id,
|
|
code: c.code,
|
|
label: c.description,
|
|
segments: props.periods
|
|
.map((p) => {
|
|
const slot = c.periods.find((x) => x.period_index === p.period_index);
|
|
if (!slot || (slot.start_pct === 0 && slot.end_pct === 0)) return null;
|
|
return { period_index: p.period_index, label: p.label, start_pct: slot.start_pct, end_pct: slot.end_pct };
|
|
})
|
|
.filter(Boolean) as { period_index: number; label: string; start_pct: number; end_pct: number }[],
|
|
}));
|
|
}
|
|
return props.chapters.map((ch) => ({
|
|
id: ch.id,
|
|
code: ch.code,
|
|
label: ch.name,
|
|
segments: props.periods
|
|
.map((p) => {
|
|
const slot = ch.periods.find((x) => x.period_index === p.period_index);
|
|
if (!slot || Number(slot.amount) <= 0) return null;
|
|
return { period_index: p.period_index, label: p.label, start_pct: 0, end_pct: 100 };
|
|
})
|
|
.filter(Boolean) as { period_index: number; label: string; start_pct: number; end_pct: number }[],
|
|
}));
|
|
});
|
|
|
|
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>
|
|
|
|
<style scoped>
|
|
.gantt-panel { display: flex; flex-direction: column; gap: 0.35rem; padding: 0.5rem 0; }
|
|
.gantt-header { display: grid; grid-template-columns: 12rem 1fr; gap: 0.5rem; font-size: 0.7rem; color: var(--text-color-secondary); }
|
|
.gantt-col-head { text-align: center; }
|
|
.gantt-row { display: grid; grid-template-columns: 12rem 1fr; gap: 0.5rem; align-items: center; min-height: 2rem; }
|
|
.gantt-row-label { display: flex; flex-direction: column; font-size: 0.8rem; overflow: hidden; }
|
|
.gantt-track { position: relative; height: 1.25rem; background: var(--surface-100); border-radius: 4px; }
|
|
.gantt-bar {
|
|
position: absolute;
|
|
top: 2px;
|
|
bottom: 2px;
|
|
background: var(--primary-color);
|
|
border-radius: 3px;
|
|
opacity: 0.85;
|
|
}
|
|
</style>
|