panels-origin/web-panel/pages/proyectos.vue
2026-08-21 16:55:01 -05:00

590 lines
22 KiB
Vue

<template>
<div class="list-page">
<DeskTableBar v-model="tableQ" placeholder="Buscar código, obra, empresa o domicilio">
<Chip :label="`${listed.length} proyectos`" icon="pi pi-building" />
<Chip :label="`${activeCount} activos`" icon="pi pi-play" />
<Chip :label="`${pausedCount} pausados`" icon="pi pi-pause" />
<Chip :label="`${pendingDocsCount} con expediente pendiente`" icon="pi pi-folder-open" />
</DeskTableBar>
<DataTable
v-model:selection="selected"
:value="listed"
data-key="id"
paginator
:rows="15"
:rows-per-page-options="[15, 25, 50, 100]"
empty-message="Sin proyectos"
selection-mode="single"
meta-key-selection="false"
scrollable
scroll-height="flex"
context-menu
:context-menu-selection="selected"
@row-click="onRow"
@row-contextmenu="onCtx"
@row-dblclick="(e: { data: Project }) => openEdit(e.data)"
>
<Column field="code" header="Código" sortable>
<template #body="{ data }">
<Tag :value="String(data.code)" />
</template>
</Column>
<Column field="name" header="Proyecto" sortable />
<Column field="company_name" header="Empresa" sortable>
<template #body="{ data }">{{ data.company_name || data.company_code || "—" }}</template>
</Column>
<Column field="address" header="Domicilio" />
<Column header="Estado" sortable field="status">
<template #body="{ data }">
<Tag :value="projectStatusLabel(String(data.status))" :severity="projectStatusSeverity(String(data.status))" />
</template>
</Column>
<Column header="Expediente">
<template #body="{ data }">
<Tag :value="Number(data.missing_docs) > 0 ? 'Falta expediente' : 'Completo'" :severity="Number(data.missing_docs) > 0 ? 'warn' : 'success'" />
</template>
</Column>
<Column header="Activos">
<template #body="{ data }">
<Badge :value="data.active_count ?? 0" />
</template>
</Column>
</DataTable>
<ContextMenu ref="cm" :model="ctxItems" />
<Dialog v-model:visible="show" :header="editId ? 'Editar proyecto' : 'Nuevo proyecto'" modal :style="{ width: '760px' }">
<Message v-if="editId" severity="secondary" :closable="false" class="mb-3">
Código {{ editCode }} · id {{ editId }}. No se modifican.
</Message>
<Message v-else severity="secondary" :closable="false" class="mb-3">
El código (PRY-0001, PRY-0002…) se genera solo. Asigne la empresa que opera esta obra.
</Message>
<Fluid>
<div class="form-grid form-grid-compact">
<FormField label="Empresa" required>
<Select v-model="companyId" :options="companyChoices" option-label="label" option-value="value" placeholder="Seleccione empresa" class="w-full" />
</FormField>
<FormField label="Nombre del proyecto" required>
<InputText v-model="name" class="w-full" />
</FormField>
<FormField label="Domicilio" class="span-2">
<InputText v-model="address" class="w-full" />
</FormField>
<FormField label="Monto de contrato">
<InputNumber
v-model="contractAmount"
mode="currency"
currency="MXN"
locale="es-MX"
:min="0"
class="w-full"
placeholder="Opcional"
/>
<div class="muted mt-1">
Opcional al dar de alta. Si adjunta Excel de presupuesto, se completa con el subtotal.
<span v-if="budgetHint"> · {{ budgetHint }}</span>
</div>
</FormField>
<FormField label="4% sobre nómina">
<InputNumber v-model="payrollTaxPct" suffix=" %" :min="0" :max="100" :min-fraction-digits="0" :max-fraction-digits="2" class="w-full" />
</FormField>
<FormField label="Fecha de inicio">
<DatePicker v-model="startDate" date-format="dd/mm/yy" show-icon class="w-full" />
</FormField>
<FormField label="Fecha de término">
<DatePicker v-model="endDate" date-format="dd/mm/yy" show-icon class="w-full" />
</FormField>
<FormField label="Residente de obra">
<InputText v-model="residentName" class="w-full" />
</FormField>
<FormField label="SIROC">
<InputText v-model="siroc" class="w-full identifier-input" />
</FormField>
<FormField label="Estado">
<Select v-model="status" :options="statusOpts" option-label="label" option-value="code" class="w-full" />
</FormField>
<FormField label="Tema de gafete">
<Select v-model="theme" :options="themes" option-label="name" option-value="id" class="w-full" />
</FormField>
<FormField label="Contrato" :class="{ 'span-2': !editId }">
<div>
<FileUpload mode="basic" :auto="false" choose-label="Adjuntar contrato" accept=".pdf,.jpg,.jpeg,.png,.doc,.docx" custom-upload @select="onPickContrato" />
<div v-if="contratoFile" class="muted mt-1">{{ contratoFile.name }}</div>
</div>
</FormField>
<FormField label="Presupuesto" :class="{ 'span-2': !editId }">
<div>
<FileUpload
mode="basic"
:auto="false"
choose-label="Adjuntar presupuesto"
accept=".pdf,.xlsx,.xls,.jpg,.jpeg,.png"
custom-upload
:disabled="readingBudget"
@select="onPickPresupuesto"
/>
<div v-if="readingBudget" class="muted mt-1">Leyendo Excel…</div>
<div v-else-if="presupuestoFile" class="muted mt-1">{{ presupuestoFile.name }}</div>
<div v-else class="muted mt-1">PDF o Excel. Si es Excel, el monto de contrato se calcula solo.</div>
</div>
</FormField>
</div>
</Fluid>
<Message v-if="formError" severity="error" class="mt-3" :closable="false">{{ formError }}</Message>
<template #footer>
<Button label="Cancelar" text @click="show = false" />
<Button :label="editId ? 'Guardar' : 'Crear'" icon="pi pi-check" @click="save" />
</template>
</Dialog>
</div>
</template>
<script setup lang="ts">
import FormField from "~/components/padron/FormField.vue";
import type { CompanyOption } from "~/types/padron";
import { companyLabel } from "~/types/padron";
type Project = Record<string, unknown> & {
id: number;
status: string;
name: string;
code: string;
address?: string;
theme_id?: string;
company_id?: number | null;
company_name?: string | null;
company_code?: string | null;
contract_amount?: number | null;
start_date?: string | null;
end_date?: string | null;
resident_name?: string | null;
siroc?: string | null;
payroll_tax_pct?: number | null;
active_count?: number;
missing_docs?: number;
};
const { projectStatusLabel, projectStatusSeverity } = useUi();
const desk = useDesktop();
const { api } = useApi();
const toast = useToast();
const confirm = useConfirm();
const projects = ref<Project[]>([]);
const companies = ref<CompanyOption[]>([]);
const themes = ref<{ id: string; name: string }[]>([]);
const statusOpts = ref<{ code: string; label: string }[]>([]);
const show = ref(false);
const editId = ref(0);
const editCode = ref("");
const name = ref("");
const address = ref("");
const status = ref("activo");
const theme = ref("arctec-dos-logos-fold");
const companyId = ref<number | null>(null);
const contractAmount = ref<number | null>(null);
const startDate = ref<Date | null>(null);
const endDate = ref<Date | null>(null);
const residentName = ref("");
const siroc = ref("");
const payrollTaxPct = ref(4);
const contratoFile = ref<File | null>(null);
const presupuestoFile = ref<File | null>(null);
const readingBudget = ref(false);
const budgetHint = ref("");
const formError = ref("");
const selected = ref<Project | null>(null);
const { tableQ, matches } = useTableSearch();
const cm = ref();
const companyChoices = computed(() =>
companies.value
.filter((company) => company.status !== "inactivo" || company.id === companyId.value)
.map((company) => ({
label: company.kind === "principal" ? `${companyLabel(company)} · principal` : companyLabel(company),
value: company.id,
})),
);
const visible = computed(() => {
const key = desk.treeKey.value || "all";
if (key.startsWith("c:")) {
const id = Number(key.slice(2));
return projects.value.filter((project) => Number(project.company_id) === id);
}
return key === "all" ? projects.value : projects.value.filter((project) => project.status === key);
});
const listed = computed(() =>
visible.value.filter((project) =>
matches(
project.code,
project.name,
project.company_name,
project.company_code,
project.address,
project.status,
project.resident_name,
project.siroc,
),
),
);
const activeCount = computed(() => listed.value.filter((project) => project.status === "activo").length);
const pausedCount = computed(() => listed.value.filter((project) => project.status === "pausado").length);
const pendingDocsCount = computed(() => listed.value.filter((project) => Number(project.missing_docs) > 0).length);
const ctxItems = computed(() => [
{ label: "Editar", icon: "pi pi-pencil", command: () => selected.value && openEdit(selected.value) },
{ separator: true },
{ label: "Activar", icon: "pi pi-play", command: () => selected.value && setStatus(selected.value, "activo") },
{ label: "Pausar", icon: "pi pi-pause", command: () => selected.value && setStatus(selected.value, "pausado") },
{ label: "Concluir", icon: "pi pi-check", command: () => selected.value && setStatus(selected.value, "concluido") },
{ label: "Cancelar proyecto", icon: "pi pi-times", command: () => selected.value && setStatus(selected.value, "cancelado") },
]);
function onRow(e: { data: Project }) {
selected.value = e.data;
desk.openProject(e.data);
}
function onCtx(e: { originalEvent: Event; data: Project }) {
selected.value = e.data;
desk.openProject(e.data);
cm.value.show(e.originalEvent);
}
function parseDate(value: unknown): Date | null {
const raw = String(value || "").slice(0, 10);
if (!/^\d{4}-\d{2}-\d{2}$/.test(raw)) return null;
const [year, month, day] = raw.split("-").map(Number);
return new Date(year, month - 1, day);
}
function isoDate(value: Date | null) {
if (!value) return null;
const year = value.getFullYear();
const month = String(value.getMonth() + 1).padStart(2, "0");
const day = String(value.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}
function fillForm(row?: Project) {
name.value = row?.name ?? "";
address.value = String(row?.address ?? "");
status.value = row?.status ?? "activo";
theme.value = String(row?.theme_id ?? "arctec-dos-logos-fold");
const principal = companies.value.find((company) => company.kind === "principal");
companyId.value = row?.company_id ? Number(row.company_id) : principal?.id ?? null;
contractAmount.value = row?.contract_amount == null ? null : Number(row.contract_amount);
startDate.value = parseDate(row?.start_date);
endDate.value = parseDate(row?.end_date);
residentName.value = String(row?.resident_name ?? "");
siroc.value = String(row?.siroc ?? "");
payrollTaxPct.value = row?.payroll_tax_pct == null ? 4 : Number(row.payroll_tax_pct);
editId.value = row?.id ?? 0;
editCode.value = row?.code ?? "";
formError.value = "";
contratoFile.value = null;
presupuestoFile.value = null;
budgetHint.value = "";
readingBudget.value = false;
}
function onPickContrato(event: { files: File[] }) {
contratoFile.value = event.files?.[0] || null;
}
function isExcelFile(file: File) {
const name = file.name.toLowerCase();
return name.endsWith(".xlsx") || name.endsWith(".xls") ||
file.type.includes("spreadsheet") || file.type.includes("excel");
}
function moneyMx(value: number) {
return value.toLocaleString("es-MX", { style: "currency", currency: "MXN" });
}
async function onPickPresupuesto(event: { files: File[] }) {
const file = event.files?.[0] || null;
presupuestoFile.value = file;
budgetHint.value = "";
if (!file || !isExcelFile(file)) return;
readingBudget.value = true;
try {
const body = new FormData();
body.append("file", file);
const preview = await api<{
totals: { subtotal: number; iva: number; total: number; item_count: number };
format_label?: string;
}>("/v1/budget/preview", { method: "POST", body });
const subtotal = Number(preview.totals?.subtotal || 0);
if (subtotal > 0) {
contractAmount.value = subtotal;
budgetHint.value = `${preview.totals.item_count} partidas · ${moneyMx(subtotal)} (sin IVA)`;
toast.add({
severity: "success",
summary: "Monto de contrato actualizado",
detail: `Subtotal del presupuesto: ${moneyMx(subtotal)}`,
life: 2800,
});
}
} catch (error) {
const detail = error as { data?: { error?: string } };
toast.add({
severity: "warn",
summary: "No se pudo leer el Excel",
detail: detail.data?.error || (error instanceof Error ? error.message : "Revise el formato"),
life: 3500,
});
} finally {
readingBudget.value = false;
}
}
async function uploadDocs(projectId: number) {
const jobs: [string, File | null][] = [
["contrato", contratoFile.value],
["presupuesto", presupuestoFile.value],
];
for (const [type, file] of jobs) {
if (!file) continue;
const body = new FormData();
body.append("type", type);
body.append("file", file);
await api(`/v1/projects/${projectId}/documents`, { method: "POST", body });
}
if (presupuestoFile.value && isExcelFile(presupuestoFile.value)) {
try {
const body = new FormData();
body.append("file", presupuestoFile.value);
const report = await api<{
inserted: number;
contract_amount?: number;
}>(`/v1/projects/${projectId}/budget/import`, { method: "POST", body });
if (report.contract_amount != null) {
contractAmount.value = Number(report.contract_amount);
}
if (report.inserted) {
toast.add({
severity: "info",
summary: "Presupuesto cargado",
detail: `${report.inserted} partidas · monto ${moneyMx(Number(report.contract_amount || contractAmount.value || 0))}`,
life: 3200,
});
}
} catch (error) {
const detail = error as { data?: { error?: string } };
toast.add({
severity: "warn",
summary: "Documento guardado, pero no se importaron partidas",
detail: detail.data?.error || (error instanceof Error ? error.message : "Revise el Excel"),
life: 4000,
});
}
}
}
function openNew() {
selected.value = null;
fillForm();
show.value = true;
}
function openEdit(row: Project) {
selected.value = row;
fillForm(row);
show.value = true;
}
function payloadOf(extra: Record<string, unknown> = {}) {
return {
name: name.value,
address: address.value,
theme_id: theme.value,
status: status.value,
company_id: companyId.value,
contract_amount: contractAmount.value,
start_date: isoDate(startDate.value),
end_date: isoDate(endDate.value),
resident_name: residentName.value,
siroc: siroc.value,
payroll_tax_pct: payrollTaxPct.value,
...extra,
};
}
async function load() {
const [p, c] = await Promise.all([
api<{ projects: Project[] }>("/v1/projects"),
api<{
themes: { id: string; name: string }[];
project_statuses: { code: string; label: string }[];
companies: CompanyOption[];
}>("/v1/catalogs"),
]);
projects.value = p.projects;
themes.value = c.themes;
statusOpts.value = c.project_statuses;
companies.value = c.companies || [];
syncChrome();
}
function syncChrome() {
const counts = (st: string) => projects.value.filter((p) => p.status === st).length;
const messages = [];
if (pendingDocsCount.value > 0) {
messages.push({
key: "proy_docs",
severity: "warn" as const,
title: `${pendingDocsCount.value} proyectos con expediente pendiente`,
detail: "Faltan documentos de obra. Completa el expediente del proyecto.",
route: "/proyectos",
treeKey: "activo",
});
}
if (pausedCount.value > 0) {
messages.push({
key: "proy_pausados",
severity: "info" as const,
title: `${pausedCount.value} proyectos pausados`,
detail: "Obras en pausa; revisa si deben reactivarse o concluirse.",
route: "/proyectos",
treeKey: "pausado",
});
}
desk.setStatusMessages(messages);
desk.configure({
tab: "Proyectos",
treeTitle: "Proyectos",
showTree: true,
showDetail: true,
treeKey: desk.treeKey.value || "all",
countLabel: `Total de registros: ${visible.value.length}`,
tree: [
{ key: "all", label: "Todos", icon: "pi pi-building", data: { count: projects.value.length } },
{
key: "statuses",
label: "Estatus",
icon: "pi pi-list",
selectable: false,
children: [
{ key: "activo", label: "Activo", icon: "pi pi-play", data: { count: counts("activo") } },
{ key: "pausado", label: "Pausado", icon: "pi pi-pause", data: { count: counts("pausado") } },
{ key: "concluido", label: "Concluido", icon: "pi pi-check", data: { count: counts("concluido") } },
{ key: "cancelado", label: "Cancelado", icon: "pi pi-times", data: { count: counts("cancelado") } },
],
},
{
key: "companies",
label: "Empresas",
icon: "pi pi-briefcase",
selectable: false,
children: companies.value.map((company) => ({
key: `c:${company.id}`,
label: company.name || company.code,
icon: "pi pi-folder",
data: { count: projects.value.filter((project) => Number(project.company_id) === company.id).length },
})),
},
],
ribbon: [
{
key: "acciones",
label: "Acciones",
items: [
{ key: "alta", label: "Nuevo", icon: "pi pi-plus", command: openNew },
{ key: "edit", label: "Editar", icon: "pi pi-pencil", disabled: !selected.value, command: () => selected.value && openEdit(selected.value) },
{ key: "docs", label: "Expediente", icon: "pi pi-folder-open", disabled: !selected.value, command: () => selected.value && desk.openProject(selected.value, "contrato") },
{ key: "budget", label: "Presupuesto", icon: "pi pi-calculator", disabled: !selected.value, command: () => selected.value && navigateTo(`/presupuesto?project=${selected.value.id}`) },
],
},
{
key: "vista",
label: "Vista",
items: [{ key: "refresh", label: "Actualizar", icon: "pi pi-refresh", command: load }],
},
],
});
}
async function save() {
formError.value = "";
if (!name.value.trim()) {
formError.value = "Nombre de proyecto obligatorio";
return;
}
if (!companyId.value) {
formError.value = "Seleccione la empresa del proyecto";
return;
}
const apply = async () => {
try {
let id = editId.value;
if (editId.value) {
await api(`/v1/projects/${editId.value}`, { method: "PATCH", json: payloadOf() });
toast.add({ severity: "success", summary: `Guardado ${editCode.value}`, life: 2000 });
} else {
const r = await api<{ id: number; code: string }>("/v1/projects", { method: "POST", json: payloadOf() });
id = r.id;
toast.add({ severity: "success", summary: `Proyecto ${r.code}`, life: 2500 });
}
await uploadDocs(id);
show.value = false;
await load();
const row = projects.value.find((project) => project.id === id);
if (row) {
selected.value = row;
desk.openProject(row, contratoFile.value || presupuestoFile.value ? "contrato" : undefined);
}
} catch (error) {
const detail = error as { data?: { error?: string } };
formError.value = detail.data?.error || (error instanceof Error ? error.message : "No se pudo guardar");
}
};
if (editId.value && (status.value === "concluido" || status.value === "cancelado")) {
const current = projects.value.find((p) => p.id === editId.value);
if (current && current.status !== status.value) {
confirm.require({
header: status.value === "concluido" ? "Concluir proyecto" : "Cancelar proyecto",
message: status.value === "concluido"
? "Ya no se podrán asignar personas ni generar gafetes."
: "Queda en historial, sin operación nueva.",
icon: "pi pi-exclamation-triangle",
acceptLabel: "Guardar",
rejectLabel: "Volver",
accept: apply,
});
return;
}
}
await apply();
}
async function setStatus(row: Project, next: string) {
fillForm(row);
status.value = next;
const apply = async () => {
await api(`/v1/projects/${row.id}`, { method: "PATCH", json: payloadOf({ status: next }) });
toast.add({ severity: "success", summary: `${row.code}: ${projectStatusLabel(next)}`, life: 2000 });
await load();
};
if (next === "concluido" || next === "cancelado") {
confirm.require({
header: next === "concluido" ? "Concluir proyecto" : "Cancelar proyecto",
message: next === "concluido"
? `¿Marcar ${row.name} como concluido? Ya no se podrán asignar personas ni generar gafetes.`
: `¿Cancelar ${row.name}? Queda en historial, sin operación nueva.`,
icon: "pi pi-exclamation-triangle",
acceptLabel: next === "concluido" ? "Concluir" : "Cancelar proyecto",
rejectLabel: "Volver",
accept: apply,
});
return;
}
await apply();
}
onMounted(async () => {
const key = desk.treeKey.value;
if (!key || (!["all", "activo", "pausado", "concluido", "cancelado"].includes(key) && !key.startsWith("c:"))) {
desk.treeKey.value = "all";
}
await load();
});
onBeforeUnmount(() => desk.clearStatusMessages());
watch([projects, selected, visible, companies], syncChrome);
</script>