mirror of
https://origin.cursor.com/mrdevmx/panels.git
synced 2026-10-09 11:23:18 +00:00
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>
180 lines
5.3 KiB
Vue
180 lines
5.3 KiB
Vue
<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>
|