panels-origin/web-panel/pages/gastos.vue
Cursor Agent 61e36696b9
feat: reclasificar gastos sin concepto o cambiar de partida
Si un gasto se guardó sin clasificar, ahora se edita desde la lista (clic o Reclasificar) y se le asigna o cambia el concepto del presupuesto.

Co-authored-by: alberto.martinez <alberto.martinez@mrdev.mx>
2026-09-08 20:19:13 +00:00

567 lines
20 KiB
Vue

<template>
<div class="gastos-page">
<DeskTableBar v-model="tableQ" placeholder="Buscar descripción, proveedor o concepto">
<Chip :label="projectLabel" icon="pi pi-building" />
<Chip :label="`${listed.length} gastos`" icon="pi pi-list" />
<Chip :label="`${unclassifiedCount} sin clasificar`" icon="pi pi-question-circle" />
<Chip :label="money(totalSubtotal)" icon="pi pi-wallet" />
<SelectButton v-model="listFilter" :options="listFilterOpts" option-label="label" option-value="value" :allow-empty="false" />
</DeskTableBar>
<Message v-if="budgetWarning" :severity="budgetWarning.level === 'critical' ? 'error' : 'warn'" :closable="true" class="mb-3">
{{ budgetWarning.message }}
</Message>
<DataTable
v-model:selection="selected"
:value="listed"
data-key="id"
paginator
:rows="25"
empty-message="No hay gastos en el periodo seleccionado."
selection-mode="single"
meta-key-selection="false"
scrollable
scroll-height="flex"
table-style="min-width: 56rem"
@row-click="onRow"
@row-dblclick="(e: { data: Expense }) => openEdit(e.data)"
>
<Column field="expense_date" header="Fecha" sortable style="width: 7rem" />
<Column field="description" header="Descripción" sortable />
<Column field="supplier_name" header="Proveedor" sortable style="width: 10rem" />
<Column field="budget_item_code" header="Concepto" style="width: 10rem">
<template #body="{ data }">
<Tag v-if="!data.budget_item_id" value="Sin clasificar" severity="warn" />
<span v-else class="identifier">{{ data.budget_item_code || "—" }}</span>
</template>
</Column>
<Column field="subtotal" header="Subtotal" sortable style="width: 9rem">
<template #body="{ data }">{{ money(data.subtotal) }}</template>
</Column>
<Column field="iva_amount" header="IVA" sortable style="width: 8rem">
<template #body="{ data }">{{ money(data.iva_amount) }}</template>
</Column>
<Column field="total" header="Total" sortable style="width: 9rem">
<template #body="{ data }"><strong>{{ money(data.total) }}</strong></template>
</Column>
<Column field="source" header="Origen" style="width: 7rem" />
</DataTable>
<Dialog v-model:visible="showForm" :header="editingId ? 'Editar gasto' : 'Nuevo gasto'" modal :style="{ width: '720px' }">
<div class="form-grid form-grid-compact">
<FormField label="Empresa" required>
<Select v-model="form.company_id" :options="companies" option-label="name" option-value="id" class="w-full" />
</FormField>
<FormField label="Obra (opcional)">
<Select v-model="form.project_id" :options="projects" option-label="name" option-value="id" show-clear placeholder="Gasto de empresa" class="w-full" />
</FormField>
<FormField label="Fecha" required>
<DatePicker v-model="expenseDate" date-format="dd/mm/yy" show-icon class="w-full" />
</FormField>
<FormField label="Concepto presupuesto" class="span-2">
<BudgetItemPicker
v-if="form.project_id"
v-model="form.budget_item_id"
:project-id="form.project_id"
@select="onBudgetSelect"
/>
<InputText v-else disabled placeholder="Seleccione una obra para ligar concepto" class="w-full" />
</FormField>
<FormField label="Descripción" class="span-2" required>
<Textarea v-model="form.description" class="w-full" rows="2" auto-resize />
</FormField>
<FormField label="Proveedor" class="span-2">
<div class="flex gap-2 align-items-center">
<Select
v-model="form.supplier_id"
:options="supplierOptions"
option-label="label"
option-value="id"
placeholder="Seleccione proveedor"
filter
show-clear
class="w-full"
/>
<Button icon="pi pi-plus" outlined aria-label="Nuevo proveedor" title="Nuevo proveedor" @click="showSupplierForm = true" />
</div>
<small v-if="selectedSupplierRfc" class="muted">RFC {{ selectedSupplierRfc }}</small>
</FormField>
<FormField label="Cantidad">
<InputNumber v-model="form.quantity" :min="0" :max-fraction-digits="4" class="w-full" />
</FormField>
<FormField label="Unidad">
<div class="flex gap-2 align-items-center">
<Select
v-model="form.unit"
:options="units"
option-label="label"
option-value="code"
placeholder="Seleccione unidad"
filter
show-clear
class="w-full"
/>
<Button icon="pi pi-plus" outlined aria-label="Nueva unidad" title="Nueva unidad" @click="showUnitForm = true" />
</div>
</FormField>
<FormField label="Precio unitario">
<InputNumber v-model="form.unit_price" mode="currency" currency="MXN" locale="es-MX" class="w-full" />
</FormField>
<FormField label="Subtotal">
<InputNumber :model-value="computedAmounts.subtotal" mode="currency" currency="MXN" locale="es-MX" class="w-full" readonly />
</FormField>
<FormField label="IVA incluido en el precio">
<div class="flex align-items-center gap-2">
<Checkbox v-model="form.tax_included" binary input-id="tax-included" />
<label for="tax-included" class="muted">El precio ya trae IVA</label>
</div>
</FormField>
<FormField label="IVA">
<InputNumber :model-value="computedAmounts.ivaAmount" mode="currency" currency="MXN" locale="es-MX" class="w-full" readonly />
</FormField>
<FormField label="Total con IVA">
<InputNumber :model-value="computedAmounts.total" mode="currency" currency="MXN" locale="es-MX" class="w-full" readonly />
</FormField>
<FormField label="Comprobante PDF" class="span-2">
<FileUpload
mode="basic"
:auto="false"
choose-label="Seleccionar PDF"
accept="application/pdf,.pdf"
custom-upload
@select="onPickReceipt"
/>
<div class="muted mt-1">{{ receiptFile ? receiptFile.name : "Ningún archivo seleccionado" }}</div>
</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="editingId ? 'Guardar cambios' : 'Guardar'" icon="pi pi-check" @click="save" />
</template>
</Dialog>
<SupplierFormDialog v-model="showSupplierForm" @saved="onSupplierSaved" />
<Dialog v-model:visible="showUnitForm" header="Nueva unidad" modal :style="{ width: '420px' }">
<div class="form-grid form-grid-compact">
<FormField label="Código" required>
<InputText v-model="unitForm.code" class="w-full identifier-input" placeholder="bulto" />
</FormField>
<FormField label="Nombre" required>
<InputText v-model="unitForm.label" class="w-full" placeholder="Bulto" />
</FormField>
</div>
<Message v-if="unitError" severity="error" class="mt-3" :closable="false">{{ unitError }}</Message>
<template #footer>
<Button label="Cancelar" text @click="showUnitForm = false" />
<Button label="Guardar" icon="pi pi-check" @click="saveUnit" />
</template>
</Dialog>
</div>
</template>
<script setup lang="ts">
import FormField from "~/components/padron/FormField.vue";
import BudgetItemPicker from "~/components/BudgetItemPicker.vue";
import SupplierFormDialog, { type Supplier } from "~/components/SupplierFormDialog.vue";
import { displayErrorMessage } from "~/composables/api-response";
import { expenseAmounts } from "~/composables/expenseAmounts";
type Expense = {
id: number;
expense_date: string;
description: string;
supplier_name: string;
supplier_id?: number | null;
budget_item_id?: number | null;
budget_item_code?: string;
company_id?: number | null;
quantity?: number;
unit?: string;
unit_price?: number;
tax_included?: boolean;
is_locked?: boolean;
subtotal: number;
iva_amount: number;
total: number;
source: string;
project_id?: number | null;
};
const desk = useDesktop();
const { api } = useApi();
const toast = useToast();
const route = useRoute();
const perms = usePermissions();
const tableQ = ref("");
const expenses = ref<Expense[]>([]);
const companies = ref<{ id: number; name: string }[]>([]);
const projects = ref<{ id: number; name: string }[]>([]);
const suppliers = ref<Supplier[]>([]);
const units = ref<{ code: string; label: string }[]>([]);
const showForm = ref(false);
const showSupplierForm = ref(false);
const showUnitForm = ref(false);
const formError = ref("");
const unitError = ref("");
const budgetWarning = ref<{ level: string; message: string } | null>(null);
const receiptFile = ref<File | null>(null);
const ivaRate = ref(0.16);
const editingId = ref<number | null>(null);
const selected = ref<Expense | null>(null);
const listFilter = ref<"all" | "unclassified">("all");
const listFilterOpts = [
{ label: "Todos", value: "all" },
{ label: "Sin clasificar", value: "unclassified" },
];
const canCreateExpense = computed(() => perms.can("expenses.create"));
const canUpdateExpense = computed(() => perms.can("expenses.update"));
const form = reactive({
company_id: null as number | null,
project_id: null as number | null,
expense_date: new Date().toISOString().slice(0, 10),
budget_item_id: null as number | null,
description: "",
supplier_id: null as number | null,
quantity: 0,
unit: "",
unit_price: 0,
tax_included: false,
});
const unitForm = reactive({ code: "", label: "" });
const expenseDate = computed({
get: () => parseDate(form.expense_date),
set: (value: Date | null) => {
form.expense_date = isoDate(value) || new Date().toISOString().slice(0, 10);
},
});
const computedAmounts = computed(() =>
expenseAmounts(form.quantity, form.unit_price, form.tax_included, ivaRate.value),
);
const supplierOptions = computed(() =>
suppliers.value.map((s) => ({
...s,
label: s.rfc ? `${s.name} · ${s.rfc}` : s.name,
})),
);
const selectedSupplierRfc = computed(() =>
suppliers.value.find((s) => s.id === form.supplier_id)?.rfc || "",
);
const projectId = computed(() => Number(route.query.project_id || 0) || null);
const projectLabel = computed(() => {
const p = projects.value.find((x) => x.id === projectId.value);
return p?.name || "Todas las obras";
});
const listed = computed(() => {
const q = tableQ.value.toLowerCase();
return expenses.value.filter((e) => {
if (listFilter.value === "unclassified" && e.budget_item_id) return false;
return !q || [e.description, e.supplier_name, e.budget_item_code].join(" ").toLowerCase().includes(q);
});
});
const unclassifiedCount = computed(() => expenses.value.filter((e) => !e.budget_item_id).length);
const totalSubtotal = computed(() => listed.value.reduce((s, e) => s + Number(e.subtotal || 0), 0));
const canEditSelected = computed(() =>
Boolean(selected.value && canUpdateExpense.value && isEditable(selected.value)),
);
function money(v: number) {
return new Intl.NumberFormat("es-MX", { style: "currency", currency: "MXN" }).format(Number(v || 0));
}
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 "";
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 onPickReceipt(event: { files: File[] }) {
receiptFile.value = event.files?.[0] || null;
}
function onBudgetSelect(item: { unit?: string } | null) {
const code = String(item?.unit || "").trim().toLowerCase();
if (code && units.value.some((u) => u.code === code)) form.unit = code;
}
function onSupplierSaved(supplier: Supplier) {
suppliers.value = [...suppliers.value.filter((s) => s.id !== supplier.id), supplier]
.sort((a, b) => a.name.localeCompare(b.name, "es"));
form.supplier_id = supplier.id;
}
function isEditable(row: Expense) {
return row.source === "manual" && !row.is_locked;
}
function resetForm() {
form.company_id = companies.value[0]?.id ?? null;
form.project_id = projectId.value;
form.expense_date = isoDate(new Date());
form.budget_item_id = Number(route.query.budget_item_id || 0) || null;
form.description = "";
form.supplier_id = null;
form.quantity = 0;
form.unit = "";
form.unit_price = 0;
form.tax_included = false;
receiptFile.value = null;
formError.value = "";
}
function openNew() {
editingId.value = null;
resetForm();
showForm.value = true;
}
function onRow(e: { data: Expense }) {
selected.value = e.data;
}
async function openEdit(row: Expense) {
selected.value = row;
if (!canUpdateExpense.value) return;
if (!isEditable(row)) {
toast.add({
severity: "info",
summary: "Este gasto no se edita aquí",
detail: row.source === "manual" ? "Está bloqueado" : "Viene de nómina o destajo",
life: 3000,
});
return;
}
formError.value = "";
try {
const r = await api<{ expense?: Expense; data?: { expense?: Expense } }>(`/v1/expenses/${row.id}`);
const e = r.expense ?? r.data?.expense ?? row;
editingId.value = e.id;
form.company_id = e.company_id ?? row.company_id ?? companies.value[0]?.id ?? null;
form.project_id = e.project_id ?? null;
form.expense_date = String(e.expense_date || "").slice(0, 10);
form.budget_item_id = e.budget_item_id ?? null;
form.description = e.description || "";
form.supplier_id = e.supplier_id ?? null;
form.quantity = Number(e.quantity || 0);
form.unit = e.unit || "";
form.unit_price = Number(e.unit_price || 0);
form.tax_included = Boolean(e.tax_included);
receiptFile.value = null;
showForm.value = true;
} catch (err) {
toast.add({
severity: "error",
summary: displayErrorMessage((err as { data?: unknown }).data ?? err),
life: 4000,
});
}
}
function syncChrome() {
desk.configure({
tab: "Gastos",
treeTitle: "Obras",
showTree: true,
showDetail: false,
treeKey: projectId.value ? `p:${projectId.value}` : "",
tree: projects.value.map((p) => ({ key: `p:${p.id}`, label: p.name, icon: "pi pi-building" })),
onTreeSelect: (key) => {
if (key.startsWith("p:")) navigateTo(`/gastos?project_id=${key.slice(2)}`);
},
ribbon: [
{
key: "gastos",
label: "Captura",
items: [
{
key: "new",
label: "Nuevo gasto",
icon: "pi pi-plus",
disabled: !canCreateExpense.value,
command: openNew,
},
{
key: "edit",
label: "Reclasificar",
icon: "pi pi-pencil",
disabled: !canEditSelected.value,
title: canEditSelected.value
? "Cambiar concepto u otros datos del gasto"
: "Seleccione un gasto manual sin clasificar o para cambiar de partida",
command: () => selected.value && openEdit(selected.value),
},
],
},
{
key: "control",
label: "Control",
items: [
{
key: "control",
label: "Control presupuesto",
icon: "pi pi-chart-bar",
disabled: !projectId.value,
command: () => navigateTo(`/control-presupuesto?project_id=${projectId.value}`),
},
{ key: "refresh", label: "Actualizar", icon: "pi pi-refresh", command: loadExpenses },
],
},
],
});
}
async function loadCatalogs() {
const [cat, pr] = await Promise.all([
api<{ companies?: { id: number; name: string }[] }>("/v1/catalogs"),
api<{ projects?: { id: number; name: string }[] }>("/v1/projects"),
]);
companies.value = cat.companies ?? [];
projects.value = pr.projects ?? [];
if (!form.company_id && companies.value[0]) form.company_id = companies.value[0].id;
try {
const sup = await api<{ suppliers?: Supplier[]; data?: { suppliers?: Supplier[] } }>("/v1/suppliers?status=activo");
suppliers.value = sup.suppliers ?? sup.data?.suppliers ?? [];
} catch {
suppliers.value = [];
}
try {
const un = await api<{ units?: { code: string; label: string }[]; data?: { units?: { code: string; label: string }[] } }>("/v1/measure-units");
units.value = un.units ?? un.data?.units ?? [];
} catch {
units.value = [];
}
try {
const settings = await api<{ default_iva_rate?: number; data?: { default_iva_rate?: number } }>("/v1/cost-settings");
ivaRate.value = Number(settings.default_iva_rate ?? settings.data?.default_iva_rate ?? 0.16) || 0.16;
} catch {
ivaRate.value = 0.16;
}
}
async function saveUnit() {
unitError.value = "";
if (!unitForm.code.trim()) {
unitError.value = "El código de unidad es obligatorio";
return;
}
try {
const r = await api<{ unit?: { code: string; label: string }; data?: { unit?: { code: string; label: string } } }>(
"/v1/measure-units",
{ method: "POST", json: { code: unitForm.code, label: unitForm.label } },
);
const unit = r.unit ?? r.data?.unit;
if (unit) {
units.value = [...units.value.filter((u) => u.code !== unit.code), unit]
.sort((a, b) => a.label.localeCompare(b.label, "es"));
form.unit = unit.code;
}
unitForm.code = "";
unitForm.label = "";
showUnitForm.value = false;
} catch (e) {
unitError.value = displayErrorMessage((e as { data?: unknown }).data ?? e);
}
}
async function loadExpenses() {
const qs = new URLSearchParams();
if (projectId.value) qs.set("project_id", String(projectId.value));
const r = await api<{ expenses?: Expense[]; data?: { expenses?: Expense[] }; message?: string }>(`/v1/expenses?${qs}`);
const keep = selected.value?.id;
expenses.value = (r.expenses ?? r.data?.expenses ?? []) as Expense[];
selected.value = keep ? expenses.value.find((e) => e.id === keep) ?? null : null;
}
function payload() {
return {
...form,
project_id: form.project_id || null,
budget_item_id: form.budget_item_id || null,
supplier_id: form.supplier_id || null,
subtotal: computedAmounts.value.subtotal,
total: computedAmounts.value.total,
iva_rate: ivaRate.value,
};
}
async function save() {
formError.value = "";
if (!form.company_id || !form.description.trim()) {
formError.value = "Empresa y descripción son obligatorios";
return;
}
try {
const r = editingId.value
? await api<{ data?: { expense?: { id: number }; budget_warning?: { level: string; message: string } }; message?: string }>(
`/v1/expenses/${editingId.value}`,
{ method: "PATCH", json: payload() },
)
: await api<{ data?: { expense?: { id: number }; budget_warning?: { level: string; message: string } }; message?: string }>(
"/v1/expenses",
{ method: "POST", json: payload() },
);
const expenseId = r.data?.expense?.id ?? editingId.value;
if (expenseId && receiptFile.value) {
const fd = new FormData();
fd.append("file", receiptFile.value);
await fetch(`/v1/expenses/${expenseId}/attachments`, { method: "POST", body: fd, credentials: "include" });
}
budgetWarning.value = r.data?.budget_warning ?? null;
toast.add({
severity: "success",
summary: r.message || (editingId.value ? "Gasto actualizado" : "Gasto registrado"),
life: 2500,
});
showForm.value = false;
editingId.value = null;
await loadExpenses();
if (route.query.nuevo || route.query.budget_item_id) {
const qs = new URLSearchParams();
if (projectId.value) qs.set("project_id", String(projectId.value));
await navigateTo(`/gastos${qs.toString() ? `?${qs}` : ""}`, { replace: true });
}
} catch (e) {
formError.value = displayErrorMessage((e as { data?: unknown }).data ?? e);
}
}
onMounted(async () => {
await loadCatalogs();
syncChrome();
await loadExpenses();
if (route.query.nuevo) openNew();
});
watch(projectId, () => {
loadExpenses();
syncChrome();
});
watch(selected, syncChrome);
watch(
() => String(route.query.nuevo || ""),
(nuevo) => {
if (nuevo) openNew();
},
);
</script>