diff --git a/db/core/changelog-master.xml b/db/core/changelog-master.xml index acf04c1..68c42c2 100644 --- a/db/core/changelog-master.xml +++ b/db/core/changelog-master.xml @@ -39,5 +39,6 @@ + diff --git a/db/core/changesets/034-fn-expense-update-reclassify.sql b/db/core/changesets/034-fn-expense-update-reclassify.sql new file mode 100644 index 0000000..15e1c3b --- /dev/null +++ b/db/core/changesets/034-fn-expense-update-reclassify.sql @@ -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; diff --git a/web-panel/assets/css/desktop-v2.css b/web-panel/assets/css/desktop-v2.css index 3ea0639..aec53a5 100644 --- a/web-panel/assets/css/desktop-v2.css +++ b/web-panel/assets/css/desktop-v2.css @@ -2286,7 +2286,8 @@ html.dark .p-dialog .p-dialog-header-icon { line-height: 1.35; 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; } diff --git a/web-panel/pages/gastos.vue b/web-panel/pages/gastos.vue index deb25e3..168cc69 100644 --- a/web-panel/pages/gastos.vue +++ b/web-panel/pages/gastos.vue @@ -3,7 +3,9 @@ + + @@ -11,20 +13,28 @@ openEdit(e.data)" > - - {{ data.budget_item_code || "—" }} + + + + {{ data.budget_item_code || "—" }} + {{ money(data.subtotal) }} @@ -38,7 +48,7 @@ - + @@ -128,7 +138,7 @@ {{ formError }} - + @@ -164,7 +174,15 @@ type Expense = { 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; @@ -192,7 +210,15 @@ const unitError = ref(""); const budgetWarning = ref<{ level: string; message: string } | null>(null); const receiptFile = ref(null); const ivaRate = ref(0.16); +const editingId = ref(null); +const selected = ref(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, @@ -238,11 +264,16 @@ const projectLabel = computed(() => { }); const listed = computed(() => { const q = tableQ.value.toLowerCase(); - return expenses.value.filter((e) => - !q || [e.description, e.supplier_name, e.budget_item_code].join(" ").toLowerCase().includes(q), - ); + 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)); @@ -278,6 +309,10 @@ function onSupplierSaved(supplier: Supplier) { 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; @@ -294,10 +329,53 @@ function resetForm() { } 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", @@ -321,6 +399,16 @@ function syncChrome() { 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), + }, ], }, { @@ -397,8 +485,22 @@ async function saveUnit() { async function loadExpenses() { const qs = new URLSearchParams(); if (projectId.value) qs.set("project_id", String(projectId.value)); - const r = await api<{ data?: { expenses?: Expense[] }; message?: string }>(`/v1/expenses?${qs}`); - expenses.value = (r.data?.expenses ?? []) as Expense[]; + 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() { @@ -408,28 +510,29 @@ async function save() { return; } try { - const r = await api<{ data?: { expense?: { id: number }; budget_warning?: { level: string; message: string } }; message?: string }>( - "/v1/expenses", - { - method: "POST", - json: { - ...form, - project_id: form.project_id || null, - subtotal: computedAmounts.value.subtotal, - total: computedAmounts.value.total, - iva_rate: ivaRate.value, - }, - }, - ); - const expenseId = r.data?.expense?.id; + 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 || "Gasto registrado", life: 2500 }); + 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(); @@ -453,6 +556,8 @@ watch(projectId, () => { syncChrome(); }); +watch(selected, syncChrome); + watch( () => String(route.query.nuevo || ""), (nuevo) => {