merge: #15+#16 gastos, proveedores y almacén

Conserva los changesets 032 ya unidos y añade 033–035.

Co-authored-by: alberto.martinez <alberto.martinez@mrdev.mx>
This commit is contained in:
Cursor Agent 2026-09-08 21:13:02 +00:00
commit 15ffb3ca29
No known key found for this signature in database
18 changed files with 2835 additions and 180 deletions

View file

@ -202,6 +202,59 @@ Deno.test("RPC almacén: central, obra, entrada, transferencia, salida", async (
global: true,
});
assertEquals(globalStock.ok, true);
const consolidated = (globalStock.data as { consolidated: Array<{ material_id: number; qty_on_hand: number }> }).consolidated;
const cem = consolidated.find((row) => Number(row.material_id) === materialId);
assertExists(cem);
assertEquals(Number(cem.qty_on_hand), 45);
await db.prepare(
`INSERT INTO projects (code, name, status, theme_id, company_id, tenant_id)
VALUES ('COST-0002', 'Obra sin almacén', 'activo', 'arctec-dos-logos-fold', ?, ?)`,
).run(fx.companyId, TENANT_ID);
const listedAgain = await callCoreFn(db, "core.fn_warehouse_list", base);
const warehouses = (listedAgain.data as { warehouses: Array<{ kind: string; project_id?: number }> }).warehouses;
assertEquals(warehouses.filter((w) => w.kind === "central").length, 1);
assertEquals(warehouses.filter((w) => w.kind === "project").length, 1);
const supplier = await callCoreFn(db, "core.fn_supplier_create", {
tenant_id: TENANT_ID,
name: "Cemex Test",
});
assertEquals(supplier.ok, true);
const supplierId = Number(((supplier.data as { supplier: { id: number } }).supplier).id);
const units = await callCoreFn(db, "core.fn_measure_unit_list", base);
assertEquals(units.ok, true);
const bulto = ((units.data as { units: Array<{ id: number; code: string }> }).units)
.find((u) => u.code === "bulto");
assertExists(bulto);
const catalog = await callCoreFn(db, "core.fn_warehouse_material_upsert", {
...base,
code: "VAR001",
name: "Varilla 3/8",
unit_id: bulto.id,
supplier_ids: [supplierId],
});
assertEquals(catalog.ok, true);
const listedMats = await callCoreFn(db, "core.fn_warehouse_material_list", base);
assertEquals(listedMats.ok, true);
const mats = (listedMats.data as { materials: Array<{ code: string; suppliers: Array<{ id: number }> }> }).materials;
const varilla = mats.find((m) => m.code === "VAR001");
assertExists(varilla);
assertEquals(varilla.suppliers.length, 1);
const closed = await callCoreFn(db, "core.fn_warehouse_close_project", {
project_id: fx.projectId,
transfer_remaining: true,
closed_by_name: "Tester",
close_notes: "Cierre de prueba",
});
assertEquals(closed.ok, true);
const afterClose = await callCoreFn(db, "core.fn_warehouse_list", base);
const openProjectWh = ((afterClose.data as { warehouses: Array<{ kind: string; status: string }> }).warehouses)
.filter((w) => w.kind === "project" && w.status === "open");
assertEquals(openProjectWh.length, 0);
});
});
@ -233,6 +286,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", "warehouse.view")] as const;
const write = [...requireCoreAuth, requireAnyPermission("expenses.create", "settings.update", "warehouse.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

@ -66,6 +66,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";
@ -1416,6 +1417,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

@ -68,12 +68,15 @@ export function registerWarehouseRoutes(app: App) {
app.post("/v1/projects/:id/warehouse/close", ...requireCoreAuth, requirePermission("warehouse.update"), async (c) => {
const projectId = Number(c.req.param("id"));
if (!await denyUnlessProjectScope(c, projectId)) return;
const body = await c.req.json<{ force?: boolean; close_notes?: string }>().catch(() => ({} as { force?: boolean; close_notes?: string }));
const body = await c.req.json<{ force?: boolean; close_notes?: string; transfer_remaining?: boolean }>().catch(
() => ({} as { force?: boolean; close_notes?: string; transfer_remaining?: boolean }),
);
const db = c.get("db");
const a = actor(c);
const env = await callCoreFn(db, "core.fn_warehouse_close_project", {
project_id: projectId,
force: body.force,
transfer_remaining: body.transfer_remaining,
closed_by_name: a.name,
close_notes: body.close_notes,
}, { route: routeLabel(c) });
@ -119,12 +122,32 @@ export function registerWarehouseRoutes(app: App) {
if (!await denyUnlessWarehouseCentral(c)) return;
} else if (!await denyUnlessProjectScope(c, wh.project_id)) return;
const env = await callCoreFn(db, "core.fn_warehouse_movement_list", {
tenant_id: tid(c),
warehouse_id: whId,
material_id: c.req.query("material_id"),
}, { route: routeLabel(c) });
return respondRpc(c, env);
});
app.get("/v1/warehouses/movements/global", ...requireCoreAuth, requirePermission("warehouse.view"), async (c) => {
if (!await denyUnlessWarehouseCentral(c)) return;
const db = c.get("db");
const env = await callCoreFn(db, "core.fn_warehouse_movement_list", {
tenant_id: tid(c),
global: true,
material_id: c.req.query("material_id"),
}, { route: routeLabel(c) });
return respondRpc(c, env);
});
app.get("/v1/warehouse-materials", ...requireCoreAuth, requirePermission("warehouse.view"), async (c) => {
const db = c.get("db");
const env = await callCoreFn(db, "core.fn_warehouse_material_list", {
tenant_id: tid(c),
}, { route: routeLabel(c) });
return respondRpc(c, env);
});
app.post("/v1/warehouse-materials", ...requireCoreAuth, requirePermission("warehouse.create"), async (c) => {
const body = await c.req.json<Record<string, unknown>>();
const db = c.get("db");
@ -132,6 +155,17 @@ export function registerWarehouseRoutes(app: App) {
return respondRpc(c, env);
});
app.patch("/v1/warehouse-materials/:id", ...requireCoreAuth, requirePermission("warehouse.update"), async (c) => {
const body = await c.req.json<Record<string, unknown>>();
const db = c.get("db");
const env = await callCoreFn(db, "core.fn_warehouse_material_upsert", {
...body,
id: Number(c.req.param("id")),
tenant_id: tid(c),
}, { route: routeLabel(c) });
return respondRpc(c, env);
});
app.post("/v1/warehouses/:id/entries", ...requireCoreAuth, requirePermission("warehouse.create"), async (c) => {
const whId = Number(c.req.param("id"));
const db = c.get("db");

View file

@ -40,5 +40,8 @@
<include file="changesets/031-project-doc-types-technical.sql" relativeToChangelogFile="true"/>
<include file="changesets/032-rpc-document-get-id.sql" relativeToChangelogFile="true"/>
<include file="changesets/032-fix-work-program-jsonb-object-length.sql" relativeToChangelogFile="true"/>
<include file="changesets/033-catalog-suppliers-units.sql" relativeToChangelogFile="true"/>
<include file="changesets/034-fn-expense-update-reclassify.sql" relativeToChangelogFile="true"/>
<include file="changesets/035-warehouse-materials-catalog.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

@ -0,0 +1,464 @@
--liquibase formatted sql
-- PANELS · core · catálogo de materiales de almacén (unidades + proveedores)
--changeset panel:core-035a-warehouse-materials-unit-id endDelimiter:; splitStatements:true
ALTER TABLE core.warehouse_materials
ADD COLUMN IF NOT EXISTS unit_id BIGINT REFERENCES core.measure_units(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS idx_core_warehouse_materials_unit ON core.warehouse_materials(unit_id);
--changeset panel:core-035b-warehouse-material-suppliers 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='warehouse_material_suppliers'
CREATE TABLE core.warehouse_material_suppliers (
material_id BIGINT NOT NULL REFERENCES core.warehouse_materials(id) ON DELETE CASCADE,
supplier_id BIGINT NOT NULL REFERENCES core.suppliers(id) ON DELETE CASCADE,
PRIMARY KEY (material_id, supplier_id)
);
CREATE INDEX idx_core_wh_mat_suppliers_supplier ON core.warehouse_material_suppliers(supplier_id);
GRANT SELECT, INSERT, UPDATE, DELETE ON core.warehouse_material_suppliers TO panels_core_app;
--changeset panel:core-035c-rls-warehouse-material-suppliers endDelimiter:; splitStatements:true
--preconditions onFail:MARK_RAN
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM pg_policies WHERE schemaname='core' AND tablename='warehouse_material_suppliers' AND policyname='tenant_isolation'
ALTER TABLE core.warehouse_material_suppliers ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON core.warehouse_material_suppliers
USING (EXISTS (
SELECT 1 FROM core.warehouse_materials m
WHERE m.id = material_id
AND m.tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::integer
))
WITH CHECK (EXISTS (
SELECT 1 FROM core.warehouse_materials m
WHERE m.id = material_id
AND m.tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::integer
));
--changeset panel:core-035d-fn-warehouse-material-list splitStatements:false
CREATE OR REPLACE FUNCTION core.fn_warehouse_material_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
SELECT COALESCE(jsonb_agg(to_jsonb(x) ORDER BY lower(x.name), x.code), '[]'::jsonb)
INTO v_rows
FROM (
SELECT
m.id,
m.code,
m.name,
m.unit,
m.unit_id,
COALESCE(u.label, m.unit) AS unit_label,
COALESCE((
SELECT jsonb_agg(jsonb_build_object('id', s.id, 'name', s.name) ORDER BY lower(s.name))
FROM warehouse_material_suppliers ms
JOIN suppliers s ON s.id = ms.supplier_id
WHERE ms.material_id = m.id
), '[]'::jsonb) AS suppliers
FROM warehouse_materials m
LEFT JOIN measure_units u ON u.id = m.unit_id
WHERE m.tenant_id = v_tid
) x;
RETURN core.rpc_ok(
jsonb_build_object('materials', v_rows, 'count', jsonb_array_length(v_rows)),
format('Catálogo de materiales del tenant %s: %s registro(s)', v_tid, jsonb_array_length(v_rows)),
jsonb_build_object('fn', 'fn_warehouse_material_list', 'tenant_id', v_tid, 'count', jsonb_array_length(v_rows))
);
EXCEPTION WHEN OTHERS THEN
RETURN core.rpc_from_exception('fn_warehouse_material_list', SQLSTATE, SQLERRM);
END;
$$;
--changeset panel:core-035e-fn-warehouse-material-upsert splitStatements:false
CREATE OR REPLACE FUNCTION core.fn_warehouse_material_upsert(payload jsonb)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path = core
AS $$
DECLARE
v_tid integer := core._cost_tenant_id(payload);
v_id bigint := NULLIF(payload->>'id', '')::bigint;
v_code text := upper(coalesce(nullif(btrim(payload->>'code'), ''), ''));
v_name text := coalesce(nullif(btrim(payload->>'name'), ''), '');
v_unit text := coalesce(nullif(btrim(payload->>'unit'), ''), '');
v_unit_id bigint := NULLIF(payload->>'unit_id', '')::bigint;
v_row jsonb;
BEGIN
IF v_unit_id IS NOT NULL THEN
SELECT code INTO v_unit FROM measure_units WHERE id = v_unit_id AND tenant_id = v_tid;
IF NOT FOUND THEN
RETURN core.rpc_err('VALIDATION', format('fn_warehouse_material_upsert: unidad id=%s no encontrada', v_unit_id),
jsonb_build_object('fn', 'fn_warehouse_material_upsert', 'unit_id', v_unit_id));
END IF;
END IF;
IF v_id IS NOT NULL THEN
IF NOT EXISTS (SELECT 1 FROM warehouse_materials WHERE id = v_id AND tenant_id = v_tid) THEN
RETURN core.rpc_err('NOT_FOUND', format('fn_warehouse_material_upsert: material id=%s no encontrado', v_id),
jsonb_build_object('fn', 'fn_warehouse_material_upsert', 'id', v_id));
END IF;
IF v_code = '' THEN
SELECT code INTO v_code FROM warehouse_materials WHERE id = v_id;
END IF;
IF v_name = '' THEN
SELECT name INTO v_name FROM warehouse_materials WHERE id = v_id;
END IF;
UPDATE warehouse_materials
SET code = v_code,
name = v_name,
unit = CASE WHEN payload ? 'unit' OR payload ? 'unit_id' THEN v_unit ELSE unit END,
unit_id = CASE WHEN payload ? 'unit_id' THEN v_unit_id ELSE unit_id END
WHERE id = v_id AND tenant_id = v_tid;
ELSE
IF v_code = '' OR v_name = '' THEN
RETURN core.rpc_err('VALIDATION', 'fn_warehouse_material_upsert: code y name son obligatorios',
jsonb_build_object('fn', 'fn_warehouse_material_upsert'));
END IF;
INSERT INTO warehouse_materials (tenant_id, code, name, unit, unit_id)
VALUES (v_tid, v_code, v_name, v_unit, v_unit_id)
ON CONFLICT (tenant_id, code) DO UPDATE
SET name = EXCLUDED.name,
unit = EXCLUDED.unit,
unit_id = COALESCE(EXCLUDED.unit_id, warehouse_materials.unit_id)
RETURNING id INTO v_id;
END IF;
IF payload ? 'supplier_ids' THEN
DELETE FROM warehouse_material_suppliers WHERE material_id = v_id;
INSERT INTO warehouse_material_suppliers (material_id, supplier_id)
SELECT v_id, x.sid
FROM (
SELECT DISTINCT NULLIF(elem #>> '{}', '')::bigint AS sid
FROM jsonb_array_elements(
CASE WHEN jsonb_typeof(payload->'supplier_ids') = 'array'
THEN payload->'supplier_ids'
ELSE '[]'::jsonb
END
) elem
) x
WHERE x.sid IS NOT NULL
AND EXISTS (SELECT 1 FROM suppliers s WHERE s.id = x.sid AND s.tenant_id = v_tid);
END IF;
SELECT to_jsonb(x) INTO v_row
FROM (
SELECT
m.id, m.code, m.name, m.unit, m.unit_id,
COALESCE(u.label, m.unit) AS unit_label,
COALESCE((
SELECT jsonb_agg(jsonb_build_object('id', s.id, 'name', s.name) ORDER BY lower(s.name))
FROM warehouse_material_suppliers ms
JOIN suppliers s ON s.id = ms.supplier_id
WHERE ms.material_id = m.id
), '[]'::jsonb) AS suppliers
FROM warehouse_materials m
LEFT JOIN measure_units u ON u.id = m.unit_id
WHERE m.id = v_id
) x;
RETURN core.rpc_ok(
v_row,
format('Material %s (%s) registrado', v_code, v_name),
jsonb_build_object('fn', 'fn_warehouse_material_upsert', 'id', v_id)
);
EXCEPTION WHEN unique_violation THEN
RETURN core.rpc_err('CONFLICT', format('Ya existe un material con clave %s', v_code),
jsonb_build_object('fn', 'fn_warehouse_material_upsert', 'code', v_code));
WHEN OTHERS THEN
RETURN core.rpc_from_exception('fn_warehouse_material_upsert', SQLSTATE, SQLERRM);
END;
$$;
--changeset panel:core-035f-fn-warehouse-stock-list splitStatements:false
CREATE OR REPLACE FUNCTION core.fn_warehouse_stock_list(payload jsonb)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path = core
AS $$
DECLARE
v_warehouse_id bigint := NULLIF(payload->>'warehouse_id', '')::bigint;
v_global boolean := COALESCE((payload->>'global')::boolean, false);
v_tid integer := core._cost_tenant_id(payload);
v_rows jsonb;
v_consolidated jsonb := '[]'::jsonb;
BEGIN
IF v_global THEN
SELECT COALESCE(jsonb_agg(to_jsonb(x)), '[]'::jsonb) INTO v_rows FROM (
SELECT
w.id AS warehouse_id,
w.kind,
w.project_id,
p.code AS project_code,
p.name AS project_name,
CASE WHEN w.kind = 'central' THEN 'Almacén global'
ELSE COALESCE(p.code || ' — ' || p.name, 'Obra')
END AS warehouse_label,
m.id AS material_id,
m.code AS material_code,
m.name AS material_name,
m.unit,
COALESCE(u.label, m.unit) AS unit_label,
s.qty_on_hand,
s.avg_unit_cost,
round(s.qty_on_hand * s.avg_unit_cost, 2) AS stock_value
FROM warehouse_stock s
JOIN warehouses w ON w.id = s.warehouse_id
JOIN warehouse_materials m ON m.id = s.material_id
LEFT JOIN measure_units u ON u.id = m.unit_id
LEFT JOIN projects p ON p.id = w.project_id
WHERE w.tenant_id = v_tid AND w.status = 'open' AND s.qty_on_hand > 0
) x;
SELECT COALESCE(jsonb_agg(to_jsonb(x)), '[]'::jsonb) INTO v_consolidated FROM (
SELECT
m.id AS material_id,
m.code AS material_code,
m.name AS material_name,
m.unit,
COALESCE(u.label, m.unit) AS unit_label,
SUM(s.qty_on_hand) AS qty_on_hand,
CASE WHEN SUM(s.qty_on_hand) > 0
THEN round(SUM(s.qty_on_hand * s.avg_unit_cost) / SUM(s.qty_on_hand), 4)
ELSE 0
END AS avg_unit_cost,
round(SUM(s.qty_on_hand * s.avg_unit_cost), 2) AS stock_value,
COUNT(*) FILTER (WHERE s.qty_on_hand > 0)::integer AS warehouse_count
FROM warehouse_stock s
JOIN warehouses w ON w.id = s.warehouse_id
JOIN warehouse_materials m ON m.id = s.material_id
LEFT JOIN measure_units u ON u.id = m.unit_id
WHERE w.tenant_id = v_tid AND w.status = 'open' AND s.qty_on_hand > 0
GROUP BY m.id, m.code, m.name, m.unit, u.label
) x;
ELSE
IF v_warehouse_id IS NULL THEN
RETURN core.rpc_err('VALIDATION', 'fn_warehouse_stock_list: warehouse_id es obligatorio si global=false',
jsonb_build_object('fn', 'fn_warehouse_stock_list'));
END IF;
SELECT COALESCE(jsonb_agg(to_jsonb(x)), '[]'::jsonb) INTO v_rows FROM (
SELECT
s.*,
m.code AS material_code,
m.name AS material_name,
m.unit,
COALESCE(u.label, m.unit) AS unit_label,
CASE WHEN w.kind = 'central' THEN 'Almacén global'
ELSE COALESCE(p.code || ' — ' || p.name, 'Obra')
END AS warehouse_label,
w.kind,
w.project_id,
p.code AS project_code,
round(s.qty_on_hand * s.avg_unit_cost, 2) AS stock_value
FROM warehouse_stock s
JOIN warehouses w ON w.id = s.warehouse_id
JOIN warehouse_materials m ON m.id = s.material_id
LEFT JOIN measure_units u ON u.id = m.unit_id
LEFT JOIN projects p ON p.id = w.project_id
WHERE s.warehouse_id = v_warehouse_id AND s.qty_on_hand > 0
) x;
END IF;
RETURN core.rpc_ok(
jsonb_build_object('stock', COALESCE(v_rows, '[]'::jsonb), 'consolidated', v_consolidated),
format('Stock consultado: %s línea(s)', jsonb_array_length(COALESCE(v_rows, '[]'::jsonb))),
jsonb_build_object('fn', 'fn_warehouse_stock_list', 'warehouse_id', v_warehouse_id)
);
EXCEPTION WHEN OTHERS THEN
RETURN core.rpc_from_exception('fn_warehouse_stock_list', SQLSTATE, SQLERRM);
END;
$$;
--changeset panel:core-035g-fn-warehouse-transfer-create splitStatements:false
CREATE OR REPLACE FUNCTION core.fn_warehouse_transfer_create(payload jsonb)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path = core
AS $$
DECLARE
v_tid integer := core._cost_tenant_id(payload);
v_from_id bigint := NULLIF(payload->>'from_warehouse_id', '')::bigint;
v_to_id bigint := NULLIF(payload->>'to_warehouse_id', '')::bigint;
v_material_id bigint := NULLIF(payload->>'material_id', '')::bigint;
v_qty numeric := COALESCE((payload->>'quantity')::numeric, 0);
v_transfer_date date := COALESCE(NULLIF(payload->>'transfer_date', '')::date, CURRENT_DATE);
v_notes text := coalesce(nullif(btrim(payload->>'notes'), ''), '');
v_from record;
v_to record;
v_stock record;
v_unit_cost numeric;
v_transfer_id bigint;
v_created_by text := coalesce(nullif(btrim(payload->>'created_by_name'), ''), '');
BEGIN
IF v_from_id IS NULL OR v_to_id IS NULL OR v_material_id IS NULL OR v_qty <= 0 OR v_from_id = v_to_id THEN
RETURN core.rpc_err('VALIDATION', 'fn_warehouse_transfer_create: origen, destino, material y cantidad válidos son obligatorios',
jsonb_build_object('fn', 'fn_warehouse_transfer_create'));
END IF;
SELECT * INTO v_from FROM warehouses WHERE id = v_from_id AND tenant_id = v_tid;
IF NOT FOUND OR v_from.status <> 'open' THEN
RETURN core.rpc_err('VALIDATION', 'fn_warehouse_transfer_create: el almacén origen no está abierto',
jsonb_build_object('fn', 'fn_warehouse_transfer_create', 'from_warehouse_id', v_from_id));
END IF;
SELECT * INTO v_to FROM warehouses WHERE id = v_to_id AND tenant_id = v_tid;
IF NOT FOUND OR v_to.status <> 'open' THEN
RETURN core.rpc_err('VALIDATION', 'fn_warehouse_transfer_create: el almacén destino no está abierto',
jsonb_build_object('fn', 'fn_warehouse_transfer_create', 'to_warehouse_id', v_to_id));
END IF;
SELECT * INTO v_stock FROM warehouse_stock WHERE warehouse_id = v_from_id AND material_id = v_material_id;
IF NOT FOUND OR v_stock.qty_on_hand < v_qty THEN
RETURN core.rpc_err('VALIDATION', 'fn_warehouse_transfer_create: stock insuficiente en almacén origen',
jsonb_build_object('fn', 'fn_warehouse_transfer_create', 'from_warehouse_id', v_from_id));
END IF;
v_unit_cost := v_stock.avg_unit_cost;
INSERT INTO warehouse_transfers (
tenant_id, from_warehouse_id, to_warehouse_id, material_id, quantity, unit_cost,
status, transfer_date, notes, created_by_name
) VALUES (
v_tid, v_from_id, v_to_id, v_material_id, v_qty, v_unit_cost,
'completed', v_transfer_date, v_notes, v_created_by
) RETURNING id INTO v_transfer_id;
PERFORM core._warehouse_stock_apply(v_from_id, v_material_id, -v_qty, v_unit_cost);
PERFORM core._warehouse_stock_apply(v_to_id, v_material_id, v_qty, v_unit_cost);
INSERT INTO warehouse_movements (tenant_id, warehouse_id, material_id, movement_type, transfer_id, movement_date, quantity, unit_cost, total_cost, created_by_name)
VALUES (v_tid, v_from_id, v_material_id, 'transfer_out', v_transfer_id, v_transfer_date, v_qty, v_unit_cost, round(v_qty*v_unit_cost,2), v_created_by);
INSERT INTO warehouse_movements (tenant_id, warehouse_id, material_id, movement_type, transfer_id, movement_date, quantity, unit_cost, total_cost, created_by_name)
VALUES (v_tid, v_to_id, v_material_id, 'transfer_in', v_transfer_id, v_transfer_date, v_qty, v_unit_cost, round(v_qty*v_unit_cost,2), v_created_by);
RETURN core.rpc_created(
jsonb_build_object('transfer_id', v_transfer_id, 'quantity', v_qty, 'unit_cost', v_unit_cost),
format('Transferencia %s unidades del almacén %s al %s', v_qty, v_from_id, v_to_id),
jsonb_build_object('fn', 'fn_warehouse_transfer_create', 'transfer_id', v_transfer_id));
EXCEPTION WHEN OTHERS THEN
RETURN core.rpc_from_exception('fn_warehouse_transfer_create', SQLSTATE, SQLERRM);
END;
$$;
--changeset panel:core-035h-fn-warehouse-movement-list splitStatements:false
CREATE OR REPLACE FUNCTION core.fn_warehouse_movement_list(payload jsonb)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path = core
AS $$
DECLARE
v_tid integer := core._cost_tenant_id(payload);
v_warehouse_id bigint := NULLIF(payload->>'warehouse_id', '')::bigint;
v_material_id bigint := NULLIF(payload->>'material_id', '')::bigint;
v_global boolean := COALESCE((payload->>'global')::boolean, false);
v_rows jsonb;
BEGIN
SELECT COALESCE(jsonb_agg(to_jsonb(x) ORDER BY x.movement_date DESC, x.id DESC), '[]'::jsonb) INTO v_rows
FROM (
SELECT
m.*,
mat.code AS material_code,
mat.name AS material_name,
bi.description AS budget_item_description,
CASE WHEN w.kind = 'central' THEN 'Almacén global'
ELSE COALESCE(p.code || ' — ' || p.name, 'Obra')
END AS warehouse_label
FROM warehouse_movements m
JOIN warehouse_materials mat ON mat.id = m.material_id
JOIN warehouses w ON w.id = m.warehouse_id
LEFT JOIN projects p ON p.id = w.project_id
LEFT JOIN budget_items bi ON bi.id = m.budget_item_id
WHERE (v_tid <= 0 OR m.tenant_id = v_tid)
AND (v_warehouse_id IS NULL OR m.warehouse_id = v_warehouse_id)
AND (v_material_id IS NULL OR m.material_id = v_material_id)
) x;
RETURN core.rpc_ok(jsonb_build_object('movements', v_rows),
format('Kardex: %s movimiento(s)', jsonb_array_length(v_rows)),
jsonb_build_object('fn', 'fn_warehouse_movement_list', 'warehouse_id', v_warehouse_id));
EXCEPTION WHEN OTHERS THEN
RETURN core.rpc_from_exception('fn_warehouse_movement_list', SQLSTATE, SQLERRM);
END;
$$;
--changeset panel:core-035i-fn-warehouse-close-project splitStatements:false
CREATE OR REPLACE FUNCTION core.fn_warehouse_close_project(payload jsonb)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path = core
AS $$
DECLARE
v_project_id bigint := NULLIF(payload->>'project_id', '')::bigint;
v_force boolean := COALESCE((payload->>'force')::boolean, false);
v_transfer_remaining boolean := COALESCE((payload->>'transfer_remaining')::boolean, false);
v_closed_by text := coalesce(nullif(btrim(payload->>'closed_by_name'), ''), '');
v_notes text := coalesce(nullif(btrim(payload->>'close_notes'), ''), '');
v_wh record;
v_central_id bigint;
v_stock_count integer;
v_line record;
v_xfer jsonb;
BEGIN
IF v_project_id IS NULL THEN
RETURN core.rpc_err('VALIDATION', 'fn_warehouse_close_project: project_id es obligatorio',
jsonb_build_object('fn', 'fn_warehouse_close_project'));
END IF;
SELECT * INTO v_wh FROM warehouses WHERE project_id = v_project_id AND kind = 'project';
IF NOT FOUND THEN
RETURN core.rpc_ok(jsonb_build_object('closed', false, 'reason', 'no_warehouse'),
format('Proyecto %s no tenía almacén abierto', v_project_id),
jsonb_build_object('fn', 'fn_warehouse_close_project', 'project_id', v_project_id));
END IF;
IF v_wh.status = 'closed' THEN
RETURN core.rpc_ok(jsonb_build_object('warehouse_id', v_wh.id, 'status', 'closed'),
format('Almacén del proyecto %s ya estaba cerrado', v_project_id),
jsonb_build_object('fn', 'fn_warehouse_close_project', 'project_id', v_project_id));
END IF;
IF v_transfer_remaining THEN
PERFORM core.fn_warehouse_ensure_central(jsonb_build_object('tenant_id', v_wh.tenant_id));
SELECT id INTO v_central_id FROM warehouses WHERE tenant_id = v_wh.tenant_id AND kind = 'central' LIMIT 1;
IF v_central_id IS NOT NULL THEN
FOR v_line IN
SELECT material_id, qty_on_hand
FROM warehouse_stock
WHERE warehouse_id = v_wh.id AND qty_on_hand > 0
LOOP
v_xfer := core.fn_warehouse_transfer_create(jsonb_build_object(
'tenant_id', v_wh.tenant_id,
'from_warehouse_id', v_wh.id,
'to_warehouse_id', v_central_id,
'material_id', v_line.material_id,
'quantity', v_line.qty_on_hand,
'notes', 'Traspaso automático al cerrar almacén de obra',
'created_by_name', v_closed_by
));
IF NOT COALESCE((v_xfer->>'ok')::boolean, false) THEN
RETURN v_xfer;
END IF;
END LOOP;
END IF;
END IF;
SELECT COUNT(*)::integer INTO v_stock_count FROM warehouse_stock
WHERE warehouse_id = v_wh.id AND qty_on_hand > 0;
IF v_stock_count > 0 AND NOT v_force THEN
RETURN core.rpc_err('VALIDATION',
format('fn_warehouse_close_project: quedan %s material(es) con stock; transfiera al almacén global o ajuste antes de cerrar', v_stock_count),
jsonb_build_object('fn', 'fn_warehouse_close_project', 'project_id', v_project_id, 'stock_lines', v_stock_count));
END IF;
UPDATE warehouses SET status = 'closed', closed_at = now(), closed_by_name = v_closed_by, close_notes = v_notes
WHERE id = v_wh.id;
RETURN core.rpc_ok(
jsonb_build_object(
'warehouse_id', v_wh.id,
'status', 'closed',
'forced', v_force,
'transferred_remaining', v_transfer_remaining
),
format('Almacén del proyecto %s cerrado', v_project_id),
jsonb_build_object('fn', 'fn_warehouse_close_project', 'project_id', v_project_id));
EXCEPTION WHEN OTHERS THEN
RETURN core.rpc_from_exception('fn_warehouse_close_project', SQLSTATE, SQLERRM);
END;
$$;
--changeset panel:core-035j-grants endDelimiter:; splitStatements:true
GRANT EXECUTE ON FUNCTION core.fn_warehouse_material_list(jsonb) TO panels_core_app;
GRANT EXECUTE ON FUNCTION core.fn_warehouse_material_upsert(jsonb) TO panels_core_app;
GRANT EXECUTE ON FUNCTION core.fn_warehouse_stock_list(jsonb) TO panels_core_app;
GRANT EXECUTE ON FUNCTION core.fn_warehouse_transfer_create(jsonb) TO panels_core_app;
GRANT EXECUTE ON FUNCTION core.fn_warehouse_movement_list(jsonb) TO panels_core_app;
GRANT EXECUTE ON FUNCTION core.fn_warehouse_close_project(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,269 @@
<template>
<Dialog
v-model:visible="visible"
header="Catálogo de materiales"
modal
:style="{ width: '920px' }"
:breakpoints="{ '960px': '95vw' }"
>
<DeskTableBar v-model="query" placeholder="Buscar clave, material o proveedor">
<Chip :label="`${filtered.length} materiales`" icon="pi pi-box" />
<Button label="Nuevo material" icon="pi pi-plus" size="small" @click="openNew" />
</DeskTableBar>
<DataTable
:value="filtered"
data-key="id"
paginator
:rows="12"
scrollable
scroll-height="22rem"
empty-message="No hay materiales. Capture el catálogo para usarlo en entradas y traspasos."
@row-click="(e: { data: Material }) => openEdit(e.data)"
>
<Column field="code" header="Clave" style="width: 8rem">
<template #body="{ data }"><span class="identifier">{{ data.code }}</span></template>
</Column>
<Column field="name" header="Material" />
<Column field="unit_label" header="Unidad" style="width: 8rem">
<template #body="{ data }">{{ data.unit_label || data.unit || "—" }}</template>
</Column>
<Column header="Proveedores">
<template #body="{ data }">{{ supplierNames(data) }}</template>
</Column>
</DataTable>
<Dialog
v-model:visible="showForm"
:header="form.id ? 'Editar material' : 'Nuevo material'"
modal
:style="{ width: '640px' }"
>
<div class="form-grid form-grid-compact">
<FormField label="Clave" required>
<InputText v-model="form.code" class="w-full identifier-input" placeholder="CEM-001" />
</FormField>
<FormField label="Unidad" required>
<div class="flex gap-2 align-items-center">
<Select
v-model="form.unit_id"
:options="units"
option-label="label"
option-value="id"
placeholder="Catálogo de unidades"
filter
class="w-full"
/>
<Button icon="pi pi-plus" outlined aria-label="Nueva unidad" title="Nueva unidad" @click="showUnitForm = true" />
</div>
</FormField>
<FormField label="Nombre" required class="span-2">
<InputText v-model="form.name" class="w-full" placeholder="Cemento gris 50 kg" />
</FormField>
<FormField label="Proveedores" class="span-2">
<div class="flex gap-2 align-items-center">
<MultiSelect
v-model="form.supplier_ids"
:options="supplierOptions"
option-label="label"
option-value="id"
placeholder="Uno o varios proveedores"
filter
display="chip"
class="w-full"
/>
<Button icon="pi pi-plus" outlined aria-label="Nuevo proveedor" title="Nuevo proveedor" @click="showSupplierForm = true" />
</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" :loading="saving" @click="save" />
</template>
</Dialog>
<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>
<SupplierFormDialog v-model="showSupplierForm" @saved="onSupplierSaved" />
</Dialog>
</template>
<script setup lang="ts">
import FormField from "~/components/padron/FormField.vue";
import SupplierFormDialog, { type Supplier } from "~/components/SupplierFormDialog.vue";
import { displayErrorMessage } from "~/composables/api-response";
export type Material = {
id: number;
code: string;
name: string;
unit?: string;
unit_id?: number | null;
unit_label?: string;
suppliers?: { id: number; name: string }[];
};
type MeasureUnit = { id: number; code: string; label: string };
const visible = defineModel<boolean>({ default: false });
const emit = defineEmits<{ saved: [material: Material] }>();
const { api } = useApi();
const toast = useToast();
const query = ref("");
const materials = ref<Material[]>([]);
const units = ref<MeasureUnit[]>([]);
const suppliers = ref<Supplier[]>([]);
const showForm = ref(false);
const showUnitForm = ref(false);
const showSupplierForm = ref(false);
const saving = ref(false);
const formError = ref("");
const unitError = ref("");
const unitForm = reactive({ code: "", label: "" });
const form = reactive({
id: 0,
code: "",
name: "",
unit_id: null as number | null,
supplier_ids: [] as number[],
});
const supplierOptions = computed(() =>
suppliers.value.map((s) => ({
id: s.id,
label: s.rfc ? `${s.name} · ${s.rfc}` : s.name,
})),
);
const filtered = computed(() => {
const q = query.value.trim().toLowerCase();
return materials.value.filter((m) =>
!q || [m.code, m.name, m.unit, m.unit_label, supplierNames(m)].join(" ").toLowerCase().includes(q),
);
});
function supplierNames(row: Material) {
return (row.suppliers ?? []).map((s) => s.name).join(", ") || "—";
}
function resetForm() {
form.id = 0;
form.code = "";
form.name = "";
form.unit_id = units.value[0]?.id ?? null;
form.supplier_ids = [];
formError.value = "";
}
function openNew() {
resetForm();
showForm.value = true;
}
function openEdit(row: Material) {
form.id = row.id;
form.code = row.code;
form.name = row.name;
form.unit_id = row.unit_id ?? null;
form.supplier_ids = (row.suppliers ?? []).map((s) => s.id);
formError.value = "";
showForm.value = true;
}
async function load() {
const [mats, un, sup] = await Promise.all([
api<{ materials?: Material[] }>("/v1/warehouse-materials"),
api<{ units?: MeasureUnit[] }>("/v1/measure-units"),
api<{ suppliers?: Supplier[] }>("/v1/suppliers?status=activo"),
]);
materials.value = mats.materials ?? [];
units.value = un.units ?? [];
suppliers.value = sup.suppliers ?? [];
}
async function save() {
if (!form.code.trim() || !form.name.trim()) {
formError.value = "Clave y nombre son obligatorios";
return;
}
if (!form.unit_id) {
formError.value = "Seleccione una unidad del catálogo";
return;
}
saving.value = true;
formError.value = "";
try {
const payload = {
code: form.code,
name: form.name,
unit_id: form.unit_id,
supplier_ids: form.supplier_ids,
};
const r = form.id
? await api<Material>(`/v1/warehouse-materials/${form.id}`, { method: "PATCH", json: payload })
: await api<Material>("/v1/warehouse-materials", { method: "POST", json: payload });
toast.add({ severity: "success", summary: "Material guardado", life: 2000 });
showForm.value = false;
await load();
const saved = materials.value.find((m) => m.id === Number(r.id)) ?? r;
emit("saved", saved);
} catch (e) {
formError.value = displayErrorMessage((e as { data?: unknown }).data ?? e);
} finally {
saving.value = false;
}
}
async function saveUnit() {
unitError.value = "";
if (!unitForm.code.trim()) {
unitError.value = "El código es obligatorio";
return;
}
try {
const r = await api<{ unit?: MeasureUnit }>("/v1/measure-units", {
method: "POST",
json: { code: unitForm.code, label: unitForm.label },
});
const created = r.unit;
if (created) {
units.value = [...units.value.filter((u) => u.id !== created.id), created]
.sort((a, b) => a.label.localeCompare(b.label, "es"));
form.unit_id = created.id;
}
unitForm.code = "";
unitForm.label = "";
showUnitForm.value = false;
} catch (e) {
unitError.value = displayErrorMessage((e as { data?: unknown }).data ?? e);
}
}
function onSupplierSaved(supplier: Supplier) {
suppliers.value = [...suppliers.value.filter((s) => s.id !== supplier.id), supplier]
.sort((a, b) => a.name.localeCompare(b.name, "es"));
if (!form.supplier_ids.includes(supplier.id)) form.supplier_ids = [...form.supplier_ids, supplier.id];
}
watch(visible, async (on) => {
if (on) await load();
});
defineExpose({ load, materials });
</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

@ -1,17 +1,55 @@
<template>
<div class="almacen-page">
<DeskTableBar v-model="tableQ" placeholder="Buscar material o obra">
<Chip label="Almacén global" icon="pi pi-box" />
<Chip :label="`${stockRows.length} líneas`" icon="pi pi-list" />
<DeskTableBar v-model="tableQ" :placeholder="isGlobalView ? 'Buscar material o almacén' : 'Buscar material'">
<Chip :label="currentLabel" icon="pi pi-box" />
<Chip :label="`${visibleStock.length} líneas`" icon="pi pi-list" />
<Chip :label="money(stockValue)" icon="pi pi-wallet" />
</DeskTableBar>
<TabView>
<TabPanel header="Stock global">
<DataTable :value="filteredStock" data-key="material_id" paginator :rows="25" scrollable scroll-height="flex">
<Column field="project_code" header="Obra / Central" />
<Column field="material_code" header="Clave" style="width: 8rem" />
<TabPanel :header="isGlobalView ? 'Consolidado' : 'Stock'">
<DataTable
:value="visibleStock"
data-key="row_key"
paginator
:rows="25"
scrollable
scroll-height="flex"
empty-message="No hay existencias en este almacén."
>
<Column v-if="isGlobalView && stockMode === 'detail'" field="warehouse_label" header="Almacén" />
<Column field="material_code" header="Clave" style="width: 8rem">
<template #body="{ data }"><span class="identifier">{{ data.material_code }}</span></template>
</Column>
<Column field="material_name" header="Material" />
<Column field="unit_label" header="Unidad" style="width: 8rem">
<template #body="{ data }">{{ data.unit_label || data.unit || "—" }}</template>
</Column>
<Column field="qty_on_hand" header="Cantidad" style="width: 8rem" />
<Column v-if="isGlobalView && stockMode === 'consolidated'" field="warehouse_count" header="Almacenes" style="width: 8rem" />
<Column field="stock_value" header="Valor" style="width: 9rem">
<template #body="{ data }">{{ money(data.stock_value) }}</template>
</Column>
</DataTable>
</TabPanel>
<TabPanel v-if="isGlobalView" header="Por almacén">
<DataTable
:value="filteredDetail"
data-key="row_key"
paginator
:rows="25"
scrollable
scroll-height="flex"
empty-message="No hay existencias en almacenes abiertos."
>
<Column field="warehouse_label" header="Almacén" />
<Column field="material_code" header="Clave" style="width: 8rem">
<template #body="{ data }"><span class="identifier">{{ data.material_code }}</span></template>
</Column>
<Column field="material_name" header="Material" />
<Column field="unit_label" header="Unidad" style="width: 8rem">
<template #body="{ data }">{{ data.unit_label || data.unit || "—" }}</template>
</Column>
<Column field="qty_on_hand" header="Cantidad" style="width: 8rem" />
<Column field="stock_value" header="Valor" style="width: 9rem">
<template #body="{ data }">{{ money(data.stock_value) }}</template>
@ -19,9 +57,12 @@
</DataTable>
</TabPanel>
<TabPanel header="Kardex">
<DataTable :value="movements" data-key="id" paginator :rows="25" scrollable scroll-height="flex">
<DataTable :value="filteredMovements" data-key="id" paginator :rows="25" scrollable scroll-height="flex">
<Column field="movement_date" header="Fecha" style="width: 7rem" />
<Column field="movement_type" header="Tipo" style="width: 8rem" />
<Column v-if="isGlobalView" field="warehouse_label" header="Almacén" />
<Column header="Tipo" style="width: 9rem">
<template #body="{ data }">{{ movementLabel(data.movement_type) }}</template>
</Column>
<Column field="material_name" header="Material" />
<Column field="quantity" header="Cant." style="width: 7rem" />
<Column field="total_cost" header="Importe" style="width: 9rem">
@ -32,23 +73,82 @@
</TabPanel>
</TabView>
<Dialog v-model:visible="showEntry" header="Entrada de material" modal :style="{ width: '640px' }">
<Dialog v-model:visible="showOpen" header="Abrir almacén de obra" modal :style="{ width: '520px' }">
<p class="muted mb-3">
No todas las obras tienen almacén. Elija el proyecto para abrirlo. El almacén global permanece disponible.
</p>
<FormField label="Proyecto" required>
<Select
v-model="openProjectId"
:options="projectsWithoutWarehouse"
option-label="label"
option-value="id"
filter
placeholder="Seleccione una obra"
class="w-full"
/>
</FormField>
<Message v-if="!projectsWithoutWarehouse.length" severity="info" class="mt-3" :closable="false">
Todas las obras activas ya tienen almacén abierto.
</Message>
<template #footer>
<Button label="Cancelar" text @click="showOpen = false" />
<Button label="Abrir almacén" icon="pi pi-folder-open" :disabled="!openProjectId" @click="openWarehouse" />
</template>
</Dialog>
<Dialog v-model:visible="showClose" header="Cerrar almacén de obra" modal :style="{ width: '560px' }">
<p class="muted mb-3">
El almacén global no se cierra. Al terminar la obra puede cerrar este almacén y devolver el stock restante.
</p>
<Message :closable="false" class="mb-3">{{ selectedWarehouse?.label }}</Message>
<FormField label="Notas de cierre">
<Textarea v-model="closeNotes" class="w-full" rows="2" auto-resize />
</FormField>
<div class="flex align-items-center gap-2 mt-3">
<Checkbox v-model="transferRemaining" binary input-id="xfer-remaining" />
<label for="xfer-remaining">Traspasar stock restante al almacén global</label>
</div>
<template #footer>
<Button label="Cancelar" text @click="showClose = false" />
<Button label="Cerrar almacén" icon="pi pi-lock" severity="danger" @click="closeWarehouse" />
</template>
</Dialog>
<Dialog v-model:visible="showEntry" header="Entrada de material" modal :style="{ width: '680px' }">
<div class="form-grid form-grid-compact">
<FormField label="Almacén">
<Select v-model="entryForm.warehouse_id" :options="warehouses" option-label="label" option-value="id" class="w-full" />
<FormField label="Almacén destino" required>
<Select v-model="entryForm.warehouse_id" :options="openWarehouses" option-label="label" option-value="id" class="w-full" />
</FormField>
<FormField label="Código material">
<InputText v-model="entryForm.material_code" class="w-full identifier-input" />
<FormField label="Fecha">
<DatePicker v-model="entryDate" date-format="dd/mm/yy" show-icon class="w-full" />
</FormField>
<FormField label="Nombre material">
<InputText v-model="entryForm.material_name" class="w-full" />
<FormField label="Material" required class="span-2">
<div class="flex gap-2 align-items-center">
<Select
v-model="entryForm.material_id"
:options="materialSelectOptions"
option-label="label"
option-value="id"
filter
placeholder="Seleccione del catálogo"
class="w-full"
/>
<Button icon="pi pi-plus" outlined aria-label="Catálogo" title="Catálogo de materiales" @click="showCatalog = true" />
</div>
</FormField>
<FormField label="Cantidad">
<InputNumber v-model="entryForm.quantity" :min="0" class="w-full" />
<FormField label="Unidad">
<InputText :model-value="selectedEntryMaterial?.unit_label || selectedEntryMaterial?.unit || ''" class="w-full" readonly />
</FormField>
<FormField label="Cantidad" required>
<InputNumber v-model="entryForm.quantity" :min="0" :max-fraction-digits="4" class="w-full" />
</FormField>
<FormField label="Costo unitario">
<InputNumber v-model="entryForm.unit_cost" mode="currency" currency="MXN" locale="es-MX" class="w-full" />
</FormField>
<FormField v-if="selectedEntrySuppliers" label="Proveedores del material" class="span-2">
<small class="muted">{{ selectedEntrySuppliers }}</small>
</FormField>
</div>
<template #footer>
<Button label="Cancelar" text @click="showEntry = false" />
@ -56,19 +156,27 @@
</template>
</Dialog>
<Dialog v-model:visible="showExit" header="Salida a obra / concepto" modal :style="{ width: '720px' }">
<Dialog v-model:visible="showExit" header="Salida de material" modal :style="{ width: '720px' }">
<div class="form-grid form-grid-compact">
<FormField label="Almacén obra">
<Select v-model="exitForm.warehouse_id" :options="projectWarehouses" option-label="label" option-value="id" class="w-full" />
<FormField label="Almacén" required>
<Select v-model="exitForm.warehouse_id" :options="openWarehouses" option-label="label" option-value="id" class="w-full" />
</FormField>
<FormField label="Material">
<Select v-model="exitForm.material_id" :options="materialOptions" option-label="label" option-value="id" class="w-full" />
<FormField label="Material" required>
<Select
v-model="exitForm.material_id"
:options="stockMaterialOptions(exitForm.warehouse_id)"
option-label="label"
option-value="id"
filter
class="w-full"
/>
</FormField>
<FormField label="Cantidad">
<InputNumber v-model="exitForm.quantity" :min="0" class="w-full" />
<FormField label="Cantidad" required>
<InputNumber v-model="exitForm.quantity" :min="0" :max-fraction-digits="4" class="w-full" />
</FormField>
<FormField label="Concepto presupuesto" class="span-2">
<BudgetItemPicker v-if="exitProjectId" v-model="exitForm.budget_item_id" :project-id="exitProjectId" />
<InputText v-else disabled placeholder="Solo aplica en almacén de obra" class="w-full" />
</FormField>
<FormField label="Destino físico" class="span-2">
<InputText v-model="exitForm.destination_note" class="w-full" />
@ -81,19 +189,29 @@
</template>
</Dialog>
<Dialog v-model:visible="showTransfer" header="Transferencia central ↔ obra" modal :style="{ width: '640px' }">
<Dialog v-model:visible="showTransfer" header="Traspaso entre almacenes" modal :style="{ width: '680px' }">
<div class="form-grid form-grid-compact">
<FormField label="Desde">
<Select v-model="transferForm.from_warehouse_id" :options="warehouses" option-label="label" option-value="id" class="w-full" />
<FormField label="Desde" required>
<Select v-model="transferForm.from_warehouse_id" :options="openWarehouses" option-label="label" option-value="id" class="w-full" />
</FormField>
<FormField label="Hacia">
<Select v-model="transferForm.to_warehouse_id" :options="warehouses" option-label="label" option-value="id" class="w-full" />
<FormField label="Hacia" required>
<Select v-model="transferForm.to_warehouse_id" :options="openWarehouses" option-label="label" option-value="id" class="w-full" />
</FormField>
<FormField label="Material">
<Select v-model="transferForm.material_id" :options="materialOptions" option-label="label" option-value="id" class="w-full" />
<FormField label="Material" required>
<Select
v-model="transferForm.material_id"
:options="stockMaterialOptions(transferForm.from_warehouse_id)"
option-label="label"
option-value="id"
filter
class="w-full"
/>
</FormField>
<FormField label="Cantidad">
<InputNumber v-model="transferForm.quantity" :min="0" class="w-full" />
<FormField label="Cantidad" required>
<InputNumber v-model="transferForm.quantity" :min="0" :max-fraction-digits="4" class="w-full" />
</FormField>
<FormField label="Notas" class="span-2">
<InputText v-model="transferForm.notes" class="w-full" />
</FormField>
</div>
<template #footer>
@ -101,89 +219,347 @@
<Button label="Transferir" icon="pi pi-arrow-right-arrow-left" @click="saveTransfer" />
</template>
</Dialog>
<MaterialCatalogDialog v-model="showCatalog" @saved="onMaterialSaved" />
</div>
</template>
<script setup lang="ts">
import FormField from "~/components/padron/FormField.vue";
import BudgetItemPicker from "~/components/BudgetItemPicker.vue";
import MaterialCatalogDialog, { type Material } from "~/components/MaterialCatalogDialog.vue";
import { displayErrorMessage } from "~/composables/api-response";
type WarehouseRow = { id: number; kind: string; project_id?: number; project_code?: string; label: string };
type StockRow = Record<string, unknown>;
type WarehouseRow = {
id: number;
kind: string;
status?: string;
project_id?: number;
project_code?: string;
project_name?: string;
label: string;
};
type StockRow = Record<string, unknown> & {
row_key?: string;
material_id?: number;
material_code?: string;
material_name?: string;
warehouse_id?: number;
warehouse_label?: string;
qty_on_hand?: number;
stock_value?: number;
unit?: string;
unit_label?: string;
warehouse_count?: number;
};
type MovementRow = Record<string, unknown>;
type ProjectOption = { id: number; name: string; code?: string; status?: string; label: string };
const desk = useDesktop();
const { api } = useApi();
const toast = useToast();
const perms = usePermissions();
const tableQ = ref("");
const warehouses = ref<WarehouseRow[]>([]);
const projects = ref<ProjectOption[]>([]);
const materials = ref<Material[]>([]);
const stockRows = ref<StockRow[]>([]);
const consolidated = ref<StockRow[]>([]);
const movements = ref<MovementRow[]>([]);
const materialOptions = ref<{ id: number; label: string }[]>([]);
const showEntry = ref(false);
const showExit = ref(false);
const showTransfer = ref(false);
const showOpen = ref(false);
const showClose = ref(false);
const showCatalog = ref(false);
const exitWarning = ref("");
const openProjectId = ref<number | null>(null);
const closeNotes = ref("");
const transferRemaining = ref(true);
const entryForm = reactive({ warehouse_id: 0, material_code: "", material_name: "", quantity: 0, unit_cost: 0 });
const exitForm = reactive({ warehouse_id: 0, material_id: 0, quantity: 0, budget_item_id: null as number | null, destination_note: "" });
const transferForm = reactive({ from_warehouse_id: 0, to_warehouse_id: 0, material_id: 0, quantity: 0 });
const entryForm = reactive({
warehouse_id: 0,
material_id: 0,
quantity: 0,
unit_cost: 0,
movement_date: new Date().toISOString().slice(0, 10),
});
const exitForm = reactive({
warehouse_id: 0,
material_id: 0,
quantity: 0,
budget_item_id: null as number | null,
destination_note: "",
});
const transferForm = reactive({
from_warehouse_id: 0,
to_warehouse_id: 0,
material_id: 0,
quantity: 0,
notes: "",
});
const projectWarehouses = computed(() => warehouses.value.filter((w) => w.kind === "project"));
const exitProjectId = computed(() => projectWarehouses.value.find((w) => w.id === exitForm.warehouse_id)?.project_id ?? 0);
const stockValue = computed(() => stockRows.value.reduce((s, r) => s + Number(r.stock_value || 0), 0));
const filteredStock = computed(() => {
const canUpdate = computed(() => perms.can("warehouse.update"));
const canCreate = computed(() => perms.can("warehouse.create"));
const openWarehouses = computed(() => warehouses.value.filter((w) => w.status !== "closed"));
const projectWarehouses = computed(() => openWarehouses.value.filter((w) => w.kind === "project"));
const centralWarehouse = computed(() => warehouses.value.find((w) => w.kind === "central") ?? null);
const isGlobalView = computed(() => !desk.treeKey.value || desk.treeKey.value === "global");
const selectedWarehouse = computed(() => {
if (isGlobalView.value) return centralWarehouse.value;
if (desk.treeKey.value.startsWith("w:")) {
const id = Number(desk.treeKey.value.slice(2));
return warehouses.value.find((w) => w.id === id) ?? null;
}
return null;
});
const currentLabel = computed(() =>
isGlobalView.value ? "Almacén global" : (selectedWarehouse.value?.label || "Almacén"),
);
const stockMode = computed(() => (isGlobalView.value ? "consolidated" : "detail"));
const exitProjectId = computed(() =>
projectWarehouses.value.find((w) => w.id === exitForm.warehouse_id)?.project_id ?? 0,
);
const projectsWithoutWarehouse = computed(() => {
const openIds = new Set(projectWarehouses.value.map((w) => w.project_id));
return projects.value.filter((p) => !openIds.has(p.id));
});
const materialSelectOptions = computed(() =>
materials.value.map((m) => ({
id: m.id,
label: `${m.code} — ${m.name}`,
})),
);
const selectedEntryMaterial = computed(() =>
materials.value.find((m) => m.id === entryForm.material_id) ?? null,
);
const selectedEntrySuppliers = computed(() =>
(selectedEntryMaterial.value?.suppliers ?? []).map((s) => s.name).join(", "),
);
const visibleStock = computed(() => {
const rows = isGlobalView.value ? consolidated.value : stockRows.value;
return filterStock(rows);
});
const filteredDetail = computed(() => filterStock(stockRows.value));
const stockValue = computed(() =>
visibleStock.value.reduce((s, r) => s + Number(r.stock_value || 0), 0),
);
const filteredMovements = computed(() => {
const q = tableQ.value.toLowerCase();
return stockRows.value.filter((r) =>
!q || [r.material_name, r.material_code, r.project_code].join(" ").toLowerCase().includes(q),
return movements.value.filter((r) =>
!q || [r.material_name, r.material_code, r.warehouse_label, r.destination_note].join(" ").toLowerCase().includes(q),
);
});
function filterStock(rows: StockRow[]) {
const q = tableQ.value.toLowerCase();
return rows.filter((r) =>
!q || [r.material_name, r.material_code, r.warehouse_label, r.project_code].join(" ").toLowerCase().includes(q),
);
}
function stockMaterialOptions(warehouseId: number) {
const seen = new Map<number, string>();
for (const row of stockRows.value) {
if (warehouseId && Number(row.warehouse_id) !== warehouseId && row.warehouse_id != null) continue;
const id = Number(row.material_id);
if (!id || seen.has(id)) continue;
seen.set(id, `${row.material_code} — ${row.material_name}`);
}
if (!seen.size) {
for (const m of materials.value) seen.set(m.id, `${m.code} — ${m.name}`);
}
return [...seen.entries()].map(([id, label]) => ({ id, label }));
}
function money(v: number) {
return new Intl.NumberFormat("es-MX", { style: "currency", currency: "MXN" }).format(Number(v || 0));
}
async function loadWarehouses() {
await api("/v1/warehouses/central/ensure", { method: "POST", json: {} });
const r = await api<{ data?: { warehouses?: Array<Record<string, unknown>> } }>("/v1/warehouses");
const rows = (r.data?.warehouses ?? []) as Array<Record<string, unknown>>;
warehouses.value = rows.map((w) => ({
id: Number(w.id),
kind: String(w.kind),
project_id: w.project_id ? Number(w.project_id) : undefined,
project_code: String(w.project_code ?? (w.kind === "central" ? "CENTRAL" : "")),
label: w.kind === "central" ? "Almacén central" : `${w.project_code} — ${w.project_name ?? "Obra"}`,
function movementLabel(type: unknown) {
switch (String(type || "")) {
case "entry": return "Entrada";
case "exit": return "Salida";
case "transfer_out": return "Traspaso salida";
case "transfer_in": return "Traspaso entrada";
case "adjustment": return "Ajuste";
default: return String(type || "—");
}
}
function warehouseLabel(w: Record<string, unknown>) {
if (w.kind === "central") return "Almacén global";
const code = String(w.project_code || "");
const name = String(w.project_name || "Obra");
return code ? `${code} — ${name}` : name;
}
function withRowKeys(rows: StockRow[], prefix: string) {
return rows.map((r, i) => ({
...r,
row_key: `${prefix}-${r.warehouse_id ?? "g"}-${r.material_id ?? i}`,
}));
}
async function loadStock() {
const r = await api<{ data?: { stock?: StockRow[] } }>("/v1/warehouses/stock/global");
stockRows.value = r.data?.stock ?? [];
const mats = new Map<number, string>();
for (const row of stockRows.value) {
mats.set(Number(row.material_id), `${row.material_code} — ${row.material_name}`);
const entryDate = computed({
get: () => {
const [y, m, d] = entryForm.movement_date.split("-").map(Number);
return y ? new Date(y, (m || 1) - 1, d || 1) : new Date();
},
set: (value: Date | null) => {
if (!value) return;
const y = value.getFullYear();
const m = String(value.getMonth() + 1).padStart(2, "0");
const d = String(value.getDate()).padStart(2, "0");
entryForm.movement_date = `${y}-${m}-${d}`;
},
});
async function loadProjects() {
const r = await api<{ projects?: Array<{ id: number; name: string; code?: string; status?: string }> }>(
"/v1/projects?status=activo,pausado",
);
projects.value = (r.projects ?? []).map((p) => ({
...p,
label: p.code ? `${p.code} — ${p.name}` : p.name,
}));
}
async function loadMaterials() {
try {
const r = await api<{ materials?: Material[] }>("/v1/warehouse-materials");
materials.value = r.materials ?? [];
} catch {
materials.value = [];
}
materialOptions.value = [...mats.entries()].map(([id, label]) => ({ id, label }));
}
async function loadWarehouses() {
await api("/v1/warehouses/central/ensure", { method: "POST", json: {} }).catch(() => undefined);
const r = await api<{ warehouses?: Array<Record<string, unknown>> }>("/v1/warehouses");
const rows = r.warehouses ?? [];
warehouses.value = rows.map((w) => ({
id: Number(w.id),
kind: String(w.kind),
status: String(w.status ?? "open"),
project_id: w.project_id ? Number(w.project_id) : undefined,
project_code: String(w.project_code ?? ""),
project_name: String(w.project_name ?? ""),
label: warehouseLabel(w),
}));
}
function aggregateClient(rows: StockRow[]): StockRow[] {
const map = new Map<number, StockRow>();
for (const row of rows) {
const id = Number(row.material_id);
if (!id) continue;
const cur = map.get(id);
if (!cur) {
map.set(id, { ...row, warehouse_id: undefined, warehouse_count: 1 });
continue;
}
cur.qty_on_hand = Number(cur.qty_on_hand || 0) + Number(row.qty_on_hand || 0);
cur.stock_value = Number(cur.stock_value || 0) + Number(row.stock_value || 0);
cur.warehouse_count = Number(cur.warehouse_count || 1) + 1;
}
return [...map.values()];
}
async function loadStock() {
if (isGlobalView.value) {
const r = await api<{ stock?: StockRow[]; consolidated?: StockRow[] }>("/v1/warehouses/stock/global");
const detail = r.stock ?? [];
stockRows.value = withRowKeys(detail, "d");
const cons = r.consolidated ?? [];
consolidated.value = withRowKeys(cons.length ? cons : aggregateClient(detail), "c");
return;
}
const id = selectedWarehouse.value?.id;
if (!id) {
stockRows.value = [];
consolidated.value = [];
return;
}
const r = await api<{ stock?: StockRow[] }>(`/v1/warehouses/${id}/stock`);
stockRows.value = withRowKeys(r.stock ?? [], "w");
consolidated.value = [];
}
async function loadMovements() {
const wh = warehouses.value[0]?.id;
if (!wh) return;
const r = await api<{ data?: { movements?: MovementRow[] } }>(`/v1/warehouses/${wh}/movements`);
movements.value = r.data?.movements ?? [];
if (isGlobalView.value) {
const r = await api<{ movements?: MovementRow[] }>("/v1/warehouses/movements/global");
movements.value = r.movements ?? [];
return;
}
const id = selectedWarehouse.value?.id;
if (!id) {
movements.value = [];
return;
}
const r = await api<{ movements?: MovementRow[] }>(`/v1/warehouses/${id}/movements`);
movements.value = r.movements ?? [];
}
function defaultWarehouseId() {
return selectedWarehouse.value?.id || centralWarehouse.value?.id || 0;
}
function openEntry() {
entryForm.warehouse_id = defaultWarehouseId();
entryForm.material_id = materials.value[0]?.id ?? 0;
entryForm.quantity = 0;
entryForm.unit_cost = 0;
showEntry.value = true;
}
function openExitDlg() {
exitForm.warehouse_id = defaultWarehouseId();
exitForm.material_id = 0;
exitForm.quantity = 0;
exitForm.budget_item_id = null;
exitForm.destination_note = "";
exitWarning.value = "";
showExit.value = true;
}
function openTransferDlg() {
transferForm.from_warehouse_id = defaultWarehouseId();
transferForm.to_warehouse_id = isGlobalView.value
? (projectWarehouses.value[0]?.id ?? 0)
: (centralWarehouse.value?.id ?? 0);
transferForm.material_id = 0;
transferForm.quantity = 0;
transferForm.notes = "";
showTransfer.value = true;
}
async function saveEntry() {
if (!entryForm.warehouse_id || !entryForm.material_id || entryForm.quantity <= 0) {
toast.add({ severity: "warn", summary: "Seleccione almacén, material y una cantidad mayor a cero", life: 3000 });
return;
}
try {
const r = await api<{ message?: string }>(`/v1/warehouses/${entryForm.warehouse_id}/entries`, {
method: "POST",
json: entryForm,
json: {
warehouse_id: entryForm.warehouse_id,
material_id: entryForm.material_id,
quantity: entryForm.quantity,
unit_cost: entryForm.unit_cost,
movement_date: entryForm.movement_date,
},
});
toast.add({ severity: "success", summary: r.message || "Entrada registrada", life: 2000 });
showEntry.value = false;
await loadStock();
await refresh();
} catch (e) {
toast.add({ severity: "error", summary: displayErrorMessage((e as { data?: unknown }).data ?? e), life: 4000 });
}
@ -191,14 +567,14 @@ async function saveEntry() {
async function saveExit() {
try {
const r = await api<{ data?: { budget_warning?: { message: string } }; message?: string }>(
const r = await api<{ budget_warning?: { message: string }; message?: string }>(
`/v1/warehouses/${exitForm.warehouse_id}/exits`,
{ method: "POST", json: exitForm },
);
exitWarning.value = r.data?.budget_warning?.message ?? "";
exitWarning.value = r.budget_warning?.message ?? "";
toast.add({ severity: "success", summary: r.message || "Salida registrada", life: 2000 });
if (!exitWarning.value) showExit.value = false;
await loadStock();
await refresh();
} catch (e) {
toast.add({ severity: "error", summary: displayErrorMessage((e as { data?: unknown }).data ?? e), life: 4000 });
}
@ -207,29 +583,138 @@ async function saveExit() {
async function saveTransfer() {
try {
const r = await api<{ message?: string }>("/v1/warehouse-transfers", { method: "POST", json: transferForm });
toast.add({ severity: "success", summary: r.message || "Transferencia completada", life: 2000 });
toast.add({ severity: "success", summary: r.message || "Traspaso completado", life: 2000 });
showTransfer.value = false;
await loadStock();
await refresh();
} catch (e) {
toast.add({ severity: "error", summary: displayErrorMessage((e as { data?: unknown }).data ?? e), life: 4000 });
}
}
onMounted(async () => {
async function openWarehouse() {
if (!openProjectId.value) return;
try {
const r = await api<{ message?: string }>(`/v1/projects/${openProjectId.value}/warehouse/open`, {
method: "POST",
json: {},
});
toast.add({ severity: "success", summary: r.message || "Almacén abierto", life: 2200 });
showOpen.value = false;
const projectId = openProjectId.value;
openProjectId.value = null;
await loadWarehouses();
const opened = warehouses.value.find((w) => w.project_id === projectId);
desk.treeKey.value = opened ? `w:${opened.id}` : "global";
await refresh();
syncChrome();
} catch (e) {
toast.add({ severity: "error", summary: displayErrorMessage((e as { data?: unknown }).data ?? e), life: 4000 });
}
}
async function closeWarehouse() {
const projectId = selectedWarehouse.value?.project_id;
if (!projectId) return;
try {
const r = await api<{ message?: string }>(`/v1/projects/${projectId}/warehouse/close`, {
method: "POST",
json: { close_notes: closeNotes.value, transfer_remaining: transferRemaining.value },
});
toast.add({ severity: "success", summary: r.message || "Almacén cerrado", life: 2200 });
showClose.value = false;
closeNotes.value = "";
transferRemaining.value = true;
desk.treeKey.value = "global";
await loadWarehouses();
await refresh();
syncChrome();
} catch (e) {
toast.add({ severity: "error", summary: displayErrorMessage((e as { data?: unknown }).data ?? e), life: 4000 });
}
}
async function onMaterialSaved(material: Material) {
await loadMaterials();
if (material?.id) entryForm.material_id = material.id;
}
async function refresh() {
await Promise.all([loadStock(), loadMovements(), loadMaterials()]);
syncChrome();
}
function syncChrome() {
const projectNodes = projectWarehouses.value.map((w) => ({
key: `w:${w.id}`,
label: w.project_name || w.label,
icon: "pi pi-building",
}));
desk.configure({
tab: "Almacén",
ribbon: [{
treeTitle: "Almacenes",
showTree: true,
showDetail: false,
treeKey: desk.treeKey.value || "global",
countLabel: `${visibleStock.value.length} líneas`,
tree: [
{ key: "global", label: "Almacén global", icon: "pi pi-box", data: { count: consolidated.value.length || stockRows.value.length } },
...projectNodes,
],
onTreeSelect: () => {
void refresh();
},
ribbon: [
{
key: "almacenes",
label: "Almacenes",
items: [
{
key: "open",
label: "Abrir almacén",
icon: "pi pi-folder-open",
disabled: !canUpdate.value,
command: () => {
openProjectId.value = projectsWithoutWarehouse.value[0]?.id ?? null;
showOpen.value = true;
},
},
{
key: "close",
label: "Cerrar almacén",
icon: "pi pi-lock",
disabled: !canUpdate.value || isGlobalView.value || selectedWarehouse.value?.kind !== "project",
title: isGlobalView.value
? "El almacén global no se cierra"
: "Cerrar el almacén de la obra seleccionada",
command: () => { showClose.value = true; },
},
],
},
{
key: "wh",
label: "Movimientos",
items: [
{ key: "entry", label: "Entrada", icon: "pi pi-download", command: () => { showEntry.value = true; } },
{ key: "exit", label: "Salida", icon: "pi pi-upload", command: () => { showExit.value = true; } },
{ key: "xfer", label: "Transferir", icon: "pi pi-arrow-right-arrow-left", command: () => { showTransfer.value = true; } },
{ key: "entry", label: "Entrada", icon: "pi pi-download", disabled: !canCreate.value, command: openEntry },
{ key: "exit", label: "Salida", icon: "pi pi-upload", disabled: !canUpdate.value, command: openExitDlg },
{ key: "xfer", label: "Transferir", icon: "pi pi-arrow-right-arrow-left", disabled: !canUpdate.value, command: openTransferDlg },
],
},
{
key: "cat",
label: "Catálogo",
items: [
{ key: "materials", label: "Materiales", icon: "pi pi-book", command: () => { showCatalog.value = true; } },
{ key: "refresh", label: "Actualizar", icon: "pi pi-refresh", command: () => { void refresh(); } },
],
},
],
}],
});
await loadWarehouses();
await loadStock();
await loadMovements();
}
onMounted(async () => {
desk.treeKey.value = "global";
syncChrome();
await Promise.all([loadProjects(), loadWarehouses(), loadMaterials()]);
await refresh();
});
</script>

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 }>(
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: { ...form, project_id: form.project_id || null },
},
{ method: "POST", json: payload() },
);
const expenseId = r.data?.expense?.id;
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>