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

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>