panels-origin/web-panel/components/ProjectFicha.vue
Alberto Martinez 496273b41c feat: Programa de obra por proyecto
<!-- 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>&nbsp;<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>&nbsp;</div>
2026-09-08 04:51:09 +00:00

144 lines
5.6 KiB
Vue

<template>
<div class="ficha">
<Message v-if="!projectId" severity="secondary" :closable="false">Seleccione un proyecto</Message>
<div v-else-if="loading && !detail" class="detail-loading"><ProgressSpinner /></div>
<div v-else-if="view" class="ficha-body">
<div class="ficha-head">
<Avatar icon="pi pi-building" shape="circle" />
<div>
<div class="toolbar-title">{{ view.name }}</div>
<div class="muted">{{ view.code }}</div>
</div>
</div>
<div class="ficha-tags">
<Tag :value="projectStatusLabel(String(view.status))" :severity="projectStatusSeverity(String(view.status))" />
<Tag v-if="view.company_name || view.company_code" :value="String(view.company_name || view.company_code)" />
<Tag :value="docsReady ? 'Expediente completo' : 'Falta expediente'" :severity="docsReady ? 'success' : 'warn'" />
</div>
<Button label="Editar registro" icon="pi pi-pencil" outlined class="w-full mt-3" @click="emit('edit')" />
<Button label="Ver presupuesto" icon="pi pi-calculator" outlined class="w-full mt-2" @click="navigateTo(`/presupuesto?project=${projectId}`)" />
<Button label="Programa de obra" icon="pi pi-calendar" outlined class="w-full mt-2" @click="navigateTo(`/programa-obra?project=${projectId}`)" />
<Tag v-if="workProgramLabel" class="mt-2" :value="workProgramLabel" :severity="workProgramSeverity" />
<Tabs v-model:value="tab" class="mt-3 detail-tabs">
<TabList>
<Tab value="datos">Datos</Tab>
<Tab value="docs">Expediente</Tab>
</TabList>
<TabPanels>
<TabPanel value="datos">
<DataTable :value="rows" size="small">
<Column field="label" header="Campo" />
<Column field="value" header="Dato" />
</DataTable>
</TabPanel>
<TabPanel value="docs">
<ProjectDocuments
:project-id="projectId"
:documents="detail?.documents || []"
:checklist="detail?.checklist || []"
:initial-type="docType"
@changed="reload"
/>
</TabPanel>
</TabPanels>
</Tabs>
</div>
</div>
</template>
<script setup lang="ts">
import ProjectDocuments from "~/components/ProjectDocuments.vue";
type ChecklistItem = { code: string; label: string; required: boolean; present: boolean };
type DocumentRow = { id: number; type_code: string; original_name: string; uploaded_at: string; is_current: number };
type ProjectRow = Record<string, unknown> & {
id: number;
code: string;
name: string;
status: string;
};
type DetailData = {
project: ProjectRow;
documents: DocumentRow[];
checklist: ChecklistItem[];
};
const props = defineProps<{ project?: Record<string, unknown> | null; doc?: string }>();
const emit = defineEmits<{ edit: [] }>();
const { api } = useApi();
const { projectStatusLabel, projectStatusSeverity } = useUi();
const desk = useDesktop();
const tab = ref("datos");
const loading = ref(false);
const detail = ref<DetailData | null>(null);
const projectId = computed(() => Number(props.project?.id || 0) || 0);
const view = computed(() => detail.value?.project || (props.project as ProjectRow | undefined));
const docsReady = computed(() => {
const required = detail.value?.checklist.filter((item) => item.required) || [];
return required.length > 0 && required.every((item) => item.present);
});
const docType = computed(() => props.doc || "contrato");
const workProgramLabel = ref("");
const workProgramSeverity = ref<"success" | "warn" | "secondary">("secondary");
const rows = computed(() => {
const p = view.value;
if (!p) return [];
return [
{ label: "Código", value: p.code },
{ label: "Empresa", value: p.company_name || p.company_code || "—" },
{ label: "Nombre", value: p.name },
{ label: "Domicilio", value: p.address || "—" },
{ label: "Monto contrato", value: money(p.contract_amount) },
{ label: "Inicio", value: p.start_date || "—" },
{ label: "Término", value: p.end_date || "—" },
{ label: "Residente de obra", value: p.resident_name || "—" },
{ label: "SIROC", value: p.siroc || "—" },
{ label: "Sobre nómina", value: p.payroll_tax_pct == null || p.payroll_tax_pct === "" ? "—" : `${p.payroll_tax_pct}%` },
{ label: "Tema gafete", value: p.theme_id },
{ label: "Activos", value: p.active_count ?? 0 },
];
});
function money(value: unknown) {
if (value == null || value === "") return "—";
const amount = Number(value);
if (Number.isNaN(amount)) return "—";
return new Intl.NumberFormat("es-MX", { style: "currency", currency: "MXN" }).format(amount);
}
async function load() {
if (!projectId.value) {
detail.value = null;
return;
}
loading.value = true;
try {
detail.value = await api<DetailData>(`/v1/projects/${projectId.value}`);
try {
const wp = await api<{ program: { status?: string } | null }>(`/v1/projects/${projectId.value}/work-program`);
if (wp.program) {
workProgramLabel.value = wp.program.status === "active" ? "Programa activo" : "Programa borrador";
workProgramSeverity.value = wp.program.status === "active" ? "success" : "warn";
} else {
workProgramLabel.value = "Sin programa de obra";
workProgramSeverity.value = "secondary";
}
} catch {
workProgramLabel.value = "";
}
} finally {
loading.value = false;
}
}
function reload() {
load();
desk.bumpDetail();
}
watch(() => [projectId.value, props.doc, desk.detailRev.value] as const, ([, doc]) => {
if (doc) tab.value = "docs";
load();
}, { immediate: true });
</script>