mirror of
https://origin.cursor.com/mrdevmx/panels.git
synced 2026-10-09 16:33:18 +00:00
Pre-computed merge for change #15
This commit is contained in:
commit
ff04a6621a
14 changed files with 1441 additions and 92 deletions
|
|
@ -233,6 +233,37 @@ Deno.test("RPC control presupuestal: summary, items, chapters, deviations", asyn
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Deno.test("RPC catálogos: proveedores y unidades de medida", async () => {
|
||||||
|
await withTestDb(async (db) => {
|
||||||
|
const created = await callCoreFn(db, "core.fn_supplier_create", {
|
||||||
|
tenant_id: TENANT_ID,
|
||||||
|
name: "Home Depot",
|
||||||
|
rfc: "HDM123456ABC",
|
||||||
|
phone: "9980000000",
|
||||||
|
});
|
||||||
|
assertEquals(created.ok, true, `crear proveedor: ${JSON.stringify(created)}`);
|
||||||
|
const supplier = (created.data as { supplier: { id: number; rfc: string } }).supplier;
|
||||||
|
assertEquals(supplier.rfc, "HDM123456ABC");
|
||||||
|
|
||||||
|
const listed = await callCoreFn(db, "core.fn_supplier_list", { tenant_id: TENANT_ID, status: "activo" });
|
||||||
|
assertEquals(listed.ok, true);
|
||||||
|
const suppliers = (listed.data as { suppliers: { name: string }[] }).suppliers;
|
||||||
|
assert(suppliers.some((s) => s.name === "Home Depot"));
|
||||||
|
|
||||||
|
const units = await callCoreFn(db, "core.fn_measure_unit_list", { tenant_id: TENANT_ID });
|
||||||
|
assertEquals(units.ok, true);
|
||||||
|
const unitRows = (units.data as { units: { code: string }[] }).units;
|
||||||
|
assert(unitRows.some((u) => u.code === "bulto"));
|
||||||
|
|
||||||
|
const extra = await callCoreFn(db, "core.fn_measure_unit_add", {
|
||||||
|
tenant_id: TENANT_ID,
|
||||||
|
code: "tinaco",
|
||||||
|
label: "Tinaco",
|
||||||
|
});
|
||||||
|
assertEquals(extra.ok, true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
Deno.test("RPC IAM v2: roles y permisos por role_id", async () => {
|
Deno.test("RPC IAM v2: roles y permisos por role_id", async () => {
|
||||||
const url = Deno.env.get("DATABASE_URL_IAM");
|
const url = Deno.env.get("DATABASE_URL_IAM");
|
||||||
if (!url) throw new Error("DATABASE_URL_IAM requerido");
|
if (!url) throw new Error("DATABASE_URL_IAM requerido");
|
||||||
|
|
|
||||||
67
api/expense_catalogs_http.ts
Normal file
67
api/expense_catalogs_http.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
import type { Hono } from "hono";
|
||||||
|
import type { AuthUser } from "./auth.ts";
|
||||||
|
import { tenantScope } from "./auth.ts";
|
||||||
|
import { requireCoreAuth } from "./scope.ts";
|
||||||
|
import { requireAnyPermission } from "./permissions.ts";
|
||||||
|
import { callCoreFn } from "./rpc.ts";
|
||||||
|
import { respondRpc, routeLabel } from "./http_errors.ts";
|
||||||
|
|
||||||
|
type App = Hono<{ Variables: { user: AuthUser; db: Db } }>;
|
||||||
|
type Db = import("./db.ts").Db;
|
||||||
|
|
||||||
|
function tid(c: { get: (k: "user") => AuthUser }): number {
|
||||||
|
return tenantScope(c.get("user")) ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerExpenseCatalogRoutes(app: App) {
|
||||||
|
const view = [...requireCoreAuth, requireAnyPermission("expenses.view", "settings.view")] as const;
|
||||||
|
const write = [...requireCoreAuth, requireAnyPermission("expenses.create", "settings.update")] as const;
|
||||||
|
|
||||||
|
app.get("/v1/suppliers", ...view, async (c) => {
|
||||||
|
const db = c.get("db");
|
||||||
|
const env = await callCoreFn(db, "core.fn_supplier_list", {
|
||||||
|
tenant_id: tid(c),
|
||||||
|
status: c.req.query("status"),
|
||||||
|
}, { route: routeLabel(c) });
|
||||||
|
return respondRpc(c, env);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/v1/suppliers", ...write, async (c) => {
|
||||||
|
const body = await c.req.json<Record<string, unknown>>();
|
||||||
|
const db = c.get("db");
|
||||||
|
const env = await callCoreFn(db, "core.fn_supplier_create", {
|
||||||
|
...body,
|
||||||
|
tenant_id: tid(c),
|
||||||
|
}, { route: routeLabel(c) });
|
||||||
|
return respondRpc(c, env);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.patch("/v1/suppliers/:id", ...write, async (c) => {
|
||||||
|
const body = await c.req.json<Record<string, unknown>>();
|
||||||
|
const db = c.get("db");
|
||||||
|
const env = await callCoreFn(db, "core.fn_supplier_update", {
|
||||||
|
...body,
|
||||||
|
id: Number(c.req.param("id")),
|
||||||
|
tenant_id: tid(c),
|
||||||
|
}, { route: routeLabel(c) });
|
||||||
|
return respondRpc(c, env);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/v1/measure-units", ...view, async (c) => {
|
||||||
|
const db = c.get("db");
|
||||||
|
const env = await callCoreFn(db, "core.fn_measure_unit_list", {
|
||||||
|
tenant_id: tid(c),
|
||||||
|
}, { route: routeLabel(c) });
|
||||||
|
return respondRpc(c, env);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/v1/measure-units", ...write, async (c) => {
|
||||||
|
const body = await c.req.json<Record<string, unknown>>();
|
||||||
|
const db = c.get("db");
|
||||||
|
const env = await callCoreFn(db, "core.fn_measure_unit_add", {
|
||||||
|
...body,
|
||||||
|
tenant_id: tid(c),
|
||||||
|
}, { route: routeLabel(c) });
|
||||||
|
return respondRpc(c, env);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -65,6 +65,7 @@ import {
|
||||||
import { generateBadgePdf, saveJobPdf, loadCurrentPhoto, badgeQrPng } from "./pdf.ts";
|
import { generateBadgePdf, saveJobPdf, loadCurrentPhoto, badgeQrPng } from "./pdf.ts";
|
||||||
import { registerPayrollRoutes } from "./payroll_http.ts";
|
import { registerPayrollRoutes } from "./payroll_http.ts";
|
||||||
import { registerExpenseRoutes } from "./expenses_http.ts";
|
import { registerExpenseRoutes } from "./expenses_http.ts";
|
||||||
|
import { registerExpenseCatalogRoutes } from "./expense_catalogs_http.ts";
|
||||||
import { registerWarehouseRoutes } from "./warehouse_http.ts";
|
import { registerWarehouseRoutes } from "./warehouse_http.ts";
|
||||||
import { registerCostControlRoutes } from "./cost_control_http.ts";
|
import { registerCostControlRoutes } from "./cost_control_http.ts";
|
||||||
import { registerWorkProgramRoutes } from "./work_program_http.ts";
|
import { registerWorkProgramRoutes } from "./work_program_http.ts";
|
||||||
|
|
@ -1378,6 +1379,7 @@ app.patch("/v1/badge-jobs/:id/people/:workerId", ...requireCoreAuth, requirePerm
|
||||||
|
|
||||||
registerPayrollRoutes(app);
|
registerPayrollRoutes(app);
|
||||||
registerExpenseRoutes(app);
|
registerExpenseRoutes(app);
|
||||||
|
registerExpenseCatalogRoutes(app);
|
||||||
registerWarehouseRoutes(app);
|
registerWarehouseRoutes(app);
|
||||||
registerCostControlRoutes(app);
|
registerCostControlRoutes(app);
|
||||||
registerWorkProgramRoutes(app);
|
registerWorkProgramRoutes(app);
|
||||||
|
|
|
||||||
|
|
@ -38,5 +38,7 @@
|
||||||
<include file="changesets/029-schema-work-program.sql" relativeToChangelogFile="true"/>
|
<include file="changesets/029-schema-work-program.sql" relativeToChangelogFile="true"/>
|
||||||
<include file="changesets/030-rpc-work-program.sql" relativeToChangelogFile="true"/>
|
<include file="changesets/030-rpc-work-program.sql" relativeToChangelogFile="true"/>
|
||||||
<include file="changesets/031-project-doc-types-technical.sql" relativeToChangelogFile="true"/>
|
<include file="changesets/031-project-doc-types-technical.sql" relativeToChangelogFile="true"/>
|
||||||
|
<include file="changesets/033-catalog-suppliers-units.sql" relativeToChangelogFile="true"/>
|
||||||
|
<include file="changesets/034-fn-expense-update-reclassify.sql" relativeToChangelogFile="true"/>
|
||||||
|
|
||||||
</databaseChangeLog>
|
</databaseChangeLog>
|
||||||
|
|
|
||||||
397
db/core/changesets/033-catalog-suppliers-units.sql
Normal file
397
db/core/changesets/033-catalog-suppliers-units.sql
Normal 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;
|
||||||
111
db/core/changesets/034-fn-expense-update-reclassify.sql
Normal file
111
db/core/changesets/034-fn-expense-update-reclassify.sql
Normal 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;
|
||||||
|
|
@ -1844,7 +1844,8 @@ html.dark .p-tieredmenu,
|
||||||
html.dark .p-popover,
|
html.dark .p-popover,
|
||||||
html.dark .p-datepicker,
|
html.dark .p-datepicker,
|
||||||
html.dark .p-select-overlay,
|
html.dark .p-select-overlay,
|
||||||
html.dark .p-multiselect-overlay {
|
html.dark .p-multiselect-overlay,
|
||||||
|
html.dark .p-autocomplete-overlay {
|
||||||
--p-content-background: #111b2e;
|
--p-content-background: #111b2e;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2165,7 +2166,8 @@ html.dark .p-select-label.p-placeholder {
|
||||||
|
|
||||||
html.dark .p-select-overlay,
|
html.dark .p-select-overlay,
|
||||||
html.dark .p-datepicker-panel,
|
html.dark .p-datepicker-panel,
|
||||||
html.dark .p-multiselect-overlay {
|
html.dark .p-multiselect-overlay,
|
||||||
|
html.dark .p-autocomplete-overlay {
|
||||||
background: #111b2e !important;
|
background: #111b2e !important;
|
||||||
border-color: #243044 !important;
|
border-color: #243044 !important;
|
||||||
color: #e2e8f0 !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 { background: #ffebee !important; color: #b42318 !important; }
|
||||||
.cost-critical-text { color: #b42318; font-weight: 600; }
|
.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. */
|
/* Grouped menubar: single row, icons, card-style submenus. */
|
||||||
.menubar.p-menubar {
|
.menubar.p-menubar {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,35 @@
|
||||||
<template>
|
<template>
|
||||||
<AutoComplete
|
<Select
|
||||||
v-model="selected"
|
:model-value="modelValue ?? null"
|
||||||
:suggestions="filtered"
|
:options="items"
|
||||||
option-label="label"
|
option-label="label"
|
||||||
|
option-value="id"
|
||||||
placeholder="Buscar concepto del presupuesto..."
|
placeholder="Buscar concepto del presupuesto..."
|
||||||
class="w-full"
|
filter
|
||||||
force-selection
|
filter-placeholder="Clave o descripción"
|
||||||
@complete="onSearch"
|
show-clear
|
||||||
@item-select="onSelect"
|
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>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
|
@ -29,8 +50,8 @@ type BudgetOption = {
|
||||||
|
|
||||||
const { api } = useApi();
|
const { api } = useApi();
|
||||||
const items = ref<BudgetOption[]>([]);
|
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() {
|
async function load() {
|
||||||
if (!props.projectId) {
|
if (!props.projectId) {
|
||||||
|
|
@ -49,25 +70,13 @@ async function load() {
|
||||||
amount: Number(row.amount ?? 0),
|
amount: Number(row.amount ?? 0),
|
||||||
label: [row.code, row.description].filter(Boolean).join(" — "),
|
label: [row.code, row.description].filter(Boolean).join(" — "),
|
||||||
}));
|
}));
|
||||||
filtered.value = items.value.slice(0, 30);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function onSearch(event: { query: string }) {
|
function onUpdate(id: number | null) {
|
||||||
const q = event.query.toLowerCase();
|
const option = id == null ? null : items.value.find((i) => i.id === id) ?? null;
|
||||||
filtered.value = items.value.filter((i) => i.label.toLowerCase().includes(q)).slice(0, 30);
|
emit("update:modelValue", option?.id ?? null);
|
||||||
}
|
emit("select", option);
|
||||||
|
|
||||||
function onSelect(event: { value: BudgetOption }) {
|
|
||||||
emit("update:modelValue", event.value?.id ?? null);
|
|
||||||
emit("select", event.value ?? null);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(() => props.projectId, load, { immediate: true });
|
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>
|
</script>
|
||||||
|
|
|
||||||
129
web-panel/components/SupplierFormDialog.vue
Normal file
129
web-panel/components/SupplierFormDialog.vue
Normal 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>
|
||||||
21
web-panel/composables/expenseAmounts.ts
Normal file
21
web-panel/composables/expenseAmounts.ts
Normal 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) };
|
||||||
|
}
|
||||||
|
|
@ -87,7 +87,7 @@ export function useDesktop() {
|
||||||
}) {
|
}) {
|
||||||
tabLabel.value = opts.tab;
|
tabLabel.value = opts.tab;
|
||||||
if (opts.treeTitle != null) treeTitle.value = opts.treeTitle;
|
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.tree) tree.value = opts.tree;
|
||||||
if (opts.treeKey != null) treeKey.value = opts.treeKey;
|
if (opts.treeKey != null) treeKey.value = opts.treeKey;
|
||||||
if (opts.showTree != null) showTree.value = opts.showTree;
|
if (opts.showTree != null) showTree.value = opts.showTree;
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,16 @@
|
||||||
<Chip v-if="tab === 'empresas'" :label="`${listedCompanies.length} empresas`" icon="pi pi-briefcase" />
|
<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 === '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 === '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 === 'riesgos'" :label="`${listedRisks.length} riesgos`" icon="pi pi-exclamation-triangle" />
|
||||||
<Chip v-if="tab === 'temas'" :label="`${listedThemes.length} temas`" icon="pi pi-palette" />
|
<Chip v-if="tab === 'temas'" :label="`${listedThemes.length} temas`" icon="pi pi-palette" />
|
||||||
</DeskTableBar>
|
</DeskTableBar>
|
||||||
<Tabs v-model:value="tab">
|
<Tabs v-model:value="tab">
|
||||||
<TabList>
|
<TabList>
|
||||||
<Tab value="empresas">Empresas</Tab>
|
<Tab value="empresas">Empresas</Tab>
|
||||||
|
<Tab value="proveedores">Proveedores</Tab>
|
||||||
|
<Tab value="unidades">Unidades</Tab>
|
||||||
<Tab value="nomina">Nómina</Tab>
|
<Tab value="nomina">Nómina</Tab>
|
||||||
<Tab value="riesgos">Riesgos</Tab>
|
<Tab value="riesgos">Riesgos</Tab>
|
||||||
<Tab value="temas">Temas de gafete</Tab>
|
<Tab value="temas">Temas de gafete</Tab>
|
||||||
|
|
@ -74,6 +78,7 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<h3 class="mt-4">Unidades de destajo</h3>
|
<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">
|
<DataTable :value="units" class="mt-2" empty-message="Sin unidades">
|
||||||
<Column field="code" header="Código" />
|
<Column field="code" header="Código" />
|
||||||
<Column field="label" header="Nombre" />
|
<Column field="label" header="Nombre" />
|
||||||
|
|
@ -90,6 +95,51 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</TabPanel>
|
</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">
|
<TabPanel value="riesgos">
|
||||||
<DataTable :value="listedRisks" paginator :rows="15">
|
<DataTable :value="listedRisks" paginator :rows="15">
|
||||||
<Column field="code" header="Barra gafete" />
|
<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" />
|
<Button v-if="formTab === 'datos'" :label="editing ? 'Guardar' : 'Crear'" icon="pi pi-check" :loading="saving" @click="save" />
|
||||||
</template>
|
</template>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
<SupplierFormDialog v-model="showSupplierForm" :supplier="editingSupplier" @saved="onSupplierSaved" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import FormField from "~/components/padron/FormField.vue";
|
import FormField from "~/components/padron/FormField.vue";
|
||||||
import CompanyDocuments from "~/components/CompanyDocuments.vue";
|
import CompanyDocuments from "~/components/CompanyDocuments.vue";
|
||||||
|
import SupplierFormDialog, { type Supplier } from "~/components/SupplierFormDialog.vue";
|
||||||
import type { CompanyOption } from "~/types/padron";
|
import type { CompanyOption } from "~/types/padron";
|
||||||
|
|
||||||
const emptyForm = () => ({
|
const emptyForm = () => ({
|
||||||
|
|
@ -258,6 +311,11 @@ const payroll = reactive({
|
||||||
});
|
});
|
||||||
const units = ref<{ code: string; label: string }[]>([]);
|
const units = ref<{ code: string; label: string }[]>([]);
|
||||||
const unitForm = reactive({ code: "", label: "" });
|
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 savingPayroll = ref(false);
|
||||||
const showForm = ref(false);
|
const showForm = ref(false);
|
||||||
const saving = ref(false);
|
const saving = ref(false);
|
||||||
|
|
@ -293,7 +351,17 @@ const claseRiesgoOpts = [
|
||||||
const principal = computed(() => companies.value.find((company) => company.kind === "principal"));
|
const principal = computed(() => companies.value.find((company) => company.kind === "principal"));
|
||||||
const { tableQ, matches } = useTableSearch();
|
const { tableQ, matches } = useTableSearch();
|
||||||
const searchPlaceholder = computed(() =>
|
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(() =>
|
const listedCompanies = computed(() =>
|
||||||
companies.value.filter((company) =>
|
companies.value.filter((company) =>
|
||||||
|
|
@ -315,6 +383,12 @@ const listedCompanies = computed(() =>
|
||||||
const subCompanies = computed(() => listedCompanies.value.filter((company) => company.kind !== "principal").length);
|
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 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 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) {
|
function regimenLabel(code?: string | null) {
|
||||||
if (!code) return "—";
|
if (!code) return "—";
|
||||||
|
|
@ -344,6 +418,18 @@ async function load() {
|
||||||
payroll.loan_commission_pct = Number(pay.settings.loan_commission_pct);
|
payroll.loan_commission_pct = Number(pay.settings.loan_commission_pct);
|
||||||
payroll.loan_small_max = Number(pay.settings.loan_small_max);
|
payroll.loan_small_max = Number(pay.settings.loan_small_max);
|
||||||
units.value = pay.units || [];
|
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();
|
syncChrome();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -367,6 +453,30 @@ async function addUnit() {
|
||||||
await load();
|
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() {
|
function openNew() {
|
||||||
editing.value = null;
|
editing.value = null;
|
||||||
formTab.value = "datos";
|
formTab.value = "datos";
|
||||||
|
|
@ -474,10 +584,12 @@ function syncChrome() {
|
||||||
showDetail: false,
|
showDetail: false,
|
||||||
treeKey: tab.value,
|
treeKey: tab.value,
|
||||||
onTreeSelect: (key) => {
|
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: [
|
tree: [
|
||||||
{ key: "empresas", label: "Empresas", icon: "pi pi-briefcase", data: { count: companies.value.length } },
|
{ 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: "nomina", label: "Nómina", icon: "pi pi-wallet" },
|
||||||
{ key: "riesgos", label: "Riesgos", icon: "pi pi-exclamation-triangle", data: { count: risks.value.length } },
|
{ 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 } },
|
{ key: "temas", label: "Temas de gafete", icon: "pi pi-palette", data: { count: themes.value.length } },
|
||||||
|
|
@ -488,6 +600,7 @@ function syncChrome() {
|
||||||
label: "Acciones",
|
label: "Acciones",
|
||||||
items: [
|
items: [
|
||||||
{ key: "alta", label: "Nueva subempresa", icon: "pi pi-plus", disabled: tab.value !== "empresas", command: openNew },
|
{ 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(
|
watch(
|
||||||
() => String(route.query.tab || ""),
|
() => String(route.query.tab || ""),
|
||||||
(key) => {
|
(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 },
|
{ immediate: true },
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,16 @@
|
||||||
<Chip v-if="summary" :label="money(summary.executed_subtotal)" icon="pi pi-wallet" />
|
<Chip v-if="summary" :label="money(summary.executed_subtotal)" icon="pi pi-wallet" />
|
||||||
</DeskTableBar>
|
</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 v-if="summary" class="kpi-row mb-3">
|
||||||
<div class="kpi-card">
|
<div class="kpi-card">
|
||||||
<span class="kpi-label">Presupuesto</span>
|
<span class="kpi-label">Presupuesto</span>
|
||||||
|
|
@ -38,6 +48,7 @@
|
||||||
scroll-height="flex"
|
scroll-height="flex"
|
||||||
table-style="min-width: 72rem"
|
table-style="min-width: 72rem"
|
||||||
empty-message="Seleccione una obra con presupuesto cargado."
|
empty-message="Seleccione una obra con presupuesto cargado."
|
||||||
|
@row-click="onRowClick"
|
||||||
>
|
>
|
||||||
<Column field="code" header="Clave" style="width: 7rem">
|
<Column field="code" header="Clave" style="width: 7rem">
|
||||||
<template #body="{ data }"><span class="identifier">{{ data.code || "—" }}</span></template>
|
<template #body="{ data }"><span class="identifier">{{ data.code || "—" }}</span></template>
|
||||||
|
|
@ -91,6 +102,7 @@ type Summary = {
|
||||||
const desk = useDesktop();
|
const desk = useDesktop();
|
||||||
const { api } = useApi();
|
const { api } = useApi();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
|
const perms = usePermissions();
|
||||||
|
|
||||||
const tableQ = ref("");
|
const tableQ = ref("");
|
||||||
const projects = ref<{ id: number; name: string }[]>([]);
|
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 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 projectName = computed(() => projects.value.find((p) => p.id === projectId.value)?.name || "Sin obra");
|
||||||
|
const canCreateExpense = computed(() => perms.can("expenses.create"));
|
||||||
const filteredItems = computed(() => {
|
const filteredItems = computed(() => {
|
||||||
const q = tableQ.value.toLowerCase();
|
const q = tableQ.value.toLowerCase();
|
||||||
return items.value.filter((i) => !q || [i.code, i.description].join(" ").toLowerCase().includes(q));
|
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));
|
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() {
|
async function loadProjects() {
|
||||||
const r = await api<{ projects?: { id: number; name: string }[] }>("/v1/projects");
|
const r = await api<{ projects?: { id: number; name: string }[] }>("/v1/projects");
|
||||||
projects.value = r.projects ?? [];
|
projects.value = r.projects ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadControl() {
|
async function loadControl() {
|
||||||
if (!projectId.value) return;
|
if (!projectId.value) {
|
||||||
|
summary.value = null;
|
||||||
|
items.value = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
const [s, i] = await Promise.all([
|
const [s, i] = await Promise.all([
|
||||||
api<{ data?: Summary }>(`/v1/projects/${projectId.value}/cost-control/summary`),
|
api<{ data?: Summary }>(`/v1/projects/${projectId.value}/cost-control/summary`),
|
||||||
api<{ data?: { items?: ControlItem[] } }>(`/v1/projects/${projectId.value}/cost-control/items`),
|
api<{ data?: { items?: ControlItem[] } }>(`/v1/projects/${projectId.value}/cost-control/items`),
|
||||||
|
|
@ -123,20 +156,67 @@ async function loadControl() {
|
||||||
items.value = i.data?.items ?? [];
|
items.value = i.data?.items ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
function syncChrome() {
|
||||||
await loadProjects();
|
|
||||||
desk.configure({
|
desk.configure({
|
||||||
tab: "Control presupuesto",
|
tab: "Control presupuesto",
|
||||||
treeTitle: "Obras",
|
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" })),
|
tree: projects.value.map((p) => ({ key: `p:${p.id}`, label: p.name, icon: "pi pi-building" })),
|
||||||
onTreeSelect: (key) => {
|
onTreeSelect: (key) => {
|
||||||
if (key.startsWith("p:")) navigateTo(`/control-presupuesto?project_id=${key.slice(2)}`);
|
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();
|
await loadControl();
|
||||||
|
syncChrome();
|
||||||
});
|
});
|
||||||
|
|
||||||
watch(projectId, loadControl);
|
watch(projectId, async () => {
|
||||||
|
await loadControl();
|
||||||
|
syncChrome();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
@ -150,4 +230,5 @@ watch(projectId, loadControl);
|
||||||
}
|
}
|
||||||
.kpi-label { display: block; font-size: 11px; color: #748095; margin-bottom: 4px; }
|
.kpi-label { display: block; font-size: 11px; color: #748095; margin-bottom: 4px; }
|
||||||
.cost-critical-text { color: #b42318; font-weight: 600; }
|
.cost-critical-text { color: #b42318; font-weight: 600; }
|
||||||
|
.control-page :deep(.p-datatable-tbody > tr) { cursor: pointer; }
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,9 @@
|
||||||
<DeskTableBar v-model="tableQ" placeholder="Buscar descripción, proveedor o concepto">
|
<DeskTableBar v-model="tableQ" placeholder="Buscar descripción, proveedor o concepto">
|
||||||
<Chip :label="projectLabel" icon="pi pi-building" />
|
<Chip :label="projectLabel" icon="pi pi-building" />
|
||||||
<Chip :label="`${listed.length} gastos`" icon="pi pi-list" />
|
<Chip :label="`${listed.length} gastos`" icon="pi pi-list" />
|
||||||
|
<Chip :label="`${unclassifiedCount} sin clasificar`" icon="pi pi-question-circle" />
|
||||||
<Chip :label="money(totalSubtotal)" icon="pi pi-wallet" />
|
<Chip :label="money(totalSubtotal)" icon="pi pi-wallet" />
|
||||||
|
<SelectButton v-model="listFilter" :options="listFilterOpts" option-label="label" option-value="value" :allow-empty="false" />
|
||||||
</DeskTableBar>
|
</DeskTableBar>
|
||||||
|
|
||||||
<Message v-if="budgetWarning" :severity="budgetWarning.level === 'critical' ? 'error' : 'warn'" :closable="true" class="mb-3">
|
<Message v-if="budgetWarning" :severity="budgetWarning.level === 'critical' ? 'error' : 'warn'" :closable="true" class="mb-3">
|
||||||
|
|
@ -11,20 +13,28 @@
|
||||||
</Message>
|
</Message>
|
||||||
|
|
||||||
<DataTable
|
<DataTable
|
||||||
|
v-model:selection="selected"
|
||||||
:value="listed"
|
:value="listed"
|
||||||
data-key="id"
|
data-key="id"
|
||||||
paginator
|
paginator
|
||||||
:rows="25"
|
:rows="25"
|
||||||
empty-message="No hay gastos en el periodo seleccionado."
|
empty-message="No hay gastos en el periodo seleccionado."
|
||||||
|
selection-mode="single"
|
||||||
|
meta-key-selection="false"
|
||||||
scrollable
|
scrollable
|
||||||
scroll-height="flex"
|
scroll-height="flex"
|
||||||
table-style="min-width: 56rem"
|
table-style="min-width: 56rem"
|
||||||
|
@row-click="onRow"
|
||||||
|
@row-dblclick="(e: { data: Expense }) => openEdit(e.data)"
|
||||||
>
|
>
|
||||||
<Column field="expense_date" header="Fecha" sortable style="width: 7rem" />
|
<Column field="expense_date" header="Fecha" sortable style="width: 7rem" />
|
||||||
<Column field="description" header="Descripción" sortable />
|
<Column field="description" header="Descripción" sortable />
|
||||||
<Column field="supplier_name" header="Proveedor" sortable style="width: 10rem" />
|
<Column field="supplier_name" header="Proveedor" sortable style="width: 10rem" />
|
||||||
<Column field="budget_item_code" header="Concepto" style="width: 8rem">
|
<Column field="budget_item_code" header="Concepto" style="width: 10rem">
|
||||||
<template #body="{ data }"><span class="identifier">{{ data.budget_item_code || "—" }}</span></template>
|
<template #body="{ data }">
|
||||||
|
<Tag v-if="!data.budget_item_id" value="Sin clasificar" severity="warn" />
|
||||||
|
<span v-else class="identifier">{{ data.budget_item_code || "—" }}</span>
|
||||||
|
</template>
|
||||||
</Column>
|
</Column>
|
||||||
<Column field="subtotal" header="Subtotal" sortable style="width: 9rem">
|
<Column field="subtotal" header="Subtotal" sortable style="width: 9rem">
|
||||||
<template #body="{ data }">{{ money(data.subtotal) }}</template>
|
<template #body="{ data }">{{ money(data.subtotal) }}</template>
|
||||||
|
|
@ -38,7 +48,7 @@
|
||||||
<Column field="source" header="Origen" style="width: 7rem" />
|
<Column field="source" header="Origen" style="width: 7rem" />
|
||||||
</DataTable>
|
</DataTable>
|
||||||
|
|
||||||
<Dialog v-model:visible="showForm" header="Nuevo gasto" modal :style="{ width: '720px' }">
|
<Dialog v-model:visible="showForm" :header="editingId ? 'Editar gasto' : 'Nuevo gasto'" modal :style="{ width: '720px' }">
|
||||||
<div class="form-grid form-grid-compact">
|
<div class="form-grid form-grid-compact">
|
||||||
<FormField label="Empresa" required>
|
<FormField label="Empresa" required>
|
||||||
<Select v-model="form.company_id" :options="companies" option-label="name" option-value="id" class="w-full" />
|
<Select v-model="form.company_id" :options="companies" option-label="name" option-value="id" class="w-full" />
|
||||||
|
|
@ -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" />
|
<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>
|
||||||
<FormField label="Fecha" required>
|
<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>
|
||||||
<FormField label="Concepto presupuesto" class="span-2">
|
<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" />
|
<InputText v-else disabled placeholder="Seleccione una obra para ligar concepto" class="w-full" />
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="Descripción" class="span-2" required>
|
<FormField label="Descripción" class="span-2" required>
|
||||||
<Textarea v-model="form.description" class="w-full" rows="2" auto-resize />
|
<Textarea v-model="form.description" class="w-full" rows="2" auto-resize />
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="Proveedor">
|
<FormField label="Proveedor" class="span-2">
|
||||||
<InputText v-model="form.supplier_name" class="w-full" />
|
<div class="flex gap-2 align-items-center">
|
||||||
</FormField>
|
<Select
|
||||||
<FormField label="RFC proveedor">
|
v-model="form.supplier_id"
|
||||||
<InputText v-model="form.supplier_rfc" class="w-full identifier-input" />
|
: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>
|
||||||
<FormField label="Cantidad">
|
<FormField label="Cantidad">
|
||||||
<InputNumber v-model="form.quantity" :min="0" :max-fraction-digits="4" class="w-full" />
|
<InputNumber v-model="form.quantity" :min="0" :max-fraction-digits="4" class="w-full" />
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="Unidad">
|
<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>
|
||||||
<FormField label="Precio unitario">
|
<FormField label="Precio unitario">
|
||||||
<InputNumber v-model="form.unit_price" mode="currency" currency="MXN" locale="es-MX" class="w-full" />
|
<InputNumber v-model="form.unit_price" mode="currency" currency="MXN" locale="es-MX" class="w-full" />
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="Subtotal">
|
<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>
|
||||||
<FormField label="IVA incluido en total">
|
<FormField label="IVA incluido en el precio">
|
||||||
<Checkbox v-model="form.tax_included" binary />
|
<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>
|
||||||
<FormField label="Total con IVA">
|
<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>
|
||||||
<FormField label="Comprobante PDF" class="span-2">
|
<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>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
<Message v-if="formError" severity="error" class="mt-3" :closable="false">{{ formError }}</Message>
|
<Message v-if="formError" severity="error" class="mt-3" :closable="false">{{ formError }}</Message>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<Button label="Cancelar" text @click="showForm = false" />
|
<Button label="Cancelar" text @click="showForm = false" />
|
||||||
<Button label="Guardar" icon="pi pi-check" @click="save" />
|
<Button :label="editingId ? 'Guardar cambios' : 'Guardar'" icon="pi pi-check" @click="save" />
|
||||||
|
</template>
|
||||||
|
</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>
|
</template>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -96,14 +165,24 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import FormField from "~/components/padron/FormField.vue";
|
import FormField from "~/components/padron/FormField.vue";
|
||||||
import BudgetItemPicker from "~/components/BudgetItemPicker.vue";
|
import BudgetItemPicker from "~/components/BudgetItemPicker.vue";
|
||||||
|
import SupplierFormDialog, { type Supplier } from "~/components/SupplierFormDialog.vue";
|
||||||
import { displayErrorMessage } from "~/composables/api-response";
|
import { displayErrorMessage } from "~/composables/api-response";
|
||||||
|
import { expenseAmounts } from "~/composables/expenseAmounts";
|
||||||
|
|
||||||
type Expense = {
|
type Expense = {
|
||||||
id: number;
|
id: number;
|
||||||
expense_date: string;
|
expense_date: string;
|
||||||
description: string;
|
description: string;
|
||||||
supplier_name: string;
|
supplier_name: string;
|
||||||
|
supplier_id?: number | null;
|
||||||
|
budget_item_id?: number | null;
|
||||||
budget_item_code?: string;
|
budget_item_code?: string;
|
||||||
|
company_id?: number | null;
|
||||||
|
quantity?: number;
|
||||||
|
unit?: string;
|
||||||
|
unit_price?: number;
|
||||||
|
tax_included?: boolean;
|
||||||
|
is_locked?: boolean;
|
||||||
subtotal: number;
|
subtotal: number;
|
||||||
iva_amount: number;
|
iva_amount: number;
|
||||||
total: number;
|
total: number;
|
||||||
|
|
@ -115,15 +194,31 @@ const desk = useDesktop();
|
||||||
const { api } = useApi();
|
const { api } = useApi();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
|
const perms = usePermissions();
|
||||||
|
|
||||||
const tableQ = ref("");
|
const tableQ = ref("");
|
||||||
const expenses = ref<Expense[]>([]);
|
const expenses = ref<Expense[]>([]);
|
||||||
const companies = ref<{ id: number; name: string }[]>([]);
|
const companies = ref<{ id: number; name: string }[]>([]);
|
||||||
const projects = 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 showForm = ref(false);
|
||||||
|
const showSupplierForm = ref(false);
|
||||||
|
const showUnitForm = ref(false);
|
||||||
const formError = ref("");
|
const formError = ref("");
|
||||||
|
const unitError = ref("");
|
||||||
const budgetWarning = ref<{ level: string; message: string } | null>(null);
|
const budgetWarning = ref<{ level: string; message: string } | null>(null);
|
||||||
const receiptFile = ref<File | null>(null);
|
const receiptFile = ref<File | null>(null);
|
||||||
|
const ivaRate = ref(0.16);
|
||||||
|
const 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({
|
const form = reactive({
|
||||||
company_id: null as number | null,
|
company_id: null as number | null,
|
||||||
|
|
@ -131,16 +226,37 @@ const form = reactive({
|
||||||
expense_date: new Date().toISOString().slice(0, 10),
|
expense_date: new Date().toISOString().slice(0, 10),
|
||||||
budget_item_id: null as number | null,
|
budget_item_id: null as number | null,
|
||||||
description: "",
|
description: "",
|
||||||
supplier_name: "",
|
supplier_id: null as number | null,
|
||||||
supplier_rfc: "",
|
|
||||||
quantity: 0,
|
quantity: 0,
|
||||||
unit: "",
|
unit: "",
|
||||||
unit_price: 0,
|
unit_price: 0,
|
||||||
subtotal: 0,
|
|
||||||
total: 0,
|
|
||||||
tax_included: false,
|
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 projectId = computed(() => Number(route.query.project_id || 0) || null);
|
||||||
const projectLabel = computed(() => {
|
const projectLabel = computed(() => {
|
||||||
const p = projects.value.find((x) => x.id === projectId.value);
|
const p = projects.value.find((x) => x.id === projectId.value);
|
||||||
|
|
@ -148,19 +264,169 @@ const projectLabel = computed(() => {
|
||||||
});
|
});
|
||||||
const listed = computed(() => {
|
const listed = computed(() => {
|
||||||
const q = tableQ.value.toLowerCase();
|
const q = tableQ.value.toLowerCase();
|
||||||
return expenses.value.filter((e) =>
|
return expenses.value.filter((e) => {
|
||||||
!q || [e.description, e.supplier_name, e.budget_item_code].join(" ").toLowerCase().includes(q),
|
if (listFilter.value === "unclassified" && e.budget_item_id) return false;
|
||||||
);
|
return !q || [e.description, e.supplier_name, e.budget_item_code].join(" ").toLowerCase().includes(q);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
const unclassifiedCount = computed(() => expenses.value.filter((e) => !e.budget_item_id).length);
|
||||||
const totalSubtotal = computed(() => listed.value.reduce((s, e) => s + Number(e.subtotal || 0), 0));
|
const totalSubtotal = computed(() => listed.value.reduce((s, e) => s + Number(e.subtotal || 0), 0));
|
||||||
|
const canEditSelected = computed(() =>
|
||||||
|
Boolean(selected.value && canUpdateExpense.value && isEditable(selected.value)),
|
||||||
|
);
|
||||||
|
|
||||||
function money(v: number) {
|
function money(v: number) {
|
||||||
return new Intl.NumberFormat("es-MX", { style: "currency", currency: "MXN" }).format(Number(v || 0));
|
return new Intl.NumberFormat("es-MX", { style: "currency", currency: "MXN" }).format(Number(v || 0));
|
||||||
}
|
}
|
||||||
|
|
||||||
function onFile(e: Event) {
|
function parseDate(value: unknown): Date | null {
|
||||||
const input = e.target as HTMLInputElement;
|
const raw = String(value || "").slice(0, 10);
|
||||||
receiptFile.value = input.files?.[0] ?? null;
|
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() {
|
async function loadCatalogs() {
|
||||||
|
|
@ -171,13 +437,70 @@ async function loadCatalogs() {
|
||||||
companies.value = cat.companies ?? [];
|
companies.value = cat.companies ?? [];
|
||||||
projects.value = pr.projects ?? [];
|
projects.value = pr.projects ?? [];
|
||||||
if (!form.company_id && companies.value[0]) form.company_id = companies.value[0].id;
|
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() {
|
async function loadExpenses() {
|
||||||
const qs = new URLSearchParams();
|
const qs = new URLSearchParams();
|
||||||
if (projectId.value) qs.set("project_id", String(projectId.value));
|
if (projectId.value) qs.set("project_id", String(projectId.value));
|
||||||
const r = await api<{ data?: { expenses?: Expense[] }; message?: string }>(`/v1/expenses?${qs}`);
|
const r = await api<{ expenses?: Expense[]; data?: { expenses?: Expense[] }; message?: string }>(`/v1/expenses?${qs}`);
|
||||||
expenses.value = (r.data?.expenses ?? []) as Expense[];
|
const keep = selected.value?.id;
|
||||||
|
expenses.value = (r.expenses ?? r.data?.expenses ?? []) as Expense[];
|
||||||
|
selected.value = keep ? expenses.value.find((e) => e.id === keep) ?? null : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function payload() {
|
||||||
|
return {
|
||||||
|
...form,
|
||||||
|
project_id: form.project_id || null,
|
||||||
|
budget_item_id: form.budget_item_id || null,
|
||||||
|
supplier_id: form.supplier_id || null,
|
||||||
|
subtotal: computedAmounts.value.subtotal,
|
||||||
|
total: computedAmounts.value.total,
|
||||||
|
iva_rate: ivaRate.value,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function save() {
|
async function save() {
|
||||||
|
|
@ -187,23 +510,35 @@ async function save() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const r = await api<{ data?: { expense?: { id: number }; budget_warning?: { level: string; message: string } }; message?: string }>(
|
const r = editingId.value
|
||||||
"/v1/expenses",
|
? await api<{ data?: { expense?: { id: number }; budget_warning?: { level: string; message: string } }; message?: string }>(
|
||||||
{
|
`/v1/expenses/${editingId.value}`,
|
||||||
method: "POST",
|
{ method: "PATCH", json: payload() },
|
||||||
json: { ...form, project_id: form.project_id || null },
|
)
|
||||||
},
|
: await api<{ data?: { expense?: { id: number }; budget_warning?: { level: string; message: string } }; message?: string }>(
|
||||||
);
|
"/v1/expenses",
|
||||||
const expenseId = r.data?.expense?.id;
|
{ method: "POST", json: payload() },
|
||||||
|
);
|
||||||
|
const expenseId = r.data?.expense?.id ?? editingId.value;
|
||||||
if (expenseId && receiptFile.value) {
|
if (expenseId && receiptFile.value) {
|
||||||
const fd = new FormData();
|
const fd = new FormData();
|
||||||
fd.append("file", receiptFile.value);
|
fd.append("file", receiptFile.value);
|
||||||
await fetch(`/v1/expenses/${expenseId}/attachments`, { method: "POST", body: fd, credentials: "include" });
|
await fetch(`/v1/expenses/${expenseId}/attachments`, { method: "POST", body: fd, credentials: "include" });
|
||||||
}
|
}
|
||||||
budgetWarning.value = r.data?.budget_warning ?? null;
|
budgetWarning.value = r.data?.budget_warning ?? null;
|
||||||
toast.add({ severity: "success", summary: r.message || "Gasto registrado", life: 2500 });
|
toast.add({
|
||||||
|
severity: "success",
|
||||||
|
summary: r.message || (editingId.value ? "Gasto actualizado" : "Gasto registrado"),
|
||||||
|
life: 2500,
|
||||||
|
});
|
||||||
showForm.value = false;
|
showForm.value = false;
|
||||||
|
editingId.value = null;
|
||||||
await loadExpenses();
|
await loadExpenses();
|
||||||
|
if (route.query.nuevo || route.query.budget_item_id) {
|
||||||
|
const qs = new URLSearchParams();
|
||||||
|
if (projectId.value) qs.set("project_id", String(projectId.value));
|
||||||
|
await navigateTo(`/gastos${qs.toString() ? `?${qs}` : ""}`, { replace: true });
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
formError.value = displayErrorMessage((e as { data?: unknown }).data ?? e);
|
formError.value = displayErrorMessage((e as { data?: unknown }).data ?? e);
|
||||||
}
|
}
|
||||||
|
|
@ -211,25 +546,22 @@ async function save() {
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await loadCatalogs();
|
await loadCatalogs();
|
||||||
desk.configure({
|
syncChrome();
|
||||||
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; } },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
await loadExpenses();
|
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>
|
</script>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue