Pre-computed merge for change #15

This commit is contained in:
Cursor Origin 1970-01-01 00:00:00 +00:00
commit ff04a6621a
14 changed files with 1441 additions and 92 deletions

View file

@ -233,6 +233,37 @@ Deno.test("RPC control presupuestal: summary, items, chapters, deviations", asyn
});
});
Deno.test("RPC catálogos: proveedores y unidades de medida", async () => {
await withTestDb(async (db) => {
const created = await callCoreFn(db, "core.fn_supplier_create", {
tenant_id: TENANT_ID,
name: "Home Depot",
rfc: "HDM123456ABC",
phone: "9980000000",
});
assertEquals(created.ok, true, `crear proveedor: ${JSON.stringify(created)}`);
const supplier = (created.data as { supplier: { id: number; rfc: string } }).supplier;
assertEquals(supplier.rfc, "HDM123456ABC");
const listed = await callCoreFn(db, "core.fn_supplier_list", { tenant_id: TENANT_ID, status: "activo" });
assertEquals(listed.ok, true);
const suppliers = (listed.data as { suppliers: { name: string }[] }).suppliers;
assert(suppliers.some((s) => s.name === "Home Depot"));
const units = await callCoreFn(db, "core.fn_measure_unit_list", { tenant_id: TENANT_ID });
assertEquals(units.ok, true);
const unitRows = (units.data as { units: { code: string }[] }).units;
assert(unitRows.some((u) => u.code === "bulto"));
const extra = await callCoreFn(db, "core.fn_measure_unit_add", {
tenant_id: TENANT_ID,
code: "tinaco",
label: "Tinaco",
});
assertEquals(extra.ok, true);
});
});
Deno.test("RPC IAM v2: roles y permisos por role_id", async () => {
const url = Deno.env.get("DATABASE_URL_IAM");
if (!url) throw new Error("DATABASE_URL_IAM requerido");

View file

@ -0,0 +1,67 @@
import type { Hono } from "hono";
import type { AuthUser } from "./auth.ts";
import { tenantScope } from "./auth.ts";
import { requireCoreAuth } from "./scope.ts";
import { requireAnyPermission } from "./permissions.ts";
import { callCoreFn } from "./rpc.ts";
import { respondRpc, routeLabel } from "./http_errors.ts";
type App = Hono<{ Variables: { user: AuthUser; db: Db } }>;
type Db = import("./db.ts").Db;
function tid(c: { get: (k: "user") => AuthUser }): number {
return tenantScope(c.get("user")) ?? 0;
}
export function registerExpenseCatalogRoutes(app: App) {
const view = [...requireCoreAuth, requireAnyPermission("expenses.view", "settings.view")] as const;
const write = [...requireCoreAuth, requireAnyPermission("expenses.create", "settings.update")] as const;
app.get("/v1/suppliers", ...view, async (c) => {
const db = c.get("db");
const env = await callCoreFn(db, "core.fn_supplier_list", {
tenant_id: tid(c),
status: c.req.query("status"),
}, { route: routeLabel(c) });
return respondRpc(c, env);
});
app.post("/v1/suppliers", ...write, async (c) => {
const body = await c.req.json<Record<string, unknown>>();
const db = c.get("db");
const env = await callCoreFn(db, "core.fn_supplier_create", {
...body,
tenant_id: tid(c),
}, { route: routeLabel(c) });
return respondRpc(c, env);
});
app.patch("/v1/suppliers/:id", ...write, async (c) => {
const body = await c.req.json<Record<string, unknown>>();
const db = c.get("db");
const env = await callCoreFn(db, "core.fn_supplier_update", {
...body,
id: Number(c.req.param("id")),
tenant_id: tid(c),
}, { route: routeLabel(c) });
return respondRpc(c, env);
});
app.get("/v1/measure-units", ...view, async (c) => {
const db = c.get("db");
const env = await callCoreFn(db, "core.fn_measure_unit_list", {
tenant_id: tid(c),
}, { route: routeLabel(c) });
return respondRpc(c, env);
});
app.post("/v1/measure-units", ...write, async (c) => {
const body = await c.req.json<Record<string, unknown>>();
const db = c.get("db");
const env = await callCoreFn(db, "core.fn_measure_unit_add", {
...body,
tenant_id: tid(c),
}, { route: routeLabel(c) });
return respondRpc(c, env);
});
}

View file

@ -65,6 +65,7 @@ import {
import { generateBadgePdf, saveJobPdf, loadCurrentPhoto, badgeQrPng } from "./pdf.ts";
import { registerPayrollRoutes } from "./payroll_http.ts";
import { registerExpenseRoutes } from "./expenses_http.ts";
import { registerExpenseCatalogRoutes } from "./expense_catalogs_http.ts";
import { registerWarehouseRoutes } from "./warehouse_http.ts";
import { registerCostControlRoutes } from "./cost_control_http.ts";
import { registerWorkProgramRoutes } from "./work_program_http.ts";
@ -1378,6 +1379,7 @@ app.patch("/v1/badge-jobs/:id/people/:workerId", ...requireCoreAuth, requirePerm
registerPayrollRoutes(app);
registerExpenseRoutes(app);
registerExpenseCatalogRoutes(app);
registerWarehouseRoutes(app);
registerCostControlRoutes(app);
registerWorkProgramRoutes(app);

View file

@ -38,5 +38,7 @@
<include file="changesets/029-schema-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/033-catalog-suppliers-units.sql" relativeToChangelogFile="true"/>
<include file="changesets/034-fn-expense-update-reclassify.sql" relativeToChangelogFile="true"/>
</databaseChangeLog>

View file

@ -0,0 +1,397 @@
--liquibase formatted sql
-- PANELS · core · catálogo de proveedores y unidades de medida (gastos)
--changeset panel:core-033a-suppliers-table endDelimiter:; splitStatements:true
--preconditions onFail:MARK_RAN
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='core' AND table_name='suppliers'
CREATE TABLE core.suppliers (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
tenant_id INTEGER NOT NULL,
name TEXT NOT NULL,
legal_name TEXT NOT NULL DEFAULT '',
rfc TEXT NOT NULL DEFAULT '',
email TEXT NOT NULL DEFAULT '',
phone TEXT NOT NULL DEFAULT '',
address TEXT NOT NULL DEFAULT '',
city TEXT NOT NULL DEFAULT '',
state TEXT NOT NULL DEFAULT '',
postal_code TEXT NOT NULL DEFAULT '',
notes TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'activo' CHECK (status IN ('activo', 'inactivo')),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX idx_core_suppliers_rfc
ON core.suppliers (tenant_id, rfc)
WHERE rfc <> '';
CREATE INDEX idx_core_suppliers_tenant_name ON core.suppliers (tenant_id, lower(name));
GRANT SELECT, INSERT, UPDATE, DELETE ON core.suppliers TO panels_core_app;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA core TO panels_core_app;
--changeset panel:core-033b-measure-units-table endDelimiter:; splitStatements:true
--preconditions onFail:MARK_RAN
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='core' AND table_name='measure_units'
CREATE TABLE core.measure_units (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
tenant_id INTEGER NOT NULL,
code TEXT NOT NULL,
label TEXT NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'activo' CHECK (status IN ('activo', 'inactivo')),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (tenant_id, code)
);
CREATE INDEX idx_core_measure_units_tenant ON core.measure_units (tenant_id);
GRANT SELECT, INSERT, UPDATE, DELETE ON core.measure_units TO panels_core_app;
--changeset panel:core-033c-expense-supplier-id endDelimiter:; splitStatements:true
ALTER TABLE core.expense_entries
ADD COLUMN IF NOT EXISTS supplier_id BIGINT REFERENCES core.suppliers(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS idx_core_expense_entries_supplier ON core.expense_entries (supplier_id);
--changeset panel:core-033d-rls-suppliers-units endDelimiter:; splitStatements:true
ALTER TABLE core.suppliers ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON core.suppliers
USING (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::integer)
WITH CHECK (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::integer);
ALTER TABLE core.measure_units ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON core.measure_units
USING (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::integer)
WITH CHECK (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::integer);
--changeset panel:core-033e-fn-measure-units-seed splitStatements:false
CREATE OR REPLACE FUNCTION core._measure_units_seed(p_tenant_id integer)
RETURNS void
LANGUAGE plpgsql
SET search_path = core
AS $$
BEGIN
IF p_tenant_id IS NULL OR p_tenant_id <= 0 THEN
RETURN;
END IF;
IF EXISTS (SELECT 1 FROM measure_units WHERE tenant_id = p_tenant_id) THEN
RETURN;
END IF;
INSERT INTO measure_units (tenant_id, code, label, sort_order) VALUES
(p_tenant_id, 'pza', 'Pieza', 10),
(p_tenant_id, 'm', 'Metro', 20),
(p_tenant_id, 'ml', 'Metro lineal', 30),
(p_tenant_id, 'm2', 'Metro cuadrado', 40),
(p_tenant_id, 'm3', 'Metro cúbico', 50),
(p_tenant_id, 'kg', 'Kilogramo', 60),
(p_tenant_id, 'ton', 'Tonelada', 70),
(p_tenant_id, 'lt', 'Litro', 80),
(p_tenant_id, 'gl', 'Galón', 90),
(p_tenant_id, 'bulto', 'Bulto', 100),
(p_tenant_id, 'saco', 'Saco', 110),
(p_tenant_id, 'jgo', 'Juego', 120),
(p_tenant_id, 'lote', 'Lote', 130),
(p_tenant_id, 'par', 'Par', 140),
(p_tenant_id, 'hr', 'Hora', 150),
(p_tenant_id, 'dia', 'Día', 160),
(p_tenant_id, 'via', 'Viaje', 170);
END;
$$;
--changeset panel:core-033f-fn-supplier-list splitStatements:false
CREATE OR REPLACE FUNCTION core.fn_supplier_list(payload jsonb)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path = core
AS $$
DECLARE
v_tid integer := core._cost_tenant_id(payload);
v_status text := nullif(btrim(payload->>'status'), '');
v_rows jsonb;
BEGIN
SELECT COALESCE(jsonb_agg(to_jsonb(s) ORDER BY lower(s.name), s.id), '[]'::jsonb)
INTO v_rows
FROM suppliers s
WHERE s.tenant_id = v_tid
AND (v_status IS NULL OR s.status = v_status);
RETURN core.rpc_ok(
jsonb_build_object('suppliers', v_rows, 'count', jsonb_array_length(v_rows)),
format('Catálogo de proveedores: %s registro(s) para tenant %s', jsonb_array_length(v_rows), v_tid),
jsonb_build_object('fn', 'fn_supplier_list', 'tenant_id', v_tid, 'count', jsonb_array_length(v_rows))
);
EXCEPTION WHEN OTHERS THEN
RETURN core.rpc_from_exception('fn_supplier_list', SQLSTATE, SQLERRM);
END;
$$;
--changeset panel:core-033g-fn-supplier-create splitStatements:false
CREATE OR REPLACE FUNCTION core.fn_supplier_create(payload jsonb)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path = core
AS $$
DECLARE
v_tid integer := core._cost_tenant_id(payload);
v_name text := coalesce(nullif(btrim(payload->>'name'), ''), '');
v_legal text := coalesce(nullif(btrim(payload->>'legal_name'), ''), '');
v_rfc text := upper(regexp_replace(coalesce(payload->>'rfc', ''), '\s+', '', 'g'));
v_email text := coalesce(nullif(btrim(payload->>'email'), ''), '');
v_phone text := coalesce(nullif(btrim(payload->>'phone'), ''), '');
v_address text := coalesce(nullif(btrim(payload->>'address'), ''), '');
v_city text := coalesce(nullif(btrim(payload->>'city'), ''), '');
v_state text := coalesce(nullif(btrim(payload->>'state'), ''), '');
v_zip text := coalesce(nullif(btrim(payload->>'postal_code'), ''), '');
v_notes text := coalesce(nullif(btrim(payload->>'notes'), ''), '');
v_id bigint;
v_row jsonb;
BEGIN
IF v_name = '' THEN
RETURN core.rpc_err('VALIDATION', 'fn_supplier_create: el nombre del proveedor es obligatorio',
jsonb_build_object('fn', 'fn_supplier_create', 'field', 'name'));
END IF;
IF v_rfc <> '' AND char_length(v_rfc) NOT IN (12, 13) THEN
RETURN core.rpc_err('VALIDATION', format('fn_supplier_create: RFC %s no tiene 12 o 13 caracteres', v_rfc),
jsonb_build_object('fn', 'fn_supplier_create', 'field', 'rfc'));
END IF;
INSERT INTO suppliers (
tenant_id, name, legal_name, rfc, email, phone, address, city, state, postal_code, notes
) VALUES (
v_tid, v_name, v_legal, v_rfc, v_email, v_phone, v_address, v_city, v_state, v_zip, v_notes
) RETURNING id INTO v_id;
SELECT to_jsonb(s) INTO v_row FROM suppliers s WHERE id = v_id;
RETURN core.rpc_created(
jsonb_build_object('supplier', v_row),
format('Proveedor %s registrado', v_name),
jsonb_build_object('fn', 'fn_supplier_create', 'id', v_id, 'tenant_id', v_tid)
);
EXCEPTION
WHEN unique_violation THEN
RETURN core.rpc_err('CONFLICT', format('Ya existe un proveedor con RFC %s en este tenant', v_rfc),
jsonb_build_object('fn', 'fn_supplier_create', 'rfc', v_rfc));
WHEN OTHERS THEN
RETURN core.rpc_from_exception('fn_supplier_create', SQLSTATE, SQLERRM);
END;
$$;
--changeset panel:core-033h-fn-supplier-update splitStatements:false
CREATE OR REPLACE FUNCTION core.fn_supplier_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_rfc text;
v_row jsonb;
BEGIN
IF v_id IS NULL THEN
RETURN core.rpc_err('VALIDATION', 'fn_supplier_update: id es obligatorio',
jsonb_build_object('fn', 'fn_supplier_update', 'field', 'id'));
END IF;
IF NOT EXISTS (SELECT 1 FROM suppliers WHERE id = v_id AND tenant_id = v_tid) THEN
RETURN core.rpc_err('NOT_FOUND', format('fn_supplier_update: proveedor id=%s no encontrado', v_id),
jsonb_build_object('fn', 'fn_supplier_update', 'id', v_id));
END IF;
v_rfc := upper(regexp_replace(coalesce(payload->>'rfc', ''), '\s+', '', 'g'));
IF payload ? 'rfc' AND v_rfc <> '' AND char_length(v_rfc) NOT IN (12, 13) THEN
RETURN core.rpc_err('VALIDATION', format('fn_supplier_update: RFC %s no tiene 12 o 13 caracteres', v_rfc),
jsonb_build_object('fn', 'fn_supplier_update', 'field', 'rfc'));
END IF;
UPDATE suppliers SET
name = CASE WHEN payload ? 'name' THEN coalesce(nullif(btrim(payload->>'name'), ''), name) ELSE name END,
legal_name = CASE WHEN payload ? 'legal_name' THEN coalesce(nullif(btrim(payload->>'legal_name'), ''), '') ELSE legal_name END,
rfc = CASE WHEN payload ? 'rfc' THEN v_rfc ELSE rfc END,
email = CASE WHEN payload ? 'email' THEN coalesce(nullif(btrim(payload->>'email'), ''), '') ELSE email END,
phone = CASE WHEN payload ? 'phone' THEN coalesce(nullif(btrim(payload->>'phone'), ''), '') ELSE phone END,
address = CASE WHEN payload ? 'address' THEN coalesce(nullif(btrim(payload->>'address'), ''), '') ELSE address END,
city = CASE WHEN payload ? 'city' THEN coalesce(nullif(btrim(payload->>'city'), ''), '') ELSE city END,
state = CASE WHEN payload ? 'state' THEN coalesce(nullif(btrim(payload->>'state'), ''), '') ELSE state END,
postal_code = CASE WHEN payload ? 'postal_code' THEN coalesce(nullif(btrim(payload->>'postal_code'), ''), '') ELSE postal_code END,
notes = CASE WHEN payload ? 'notes' THEN coalesce(nullif(btrim(payload->>'notes'), ''), '') ELSE notes END,
status = CASE WHEN payload ? 'status' THEN coalesce(nullif(btrim(payload->>'status'), ''), status) ELSE status END,
updated_at = now()
WHERE id = v_id AND tenant_id = v_tid;
SELECT to_jsonb(s) INTO v_row FROM suppliers s WHERE id = v_id;
RETURN core.rpc_ok(
jsonb_build_object('supplier', v_row),
format('Proveedor id=%s actualizado', v_id),
jsonb_build_object('fn', 'fn_supplier_update', 'id', v_id)
);
EXCEPTION
WHEN unique_violation THEN
RETURN core.rpc_err('CONFLICT', 'Ya existe otro proveedor con ese RFC en este tenant',
jsonb_build_object('fn', 'fn_supplier_update', 'id', v_id));
WHEN OTHERS THEN
RETURN core.rpc_from_exception('fn_supplier_update', SQLSTATE, SQLERRM);
END;
$$;
--changeset panel:core-033i-fn-measure-unit-list splitStatements:false
CREATE OR REPLACE FUNCTION core.fn_measure_unit_list(payload jsonb)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path = core
AS $$
DECLARE
v_tid integer := core._cost_tenant_id(payload);
v_rows jsonb;
BEGIN
PERFORM core._measure_units_seed(v_tid);
SELECT COALESCE(jsonb_agg(to_jsonb(u) ORDER BY u.sort_order, u.code), '[]'::jsonb)
INTO v_rows
FROM measure_units u
WHERE u.tenant_id = v_tid
AND u.status = 'activo';
RETURN core.rpc_ok(
jsonb_build_object('units', v_rows, 'count', jsonb_array_length(v_rows)),
format('Unidades de medida del tenant %s: %s registro(s)', v_tid, jsonb_array_length(v_rows)),
jsonb_build_object('fn', 'fn_measure_unit_list', 'tenant_id', v_tid, 'count', jsonb_array_length(v_rows))
);
EXCEPTION WHEN OTHERS THEN
RETURN core.rpc_from_exception('fn_measure_unit_list', SQLSTATE, SQLERRM);
END;
$$;
--changeset panel:core-033j-fn-measure-unit-add splitStatements:false
CREATE OR REPLACE FUNCTION core.fn_measure_unit_add(payload jsonb)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path = core
AS $$
DECLARE
v_tid integer := core._cost_tenant_id(payload);
v_code text := lower(regexp_replace(btrim(COALESCE(payload->>'code', '')), '\s+', '', 'g'));
v_label text := COALESCE(NULLIF(btrim(payload->>'label'), ''), v_code);
v_id bigint;
v_row jsonb;
BEGIN
IF v_code = '' THEN
RETURN core.rpc_err('VALIDATION', 'fn_measure_unit_add: el código de unidad es obligatorio',
jsonb_build_object('fn', 'fn_measure_unit_add', 'field', 'code'));
END IF;
PERFORM core._measure_units_seed(v_tid);
INSERT INTO measure_units (tenant_id, code, label, sort_order)
VALUES (v_tid, v_code, v_label, 500)
RETURNING id INTO v_id;
SELECT to_jsonb(u) INTO v_row FROM measure_units u WHERE id = v_id;
RETURN core.rpc_created(
jsonb_build_object('unit', v_row),
format('Unidad de medida %s (%s) agregada', v_label, v_code),
jsonb_build_object('fn', 'fn_measure_unit_add', 'id', v_id, 'tenant_id', v_tid)
);
EXCEPTION
WHEN unique_violation THEN
RETURN core.rpc_err('CONFLICT', format('Ya existe la unidad %s en este tenant', v_code),
jsonb_build_object('fn', 'fn_measure_unit_add', 'code', v_code));
WHEN OTHERS THEN
RETURN core.rpc_from_exception('fn_measure_unit_add', SQLSTATE, SQLERRM);
END;
$$;
--changeset panel:core-033k-fn-expense-create-supplier splitStatements:false
CREATE OR REPLACE FUNCTION core.fn_expense_create(payload jsonb)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path = core
AS $$
DECLARE
v_tid integer := core._cost_tenant_id(payload);
v_company_id bigint := NULLIF(payload->>'company_id', '')::bigint;
v_project_id bigint := NULLIF(payload->>'project_id', '')::bigint;
v_expense_date date := COALESCE(NULLIF(payload->>'expense_date', '')::date, CURRENT_DATE);
v_description text := coalesce(nullif(btrim(payload->>'description'), ''), '');
v_budget_item_id bigint := NULLIF(payload->>'budget_item_id', '')::bigint;
v_budget_chapter_id bigint := NULLIF(payload->>'budget_chapter_id', '')::bigint;
v_quantity numeric := COALESCE((payload->>'quantity')::numeric, 0);
v_unit text := coalesce(nullif(btrim(payload->>'unit'), ''), '');
v_unit_price numeric := COALESCE((payload->>'unit_price')::numeric, 0);
v_subtotal_in numeric := COALESCE((payload->>'subtotal')::numeric, 0);
v_iva_rate numeric;
v_tax_included boolean := COALESCE((payload->>'tax_included')::boolean, false);
v_total_in numeric := (payload->>'total')::numeric;
v_subtotal numeric; v_iva_amount numeric; v_total numeric;
v_is_deductible boolean := COALESCE((payload->>'is_deductible')::boolean, true);
v_counts_toward_budget boolean := COALESCE((payload->>'counts_toward_budget')::boolean, true);
v_supplier_id bigint := NULLIF(payload->>'supplier_id', '')::bigint;
v_supplier_name text := coalesce(nullif(btrim(payload->>'supplier_name'), ''), '');
v_supplier_rfc text := upper(coalesce(nullif(btrim(payload->>'supplier_rfc'), ''), ''));
v_notes text := coalesce(nullif(btrim(payload->>'notes'), ''), '');
v_created_by_id integer := NULLIF(payload->>'created_by_id', '')::integer;
v_created_by_name text := coalesce(nullif(btrim(payload->>'created_by_name'), ''), '');
v_id bigint;
v_warning jsonb;
v_row jsonb;
BEGIN
IF v_company_id IS NULL THEN
RETURN core.rpc_err('VALIDATION', 'fn_expense_create: company_id es obligatorio',
jsonb_build_object('fn', 'fn_expense_create', 'field', 'company_id'));
END IF;
IF v_description = '' THEN
RETURN core.rpc_err('VALIDATION', 'fn_expense_create: description es obligatoria',
jsonb_build_object('fn', 'fn_expense_create', 'field', 'description'));
END IF;
IF NOT EXISTS (SELECT 1 FROM companies WHERE id = v_company_id) THEN
RETURN core.rpc_err('VALIDATION', format('fn_expense_create: empresa id=%s no encontrada', v_company_id),
jsonb_build_object('fn', 'fn_expense_create', 'company_id', v_company_id));
END IF;
IF v_project_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM projects WHERE id = v_project_id) THEN
RETURN core.rpc_err('NOT_FOUND', format('fn_expense_create: proyecto id=%s no encontrado', v_project_id),
jsonb_build_object('fn', 'fn_expense_create', 'project_id', v_project_id));
END IF;
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_create: proveedor id=%s no encontrado', v_supplier_id),
jsonb_build_object('fn', 'fn_expense_create', 'supplier_id', v_supplier_id));
END IF;
END IF;
IF v_subtotal_in <= 0 AND v_quantity > 0 AND v_unit_price > 0 THEN
v_subtotal_in := round(v_quantity * v_unit_price, 2);
END IF;
SELECT default_iva_rate INTO v_iva_rate FROM core._cost_settings(v_tid);
v_iva_rate := COALESCE((payload->>'iva_rate')::numeric, v_iva_rate);
SELECT c.subtotal, c.iva_amount, c.total INTO v_subtotal, v_iva_amount, v_total
FROM core._cost_calc_amounts(v_subtotal_in, v_iva_rate, v_tax_included, v_total_in) c;
IF v_budget_item_id IS NOT NULL THEN
SELECT chapter_id INTO v_budget_chapter_id FROM budget_items WHERE id = v_budget_item_id;
END IF;
INSERT INTO expense_entries (
tenant_id, company_id, project_id, expense_date, source, description,
supplier_id, supplier_name, supplier_rfc, budget_chapter_id, budget_item_id,
quantity, unit, unit_price, subtotal, iva_rate, iva_amount, total,
tax_included, is_deductible, counts_toward_budget, status,
created_by_id, created_by_name, notes
) VALUES (
v_tid, v_company_id, v_project_id, v_expense_date, 'manual', v_description,
v_supplier_id, v_supplier_name, v_supplier_rfc, v_budget_chapter_id, v_budget_item_id,
v_quantity, v_unit, v_unit_price, v_subtotal, v_iva_rate, v_iva_amount, v_total,
v_tax_included, v_is_deductible, v_counts_toward_budget, 'confirmed',
v_created_by_id, v_created_by_name, v_notes
) RETURNING id INTO v_id;
IF v_counts_toward_budget AND v_budget_item_id IS NOT NULL THEN
v_warning := core._cost_budget_warning(v_tid, v_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_created(
jsonb_build_object('expense', v_row, 'budget_warning', v_warning),
format('Gasto registrado por %s (subtotal %s, IVA %s)', v_description, v_subtotal, v_iva_amount),
jsonb_build_object('fn', 'fn_expense_create', 'id', v_id)
);
EXCEPTION WHEN OTHERS THEN
RETURN core.rpc_from_exception('fn_expense_create', SQLSTATE, SQLERRM);
END;
$$;
--changeset panel:core-033l-grants endDelimiter:; splitStatements:true
GRANT EXECUTE ON FUNCTION core._measure_units_seed(integer) TO panels_core_app;
GRANT EXECUTE ON FUNCTION core.fn_supplier_list(jsonb) TO panels_core_app;
GRANT EXECUTE ON FUNCTION core.fn_supplier_create(jsonb) TO panels_core_app;
GRANT EXECUTE ON FUNCTION core.fn_supplier_update(jsonb) TO panels_core_app;
GRANT EXECUTE ON FUNCTION core.fn_measure_unit_list(jsonb) TO panels_core_app;
GRANT EXECUTE ON FUNCTION core.fn_measure_unit_add(jsonb) TO panels_core_app;
GRANT EXECUTE ON FUNCTION core.fn_expense_create(jsonb) TO panels_core_app;

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

@ -1844,7 +1844,8 @@ html.dark .p-tieredmenu,
html.dark .p-popover,
html.dark .p-datepicker,
html.dark .p-select-overlay,
html.dark .p-multiselect-overlay {
html.dark .p-multiselect-overlay,
html.dark .p-autocomplete-overlay {
--p-content-background: #111b2e;
}
@ -2165,7 +2166,8 @@ html.dark .p-select-label.p-placeholder {
html.dark .p-select-overlay,
html.dark .p-datepicker-panel,
html.dark .p-multiselect-overlay {
html.dark .p-multiselect-overlay,
html.dark .p-autocomplete-overlay {
background: #111b2e !important;
border-color: #243044 !important;
color: #e2e8f0 !important;
@ -2237,6 +2239,58 @@ html.dark .p-dialog .p-dialog-header-icon {
.cost-critical { background: #ffebee !important; color: #b42318 !important; }
.cost-critical-text { color: #b42318; font-weight: 600; }
.budget-item-picker-overlay.p-select-overlay,
.budget-item-picker-overlay.p-autocomplete-overlay,
.budget-item-picker-overlay {
overflow-x: hidden;
box-sizing: border-box;
}
.budget-item-picker-overlay .p-select-list,
.budget-item-picker-overlay .p-autocomplete-list,
.budget-item-picker-overlay ul {
width: 100%;
margin: 0;
padding: 4px 0;
list-style: none;
}
.budget-item-picker-overlay .p-select-option,
.budget-item-picker-overlay .p-autocomplete-option,
.budget-item-picker-overlay li {
white-space: normal;
overflow-wrap: anywhere;
align-items: flex-start;
}
.budget-item-picker .budget-item-value {
display: flex;
align-items: baseline;
gap: 8px;
min-width: 0;
max-width: 100%;
}
.budget-item-picker .budget-item-value-desc {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: #5b6573;
}
.budget-item-option {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
max-width: 100%;
}
.budget-item-option-desc {
color: #5b6573;
font-size: 12px;
line-height: 1.35;
overflow-wrap: anywhere;
}
html.dark .budget-item-option-desc,
html.dark .budget-item-picker .budget-item-value-desc {
color: #94a3b8;
}
/* Grouped menubar: single row, icons, card-style submenus. */
.menubar.p-menubar {
display: flex;

View file

@ -1,14 +1,35 @@
<template>
<AutoComplete
v-model="selected"
:suggestions="filtered"
<Select
:model-value="modelValue ?? null"
:options="items"
option-label="label"
option-value="id"
placeholder="Buscar concepto del presupuesto..."
class="w-full"
force-selection
@complete="onSearch"
@item-select="onSelect"
/>
filter
filter-placeholder="Clave o descripción"
show-clear
fluid
class="w-full budget-item-picker"
overlay-class="budget-item-picker-overlay"
panel-class="budget-item-picker-overlay"
empty-filter-message="Sin conceptos que coincidan"
empty-message="Esta obra no tiene partidas cargadas"
@update:model-value="onUpdate"
>
<template #value="{ value, placeholder }">
<span v-if="selected" class="budget-item-value">
<span class="identifier">{{ selected.code || "—" }}</span>
<span class="budget-item-value-desc">{{ selected.description }}</span>
</span>
<span v-else class="p-select-placeholder">{{ placeholder }}</span>
</template>
<template #option="{ option }">
<div class="budget-item-option">
<span class="identifier">{{ option.code || "—" }}</span>
<span class="budget-item-option-desc">{{ option.description }}</span>
</div>
</template>
</Select>
</template>
<script setup lang="ts">
@ -29,8 +50,8 @@ type BudgetOption = {
const { api } = useApi();
const items = ref<BudgetOption[]>([]);
const selected = ref<BudgetOption | null>(null);
const filtered = ref<BudgetOption[]>([]);
const selected = computed(() => items.value.find((i) => i.id === props.modelValue) ?? null);
async function load() {
if (!props.projectId) {
@ -49,25 +70,13 @@ async function load() {
amount: Number(row.amount ?? 0),
label: [row.code, row.description].filter(Boolean).join(" — "),
}));
filtered.value = items.value.slice(0, 30);
}
function onSearch(event: { query: string }) {
const q = event.query.toLowerCase();
filtered.value = items.value.filter((i) => i.label.toLowerCase().includes(q)).slice(0, 30);
}
function onSelect(event: { value: BudgetOption }) {
emit("update:modelValue", event.value?.id ?? null);
emit("select", event.value ?? null);
function onUpdate(id: number | null) {
const option = id == null ? null : items.value.find((i) => i.id === id) ?? null;
emit("update:modelValue", option?.id ?? null);
emit("select", option);
}
watch(() => props.projectId, load, { immediate: true });
watch(
() => props.modelValue,
(id) => {
if (!id) selected.value = null;
else selected.value = items.value.find((i) => i.id === id) ?? null;
},
);
</script>

View file

@ -0,0 +1,129 @@
<template>
<Dialog
v-model:visible="visible"
:header="supplierId ? 'Editar proveedor' : 'Nuevo proveedor'"
modal
:style="{ width: '640px' }"
>
<div class="form-grid form-grid-compact">
<FormField label="Nombre comercial" required>
<InputText v-model="form.name" class="w-full" placeholder="Home Depot" />
</FormField>
<FormField label="Razón social">
<InputText v-model="form.legal_name" class="w-full" />
</FormField>
<FormField label="RFC">
<InputText v-model="form.rfc" class="w-full identifier-input" maxlength="13" placeholder="XAXX010101000" />
</FormField>
<FormField label="Teléfono">
<InputText v-model="form.phone" class="w-full" />
</FormField>
<FormField label="Correo">
<InputText v-model="form.email" class="w-full no-caps" />
</FormField>
<FormField label="Ciudad">
<InputText v-model="form.city" class="w-full" />
</FormField>
<FormField label="Domicilio" class="span-2">
<InputText v-model="form.address" class="w-full" />
</FormField>
<FormField label="Notas" class="span-2">
<Textarea v-model="form.notes" class="w-full" rows="2" auto-resize />
</FormField>
</div>
<Message v-if="error" severity="error" class="mt-3" :closable="false">{{ error }}</Message>
<template #footer>
<Button label="Cancelar" text @click="visible = false" />
<Button label="Guardar" icon="pi pi-check" :loading="saving" @click="save" />
</template>
</Dialog>
</template>
<script setup lang="ts">
import FormField from "~/components/padron/FormField.vue";
import { displayErrorMessage } from "~/composables/api-response";
export type Supplier = {
id: number;
name: string;
legal_name?: string;
rfc?: string;
email?: string;
phone?: string;
address?: string;
city?: string;
state?: string;
postal_code?: string;
notes?: string;
status?: string;
};
const props = defineProps<{
modelValue: boolean;
supplier?: Supplier | null;
}>();
const emit = defineEmits<{
"update:modelValue": [boolean];
saved: [Supplier];
}>();
const { api } = useApi();
const visible = computed({
get: () => props.modelValue,
set: (v: boolean) => emit("update:modelValue", v),
});
const empty = () => ({
name: "",
legal_name: "",
rfc: "",
email: "",
phone: "",
address: "",
city: "",
notes: "",
});
const form = reactive(empty());
const error = ref("");
const saving = ref(false);
const supplierId = computed(() => props.supplier?.id ?? null);
watch(
() => [props.modelValue, props.supplier] as const,
([open, supplier]) => {
if (!open) return;
error.value = "";
Object.assign(form, empty(), supplier || {});
},
);
async function save() {
error.value = "";
if (!form.name.trim()) {
error.value = "El nombre del proveedor es obligatorio";
return;
}
saving.value = true;
try {
const payload = { ...form, rfc: form.rfc.trim().toUpperCase() };
const r = supplierId.value
? await api<{ supplier?: Supplier; data?: { supplier?: Supplier } }>(`/v1/suppliers/${supplierId.value}`, {
method: "PATCH",
json: payload,
})
: await api<{ supplier?: Supplier; data?: { supplier?: Supplier } }>("/v1/suppliers", {
method: "POST",
json: payload,
});
const saved = r.supplier ?? r.data?.supplier;
if (!saved) throw new Error("No se recibió el proveedor guardado");
emit("saved", saved);
visible.value = false;
} catch (e) {
error.value = displayErrorMessage((e as { data?: unknown }).data ?? e);
} finally {
saving.value = false;
}
}
</script>

View file

@ -0,0 +1,21 @@
export function roundMoney(value: number) {
return Math.round((Number(value) || 0) * 100) / 100;
}
export function expenseAmounts(
quantity: number,
unitPrice: number,
taxIncluded: boolean,
ivaRate = 0.16,
) {
const line = roundMoney(quantity * unitPrice);
const rate = Number(ivaRate) || 0.16;
if (taxIncluded) {
const total = line;
const subtotal = roundMoney(total / (1 + rate));
return { subtotal, ivaAmount: roundMoney(total - subtotal), total };
}
const subtotal = line;
const ivaAmount = roundMoney(subtotal * rate);
return { subtotal, ivaAmount, total: roundMoney(subtotal + ivaAmount) };
}

View file

@ -87,7 +87,7 @@ export function useDesktop() {
}) {
tabLabel.value = opts.tab;
if (opts.treeTitle != null) treeTitle.value = opts.treeTitle;
if (opts.ribbon) ribbon.value = opts.ribbon;
ribbon.value = opts.ribbon ?? [];
if (opts.tree) tree.value = opts.tree;
if (opts.treeKey != null) treeKey.value = opts.treeKey;
if (opts.showTree != null) showTree.value = opts.showTree;

View file

@ -4,12 +4,16 @@
<Chip v-if="tab === 'empresas'" :label="`${listedCompanies.length} empresas`" icon="pi pi-briefcase" />
<Chip v-if="tab === 'empresas'" :label="`${subCompanies} subempresas`" icon="pi pi-th-large" />
<Chip v-if="tab === 'nomina'" :label="`${units.length} unidades`" icon="pi pi-percentage" />
<Chip v-if="tab === 'proveedores'" :label="`${listedSuppliers.length} proveedores`" icon="pi pi-truck" />
<Chip v-if="tab === 'unidades'" :label="`${listedMeasureUnits.length} unidades`" icon="pi pi-box" />
<Chip v-if="tab === 'riesgos'" :label="`${listedRisks.length} riesgos`" icon="pi pi-exclamation-triangle" />
<Chip v-if="tab === 'temas'" :label="`${listedThemes.length} temas`" icon="pi pi-palette" />
</DeskTableBar>
<Tabs v-model:value="tab">
<TabList>
<Tab value="empresas">Empresas</Tab>
<Tab value="proveedores">Proveedores</Tab>
<Tab value="unidades">Unidades</Tab>
<Tab value="nomina">Nómina</Tab>
<Tab value="riesgos">Riesgos</Tab>
<Tab value="temas">Temas de gafete</Tab>
@ -74,6 +78,7 @@
</div>
</div>
<h3 class="mt-4">Unidades de destajo</h3>
<p class="muted">Solo para destajo de nómina. Las unidades de gastos y presupuesto están en <strong>Unidades</strong>.</p>
<DataTable :value="units" class="mt-2" empty-message="Sin unidades">
<Column field="code" header="Código" />
<Column field="label" header="Nombre" />
@ -90,6 +95,51 @@
</div>
</div>
</TabPanel>
<TabPanel value="proveedores">
<Message severity="info" :closable="false">
Catálogo de proveedores para capturar gastos. En Gastos solo se selecciona; aquí se guardan nombre, RFC y datos generales.
</Message>
<DataTable :value="listedSuppliers" class="mt-3" data-key="id" paginator :rows="15" empty-message="No hay proveedores. Use Nuevo proveedor.">
<Column field="name" header="Nombre" />
<Column field="legal_name" header="Razón social">
<template #body="{ data }">{{ data.legal_name || "—" }}</template>
</Column>
<Column field="rfc" header="RFC">
<template #body="{ data }"><span class="identifier">{{ data.rfc || "—" }}</span></template>
</Column>
<Column field="phone" header="Teléfono">
<template #body="{ data }">{{ data.phone || "—" }}</template>
</Column>
<Column field="email" header="Correo">
<template #body="{ data }">{{ data.email || "—" }}</template>
</Column>
<Column header="" header-style="width: 5rem">
<template #body="{ data }">
<Button icon="pi pi-pencil" text rounded size="small" aria-label="Editar" @click="openSupplier(data)" />
</template>
</Column>
</DataTable>
</TabPanel>
<TabPanel value="unidades">
<Message severity="info" :closable="false">
Unidades de medida para gastos (pieza, m², bulto…). Distintas de las unidades de destajo de nómina.
</Message>
<DataTable :value="listedMeasureUnits" class="mt-3" data-key="code" paginator :rows="15" empty-message="Sin unidades">
<Column field="code" header="Código" />
<Column field="label" header="Nombre" />
</DataTable>
<div class="company-form-grid mt-3" style="max-width: 36rem">
<FormField label="Código">
<InputText v-model="measureUnitForm.code" class="w-full" placeholder="bulto" />
</FormField>
<FormField label="Nombre">
<InputText v-model="measureUnitForm.label" class="w-full" placeholder="Bulto" />
</FormField>
<div>
<Button label="Agregar unidad" icon="pi pi-plus" outlined @click="addMeasureUnit" />
</div>
</div>
</TabPanel>
<TabPanel value="riesgos">
<DataTable :value="listedRisks" paginator :rows="15">
<Column field="code" header="Barra gafete" />
@ -215,12 +265,15 @@
<Button v-if="formTab === 'datos'" :label="editing ? 'Guardar' : 'Crear'" icon="pi pi-check" :loading="saving" @click="save" />
</template>
</Dialog>
<SupplierFormDialog v-model="showSupplierForm" :supplier="editingSupplier" @saved="onSupplierSaved" />
</div>
</template>
<script setup lang="ts">
import FormField from "~/components/padron/FormField.vue";
import CompanyDocuments from "~/components/CompanyDocuments.vue";
import SupplierFormDialog, { type Supplier } from "~/components/SupplierFormDialog.vue";
import type { CompanyOption } from "~/types/padron";
const emptyForm = () => ({
@ -258,6 +311,11 @@ const payroll = reactive({
});
const units = ref<{ code: string; label: string }[]>([]);
const unitForm = reactive({ code: "", label: "" });
const suppliers = ref<Supplier[]>([]);
const measureUnits = ref<{ code: string; label: string }[]>([]);
const measureUnitForm = reactive({ code: "", label: "" });
const showSupplierForm = ref(false);
const editingSupplier = ref<Supplier | null>(null);
const savingPayroll = ref(false);
const showForm = ref(false);
const saving = ref(false);
@ -293,7 +351,17 @@ const claseRiesgoOpts = [
const principal = computed(() => companies.value.find((company) => company.kind === "principal"));
const { tableQ, matches } = useTableSearch();
const searchPlaceholder = computed(() =>
tab.value === "riesgos" ? "Buscar riesgo o color" : tab.value === "temas" ? "Buscar tema" : tab.value === "nomina" ? "Nómina y destajo" : "Buscar empresa, RFC o código",
tab.value === "riesgos"
? "Buscar riesgo o color"
: tab.value === "temas"
? "Buscar tema"
: tab.value === "nomina"
? "Nómina y destajo"
: tab.value === "proveedores"
? "Buscar proveedor o RFC"
: tab.value === "unidades"
? "Buscar unidad"
: "Buscar empresa, RFC o código",
);
const listedCompanies = computed(() =>
companies.value.filter((company) =>
@ -315,6 +383,12 @@ const listedCompanies = computed(() =>
const subCompanies = computed(() => listedCompanies.value.filter((company) => company.kind !== "principal").length);
const listedRisks = computed(() => risks.value.filter((risk) => matches(risk.code, risk.label, risk.color)));
const listedThemes = computed(() => themes.value.filter((theme) => matches(theme.id, theme.name, theme.layout)));
const listedSuppliers = computed(() =>
suppliers.value.filter((s) => matches(s.name, s.legal_name, s.rfc, s.email, s.phone, s.city)),
);
const listedMeasureUnits = computed(() =>
measureUnits.value.filter((u) => matches(u.code, u.label)),
);
function regimenLabel(code?: string | null) {
if (!code) return "—";
@ -344,6 +418,18 @@ async function load() {
payroll.loan_commission_pct = Number(pay.settings.loan_commission_pct);
payroll.loan_small_max = Number(pay.settings.loan_small_max);
units.value = pay.units || [];
try {
const sup = await api<{ suppliers?: Supplier[]; data?: { suppliers?: Supplier[] } }>("/v1/suppliers");
suppliers.value = sup.suppliers ?? sup.data?.suppliers ?? [];
} catch {
suppliers.value = [];
}
try {
const mu = await api<{ units?: { code: string; label: string }[]; data?: { units?: { code: string; label: string }[] } }>("/v1/measure-units");
measureUnits.value = mu.units ?? mu.data?.units ?? [];
} catch {
measureUnits.value = [];
}
syncChrome();
}
@ -367,6 +453,30 @@ async function addUnit() {
await load();
}
async function addMeasureUnit() {
if (!measureUnitForm.code.trim()) return;
await api("/v1/measure-units", { method: "POST", json: { code: measureUnitForm.code, label: measureUnitForm.label } });
measureUnitForm.code = "";
measureUnitForm.label = "";
toast.add({ severity: "success", summary: "Unidad de medida agregada", life: 1600 });
await load();
}
function openNewSupplier() {
editingSupplier.value = null;
showSupplierForm.value = true;
}
function openSupplier(row: Supplier) {
editingSupplier.value = row;
showSupplierForm.value = true;
}
async function onSupplierSaved() {
toast.add({ severity: "success", summary: "Proveedor guardado", life: 1600 });
await load();
}
function openNew() {
editing.value = null;
formTab.value = "datos";
@ -474,10 +584,12 @@ function syncChrome() {
showDetail: false,
treeKey: tab.value,
onTreeSelect: (key) => {
if (key === "empresas" || key === "riesgos" || key === "temas" || key === "nomina") tab.value = key;
if (["empresas", "proveedores", "unidades", "riesgos", "temas", "nomina"].includes(key)) tab.value = key;
},
tree: [
{ key: "empresas", label: "Empresas", icon: "pi pi-briefcase", data: { count: companies.value.length } },
{ key: "proveedores", label: "Proveedores", icon: "pi pi-truck", data: { count: suppliers.value.length } },
{ key: "unidades", label: "Unidades", icon: "pi pi-box", data: { count: measureUnits.value.length } },
{ key: "nomina", label: "Nómina", icon: "pi pi-wallet" },
{ key: "riesgos", label: "Riesgos", icon: "pi pi-exclamation-triangle", data: { count: risks.value.length } },
{ key: "temas", label: "Temas de gafete", icon: "pi pi-palette", data: { count: themes.value.length } },
@ -488,6 +600,7 @@ function syncChrome() {
label: "Acciones",
items: [
{ key: "alta", label: "Nueva subempresa", icon: "pi pi-plus", disabled: tab.value !== "empresas", command: openNew },
{ key: "proveedor", label: "Nuevo proveedor", icon: "pi pi-plus", disabled: tab.value !== "proveedores", command: openNewSupplier },
],
},
{
@ -502,7 +615,7 @@ onMounted(load);
watch(
() => String(route.query.tab || ""),
(key) => {
if (key === "empresas" || key === "riesgos" || key === "temas" || key === "nomina") tab.value = key;
if (["empresas", "proveedores", "unidades", "riesgos", "temas", "nomina"].includes(key)) tab.value = key;
},
{ immediate: true },
);

View file

@ -6,6 +6,16 @@
<Chip v-if="summary" :label="money(summary.executed_subtotal)" icon="pi pi-wallet" />
</DeskTableBar>
<Message
v-if="summary && items.length && Number(summary.executed_subtotal) === 0"
severity="info"
:closable="false"
class="mb-3"
>
Esta pantalla compara presupuesto contra lo ejecutado. Los gastos se capturan en
<strong>Gastos</strong>, ligados a un concepto. Use «Registrar gasto» o haga clic en un renglón.
</Message>
<div v-if="summary" class="kpi-row mb-3">
<div class="kpi-card">
<span class="kpi-label">Presupuesto</span>
@ -38,6 +48,7 @@
scroll-height="flex"
table-style="min-width: 72rem"
empty-message="Seleccione una obra con presupuesto cargado."
@row-click="onRowClick"
>
<Column field="code" header="Clave" style="width: 7rem">
<template #body="{ data }"><span class="identifier">{{ data.code || "—" }}</span></template>
@ -91,6 +102,7 @@ type Summary = {
const desk = useDesktop();
const { api } = useApi();
const route = useRoute();
const perms = usePermissions();
const tableQ = ref("");
const projects = ref<{ id: number; name: string }[]>([]);
@ -99,6 +111,7 @@ const summary = ref<Summary | null>(null);
const projectId = computed(() => Number(route.query.project_id || 0) || projects.value[0]?.id || 0);
const projectName = computed(() => projects.value.find((p) => p.id === projectId.value)?.name || "Sin obra");
const canCreateExpense = computed(() => perms.can("expenses.create"));
const filteredItems = computed(() => {
const q = tableQ.value.toLowerCase();
return items.value.filter((i) => !q || [i.code, i.description].join(" ").toLowerCase().includes(q));
@ -108,13 +121,33 @@ function money(v: number) {
return new Intl.NumberFormat("es-MX", { style: "currency", currency: "MXN" }).format(Number(v || 0));
}
function goGastos(opts?: { nuevo?: boolean; budgetItemId?: number }) {
if (!projectId.value) return;
const qs = new URLSearchParams({ project_id: String(projectId.value) });
if (opts?.nuevo) qs.set("nuevo", "1");
if (opts?.budgetItemId) qs.set("budget_item_id", String(opts.budgetItemId));
navigateTo(`/gastos?${qs}`);
}
function onRowClick(e: { data: ControlItem }) {
if (!canCreateExpense.value) {
goGastos();
return;
}
goGastos({ nuevo: true, budgetItemId: e.data.id });
}
async function loadProjects() {
const r = await api<{ projects?: { id: number; name: string }[] }>("/v1/projects");
projects.value = r.projects ?? [];
}
async function loadControl() {
if (!projectId.value) return;
if (!projectId.value) {
summary.value = null;
items.value = [];
return;
}
const [s, i] = await Promise.all([
api<{ data?: Summary }>(`/v1/projects/${projectId.value}/cost-control/summary`),
api<{ data?: { items?: ControlItem[] } }>(`/v1/projects/${projectId.value}/cost-control/items`),
@ -123,20 +156,67 @@ async function loadControl() {
items.value = i.data?.items ?? [];
}
onMounted(async () => {
await loadProjects();
function syncChrome() {
desk.configure({
tab: "Control presupuesto",
treeTitle: "Obras",
showTree: true,
showDetail: false,
treeKey: projectId.value ? `p:${projectId.value}` : "",
countLabel: projectId.value ? `${items.value.length} conceptos` : "",
tree: projects.value.map((p) => ({ key: `p:${p.id}`, label: p.name, icon: "pi pi-building" })),
onTreeSelect: (key) => {
if (key.startsWith("p:")) navigateTo(`/control-presupuesto?project_id=${key.slice(2)}`);
},
ribbon: [
{
key: "control",
label: "Control",
items: [
{
key: "gasto",
label: "Registrar gasto",
icon: "pi pi-plus",
disabled: !projectId.value || !canCreateExpense.value,
title: canCreateExpense.value
? "Captura un gasto ligado a un concepto del presupuesto"
: "Sin permiso para capturar gastos",
command: () => goGastos({ nuevo: true }),
},
{
key: "gastos",
label: "Ver gastos",
icon: "pi pi-list",
disabled: !projectId.value,
command: () => goGastos(),
},
],
},
{
key: "vista",
label: "Vista",
items: [
{ key: "refresh", label: "Actualizar", icon: "pi pi-refresh", disabled: !projectId.value, command: loadControl },
],
},
],
});
}
onMounted(async () => {
await loadProjects();
if (!route.query.project_id && projectId.value) {
await navigateTo(`/control-presupuesto?project_id=${projectId.value}`, { replace: true });
}
syncChrome();
await loadControl();
syncChrome();
});
watch(projectId, loadControl);
watch(projectId, async () => {
await loadControl();
syncChrome();
});
</script>
<style scoped>
@ -150,4 +230,5 @@ watch(projectId, loadControl);
}
.kpi-label { display: block; font-size: 11px; color: #748095; margin-bottom: 4px; }
.cost-critical-text { color: #b42318; font-weight: 600; }
.control-page :deep(.p-datatable-tbody > tr) { cursor: pointer; }
</style>

View file

@ -3,7 +3,9 @@
<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">
@ -11,20 +13,28 @@
</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: 8rem">
<template #body="{ data }"><span class="identifier">{{ data.budget_item_code || "—" }}</span></template>
<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>
@ -38,7 +48,7 @@
<Column field="source" header="Origen" style="width: 7rem" />
</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">
<FormField label="Empresa" required>
<Select v-model="form.company_id" :options="companies" option-label="name" option-value="id" class="w-full" />
@ -47,47 +57,106 @@
<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>
<InputText v-model="form.expense_date" type="date" class="w-full" />
<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" />
<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">
<InputText v-model="form.supplier_name" class="w-full" />
</FormField>
<FormField label="RFC proveedor">
<InputText v-model="form.supplier_rfc" class="w-full identifier-input" />
<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">
<InputText v-model="form.unit" class="w-full" />
<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 v-model="form.subtotal" mode="currency" currency="MXN" locale="es-MX" class="w-full" />
<InputNumber :model-value="computedAmounts.subtotal" mode="currency" currency="MXN" locale="es-MX" class="w-full" readonly />
</FormField>
<FormField label="IVA incluido en total">
<Checkbox v-model="form.tax_included" binary />
<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 v-model="form.total" mode="currency" currency="MXN" locale="es-MX" class="w-full" />
<InputNumber :model-value="computedAmounts.total" mode="currency" currency="MXN" locale="es-MX" class="w-full" readonly />
</FormField>
<FormField label="Comprobante PDF" class="span-2">
<input type="file" accept="application/pdf" @change="onFile" />
<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="Guardar" icon="pi pi-check" @click="save" />
<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>
@ -96,14 +165,24 @@
<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;
@ -115,15 +194,31 @@ 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,
@ -131,16 +226,37 @@ const form = reactive({
expense_date: new Date().toISOString().slice(0, 10),
budget_item_id: null as number | null,
description: "",
supplier_name: "",
supplier_rfc: "",
supplier_id: null as number | null,
quantity: 0,
unit: "",
unit_price: 0,
subtotal: 0,
total: 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);
@ -148,19 +264,169 @@ 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));
}
function onFile(e: Event) {
const input = e.target as HTMLInputElement;
receiptFile.value = input.files?.[0] ?? null;
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() {
@ -171,13 +437,70 @@ async function loadCatalogs() {
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<{ 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() {
@ -187,23 +510,35 @@ 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 },
},
);
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();
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);
}
@ -211,25 +546,22 @@ async function save() {
onMounted(async () => {
await loadCatalogs();
desk.configure({
tab: "Gastos",
treeTitle: "Obras",
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", command: () => { showForm.value = true; } },
],
},
],
});
syncChrome();
await loadExpenses();
if (route.query.nuevo) openNew();
});
watch(projectId, loadExpenses);
watch(projectId, () => {
loadExpenses();
syncChrome();
});
watch(selected, syncChrome);
watch(
() => String(route.query.nuevo || ""),
(nuevo) => {
if (nuevo) openNew();
},
);
</script>