Compare commits

..

2 commits

Author SHA1 Message Date
Cursor Origin
ff04a6621a Pre-computed merge for change #15 1970-01-01 00:00:00 +00:00
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
4 changed files with 243 additions and 25 deletions

View file

@ -39,5 +39,6 @@
<include file="changesets/030-rpc-work-program.sql" relativeToChangelogFile="true"/> <include file="changesets/030-rpc-work-program.sql" relativeToChangelogFile="true"/>
<include file="changesets/031-project-doc-types-technical.sql" relativeToChangelogFile="true"/> <include file="changesets/031-project-doc-types-technical.sql" relativeToChangelogFile="true"/>
<include file="changesets/033-catalog-suppliers-units.sql" relativeToChangelogFile="true"/> <include file="changesets/033-catalog-suppliers-units.sql" relativeToChangelogFile="true"/>
<include file="changesets/034-fn-expense-update-reclassify.sql" relativeToChangelogFile="true"/>
</databaseChangeLog> </databaseChangeLog>

View file

@ -0,0 +1,111 @@
--liquibase formatted sql
-- PANELS · core · reclasificar gasto (concepto, proveedor, obra)
--changeset panel:core-034a-fn-expense-update-reclassify splitStatements:false
CREATE OR REPLACE FUNCTION core.fn_expense_update(payload jsonb)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path = core
AS $$
DECLARE
v_id bigint := NULLIF(payload->>'id', '')::bigint;
v_tid integer := core._cost_tenant_id(payload);
v_cur expense_entries%ROWTYPE;
v_warning jsonb;
v_row jsonb;
v_subtotal numeric; v_iva_amount numeric; v_total numeric;
v_line numeric;
v_supplier_id bigint;
v_supplier_name text;
v_supplier_rfc text;
v_budget_item_id bigint;
v_chapter_id bigint;
BEGIN
IF v_id IS NULL THEN
RETURN core.rpc_err('VALIDATION', 'fn_expense_update: id es obligatorio',
jsonb_build_object('fn', 'fn_expense_update', 'field', 'id'));
END IF;
SELECT * INTO v_cur FROM expense_entries WHERE id = v_id;
IF NOT FOUND THEN
RETURN core.rpc_err('NOT_FOUND', format('fn_expense_update: gasto id=%s no encontrado', v_id),
jsonb_build_object('fn', 'fn_expense_update', 'id', v_id));
END IF;
IF v_cur.is_locked OR v_cur.source <> 'manual' THEN
RETURN core.rpc_err('VALIDATION', 'fn_expense_update: solo se editan gastos manuales no bloqueados',
jsonb_build_object('fn', 'fn_expense_update', 'id', v_id, 'source', v_cur.source));
END IF;
IF payload ? 'supplier_id' THEN
v_supplier_id := NULLIF(payload->>'supplier_id', '')::bigint;
IF v_supplier_id IS NOT NULL THEN
SELECT name, rfc INTO v_supplier_name, v_supplier_rfc
FROM suppliers
WHERE id = v_supplier_id AND tenant_id = v_tid AND status = 'activo';
IF NOT FOUND THEN
RETURN core.rpc_err('NOT_FOUND', format('fn_expense_update: proveedor id=%s no encontrado', v_supplier_id),
jsonb_build_object('fn', 'fn_expense_update', 'supplier_id', v_supplier_id));
END IF;
ELSE
v_supplier_name := '';
v_supplier_rfc := '';
END IF;
END IF;
IF payload ? 'budget_item_id' THEN
v_budget_item_id := NULLIF(payload->>'budget_item_id', '')::bigint;
IF v_budget_item_id IS NOT NULL THEN
SELECT chapter_id INTO v_chapter_id FROM budget_items WHERE id = v_budget_item_id;
END IF;
END IF;
UPDATE expense_entries SET
expense_date = COALESCE(NULLIF(payload->>'expense_date', '')::date, expense_date),
description = COALESCE(nullif(btrim(payload->>'description'), ''), description),
project_id = CASE WHEN payload ? 'project_id' THEN NULLIF(payload->>'project_id', '')::bigint ELSE project_id END,
supplier_id = CASE WHEN payload ? 'supplier_id' THEN v_supplier_id ELSE supplier_id END,
supplier_name = CASE
WHEN payload ? 'supplier_id' THEN coalesce(v_supplier_name, '')
WHEN payload ? 'supplier_name' THEN coalesce(nullif(btrim(payload->>'supplier_name'), ''), '')
ELSE supplier_name END,
supplier_rfc = CASE
WHEN payload ? 'supplier_id' THEN coalesce(v_supplier_rfc, '')
WHEN payload ? 'supplier_rfc' THEN upper(coalesce(nullif(btrim(payload->>'supplier_rfc'), ''), ''))
ELSE supplier_rfc END,
budget_item_id = CASE WHEN payload ? 'budget_item_id' THEN v_budget_item_id ELSE budget_item_id END,
budget_chapter_id = CASE WHEN payload ? 'budget_item_id' THEN v_chapter_id ELSE budget_chapter_id END,
quantity = CASE WHEN payload ? 'quantity' THEN COALESCE((payload->>'quantity')::numeric, 0) ELSE quantity END,
unit = CASE WHEN payload ? 'unit' THEN coalesce(nullif(btrim(payload->>'unit'), ''), '') ELSE unit END,
unit_price = CASE WHEN payload ? 'unit_price' THEN COALESCE((payload->>'unit_price')::numeric, 0) ELSE unit_price END,
tax_included = CASE WHEN payload ? 'tax_included' THEN COALESCE((payload->>'tax_included')::boolean, false) ELSE tax_included END,
notes = CASE WHEN payload ? 'notes' THEN coalesce(nullif(btrim(payload->>'notes'), ''), '') ELSE notes END
WHERE id = v_id;
SELECT * INTO v_cur FROM expense_entries WHERE id = v_id;
v_line := CASE
WHEN v_cur.quantity > 0 AND v_cur.unit_price > 0 THEN round(v_cur.quantity * v_cur.unit_price, 2)
ELSE v_cur.subtotal
END;
SELECT c.subtotal, c.iva_amount, c.total INTO v_subtotal, v_iva_amount, v_total
FROM core._cost_calc_amounts(
v_line,
v_cur.iva_rate,
v_cur.tax_included,
CASE WHEN v_cur.tax_included THEN v_line ELSE NULL END
) c;
UPDATE expense_entries SET subtotal = v_subtotal, iva_amount = v_iva_amount, total = v_total WHERE id = v_id;
IF v_cur.counts_toward_budget AND v_cur.budget_item_id IS NOT NULL THEN
v_warning := core._cost_budget_warning(v_tid, v_cur.budget_item_id, 0, 0);
END IF;
SELECT to_jsonb(e) INTO v_row FROM expense_entries e WHERE id = v_id;
RETURN core.rpc_ok(
jsonb_build_object('expense', v_row, 'budget_warning', v_warning),
format('Gasto id=%s actualizado', v_id),
jsonb_build_object('fn', 'fn_expense_update', 'id', v_id, 'budget_item_id', v_cur.budget_item_id)
);
EXCEPTION WHEN OTHERS THEN
RETURN core.rpc_from_exception('fn_expense_update', SQLSTATE, SQLERRM);
END;
$$;
--changeset panel:core-034b-fn-expense-update-grant endDelimiter:; splitStatements:true
GRANT EXECUTE ON FUNCTION core.fn_expense_update(jsonb) TO panels_core_app;

View file

@ -2286,7 +2286,8 @@ html.dark .p-dialog .p-dialog-header-icon {
line-height: 1.35; line-height: 1.35;
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
html.dark .budget-item-option-desc { html.dark .budget-item-option-desc,
html.dark .budget-item-picker .budget-item-value-desc {
color: #94a3b8; color: #94a3b8;
} }

View file

@ -3,7 +3,9 @@
<DeskTableBar v-model="tableQ" placeholder="Buscar descripción, proveedor o concepto"> <DeskTableBar v-model="tableQ" placeholder="Buscar descripción, proveedor o concepto">
<Chip :label="projectLabel" icon="pi pi-building" /> <Chip :label="projectLabel" icon="pi pi-building" />
<Chip :label="`${listed.length} gastos`" icon="pi pi-list" /> <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" /> <Chip :label="money(totalSubtotal)" icon="pi pi-wallet" />
<SelectButton v-model="listFilter" :options="listFilterOpts" option-label="label" option-value="value" :allow-empty="false" />
</DeskTableBar> </DeskTableBar>
<Message v-if="budgetWarning" :severity="budgetWarning.level === 'critical' ? 'error' : 'warn'" :closable="true" class="mb-3"> <Message v-if="budgetWarning" :severity="budgetWarning.level === 'critical' ? 'error' : 'warn'" :closable="true" class="mb-3">
@ -11,20 +13,28 @@
</Message> </Message>
<DataTable <DataTable
v-model:selection="selected"
:value="listed" :value="listed"
data-key="id" data-key="id"
paginator paginator
:rows="25" :rows="25"
empty-message="No hay gastos en el periodo seleccionado." empty-message="No hay gastos en el periodo seleccionado."
selection-mode="single"
meta-key-selection="false"
scrollable scrollable
scroll-height="flex" scroll-height="flex"
table-style="min-width: 56rem" 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="expense_date" header="Fecha" sortable style="width: 7rem" />
<Column field="description" header="Descripción" sortable /> <Column field="description" header="Descripción" sortable />
<Column field="supplier_name" header="Proveedor" sortable style="width: 10rem" /> <Column field="supplier_name" header="Proveedor" sortable style="width: 10rem" />
<Column field="budget_item_code" header="Concepto" style="width: 8rem"> <Column field="budget_item_code" header="Concepto" style="width: 10rem">
<template #body="{ data }"><span class="identifier">{{ data.budget_item_code || "—" }}</span></template> <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>
<Column field="subtotal" header="Subtotal" sortable style="width: 9rem"> <Column field="subtotal" header="Subtotal" sortable style="width: 9rem">
<template #body="{ data }">{{ money(data.subtotal) }}</template> <template #body="{ data }">{{ money(data.subtotal) }}</template>
@ -38,7 +48,7 @@
<Column field="source" header="Origen" style="width: 7rem" /> <Column field="source" header="Origen" style="width: 7rem" />
</DataTable> </DataTable>
<Dialog v-model:visible="showForm" header="Nuevo gasto" modal :style="{ width: '720px' }"> <Dialog v-model:visible="showForm" :header="editingId ? 'Editar gasto' : 'Nuevo gasto'" modal :style="{ width: '720px' }">
<div class="form-grid form-grid-compact"> <div class="form-grid form-grid-compact">
<FormField label="Empresa" required> <FormField label="Empresa" required>
<Select v-model="form.company_id" :options="companies" option-label="name" option-value="id" class="w-full" /> <Select v-model="form.company_id" :options="companies" option-label="name" option-value="id" class="w-full" />
@ -128,7 +138,7 @@
<Message v-if="formError" severity="error" class="mt-3" :closable="false">{{ formError }}</Message> <Message v-if="formError" severity="error" class="mt-3" :closable="false">{{ formError }}</Message>
<template #footer> <template #footer>
<Button label="Cancelar" text @click="showForm = false" /> <Button label="Cancelar" text @click="showForm = false" />
<Button label="Guardar" icon="pi pi-check" @click="save" /> <Button :label="editingId ? 'Guardar cambios' : 'Guardar'" icon="pi pi-check" @click="save" />
</template> </template>
</Dialog> </Dialog>
@ -164,7 +174,15 @@ type Expense = {
expense_date: string; expense_date: string;
description: string; description: string;
supplier_name: string; supplier_name: string;
supplier_id?: number | null;
budget_item_id?: number | null;
budget_item_code?: string; budget_item_code?: string;
company_id?: number | null;
quantity?: number;
unit?: string;
unit_price?: number;
tax_included?: boolean;
is_locked?: boolean;
subtotal: number; subtotal: number;
iva_amount: number; iva_amount: number;
total: number; total: number;
@ -192,7 +210,15 @@ const unitError = ref("");
const budgetWarning = ref<{ level: string; message: string } | null>(null); const budgetWarning = ref<{ level: string; message: string } | null>(null);
const receiptFile = ref<File | null>(null); const receiptFile = ref<File | null>(null);
const ivaRate = ref(0.16); 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 canCreateExpense = computed(() => perms.can("expenses.create"));
const canUpdateExpense = computed(() => perms.can("expenses.update"));
const form = reactive({ const form = reactive({
company_id: null as number | null, company_id: null as number | null,
@ -238,11 +264,16 @@ const projectLabel = computed(() => {
}); });
const listed = computed(() => { const listed = computed(() => {
const q = tableQ.value.toLowerCase(); const q = tableQ.value.toLowerCase();
return expenses.value.filter((e) => return expenses.value.filter((e) => {
!q || [e.description, e.supplier_name, e.budget_item_code].join(" ").toLowerCase().includes(q), 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 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) { function money(v: number) {
return new Intl.NumberFormat("es-MX", { style: "currency", currency: "MXN" }).format(Number(v || 0)); return new Intl.NumberFormat("es-MX", { style: "currency", currency: "MXN" }).format(Number(v || 0));
@ -278,6 +309,10 @@ function onSupplierSaved(supplier: Supplier) {
form.supplier_id = supplier.id; form.supplier_id = supplier.id;
} }
function isEditable(row: Expense) {
return row.source === "manual" && !row.is_locked;
}
function resetForm() { function resetForm() {
form.company_id = companies.value[0]?.id ?? null; form.company_id = companies.value[0]?.id ?? null;
form.project_id = projectId.value; form.project_id = projectId.value;
@ -294,10 +329,53 @@ function resetForm() {
} }
function openNew() { function openNew() {
editingId.value = null;
resetForm(); resetForm();
showForm.value = true; 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() { function syncChrome() {
desk.configure({ desk.configure({
tab: "Gastos", tab: "Gastos",
@ -321,6 +399,16 @@ function syncChrome() {
disabled: !canCreateExpense.value, disabled: !canCreateExpense.value,
command: openNew, 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),
},
], ],
}, },
{ {
@ -397,8 +485,22 @@ async function saveUnit() {
async function loadExpenses() { async function loadExpenses() {
const qs = new URLSearchParams(); const qs = new URLSearchParams();
if (projectId.value) qs.set("project_id", String(projectId.value)); if (projectId.value) qs.set("project_id", String(projectId.value));
const r = await api<{ data?: { expenses?: Expense[] }; message?: string }>(`/v1/expenses?${qs}`); const r = await api<{ expenses?: Expense[]; data?: { expenses?: Expense[] }; message?: string }>(`/v1/expenses?${qs}`);
expenses.value = (r.data?.expenses ?? []) as Expense[]; 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() { async function save() {
@ -408,28 +510,29 @@ async function save() {
return; return;
} }
try { try {
const r = await api<{ data?: { expense?: { id: number }; budget_warning?: { level: string; message: string } }; message?: string }>( const r = editingId.value
"/v1/expenses", ? await api<{ data?: { expense?: { id: number }; budget_warning?: { level: string; message: string } }; message?: string }>(
{ `/v1/expenses/${editingId.value}`,
method: "POST", { method: "PATCH", json: payload() },
json: { )
...form, : await api<{ data?: { expense?: { id: number }; budget_warning?: { level: string; message: string } }; message?: string }>(
project_id: form.project_id || null, "/v1/expenses",
subtotal: computedAmounts.value.subtotal, { method: "POST", json: payload() },
total: computedAmounts.value.total, );
iva_rate: ivaRate.value, const expenseId = r.data?.expense?.id ?? editingId.value;
},
},
);
const expenseId = r.data?.expense?.id;
if (expenseId && receiptFile.value) { if (expenseId && receiptFile.value) {
const fd = new FormData(); const fd = new FormData();
fd.append("file", receiptFile.value); fd.append("file", receiptFile.value);
await fetch(`/v1/expenses/${expenseId}/attachments`, { method: "POST", body: fd, credentials: "include" }); await fetch(`/v1/expenses/${expenseId}/attachments`, { method: "POST", body: fd, credentials: "include" });
} }
budgetWarning.value = r.data?.budget_warning ?? null; budgetWarning.value = r.data?.budget_warning ?? null;
toast.add({ severity: "success", summary: r.message || "Gasto registrado", life: 2500 }); toast.add({
severity: "success",
summary: r.message || (editingId.value ? "Gasto actualizado" : "Gasto registrado"),
life: 2500,
});
showForm.value = false; showForm.value = false;
editingId.value = null;
await loadExpenses(); await loadExpenses();
if (route.query.nuevo || route.query.budget_item_id) { if (route.query.nuevo || route.query.budget_item_id) {
const qs = new URLSearchParams(); const qs = new URLSearchParams();
@ -453,6 +556,8 @@ watch(projectId, () => {
syncChrome(); syncChrome();
}); });
watch(selected, syncChrome);
watch( watch(
() => String(route.query.nuevo || ""), () => String(route.query.nuevo || ""),
(nuevo) => { (nuevo) => {