panels-origin/web-panel/components/WorkProgramGrid.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

121 lines
4.2 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="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>