mirror of
https://origin.cursor.com/mrdevmx/panels.git
synced 2026-10-09 17:43:19 +00:00
455 lines
16 KiB
Vue
455 lines
16 KiB
Vue
<template>
|
|
<div class="presupuesto-page">
|
|
<DeskTableBar v-model="tableQ" placeholder="Buscar clave, descripción o unidad">
|
|
<Chip :label="projectName" icon="pi pi-building" />
|
|
<Chip :label="`${listed.length} partidas`" icon="pi pi-list" />
|
|
<Chip :label="money(subtotal)" icon="pi pi-wallet" />
|
|
<Chip :label="`IVA ${money(iva)}`" />
|
|
<Chip :label="`Total ${money(total)}`" icon="pi pi-calculator" />
|
|
</DeskTableBar>
|
|
|
|
<Message v-if="!items.length" severity="info" :closable="false">
|
|
Esta obra no tiene partidas cargadas. El PDF del expediente no se desglosa solo: hay que importar el Excel
|
|
(plantilla del panel o presupuesto Opus/Neodata).
|
|
<div class="flex gap-2 mt-3">
|
|
<Button label="Importar Excel" icon="pi pi-upload" :disabled="!currentProject" @click="openImport" />
|
|
<Button label="Descargar plantilla" icon="pi pi-download" outlined :disabled="!currentProject" :loading="templateLoading" @click="downloadTemplate" />
|
|
</div>
|
|
</Message>
|
|
|
|
<DataTable
|
|
v-model:selection="selected"
|
|
:value="listed"
|
|
data-key="id"
|
|
paginator
|
|
:rows="25"
|
|
:rows-per-page-options="[25, 50, 100]"
|
|
empty-message="No hay partidas. Importe un Excel o capture una partida."
|
|
selection-mode="single"
|
|
meta-key-selection="false"
|
|
removable-sort
|
|
scrollable
|
|
scroll-height="flex"
|
|
table-style="min-width: 64rem"
|
|
@row-click="onRow"
|
|
@row-dblclick="(e: { data: BudgetItem }) => openEdit(e.data)"
|
|
>
|
|
<Column field="code" header="Clave" sortable style="width: 8rem">
|
|
<template #body="{ data }"><span class="identifier">{{ data.code || "—" }}</span></template>
|
|
</Column>
|
|
<Column field="description" header="Descripción" sortable>
|
|
<template #body="{ data }">{{ shortDesc(data.description) }}</template>
|
|
</Column>
|
|
<Column field="unit" header="Unidad" sortable style="width: 6rem" />
|
|
<Column field="quantity" header="Cantidad" sortable style="width: 8rem">
|
|
<template #body="{ data }">{{ qty(data.quantity) }}</template>
|
|
</Column>
|
|
<Column field="unit_price" header="P.U." sortable style="width: 9rem">
|
|
<template #body="{ data }">{{ money(data.unit_price) }}</template>
|
|
</Column>
|
|
<Column field="amount" header="Importe" sortable style="width: 10rem">
|
|
<template #body="{ data }"><strong>{{ money(data.amount) }}</strong></template>
|
|
</Column>
|
|
</DataTable>
|
|
|
|
<Dialog v-model:visible="showForm" :header="form.id ? 'Editar partida' : 'Nueva partida'" modal :style="{ width: '640px' }">
|
|
<div class="form-grid form-grid-compact">
|
|
<FormField label="Capítulo">
|
|
<Select v-model="form.chapter_id" :options="chapterOptions" option-label="name" option-value="id" placeholder="Sin capítulo" show-clear class="w-full" />
|
|
</FormField>
|
|
<FormField label="Clave">
|
|
<InputText v-model="form.code" class="w-full identifier-input" />
|
|
</FormField>
|
|
<FormField label="Descripción" class="span-2" required>
|
|
<Textarea v-model="form.description" class="w-full" rows="4" auto-resize />
|
|
</FormField>
|
|
<FormField label="Unidad">
|
|
<InputText v-model="form.unit" class="w-full" />
|
|
</FormField>
|
|
<FormField label="Cantidad">
|
|
<InputNumber v-model="form.quantity" :min="0" :min-fraction-digits="0" :max-fraction-digits="4" class="w-full" />
|
|
</FormField>
|
|
<FormField label="Precio unitario">
|
|
<InputNumber v-model="form.unit_price" mode="currency" currency="MXN" locale="es-MX" :min="0" class="w-full" />
|
|
</FormField>
|
|
<FormField label="Importe">
|
|
<InputText :model-value="money(form.quantity * form.unit_price)" readonly class="w-full" />
|
|
</FormField>
|
|
</div>
|
|
<Message v-if="formError" severity="error" class="mt-3" :closable="false">{{ formError }}</Message>
|
|
<template #footer>
|
|
<Button label="Cancelar" text @click="showForm = false" />
|
|
<Button label="Guardar" icon="pi pi-check" @click="saveItem" />
|
|
</template>
|
|
</Dialog>
|
|
|
|
<Dialog v-model:visible="showChapter" header="Nuevo capítulo" modal :style="{ width: '28rem' }">
|
|
<FormField label="Nombre">
|
|
<InputText v-model="chapterName" class="w-full" placeholder="Acabados, Bardas, Cimentación" />
|
|
</FormField>
|
|
<template #footer>
|
|
<Button label="Cancelar" text @click="showChapter = false" />
|
|
<Button label="Crear" icon="pi pi-check" @click="saveChapter" />
|
|
</template>
|
|
</Dialog>
|
|
|
|
<BudgetImportDialog
|
|
v-model:visible="showImport"
|
|
:project-id="currentProject?.id || 0"
|
|
:project-name="projectName"
|
|
@imported="onImported"
|
|
/>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import FormField from "~/components/padron/FormField.vue";
|
|
import BudgetImportDialog from "~/components/BudgetImportDialog.vue";
|
|
|
|
type Project = { id: number; code: string; name: string; status: string; budget_count?: number };
|
|
type Chapter = {
|
|
id: number;
|
|
name: string;
|
|
code?: string;
|
|
parent_id?: number | null;
|
|
path?: string;
|
|
item_count?: number;
|
|
amount?: number;
|
|
};
|
|
type BudgetTree = {
|
|
id: number;
|
|
parent_id: number | null;
|
|
code: string;
|
|
name: string;
|
|
wbs: string;
|
|
amount: number;
|
|
item_count: number;
|
|
children: BudgetTree[];
|
|
};
|
|
type BudgetItem = {
|
|
id: number;
|
|
chapter_id?: number | null;
|
|
chapter_name?: string | null;
|
|
parent_path?: string;
|
|
ancestor_ids?: number[];
|
|
code: string;
|
|
description: string;
|
|
unit: string;
|
|
quantity: number;
|
|
unit_price: number;
|
|
amount: number;
|
|
};
|
|
type Totals = { subtotal: number; iva: number; total: number; item_count: number };
|
|
|
|
const desk = useDesktop();
|
|
const { api, download } = useApi();
|
|
const toast = useToast();
|
|
const confirm = useConfirm();
|
|
const route = useRoute();
|
|
const projects = ref<Project[]>([]);
|
|
const chapters = ref<Chapter[]>([]);
|
|
const treeNodes = ref<BudgetTree[]>([]);
|
|
const items = ref<BudgetItem[]>([]);
|
|
const totals = ref<Totals>({ subtotal: 0, iva: 0, total: 0, item_count: 0 });
|
|
const selected = ref<BudgetItem | null>(null);
|
|
const showForm = ref(false);
|
|
const showChapter = ref(false);
|
|
const showImport = ref(false);
|
|
const chapterName = ref("");
|
|
const formError = ref("");
|
|
const templateLoading = ref(false);
|
|
const form = reactive({
|
|
id: 0,
|
|
chapter_id: null as number | null,
|
|
code: "",
|
|
description: "",
|
|
unit: "",
|
|
quantity: 0,
|
|
unit_price: 0,
|
|
});
|
|
|
|
const loadedProjectId = ref(0);
|
|
const currentProject = computed(() => projects.value.find((project) => project.id === loadedProjectId.value) || null);
|
|
const projectName = computed(() => currentProject.value?.name || "Sin proyecto");
|
|
const chapterOptions = computed(() =>
|
|
chapters.value.map((chapter) => ({ id: chapter.id, name: chapter.path || chapter.name })),
|
|
);
|
|
const currentChapterId = computed(() => {
|
|
const key = desk.treeKey.value || "";
|
|
return key.startsWith("ch:") ? Number(key.slice(3)) : 0;
|
|
});
|
|
const { tableQ, matches } = useTableSearch();
|
|
const visible = computed(() => {
|
|
const key = desk.treeKey.value || "";
|
|
if (key.startsWith("ch:")) {
|
|
const id = Number(key.slice(3));
|
|
return items.value.filter((item) =>
|
|
Number(item.chapter_id) === id || (item.ancestor_ids || []).includes(id),
|
|
);
|
|
}
|
|
if (key.endsWith(":none")) return items.value.filter((item) => !item.chapter_id);
|
|
return items.value;
|
|
});
|
|
const listed = computed(() =>
|
|
visible.value.filter((item) => matches(item.code, item.description, item.unit, item.chapter_name, item.parent_path)),
|
|
);
|
|
const subtotal = computed(() => listed.value.reduce((sum, item) => sum + Number(item.amount || 0), 0));
|
|
const iva = computed(() => Math.round(subtotal.value * 0.16 * 100) / 100);
|
|
const total = computed(() => Math.round((subtotal.value + iva.value) * 100) / 100);
|
|
|
|
function money(value: number) {
|
|
return new Intl.NumberFormat("es-MX", { style: "currency", currency: "MXN" }).format(Number(value || 0));
|
|
}
|
|
function qty(value: number) {
|
|
return new Intl.NumberFormat("es-MX", { maximumFractionDigits: 4 }).format(Number(value || 0));
|
|
}
|
|
function shortDesc(value: string) {
|
|
const text = String(value || "").replace(/\s+/g, " ").trim();
|
|
return text.length > 110 ? `${text.slice(0, 107)}…` : text;
|
|
}
|
|
|
|
function onRow(event: { data: BudgetItem }) {
|
|
selected.value = event.data;
|
|
desk.openBudgetItem(event.data);
|
|
}
|
|
|
|
function emptyForm() {
|
|
form.id = 0;
|
|
form.chapter_id = currentChapterId.value || chapters.value[0]?.id || null;
|
|
form.code = "";
|
|
form.description = "";
|
|
form.unit = "";
|
|
form.quantity = 0;
|
|
form.unit_price = 0;
|
|
formError.value = "";
|
|
}
|
|
|
|
function openNew() {
|
|
emptyForm();
|
|
showForm.value = true;
|
|
}
|
|
function openEdit(item: BudgetItem) {
|
|
selected.value = item;
|
|
Object.assign(form, {
|
|
id: item.id,
|
|
chapter_id: item.chapter_id ?? null,
|
|
code: item.code,
|
|
description: item.description,
|
|
unit: item.unit,
|
|
quantity: Number(item.quantity),
|
|
unit_price: Number(item.unit_price),
|
|
});
|
|
formError.value = "";
|
|
showForm.value = true;
|
|
}
|
|
|
|
async function saveItem() {
|
|
if (!form.description.trim()) {
|
|
formError.value = "Descripción de partida obligatoria";
|
|
return;
|
|
}
|
|
const id = currentProject.value?.id;
|
|
if (!id) return;
|
|
const payload = {
|
|
chapter_id: form.chapter_id,
|
|
code: form.code,
|
|
description: form.description,
|
|
unit: form.unit,
|
|
quantity: form.quantity,
|
|
unit_price: form.unit_price,
|
|
};
|
|
try {
|
|
if (form.id) await api(`/v1/projects/${id}/budget/items/${form.id}`, { method: "PATCH", json: payload });
|
|
else await api(`/v1/projects/${id}/budget/items`, { method: "POST", json: payload });
|
|
showForm.value = false;
|
|
toast.add({ severity: "success", summary: form.id ? "Partida actualizada" : "Partida creada", life: 2000 });
|
|
await loadBudget(id);
|
|
} catch (error) {
|
|
formError.value = error instanceof Error ? error.message : "No se pudo guardar";
|
|
}
|
|
}
|
|
|
|
function removeItem() {
|
|
const item = selected.value;
|
|
const id = currentProject.value?.id;
|
|
if (!item || !id) return;
|
|
confirm.require({
|
|
header: "Eliminar partida",
|
|
message: `¿Eliminar ${item.code || "esta partida"}?`,
|
|
icon: "pi pi-exclamation-triangle",
|
|
acceptLabel: "Eliminar",
|
|
rejectLabel: "Cancelar",
|
|
acceptProps: { severity: "danger" },
|
|
accept: async () => {
|
|
await api(`/v1/projects/${id}/budget/items/${item.id}`, { method: "DELETE" });
|
|
selected.value = null;
|
|
desk.clearDetail();
|
|
toast.add({ severity: "success", summary: "Partida eliminada", life: 1800 });
|
|
await loadBudget(id);
|
|
},
|
|
});
|
|
}
|
|
|
|
async function saveChapter() {
|
|
const id = currentProject.value?.id;
|
|
if (!id || !chapterName.value.trim()) return;
|
|
await api(`/v1/projects/${id}/budget/chapters`, {
|
|
method: "POST",
|
|
json: { name: chapterName.value, parent_id: currentChapterId.value || null },
|
|
});
|
|
showChapter.value = false;
|
|
chapterName.value = "";
|
|
toast.add({ severity: "success", summary: "Capítulo creado", life: 1800 });
|
|
await loadBudget(id);
|
|
}
|
|
|
|
function openImport() {
|
|
showImport.value = true;
|
|
}
|
|
|
|
async function downloadTemplate() {
|
|
const id = currentProject.value?.id;
|
|
if (!id) return;
|
|
templateLoading.value = true;
|
|
try {
|
|
await download(`/v1/projects/${id}/budget/template`, "plantilla-presupuesto.xlsx");
|
|
} finally {
|
|
templateLoading.value = false;
|
|
}
|
|
}
|
|
|
|
async function exportBudget() {
|
|
const project = currentProject.value;
|
|
if (!project) return;
|
|
await download(`/v1/projects/${project.id}/budget/export`, `presupuesto-${project.code}.xlsx`);
|
|
}
|
|
|
|
async function onImported() {
|
|
const id = currentProject.value?.id;
|
|
if (!id) return;
|
|
await loadProjects();
|
|
await loadBudget(id);
|
|
}
|
|
|
|
async function loadProjects() {
|
|
const response = await api<{ projects: Project[] }>("/v1/projects");
|
|
projects.value = response.projects;
|
|
}
|
|
|
|
async function loadBudget(id: number) {
|
|
try {
|
|
const budget = await api<{ tree: BudgetTree[]; chapters: Chapter[]; items: BudgetItem[]; totals: Totals }>(
|
|
`/v1/projects/${id}/budget`,
|
|
);
|
|
treeNodes.value = budget.tree || [];
|
|
chapters.value = budget.chapters || [];
|
|
items.value = budget.items || [];
|
|
totals.value = budget.totals || { subtotal: 0, iva: 0, total: 0, item_count: 0 };
|
|
} catch (error) {
|
|
treeNodes.value = [];
|
|
chapters.value = [];
|
|
items.value = [];
|
|
totals.value = { subtotal: 0, iva: 0, total: 0, item_count: 0 };
|
|
toast.add({
|
|
severity: "error",
|
|
summary: error instanceof Error ? error.message : "No se pudo cargar el presupuesto",
|
|
life: 3500,
|
|
});
|
|
}
|
|
if (selected.value) {
|
|
selected.value = items.value.find((item) => item.id === selected.value?.id) || null;
|
|
if (selected.value) desk.openBudgetItem(selected.value);
|
|
}
|
|
syncChrome();
|
|
}
|
|
|
|
function mapBudgetTree(nodes: BudgetTree[]) {
|
|
return nodes.map((node) => ({
|
|
key: `ch:${node.id}`,
|
|
label: [node.code, node.name].filter(Boolean).join(" "),
|
|
icon: node.children?.length ? "pi pi-folder" : "pi pi-bookmark",
|
|
data: { count: node.item_count || 0 },
|
|
children: mapBudgetTree(node.children || []),
|
|
}));
|
|
}
|
|
|
|
function syncChrome() {
|
|
const project = currentProject.value;
|
|
desk.configure({
|
|
tab: "Presupuesto",
|
|
treeTitle: "Presupuesto",
|
|
showTree: true,
|
|
showDetail: true,
|
|
treeKey: desk.treeKey.value || (project ? `p:${project.id}` : ""),
|
|
countLabel: project ? `${visible.value.length} partidas · ${money(subtotal.value)}` : "",
|
|
tree: projects.value.map((item) => ({
|
|
key: `p:${item.id}`,
|
|
label: item.name,
|
|
icon: "pi pi-building",
|
|
data: { count: item.id === project?.id ? items.value.length : Number(item.budget_count || 0) },
|
|
children: item.id === project?.id
|
|
? [
|
|
...mapBudgetTree(treeNodes.value),
|
|
...(items.value.some((row) => !row.chapter_id)
|
|
? [{ key: `p:${item.id}:none`, label: "Sin capítulo", icon: "pi pi-inbox", data: { count: items.value.filter((row) => !row.chapter_id).length } }]
|
|
: []),
|
|
]
|
|
: [],
|
|
})),
|
|
ribbon: [
|
|
{
|
|
key: "acciones",
|
|
label: "Acciones",
|
|
items: [
|
|
{ key: "alta", label: "Partida", icon: "pi pi-plus", disabled: !project, command: openNew },
|
|
{ key: "edit", label: "Editar", icon: "pi pi-pencil", disabled: !selected.value, command: () => selected.value && openEdit(selected.value) },
|
|
{ key: "baja", label: "Eliminar", icon: "pi pi-trash", disabled: !selected.value, command: removeItem },
|
|
{ key: "chapter", label: "Capítulo", icon: "pi pi-folder-plus", disabled: !project, command: () => { chapterName.value = ""; showChapter.value = true; } },
|
|
],
|
|
},
|
|
{
|
|
key: "datos",
|
|
label: "Datos",
|
|
items: [
|
|
{ key: "import", label: "Importar", icon: "pi pi-upload", disabled: !project, command: openImport },
|
|
{ key: "export", label: "Exportar", icon: "pi pi-download", disabled: !project, command: exportBudget },
|
|
],
|
|
},
|
|
{
|
|
key: "vista",
|
|
label: "Vista",
|
|
items: [{ key: "refresh", label: "Actualizar", icon: "pi pi-refresh", disabled: !project, command: () => project && loadBudget(project.id) }],
|
|
},
|
|
],
|
|
});
|
|
}
|
|
|
|
watch(
|
|
() => desk.treeKey.value,
|
|
async (key) => {
|
|
if (key?.startsWith("p:")) {
|
|
const id = Number(key.split(":")[1]);
|
|
if (id && id !== loadedProjectId.value) {
|
|
loadedProjectId.value = id;
|
|
await loadBudget(id);
|
|
return;
|
|
}
|
|
}
|
|
syncChrome();
|
|
},
|
|
);
|
|
|
|
onMounted(async () => {
|
|
await loadProjects();
|
|
const fromQuery = Number(route.query.project || 0);
|
|
const initial = fromQuery || projects.value[0]?.id;
|
|
if (initial) {
|
|
loadedProjectId.value = initial;
|
|
desk.treeKey.value = `p:${initial}`;
|
|
await loadBudget(initial);
|
|
} else {
|
|
syncChrome();
|
|
}
|
|
});
|
|
watch([items, selected, visible, chapters, treeNodes], syncChrome);
|
|
</script>
|