panels-origin/web-panel/components/WorkProgramGantt.vue
Cursor Agent 40703245af
feat: Gantt como vista principal del programa de obra
El Excel por partida no llena Conceptos (por eso se veían 0–0).
Gantt pasa a ser la primera pestaña, con barras de Sem primera a última
según erogaciones, y encabezado alineado por semana.

Co-authored-by: alberto.martinez <alberto.martinez@mrdev.mx>
2026-09-08 22:07:16 +00:00

180 lines
5.3 KiB
Vue
Raw Permalink 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="gantt-panel">
<Message v-if="!rows.length" severity="info" :closable="false">
{{ emptyHint }}
</Message>
<div v-else class="gantt" :style="{ '--weeks': String(periods.length || 1) }">
<div class="gantt-head">
<div class="gantt-label-col">{{ drillChapterId ? "Concepto" : "Partida" }}</div>
<div class="gantt-scale">
<span v-for="p in periods" :key="p.id" class="gantt-week">{{ p.label }}</span>
</div>
</div>
<div v-for="row in rows" :key="row.id" class="gantt-row">
<div class="gantt-label-col">
<span class="identifier">{{ row.code }}</span>
<span class="gantt-name">{{ row.label }}</span>
</div>
<div class="gantt-scale gantt-track">
<span v-for="p in periods" :key="`t-${p.id}`" class="gantt-tick" />
<div
v-if="row.span"
class="gantt-bar"
:style="{ left: `${row.span.leftPct}%`, width: `${row.span.widthPct}%` }"
:title="row.span.title"
>
<span v-if="row.span.widthPct > 12" class="gantt-bar-text">{{ row.span.caption }}</span>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ganttSpan, periodHasAmount, periodHasConceptBar } from "~/utils/workProgramGantt";
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 money = new Intl.NumberFormat("es-MX", { style: "currency", currency: "MXN", maximumFractionDigits: 0 });
const emptyHint = computed(() => {
if (props.drillChapterId) {
return "Esta partida no tiene barras de concepto. El Excel por partida programa montos semanales; véalas en Gantt (todas las partidas) o en la pestaña Partidas.";
}
return "Sin partidas con erogación. Importe el Excel por partida o capture montos en Partidas.";
});
const rows = computed(() => {
if (props.drillChapterId && props.drillConcepts?.length) {
return props.drillConcepts
.filter((c) => c.chapter_id === props.drillChapterId)
.map((c) => {
const active = props.periods
.map((p) => {
const bar = periodHasConceptBar(c.periods, p);
return bar ? p.period_index : null;
})
.filter((n): n is number => n != null);
const span = ganttSpan(active, props.periods.length);
return {
id: c.id,
code: c.code,
label: c.description,
span: span
? {
...span,
caption: `${span.first}–${span.last}`,
title: `${c.code} · Sem ${span.first} a ${span.last}`,
}
: null,
};
})
.filter((row) => row.span);
}
return props.chapters
.map((ch) => {
const weeks = props.periods
.map((p) => ({ p, amount: periodHasAmount(ch.periods, p) }))
.filter((x) => x.amount > 0);
const span = ganttSpan(weeks.map((x) => x.p.period_index), props.periods.length);
const total = weeks.reduce((s, x) => s + x.amount, 0);
return {
id: ch.id,
code: ch.code,
label: ch.name,
span: span
? {
...span,
caption: money.format(total),
title: `${ch.code} ${ch.name} · Sem ${span.first}–${span.last} · ${money.format(total)}`,
}
: null,
};
})
.filter((row) => row.span);
});
</script>
<style scoped>
.gantt-panel { display: flex; flex-direction: column; gap: 0.5rem; min-height: 0; }
.gantt { display: flex; flex-direction: column; gap: 0.35rem; overflow: auto; }
.gantt-head,
.gantt-row {
display: grid;
grid-template-columns: minmax(11rem, 16rem) minmax(24rem, 1fr);
gap: 0.75rem;
align-items: center;
}
.gantt-label-col {
display: flex;
flex-direction: column;
min-width: 0;
font-size: 0.8rem;
}
.gantt-name {
color: var(--text-color-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.gantt-scale {
display: grid;
grid-template-columns: repeat(var(--weeks), minmax(0, 1fr));
position: relative;
min-height: 1.6rem;
}
.gantt-week {
text-align: center;
font-size: 0.7rem;
color: var(--text-color-secondary);
}
.gantt-track {
background: var(--surface-100);
border-radius: 6px;
overflow: hidden;
}
.gantt-tick {
border-right: 1px solid color-mix(in srgb, var(--surface-border) 70%, transparent);
}
.gantt-tick:last-child { border-right: none; }
.gantt-bar {
position: absolute;
top: 4px;
bottom: 4px;
background: var(--primary-color);
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
min-width: 4px;
}
.gantt-bar-text {
font-size: 0.65rem;
color: var(--primary-color-text, #fff);
padding: 0 0.35rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
</style>