panels-origin/web-panel/components/WorkProgramGantt.vue
Cursor Agent dc510236c5
feat: programa de obra por proyecto (import, wizard, Gantt, vs costos)
Implementa el módulo completo de programa de obra vinculado al presupuesto:

- Schema PostgreSQL: work_programs, periods, concept_slots, partida_amounts, progress
- RPC: CRUD, import replace, generate wizard, recalculate partidas, vs-cost
- API: parser Excel Neodata (concepto Barra= + partida erogaciones), endpoints REST
- UI: página /programa-obra con cuadrículas, Gantt, comparativo programado/ejecutado
- Integración: menú, ficha proyecto, banner en presupuesto, permiso manage_budget

Co-authored-by: alberto.martinez <alberto.martinez@mrdev.mx>
2026-09-05 05:17:18 +00:00

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>