This commit is contained in:
Alberto Martinez 2026-08-25 20:21:49 -05:00
parent 6a0c6a4a49
commit 085366c287
28 changed files with 3560 additions and 387 deletions

View file

@ -163,7 +163,8 @@ export function tenantScope(user: AuthUser): number | null {
export async function login(username: string, password: string): Promise<AuthUser | null> {
const user = username.trim();
if (!user) return null;
const pass = password.trim();
if (!user || !pass) return null;
const db = await getDb();
const appRow = db.prepare(
@ -171,7 +172,7 @@ export async function login(username: string, password: string): Promise<AuthUse
FROM users u LEFT JOIN companies c ON c.id = u.company_id
WHERE u.username = ?`,
).get(user) as (Record<string, unknown> & { password_hash: string }) | undefined;
if (appRow && await verifyPassword(password, appRow.password_hash)) {
if (appRow && await verifyPassword(pass, appRow.password_hash)) {
const authUser = asAppUser(appRow);
const { tenantAccessBlocked } = await import("./saas.ts");
const pdb = await getPlatformDb();
@ -189,7 +190,7 @@ export async function login(username: string, password: string): Promise<AuthUse
).get(user) as
| { id: number; username: string; display_name: string; password_hash: string }
| undefined;
if (plat && await verifyPassword(password, plat.password_hash)) {
if (plat && await verifyPassword(pass, plat.password_hash)) {
return asPlatformUser(plat);
}
return null;

View file

@ -10,6 +10,7 @@ export const EXPEDIENTES_DIR = join(DATA_DIR, "expedientes");
export const PDFS_DIR = join(DATA_DIR, "pdfs");
export const LOGOS_DIR = join(DATA_DIR, "logos");
export const PROJECTS_DIR = join(DATA_DIR, "proyectos");
export const COMPANIES_DIR = join(DATA_DIR, "empresas");
export const config = {
port: Number(Deno.env.get("PORT") ?? "8000"),

View file

@ -1,10 +1,10 @@
import { Database } from "@db/sqlite";
import { mkdir } from "node:fs/promises";
import { join } from "node:path";
import { DATA_DIR, DB_PATH, EXPEDIENTES_DIR, PDFS_DIR, LOGOS_DIR, PROJECTS_DIR } from "./config.ts";
import { DATA_DIR, DB_PATH, EXPEDIENTES_DIR, PDFS_DIR, LOGOS_DIR, PROJECTS_DIR, COMPANIES_DIR } from "./config.ts";
import { config } from "./config.ts";
import { hashPassword } from "./crypto.ts";
import { DOCUMENT_TYPE_SEED, evaluateDocumentValidity, freshnessRequired, type ImssStatus, type WorkerImssContext } from "./document_validity.ts";
import { DOCUMENT_TYPE_SEED, PROJECT_DOCUMENT_TYPE_SEED, COMPANY_DOCUMENT_TYPE_SEED, evaluateDocumentValidity, freshnessRequired, type ImssStatus, type WorkerImssContext } from "./document_validity.ts";
import { runLiquibase } from "./liquibase.ts";
export type Db = Database;
@ -18,6 +18,7 @@ export async function getDb(): Promise<Database> {
await mkdir(PDFS_DIR, { recursive: true });
await mkdir(LOGOS_DIR, { recursive: true });
await mkdir(PROJECTS_DIR, { recursive: true });
await mkdir(COMPANIES_DIR, { recursive: true });
await runLiquibase();
db = new Database(DB_PATH);
db.exec("PRAGMA foreign_keys = ON;");
@ -28,15 +29,16 @@ export async function getDb(): Promise<Database> {
export function seedDocumentTypes(database: Database) {
const upsert = database.prepare(
`INSERT INTO document_types
(code, label, required, validity_mode, freshness_days, requires_issued_at, requires_expires_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
(code, label, required, validity_mode, freshness_days, requires_issued_at, requires_expires_at, category)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(code) DO UPDATE SET
label = excluded.label,
required = excluded.required,
validity_mode = excluded.validity_mode,
freshness_days = excluded.freshness_days,
requires_issued_at = excluded.requires_issued_at,
requires_expires_at = excluded.requires_expires_at`,
requires_expires_at = excluded.requires_expires_at,
category = excluded.category`,
);
for (const d of DOCUMENT_TYPE_SEED) {
upsert.run(
@ -47,10 +49,39 @@ export function seedDocumentTypes(database: Database) {
d.freshness_days,
d.requires_issued_at,
d.requires_expires_at,
d.category,
);
}
}
export function seedProjectDocumentTypes(database: Database) {
const upsert = database.prepare(
`INSERT INTO project_document_types (code, label, required, category)
VALUES (?, ?, ?, ?)
ON CONFLICT(code) DO UPDATE SET
label = excluded.label,
required = excluded.required,
category = excluded.category`,
);
for (const d of PROJECT_DOCUMENT_TYPE_SEED) {
upsert.run(d.code, d.label, d.required, d.category);
}
}
export function seedCompanyDocumentTypes(database: Database) {
const upsert = database.prepare(
`INSERT INTO company_document_types (code, label, required, category)
VALUES (?, ?, ?, ?)
ON CONFLICT(code) DO UPDATE SET
label = excluded.label,
required = excluded.required,
category = excluded.category`,
);
for (const d of COMPANY_DOCUMENT_TYPE_SEED) {
upsert.run(d.code, d.label, d.required, d.category);
}
}
export function nextProjectCode(database: Database): string {
const row = database.prepare(
`SELECT COALESCE(MAX(CAST(substr(code, 5) AS INTEGER)), 0) + 1 AS n
@ -114,17 +145,8 @@ async function seed(database: Database) {
);
seedDocumentTypes(database);
const projectDocs = [
["contrato", "Contrato", 1],
["presupuesto", "Presupuesto", 1],
["planos", "Planos", 0],
["otro", "Otro", 0],
];
const insPDoc = database.prepare(
"INSERT OR IGNORE INTO project_document_types (code, label, required) VALUES (?, ?, ?)",
);
for (const d of projectDocs) insPDoc.run(...d);
seedProjectDocumentTypes(database);
seedCompanyDocumentTypes(database);
const defaultTenantId = 1;
let principal = database.prepare(
@ -210,7 +232,7 @@ export function workerImssContext(database: Database, workerId: number): WorkerI
export function checklistItemsFor(database: Database, workerId: number) {
const types = database.prepare(
`SELECT code, label, required, validity_mode, freshness_days,
requires_issued_at, requires_expires_at
requires_issued_at, requires_expires_at, category
FROM document_types`,
).all() as {
code: string;
@ -220,6 +242,7 @@ export function checklistItemsFor(database: Database, workerId: number) {
freshness_days: number | null;
requires_issued_at: number;
requires_expires_at: number;
category: string;
}[];
const currentDocs = database.prepare(
`SELECT type_code, issued_at, expires_at, uploaded_at, imss_alta_at
@ -256,6 +279,7 @@ export function checklistItemsFor(database: Database, workerId: number) {
freshness_days: t.freshness_days,
requires_issued_at: policy.requires_issued_at,
requires_expires_at: policy.requires_expires_at,
category: t.category || "otro",
present: evaled.present,
valid: evaled.valid,
validity_status: evaled.validity_status,
@ -328,10 +352,14 @@ export function projectDir(projectId: number): string {
return join(PROJECTS_DIR, String(projectId));
}
export function companyDir(companyId: number): string {
return join(COMPANIES_DIR, String(companyId));
}
export function projectChecklistFor(database: Database, projectId: number) {
const types = database.prepare(
"SELECT code, label, required FROM project_document_types",
).all() as { code: string; label: string; required: number }[];
"SELECT code, label, required, category FROM project_document_types",
).all() as { code: string; label: string; required: number; category: string }[];
const current = database.prepare(
`SELECT type_code FROM project_documents WHERE project_id = ? AND is_current = 1`,
).all(projectId) as { type_code: string }[];
@ -340,6 +368,24 @@ export function projectChecklistFor(database: Database, projectId: number) {
code: t.code,
label: t.label,
required: !!t.required,
category: t.category || "otro",
present: have.has(t.code),
}));
}
export function companyChecklistFor(database: Database, companyId: number) {
const types = database.prepare(
"SELECT code, label, required, category FROM company_document_types",
).all() as { code: string; label: string; required: number; category: string }[];
const current = database.prepare(
`SELECT type_code FROM company_documents WHERE company_id = ? AND is_current = 1`,
).all(companyId) as { type_code: string }[];
const have = new Set(current.map((c) => c.type_code));
return types.map((t) => ({
code: t.code,
label: t.label,
required: !!t.required,
category: t.category || "otro",
present: have.has(t.code),
}));
}

View file

@ -148,23 +148,72 @@ export function evaluateDocumentValidity(
};
}
export const DOCUMENT_TYPE_SEED: Array<{
export type CatalogDocType = {
code: string;
label: string;
required: number;
category: string;
validity_mode: ValidityMode;
freshness_days: number | null;
requires_issued_at: number;
requires_expires_at: number;
}> = [
{ code: "foto", label: "Foto", required: 1, validity_mode: "none", freshness_days: null, requires_issued_at: 0, requires_expires_at: 0 },
{ code: "ine", label: "INE", required: 1, validity_mode: "expiry", freshness_days: null, requires_issued_at: 0, requires_expires_at: 1 },
{ code: "curp", label: "Constancia CURP", required: 1, validity_mode: "freshness", freshness_days: 90, requires_issued_at: 1, requires_expires_at: 0 },
{ code: "nss", label: "Constancia NSS", required: 1, validity_mode: "freshness", freshness_days: 90, requires_issued_at: 1, requires_expires_at: 0 },
{ code: "rfc", label: "Constancia RFC", required: 1, validity_mode: "freshness", freshness_days: 90, requires_issued_at: 1, requires_expires_at: 0 },
{ code: "domicilio", label: "Comprobante de domicilio", required: 1, validity_mode: "freshness", freshness_days: 90, requires_issued_at: 1, requires_expires_at: 0 },
{ code: "alta_imss", label: "Alta IMSS", required: 0, validity_mode: "none", freshness_days: null, requires_issued_at: 0, requires_expires_at: 0 },
{ code: "baja_imss", label: "Baja IMSS", required: 0, validity_mode: "none", freshness_days: null, requires_issued_at: 0, requires_expires_at: 0 },
{ code: "contrato", label: "Contrato", required: 0, validity_mode: "none", freshness_days: null, requires_issued_at: 0, requires_expires_at: 0 },
{ code: "otro", label: "Otro", required: 0, validity_mode: "none", freshness_days: null, requires_issued_at: 0, requires_expires_at: 0 },
};
/** Núcleo = required 1. El resto es catálogo: se sube si se tiene, no cuenta como faltante. */
export const DOCUMENT_TYPE_SEED: CatalogDocType[] = [
{ code: "foto", label: "Foto", required: 1, category: "identidad", validity_mode: "none", freshness_days: null, requires_issued_at: 0, requires_expires_at: 0 },
{ code: "ine", label: "INE", required: 1, category: "identidad", validity_mode: "expiry", freshness_days: null, requires_issued_at: 0, requires_expires_at: 1 },
{ code: "curp", label: "Constancia CURP", required: 1, category: "identidad", validity_mode: "freshness", freshness_days: 90, requires_issued_at: 1, requires_expires_at: 0 },
{ code: "nss", label: "Constancia NSS", required: 1, category: "identidad", validity_mode: "freshness", freshness_days: 90, requires_issued_at: 1, requires_expires_at: 0 },
{ code: "rfc", label: "Constancia RFC", required: 1, category: "identidad", validity_mode: "freshness", freshness_days: 90, requires_issued_at: 1, requires_expires_at: 0 },
{ code: "domicilio", label: "Comprobante de domicilio", required: 1, category: "identidad", validity_mode: "freshness", freshness_days: 90, requires_issued_at: 1, requires_expires_at: 0 },
{ code: "alta_imss", label: "Alta IMSS", required: 0, category: "imss", validity_mode: "none", freshness_days: null, requires_issued_at: 0, requires_expires_at: 0 },
{ code: "baja_imss", label: "Baja IMSS", required: 0, category: "imss", validity_mode: "none", freshness_days: null, requires_issued_at: 0, requires_expires_at: 0 },
{ code: "contrato", label: "Contrato laboral", required: 0, category: "laboral", validity_mode: "none", freshness_days: null, requires_issued_at: 0, requires_expires_at: 0 },
{ code: "aviso_privacidad", label: "Aviso de privacidad", required: 0, category: "laboral", validity_mode: "none", freshness_days: null, requires_issued_at: 0, requires_expires_at: 0 },
{ code: "dc3_nom031", label: "DC-3 NOM-031 (construcción)", required: 0, category: "sst", validity_mode: "expiry", freshness_days: null, requires_issued_at: 0, requires_expires_at: 0 },
{ code: "epp", label: "Acuse de entrega de EPP", required: 0, category: "sst", validity_mode: "none", freshness_days: null, requires_issued_at: 0, requires_expires_at: 0 },
{ code: "medico", label: "Certificado médico", required: 0, category: "sst", validity_mode: "expiry", freshness_days: null, requires_issued_at: 0, requires_expires_at: 0 },
{ code: "dc3_altura", label: "DC-3 trabajo en alturas", required: 0, category: "oficio", validity_mode: "expiry", freshness_days: null, requires_issued_at: 0, requires_expires_at: 0 },
{ code: "dc3_soldadura", label: "DC-3 soldadura", required: 0, category: "oficio", validity_mode: "expiry", freshness_days: null, requires_issued_at: 0, requires_expires_at: 0 },
{ code: "dc3_montacargas", label: "DC-3 montacargas", required: 0, category: "oficio", validity_mode: "expiry", freshness_days: null, requires_issued_at: 0, requires_expires_at: 0 },
{ code: "licencia", label: "Licencia de conducir", required: 0, category: "oficio", validity_mode: "expiry", freshness_days: null, requires_issued_at: 0, requires_expires_at: 0 },
{ code: "cedula", label: "Cédula profesional", required: 0, category: "oficio", validity_mode: "none", freshness_days: null, requires_issued_at: 0, requires_expires_at: 0 },
{ code: "otro", label: "Otro", required: 0, category: "otro", validity_mode: "none", freshness_days: null, requires_issued_at: 0, requires_expires_at: 0 },
];
export const PROJECT_DOCUMENT_TYPE_SEED: Array<{
code: string;
label: string;
required: number;
category: string;
}> = [
{ code: "contrato", label: "Contrato", required: 1, category: "contrato" },
{ code: "presupuesto", label: "Presupuesto", required: 1, category: "contrato" },
{ code: "planos", label: "Planos", required: 0, category: "tecnico" },
{ code: "licencia_construccion", label: "Licencia de construcción", required: 0, category: "permisos" },
{ code: "uso_suelo", label: "Uso de suelo", required: 0, category: "permisos" },
{ code: "proteccion_civil", label: "Anuencia de Protección Civil", required: 0, category: "permisos" },
{ code: "mia", label: "MIA / resolutivo ambiental", required: 0, category: "ambiental" },
{ code: "siroc_acuse", label: "Acuse SIROC", required: 0, category: "imss" },
{ code: "programa_sst", label: "Programa de seguridad", required: 0, category: "sst" },
{ code: "seguro_obra", label: "Seguro de obra", required: 0, category: "contrato" },
{ code: "fianza", label: "Fianza", required: 0, category: "contrato" },
{ code: "otro", label: "Otro", required: 0, category: "otro" },
];
export const COMPANY_DOCUMENT_TYPE_SEED: Array<{
code: string;
label: string;
required: number;
category: string;
}> = [
{ code: "repse_federal", label: "REPSE federal (STPS)", required: 0, category: "repse" },
{ code: "repse_estatal", label: "REPSE estatal (SATQ)", required: 0, category: "repse" },
{ code: "opinion_sat", label: "Opinión de cumplimiento SAT (32-D)", required: 0, category: "fiscal" },
{ code: "opinion_imss", label: "Opinión de cumplimiento IMSS", required: 0, category: "fiscal" },
{ code: "opinion_infonavit", label: "Opinión de cumplimiento INFONAVIT", required: 0, category: "fiscal" },
{ code: "acta_constitutiva", label: "Acta constitutiva", required: 0, category: "legal" },
{ code: "poder_notarial", label: "Poder notarial", required: 0, category: "legal" },
{ code: "otro", label: "Otro", required: 0, category: "otro" },
];

View file

@ -2,6 +2,9 @@ import { assertEquals } from "https://deno.land/std@0.224.0/assert/mod.ts";
import {
evaluateDocumentValidity,
freshnessRequired,
DOCUMENT_TYPE_SEED,
PROJECT_DOCUMENT_TYPE_SEED,
COMPANY_DOCUMENT_TYPE_SEED,
type DocTypePolicy,
type WorkerImssContext,
} from "./document_validity.ts";
@ -122,3 +125,17 @@ Deno.test("missing issued_at for freshness", () => {
);
assertEquals(result.validity_status, "missing_dates");
});
Deno.test("núcleo de persona no se agranda", () => {
const required = DOCUMENT_TYPE_SEED.filter((d) => d.required).map((d) => d.code).sort();
assertEquals(required, ["curp", "domicilio", "foto", "ine", "nss", "rfc"]);
});
Deno.test("núcleo de obra no se agranda", () => {
const required = PROJECT_DOCUMENT_TYPE_SEED.filter((d) => d.required).map((d) => d.code).sort();
assertEquals(required, ["contrato", "presupuesto"]);
});
Deno.test("empresa no tiene documentos obligatorios", () => {
assertEquals(COMPANY_DOCUMENT_TYPE_SEED.every((d) => d.required === 0), true);
});

View file

@ -5,7 +5,7 @@ import { join } from "node:path";
import { encryptBytes } from "./docs_crypto.ts";
import { sha256Hex } from "./crypto.ts";
import { canonicalRiskCode, normalizeWorker, validateWorkerFields, formatNss, normUpper, type WorkerInput } from "./mx.ts";
import { refreshPipeline, workerDir, lastInsertId, projectDir } from "./db.ts";
import { refreshPipeline, workerDir, lastInsertId, projectDir, companyDir } from "./db.ts";
import { resolveCompany } from "./companies.ts";
export const IMPORT_COLUMNS = [
@ -325,6 +325,34 @@ export async function storeProjectDocument(
).run(projectId, type, filename, mime, bytes.byteLength, hash, iv, storage, userId);
}
export async function storeCompanyDocument(
db: Database,
companyId: number,
type: string,
filename: string,
mime: string,
bytes: Uint8Array,
userId: number | null,
) {
const allowed = db.prepare("SELECT code FROM company_document_types WHERE code = ?").get(type);
if (!allowed) throw new Error("Tipo de documento no válido");
const { iv, cipher } = await encryptBytes(bytes);
const hash = await sha256Hex(bytes);
const storage = `${crypto.randomUUID()}.enc`;
const dir = companyDir(companyId);
await mkdir(dir, { recursive: true });
await Deno.writeFile(join(dir, storage), cipher);
db.prepare("UPDATE company_documents SET is_current = 0 WHERE company_id = ? AND type_code = ?").run(
companyId,
type,
);
db.prepare(
`INSERT INTO company_documents
(company_id, type_code, original_name, mime, size_bytes, sha256, iv, storage_name, is_current, parse_status, uploaded_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, 'manual', ?)`,
).run(companyId, type, filename, mime, bytes.byteLength, hash, iv, storage, userId);
}
async function fetchPhoto(url: string): Promise<Uint8Array | null> {
if (!url.startsWith("http")) return null;
try {

View file

@ -1,6 +1,6 @@
import { Hono } from "hono";
import { cors } from "hono/cors";
import { getDb, checklistFor, refreshPipeline, workerDir, lastInsertId, nextProjectCode, isProjectStatus, projectById, projectMustBe, PROJECT_STATUS_CATALOG, projectDir, projectChecklistFor, imssFlagsFor, checklistItemsFor } from "./db.ts";
import { getDb, checklistFor, refreshPipeline, workerDir, lastInsertId, nextProjectCode, isProjectStatus, projectById, projectMustBe, PROJECT_STATUS_CATALOG, projectDir, projectChecklistFor, companyDir, companyChecklistFor, imssFlagsFor, checklistItemsFor } from "./db.ts";
import { config } from "./config.ts";
import { clearSession, createSessionCookie, login, changePassword, requireAuth, requirePlatformAdmin, tenantScope, type AuthUser } from "./auth.ts";
import { getPlatformDb } from "./platform_db.ts";
@ -8,7 +8,7 @@ import { createTenant, getTenantDetail, issueTenantAdminAccess, listTenants, upd
import { smtpConfigured, testSmtp } from "./mail.ts";
import { saveSmtpSettings, smtpPublicView } from "./smtp.ts";
import { decryptBytes } from "./docs_crypto.ts";
import { importExcel, storeDocument, storeProjectDocument, findExisting, assign, buildImportTemplate } from "./excel.ts";
import { importExcel, storeDocument, storeProjectDocument, storeCompanyDocument, findExisting, assign, buildImportTemplate } from "./excel.ts";
import {
normalizeWorker,
validateCurp,
@ -23,7 +23,8 @@ import {
type WorkerInput,
} from "./mx.ts";
import { generateBadgePdf, saveJobPdf, loadCurrentPhoto, badgeQrPng } from "./pdf.ts";
import { createSubcompany, listCompanies, resolveCompany, updateCompany } from "./companies.ts";
import { registerPayrollRoutes } from "./payroll_http.ts";
import { createSubcompany, listCompanies, resolveCompany, updateCompany, companyById } from "./companies.ts";
import {
buildBudgetTemplate,
exportBudgetWorkbook,
@ -36,6 +37,17 @@ import { join } from "node:path";
const app = new Hono<{ Variables: { user: AuthUser } }>();
function scopedCompany(
db: Awaited<ReturnType<typeof getDb>>,
id: number,
tid: number | null,
) {
const company = companyById(db, id);
if (!company) return undefined;
if (tid != null && company.tenant_id != null && company.tenant_id !== tid) return undefined;
return company;
}
function corsOrigins(): string[] {
const defaults = [
"http://localhost:3000",
@ -199,6 +211,7 @@ app.get("/v1/catalogs", requireAuth, async (c) => {
themes: db.prepare("SELECT * FROM badge_themes").all(),
document_types: db.prepare("SELECT * FROM document_types").all(),
project_document_types: db.prepare("SELECT * FROM project_document_types").all(),
company_document_types: db.prepare("SELECT * FROM company_document_types").all(),
project_statuses: PROJECT_STATUS_CATALOG,
companies: listCompanies(db, tid),
});
@ -228,6 +241,59 @@ app.patch("/v1/companies/:id", requireAuth, async (c) => {
return c.json({ company: result.company });
});
app.get("/v1/companies/:id", requireAuth, async (c) => {
const id = Number(c.req.param("id"));
const db = await getDb();
const company = scopedCompany(db, id, tenantScope(c.get("user")));
if (!company) return c.json({ error: "Empresa no encontrada" }, 404);
const documents = db.prepare(
"SELECT id, type_code, original_name, mime, size_bytes, is_current, uploaded_at FROM company_documents WHERE company_id = ? ORDER BY uploaded_at DESC",
).all(id);
return c.json({
company,
documents,
checklist: companyChecklistFor(db, id),
});
});
app.post("/v1/companies/:id/documents", requireAuth, async (c) => {
const id = Number(c.req.param("id"));
const form = await c.req.formData();
const type = String(form.get("type") || "");
const file = form.get("file");
if (!(file instanceof File) || !type) return c.json({ error: "type y file requeridos" }, 400);
const db = await getDb();
if (!scopedCompany(db, id, tenantScope(c.get("user")))) return c.json({ error: "Empresa no encontrada" }, 404);
const bytes = new Uint8Array(await file.arrayBuffer());
try {
await storeCompanyDocument(db, id, type, file.name, file.type || "application/octet-stream", bytes, c.get("user").id);
} catch (error) {
return c.json({ error: error instanceof Error ? error.message : "No se pudo guardar el documento" }, 400);
}
return c.json({ ok: true, checklist: companyChecklistFor(db, id) });
});
app.get("/v1/companies/:id/documents/:docId", requireAuth, async (c) => {
const companyId = Number(c.req.param("id"));
const docId = Number(c.req.param("docId"));
const db = await getDb();
if (!scopedCompany(db, companyId, tenantScope(c.get("user")))) return c.json({ error: "Empresa no encontrada" }, 404);
const doc = db.prepare(
"SELECT * FROM company_documents WHERE id=? AND company_id=?",
).get(docId, companyId) as {
storage_name: string;
iv: string;
mime: string;
original_name: string;
} | undefined;
if (!doc) return c.json({ error: "Documento no encontrado" }, 404);
const enc = await Deno.readFile(join(companyDir(companyId), doc.storage_name));
const plain = await decryptBytes(doc.iv, enc);
c.header("Content-Type", doc.mime);
c.header("Content-Disposition", `inline; filename="${doc.original_name}"`);
return c.body(plain.buffer as ArrayBuffer);
});
app.get("/v1/projects", requireAuth, async (c) => {
const db = await getDb();
const tid = tenantScope(c.get("user"));
@ -749,7 +815,7 @@ app.get("/v1/workers/:id", requireAuth, async (c) => {
const { items, freshness_required } = checklistItemsFor(db, id);
const flags = imssFlagsFor(db, id);
const docTypes = db.prepare(
`SELECT code, label, required, validity_mode, freshness_days, requires_issued_at, requires_expires_at
`SELECT code, label, required, validity_mode, freshness_days, requires_issued_at, requires_expires_at, category
FROM document_types ORDER BY required DESC, label`,
).all();
return c.json({
@ -1094,165 +1160,7 @@ app.patch("/v1/badge-jobs/:id/people/:workerId", requireAuth, async (c) => {
return c.json({ ok: true });
});
app.get("/v1/attendance", requireAuth, async (c) => {
const projectId = Number(c.req.query("project_id"));
const from = c.req.query("from") ?? "";
const to = c.req.query("to") ?? "";
const db = await getDb();
const rows = db.prepare(
`SELECT a.*, w.first_name, w.last_name_p FROM attendance a
JOIN workers w ON w.id=a.worker_id
WHERE a.project_id=? AND a.work_date BETWEEN ? AND ?
ORDER BY a.work_date, w.last_name_p`,
).all(projectId, from, to);
return c.json({ attendance: rows });
});
app.put("/v1/attendance", requireAuth, async (c) => {
const body = await c.req.json<{
project_id: number;
worker_id: number;
work_date: string;
present: boolean;
}>();
const db = await getDb();
const blocked = projectMustBe(
projectById(db, body.project_id),
["activo", "pausado"],
"No se registra asistencia en un proyecto concluido o cancelado",
);
if (blocked) return c.json({ error: blocked.error }, blocked.status);
db.prepare(
`INSERT INTO attendance (worker_id, project_id, work_date, present)
VALUES (?, ?, ?, ?)
ON CONFLICT(worker_id, project_id, work_date) DO UPDATE SET present=excluded.present`,
).run(body.worker_id, body.project_id, body.work_date, body.present ? 1 : 0);
return c.json({ ok: true });
});
app.get("/v1/loans", requireAuth, async (c) => {
const db = await getDb();
const workerId = c.req.query("worker_id");
const sql = workerId
? "SELECT l.*, w.first_name, w.last_name_p FROM loans l JOIN workers w ON w.id=l.worker_id WHERE l.worker_id=? ORDER BY l.id DESC"
: "SELECT l.*, w.first_name, w.last_name_p FROM loans l JOIN workers w ON w.id=l.worker_id ORDER BY l.id DESC";
const loans = workerId ? db.prepare(sql).all(Number(workerId)) : db.prepare(sql).all();
return c.json({ loans });
});
app.post("/v1/loans", requireAuth, async (c) => {
const body = await c.req.json<{
worker_id: number;
amount: number;
weekly_payment: number;
note?: string;
}>();
if (!body.worker_id || !body.amount) return c.json({ error: "worker_id y amount requeridos" }, 400);
const db = await getDb();
db.prepare(
"INSERT INTO loans (worker_id, amount, balance, weekly_payment, note) VALUES (?, ?, ?, ?, ?)",
).run(body.worker_id, body.amount, body.amount, body.weekly_payment ?? 0, body.note ?? null);
return c.json({ id: lastInsertId(db) }, 201);
});
app.post("/v1/payroll/periods", requireAuth, async (c) => {
const body = await c.req.json<{
project_id: number;
week_start: string;
week_end: string;
extra_discounts?: Record<number, number>;
}>();
const db = await getDb();
const blocked = projectMustBe(
projectById(db, body.project_id),
["activo", "pausado"],
"No se genera nómina de un proyecto concluido o cancelado",
);
if (blocked) return c.json({ error: blocked.error }, blocked.status);
db.prepare(
"INSERT INTO payroll_periods (project_id, week_start, week_end, status) VALUES (?, ?, ?, 'draft')",
).run(body.project_id, body.week_start, body.week_end);
const periodId = lastInsertId(db);
const workers = db.prepare(
`SELECT w.id, w.daily_wage FROM workers w
JOIN assignments a ON a.worker_id=w.id AND a.project_id=? AND a.active=1
WHERE w.status='activo'`,
).all(body.project_id) as { id: number; daily_wage: number }[];
const ins = db.prepare(
`INSERT INTO payroll_lines (period_id, worker_id, days, daily_wage, gross, discounts, loan_payment, net)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
);
for (const w of workers) {
const att = db.prepare(
`SELECT COUNT(*) AS n FROM attendance
WHERE worker_id=? AND project_id=? AND work_date BETWEEN ? AND ? AND present=1`,
).get(w.id, body.project_id, body.week_start, body.week_end) as { n: number };
const days = att.n;
const gross = days * w.daily_wage;
const extra = Number(body.extra_discounts?.[w.id] ?? 0);
const loan = db.prepare(
"SELECT id, balance, weekly_payment FROM loans WHERE worker_id=? AND balance>0 ORDER BY id LIMIT 1",
).get(w.id) as { id: number; balance: number; weekly_payment: number } | undefined;
let loanPay = 0;
if (loan) {
loanPay = Math.min(loan.weekly_payment, loan.balance, Math.max(0, gross - extra));
db.prepare("UPDATE loans SET balance = balance - ? WHERE id=?").run(loanPay, loan.id);
}
const net = gross - extra - loanPay;
ins.run(periodId, w.id, days, w.daily_wage, gross, extra, loanPay, net);
}
return c.json({ id: periodId });
});
app.get("/v1/payroll/periods", requireAuth, async (c) => {
const db = await getDb();
const projectId = c.req.query("project_id");
const sql = projectId
? `SELECT pe.*, p.name AS project_name FROM payroll_periods pe JOIN projects p ON p.id=pe.project_id WHERE pe.project_id=? ORDER BY pe.id DESC`
: `SELECT pe.*, p.name AS project_name FROM payroll_periods pe JOIN projects p ON p.id=pe.project_id ORDER BY pe.id DESC`;
const periods = projectId ? db.prepare(sql).all(Number(projectId)) : db.prepare(sql).all();
return c.json({ periods });
});
app.get("/v1/payroll/periods/:id", requireAuth, async (c) => {
const id = Number(c.req.param("id"));
const db = await getDb();
const period = db.prepare(
`SELECT pe.*, p.name AS project_name FROM payroll_periods pe JOIN projects p ON p.id=pe.project_id WHERE pe.id=?`,
).get(id);
if (!period) return c.json({ error: "Periodo no encontrado" }, 404);
const lines = db.prepare(
`SELECT l.*, w.first_name, w.last_name_p, w.position FROM payroll_lines l
JOIN workers w ON w.id=l.worker_id WHERE l.period_id=? ORDER BY w.last_name_p`,
).all(id);
return c.json({ period, lines });
});
app.get("/v1/payroll/periods/:id/csv", requireAuth, async (c) => {
const id = Number(c.req.param("id"));
const db = await getDb();
const lines = db.prepare(
`SELECT w.first_name, w.last_name_p, l.days, l.daily_wage, l.gross, l.discounts, l.loan_payment, l.net
FROM payroll_lines l JOIN workers w ON w.id=l.worker_id WHERE l.period_id=?`,
).all(id) as Record<string, unknown>[];
const header = "Nombre,Apellido,Dias,Jornal,Bruto,Descuentos,Prestamo,Neto";
const rows = lines.map((l) =>
[l.first_name, l.last_name_p, l.days, l.daily_wage, l.gross, l.discounts, l.loan_payment, l.net].join(",")
);
const csv = [header, ...rows].join("\n");
c.header("Content-Type", "text/csv; charset=utf-8");
c.header("Content-Disposition", `attachment; filename="nomina-${id}.csv"`);
return c.body(csv);
});
app.patch("/v1/payroll/periods/:id", requireAuth, async (c) => {
const id = Number(c.req.param("id"));
const { status } = await c.req.json<{ status: string }>();
const db = await getDb();
db.prepare("UPDATE payroll_periods SET status=? WHERE id=?").run(status, id);
return c.json({ ok: true });
});
registerPayrollRoutes(app);
const port = config.port;
await getDb();

997
api/payroll.ts Normal file
View file

@ -0,0 +1,997 @@
import type { Database } from "@db/sqlite";
export const PAYROLL_TZ = "America/Cancun";
export const DEFAULT_UNITS = [
{ code: "m", label: "Metro (m)" },
{ code: "m2", label: "Metro cuadrado (m²)" },
{ code: "m3", label: "Metro cúbico (m³)" },
{ code: "ml", label: "Metro lineal (ml)" },
{ code: "pza", label: "Pieza" },
] as const;
export type WeekStatus = "draft" | "assembled" | "paid";
export type PayrollSettings = {
tenant_id: number;
loan_commission_enabled: number;
loan_commission_pct: number;
loan_small_max: number;
};
type LoanRow = {
id: number;
worker_id: number;
amount: number;
delivered: number;
balance: number;
weekly_payment: number;
commission_pct: number;
commission_amount: number;
plan: string;
installments_n: number;
first_due: string | null;
created_at: string;
};
function lid(db: Database): number {
return Number((db.prepare("SELECT last_insert_rowid() AS id").get() as { id: number }).id);
}
export function roundMoney(n: number): number {
return Math.round((Number(n) || 0) * 100) / 100;
}
export function commissionAmount(delivered: number, pct: number): number {
return roundMoney(delivered * (Number(pct) || 0) / 100);
}
export function todayIso(now = new Date()): string {
return now.toLocaleDateString("en-CA", { timeZone: PAYROLL_TZ });
}
export function addDays(iso: string, days: number): string {
const [y, m, d] = iso.split("-").map(Number);
const dt = new Date(Date.UTC(y, m - 1, d + days));
return dt.toISOString().slice(0, 10);
}
export function weekdayMon0(iso: string): number {
const [y, m, d] = iso.split("-").map(Number);
const js = new Date(Date.UTC(y, m - 1, d)).getUTCDay();
return (js + 6) % 7;
}
export function weekContaining(iso: string): { weekStart: string; weekEnd: string } {
const weekStart = addDays(iso, -weekdayMon0(iso));
return { weekStart, weekEnd: addDays(weekStart, 5) };
}
export function destajoPeriodBounds(weekStart: string): { periodStart: string; periodEnd: string } {
const periodEnd = addDays(weekStart, 3);
return { periodStart: addDays(periodEnd, -7), periodEnd };
}
export function thisSaturday(iso: string): string {
return weekContaining(iso).weekEnd;
}
export function nextSaturdayAfterThisWeek(iso: string): string {
return addDays(thisSaturday(iso), 7);
}
export function weekDays(weekStart: string): string[] {
return [0, 1, 2, 3, 4, 5].map((n) => addDays(weekStart, n));
}
export function tenantKey(tenantId: number | null | undefined): number {
return tenantId ?? 0;
}
export function moneyLabel(n: number): string {
return `$${roundMoney(n).toLocaleString("es-MX", { minimumFractionDigits: 0, maximumFractionDigits: 2 })}`;
}
export function loanConditionLabel(
loan: { delivered: number; commission_pct: number; installments_n: number },
payAmount: number,
installmentN: number,
): string {
const comm = loan.commission_pct > 0
? `préstamo ${moneyLabel(loan.delivered)} + comisión ${loan.commission_pct}%`
: `préstamo ${moneyLabel(loan.delivered)}`;
return `${moneyLabel(payAmount)} · ${installmentN}/${loan.installments_n || 1} · ${comm}`;
}
export function getSettings(db: Database, tenantId: number): PayrollSettings {
seedDefaults(db, tenantId);
const row = db.prepare("SELECT * FROM payroll_settings WHERE tenant_id=?").get(tenantId) as PayrollSettings;
return row;
}
export function saveSettings(
db: Database,
tenantId: number,
patch: Partial<PayrollSettings>,
): PayrollSettings {
const cur = getSettings(db, tenantId);
const next = {
loan_commission_enabled: patch.loan_commission_enabled ?? cur.loan_commission_enabled,
loan_commission_pct: patch.loan_commission_pct ?? cur.loan_commission_pct,
loan_small_max: patch.loan_small_max ?? cur.loan_small_max,
};
db.prepare(
`UPDATE payroll_settings
SET loan_commission_enabled=?, loan_commission_pct=?, loan_small_max=?
WHERE tenant_id=?`,
).run(
next.loan_commission_enabled ? 1 : 0,
Number(next.loan_commission_pct) || 0,
Number(next.loan_small_max) || 0,
tenantId,
);
return getSettings(db, tenantId);
}
export function seedDefaults(db: Database, tenantId: number) {
db.prepare(
`INSERT INTO payroll_settings (tenant_id, loan_commission_enabled, loan_commission_pct, loan_small_max)
VALUES (?, 1, 10, 500)
ON CONFLICT(tenant_id) DO NOTHING`,
).run(tenantId);
const n = db.prepare("SELECT COUNT(*) AS n FROM destajo_units WHERE tenant_id=?").get(tenantId) as { n: number };
if (n.n === 0) {
const ins = db.prepare("INSERT INTO destajo_units (tenant_id, code, label) VALUES (?, ?, ?)");
for (const u of DEFAULT_UNITS) ins.run(tenantId, u.code, u.label);
}
}
export function listUnits(db: Database, tenantId: number) {
seedDefaults(db, tenantId);
return db.prepare("SELECT * FROM destajo_units WHERE tenant_id=? ORDER BY id").all(tenantId);
}
export function addUnit(db: Database, tenantId: number, code: string, label: string) {
const c = code.trim().toLowerCase().replace(/\s+/g, "");
const l = label.trim() || c;
if (!c) throw new Error("Unidad requerida");
db.prepare("INSERT INTO destajo_units (tenant_id, code, label) VALUES (?, ?, ?)").run(tenantId, c, l);
return lid(db);
}
export function firstDueForLoan(opts: {
delivered: number;
smallMax: number;
plan: "single" | "installments";
grantIso: string;
weekAssembled: boolean;
}): string {
const small = opts.delivered <= opts.smallMax && opts.plan === "single";
if (small) return nextSaturdayAfterThisWeek(opts.grantIso);
if (opts.weekAssembled) return nextSaturdayAfterThisWeek(opts.grantIso);
return thisSaturday(opts.grantIso);
}
export function createLoan(
db: Database,
tenantId: number,
body: {
worker_id: number;
delivered: number;
commission_enabled?: boolean;
commission_pct?: number;
plan?: "single" | "installments";
installments_n?: number;
note?: string;
grantIso?: string;
},
): { id: number } {
const worker = db.prepare("SELECT id FROM workers WHERE id=?").get(body.worker_id);
if (!worker) throw Object.assign(new Error("Persona no encontrada"), { status: 404 });
const settings = getSettings(db, tenantId);
const delivered = roundMoney(body.delivered);
if (delivered <= 0) throw Object.assign(new Error("Monto requerido"), { status: 400 });
const enabled = body.commission_enabled ?? Boolean(settings.loan_commission_enabled);
const pct = enabled ? Number(body.commission_pct ?? settings.loan_commission_pct) || 0 : 0;
const comm = commissionAmount(delivered, pct);
const balance = roundMoney(delivered + comm);
const small = delivered <= settings.loan_small_max;
let plan = body.plan ?? "single";
let installments = Math.max(1, Math.floor(body.installments_n ?? 1));
if (small) {
plan = "single";
installments = 1;
}
if (plan === "installments" && installments < 2) installments = 2;
if (plan === "single") installments = 1;
const weekly = roundMoney(balance / installments);
const grantIso = body.grantIso ?? todayIso();
const week = weekContaining(grantIso);
const current = db.prepare(
"SELECT status FROM payroll_weeks WHERE tenant_id=? AND week_start=?",
).get(tenantId, week.weekStart) as { status: string } | undefined;
const assembled = current?.status === "assembled" || current?.status === "paid";
const firstDue = firstDueForLoan({
delivered,
smallMax: settings.loan_small_max,
plan,
grantIso,
weekAssembled: assembled && weekdayMon0(grantIso) >= 4,
});
db.prepare(
`INSERT INTO loans
(worker_id, amount, delivered, balance, weekly_payment, note,
commission_pct, commission_amount, plan, installments_n, first_due)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
).run(
body.worker_id,
delivered,
delivered,
balance,
weekly,
body.note ?? null,
pct,
comm,
plan,
installments,
firstDue,
);
return { id: lid(db) };
}
export function presentOnOtherProject(
db: Database,
workerId: number,
workDate: string,
exceptProjectId?: number,
): { project_id: number; project_name: string } | null {
const row = db.prepare(
`SELECT a.project_id, p.name AS project_name
FROM attendance a JOIN projects p ON p.id=a.project_id
WHERE a.worker_id=? AND a.work_date=? AND a.present=1
AND a.project_id != ?`,
).get(workerId, workDate, exceptProjectId ?? 0) as
| { project_id: number; project_name: string }
| undefined;
return row ?? null;
}
export function setAttendance(
db: Database,
tenantId: number,
body: { project_id: number; worker_id: number; work_date: string; present: boolean },
): { ok: true } | { error: string; status: number; other?: { project_id: number; project_name: string } } {
if (body.present) {
const other = presentOnOtherProject(db, body.worker_id, body.work_date, body.project_id);
if (other) {
return {
error: `Ese día ya está marcado en ${other.project_name}`,
status: 409,
other,
};
}
}
db.prepare(
`INSERT INTO attendance (worker_id, project_id, work_date, present)
VALUES (?, ?, ?, ?)
ON CONFLICT(worker_id, project_id, work_date) DO UPDATE SET present=excluded.present`,
).run(body.worker_id, body.project_id, body.work_date, body.present ? 1 : 0);
const { weekStart } = weekContaining(body.work_date);
const week = db.prepare(
"SELECT id, status FROM payroll_weeks WHERE tenant_id=? AND week_start=?",
).get(tenantId, weekStart) as { id: number; status: string } | undefined;
if (week && week.status !== "paid") recalcWeek(db, week.id);
return { ok: true };
}
export function ensureWeek(db: Database, tenantId: number, weekStart: string): number {
seedDefaults(db, tenantId);
const { weekEnd } = weekContaining(weekStart);
const start = weekContaining(weekStart).weekStart;
let week = db.prepare(
"SELECT id FROM payroll_weeks WHERE tenant_id=? AND week_start=?",
).get(tenantId, start) as { id: number } | undefined;
if (!week) {
db.prepare(
`INSERT INTO payroll_weeks (week_start, week_end, status, tenant_id)
VALUES (?, ?, 'draft', ?)`,
).run(start, weekEnd, tenantId);
week = { id: lid(db) };
}
const weekId = week.id;
const destajo = destajoPeriodBounds(start);
const period = db.prepare(
"SELECT id FROM destajo_periods WHERE tenant_id=? AND period_end=?",
).get(tenantId, destajo.periodEnd) as { id: number } | undefined;
if (!period) {
db.prepare(
`INSERT INTO destajo_periods (period_start, period_end, week_id, tenant_id)
VALUES (?, ?, ?, ?)`,
).run(destajo.periodStart, destajo.periodEnd, weekId, tenantId);
}
const projects = db.prepare(
`SELECT id FROM projects
WHERE status IN ('activo', 'pausado')
AND (? = 0 OR tenant_id = ? OR tenant_id IS NULL)
ORDER BY name`,
).all(tenantId, tenantId) as { id: number }[];
const hasSheet = db.prepare(
`SELECT id FROM payroll_sheets
WHERE week_id=? AND kind=? AND IFNULL(project_id, 0)=IFNULL(?, 0)`,
);
const insSheet = db.prepare(
`INSERT INTO payroll_sheets (week_id, kind, project_id) VALUES (?, ?, ?)`,
);
const ensureSheet = (kind: string, projectId: number | null) => {
if (hasSheet.get(weekId, kind, projectId)) return;
insSheet.run(weekId, kind, projectId);
};
for (const p of projects) {
ensureSheet("obra", p.id);
ensureSheet("destajo", p.id);
}
ensureSheet("admin", null);
return weekId;
}
function jornalRoster(db: Database, projectId: number, weekStart: string, weekEnd: string, weekId = 0) {
return db.prepare(
`SELECT w.id, w.first_name, w.last_name_p, w.position, w.daily_wage, w.work_type
FROM workers w
WHERE w.status='activo' AND w.pipeline_status != 'baja'
AND (
EXISTS (
SELECT 1 FROM assignments a
WHERE a.worker_id=w.id AND a.project_id=? AND a.active=1 AND w.work_type='N'
)
OR EXISTS (
SELECT 1 FROM attendance att
WHERE att.worker_id=w.id AND att.project_id=? AND att.work_date BETWEEN ? AND ?
)
OR EXISTS (
SELECT 1 FROM payroll_week_lines l
JOIN payroll_sheets s ON s.id=l.sheet_id
WHERE l.worker_id=w.id AND s.project_id=? AND s.kind='obra' AND s.week_id=?
)
)
ORDER BY w.last_name_p, w.first_name`,
).all(projectId, projectId, weekStart, weekEnd, projectId, weekId) as {
id: number;
first_name: string;
last_name_p: string;
position: string;
daily_wage: number;
work_type: string;
}[];
}
function sheetLine(
db: Database,
sheetId: number,
workerId: number,
destajoCutLineId: number | null,
): number {
const existing = db.prepare(
destajoCutLineId
? "SELECT id FROM payroll_week_lines WHERE sheet_id=? AND destajo_cut_line_id=?"
: "SELECT id FROM payroll_week_lines WHERE sheet_id=? AND worker_id=? AND destajo_cut_line_id IS NULL",
).get(sheetId, destajoCutLineId ?? workerId) as { id: number } | undefined;
if (existing) return existing.id;
db.prepare(
`INSERT INTO payroll_week_lines (sheet_id, worker_id, destajo_cut_line_id)
VALUES (?, ?, ?)`,
).run(sheetId, workerId, destajoCutLineId);
return lid(db);
}
function dueLoansForSaturday(db: Database, workerId: number, saturday: string): LoanRow[] {
return db.prepare(
`SELECT * FROM loans
WHERE worker_id=? AND balance > 0 AND first_due IS NOT NULL AND first_due <= ?
ORDER BY first_due, id`,
).all(workerId, saturday) as LoanRow[];
}
function paidInstallments(db: Database, loanId: number): number {
const row = db.prepare("SELECT COUNT(*) AS n FROM loan_payments WHERE loan_id=?").get(loanId) as { n: number };
return row.n;
}
export function recalcWeek(db: Database, weekId: number) {
const week = db.prepare("SELECT * FROM payroll_weeks WHERE id=?").get(weekId) as {
id: number;
week_start: string;
week_end: string;
status: string;
required_net: number;
tenant_id: number;
} | undefined;
if (!week || week.status === "paid") return;
const days = weekDays(week.week_start);
const saturday = week.week_end;
const sheets = db.prepare("SELECT * FROM payroll_sheets WHERE week_id=?").all(weekId) as {
id: number;
kind: string;
project_id: number | null;
}[];
db.prepare("UPDATE payroll_week_lines SET loan_id=NULL, loan_discount=0, loan_label=NULL WHERE sheet_id IN (SELECT id FROM payroll_sheets WHERE week_id=?)")
.run(weekId);
for (const sheet of sheets) {
if (sheet.kind === "obra" && sheet.project_id) {
const roster = jornalRoster(db, sheet.project_id, week.week_start, week.week_end, weekId);
for (const w of roster) {
const lineId = sheetLine(db, sheet.id, w.id, null);
const att = db.prepare(
`SELECT COUNT(*) AS n FROM attendance
WHERE worker_id=? AND project_id=? AND work_date BETWEEN ? AND ? AND present=1`,
).get(w.id, sheet.project_id, week.week_start, week.week_end) as { n: number };
const frozen = db.prepare("SELECT daily_wage FROM payroll_week_lines WHERE id=?").get(lineId) as { daily_wage: number };
const wage = week.status === "assembled" && frozen.daily_wage > 0 ? frozen.daily_wage : w.daily_wage;
const gross = roundMoney(att.n * wage);
db.prepare(
`UPDATE payroll_week_lines
SET days=?, daily_wage=?, amount=?, gross=?, payable_net=?
WHERE id=?`,
).run(att.n, wage, gross, gross, gross, lineId);
}
}
if (sheet.kind === "destajo" && sheet.project_id) {
const period = db.prepare(
"SELECT id FROM destajo_periods WHERE week_id=?",
).get(weekId) as { id: number } | undefined;
if (!period) continue;
const cuts = db.prepare(
`SELECT c.*, j.worker_id, j.unit_price, j.unit_code, j.concepto
FROM destajo_cut_lines c
JOIN destajo_jobs j ON j.id=c.job_id
WHERE c.period_id=? AND j.project_id=?`,
).all(period.id, sheet.project_id) as {
id: number;
worker_id: number;
qty_planned: number;
qty_actual: number;
qty_extra: number;
unit_price: number;
unit_code: string;
concepto: string;
}[];
const keep = new Set<number>();
for (const cut of cuts) {
const lineId = sheetLine(db, sheet.id, cut.worker_id, cut.id);
keep.add(lineId);
const payableQty = roundMoney(
week.status === "draft" && cut.qty_actual === 0 && cut.qty_extra === 0
? cut.qty_planned
: cut.qty_actual + cut.qty_extra,
);
const gross = roundMoney(payableQty * cut.unit_price);
const requiredGross = roundMoney(cut.qty_planned * cut.unit_price);
db.prepare(
`UPDATE payroll_week_lines
SET destajo_cut_line_id=?, qty_planned=?, qty_actual=?, qty_extra=?,
unit_price=?, unit_code=?, concepto=?, amount=?, gross=?, payable_net=?
WHERE id=?`,
).run(
cut.id,
cut.qty_planned,
cut.qty_actual,
cut.qty_extra,
cut.unit_price,
cut.unit_code,
cut.concepto,
requiredGross,
gross,
gross,
lineId,
);
}
const stale = db.prepare(
"SELECT id, destajo_cut_line_id FROM payroll_week_lines WHERE sheet_id=?",
).all(sheet.id) as { id: number; destajo_cut_line_id: number | null }[];
for (const row of stale) {
if (!keep.has(row.id)) db.prepare("DELETE FROM payroll_week_lines WHERE id=?").run(row.id);
}
}
if (sheet.kind === "admin") {
const lines = db.prepare("SELECT id, amount, discounts FROM payroll_week_lines WHERE sheet_id=?").all(sheet.id) as {
id: number;
amount: number;
discounts: number;
}[];
for (const line of lines) {
const gross = roundMoney(line.amount);
db.prepare("UPDATE payroll_week_lines SET gross=?, payable_net=? WHERE id=?").run(
gross,
roundMoney(gross - line.discounts),
line.id,
);
}
}
}
const allLines = db.prepare(
`SELECT l.id, l.worker_id, l.gross, l.discounts, l.payable_net
FROM payroll_week_lines l
JOIN payroll_sheets s ON s.id=l.sheet_id
WHERE s.week_id=?
ORDER BY l.gross DESC, l.id`,
).all(weekId) as { id: number; worker_id: number; gross: number; discounts: number; payable_net: number }[];
const byWorker = new Map<number, typeof allLines>();
for (const line of allLines) {
const list = byWorker.get(line.worker_id) ?? [];
list.push(line);
byWorker.set(line.worker_id, list);
}
for (const [workerId, lines] of byWorker) {
const gross = roundMoney(lines.reduce((s, l) => s + l.gross - l.discounts, 0));
const loans = dueLoansForSaturday(db, workerId, saturday);
let remainingNet = Math.max(0, gross);
let placed = false;
for (const loan of loans) {
if (remainingNet <= 0) break;
const n = paidInstallments(db, loan.id) + 1;
const isLast = n >= (loan.installments_n || 1);
const want = isLast ? loan.balance : Math.min(loan.weekly_payment || loan.balance, loan.balance);
const take = roundMoney(Math.min(want, remainingNet));
if (take <= 0) continue;
remainingNet = roundMoney(remainingNet - take);
if (!placed) {
const host = lines[0];
const label = loanConditionLabel(loan, take, n);
db.prepare(
`UPDATE payroll_week_lines SET loan_id=?, loan_discount=?, loan_label=?, payable_net=? WHERE id=?`,
).run(loan.id, take, label, roundMoney(host.gross - host.discounts - take), host.id);
placed = true;
}
break;
}
if (!placed) {
for (const line of lines) {
db.prepare("UPDATE payroll_week_lines SET payable_net=? WHERE id=?").run(
roundMoney(line.gross - line.discounts),
line.id,
);
}
} else {
for (const line of lines.slice(1)) {
db.prepare("UPDATE payroll_week_lines SET payable_net=? WHERE id=?").run(
roundMoney(line.gross - line.discounts),
line.id,
);
}
}
}
const tot = db.prepare(
`SELECT COALESCE(SUM(l.payable_net), 0) AS n
FROM payroll_week_lines l JOIN payroll_sheets s ON s.id=l.sheet_id
WHERE s.week_id=?`,
).get(weekId) as { n: number };
db.prepare("UPDATE payroll_weeks SET payable_net=? WHERE id=?").run(roundMoney(tot.n), weekId);
if (week.status === "draft") {
db.prepare("UPDATE payroll_weeks SET required_net=? WHERE id=?").run(roundMoney(tot.n), weekId);
db.prepare(
`UPDATE payroll_week_lines SET required_net=payable_net
WHERE sheet_id IN (SELECT id FROM payroll_sheets WHERE week_id=?)`,
).run(weekId);
}
}
function prefillThuSat(db: Database, projectId: number, weekStart: string, workerId: number) {
for (const offset of [3, 4, 5]) {
const day = addDays(weekStart, offset);
if (presentOnOtherProject(db, workerId, day, projectId)) continue;
const existing = db.prepare(
"SELECT id, present FROM attendance WHERE worker_id=? AND project_id=? AND work_date=?",
).get(workerId, projectId, day) as { id: number; present: number } | undefined;
if (existing) continue;
db.prepare(
"INSERT INTO attendance (worker_id, project_id, work_date, present) VALUES (?, ?, ?, 1)",
).run(workerId, projectId, day);
}
}
export function assembleWeek(db: Database, weekId: number) {
const week = db.prepare("SELECT * FROM payroll_weeks WHERE id=?").get(weekId) as {
id: number;
week_start: string;
status: string;
tenant_id: number;
} | undefined;
if (!week) throw Object.assign(new Error("Semana no encontrada"), { status: 404 });
if (week.status === "paid") throw Object.assign(new Error("La semana ya está pagada"), { status: 400 });
const sheets = db.prepare(
"SELECT * FROM payroll_sheets WHERE week_id=? AND kind='obra'",
).all(weekId) as { id: number; project_id: number | null }[];
for (const sheet of sheets) {
if (!sheet.project_id) continue;
const roster = jornalRoster(db, sheet.project_id, week.week_start, addDays(week.week_start, 5), weekId);
for (const w of roster) {
const assigned = db.prepare(
"SELECT 1 AS ok FROM assignments WHERE worker_id=? AND project_id=? AND active=1",
).get(w.id, sheet.project_id) as { ok: number } | undefined;
if (!assigned) continue;
if (w.work_type !== "N") {
const hasLine = db.prepare(
"SELECT id FROM payroll_week_lines WHERE sheet_id=? AND worker_id=?",
).get(sheet.id, w.id);
if (!hasLine) continue;
}
prefillThuSat(db, sheet.project_id, week.week_start, w.id);
}
}
const period = db.prepare("SELECT id FROM destajo_periods WHERE week_id=?").get(weekId) as { id: number } | undefined;
if (period) {
db.prepare(
`UPDATE destajo_cut_lines SET qty_actual = qty_planned
WHERE period_id=? AND qty_actual=0 AND qty_planned>0`,
).run(period.id);
}
db.prepare(
"UPDATE payroll_weeks SET status='assembled', assembled_at=datetime('now') WHERE id=?",
).run(weekId);
recalcWeek(db, weekId);
const payable = db.prepare("SELECT payable_net FROM payroll_weeks WHERE id=?").get(weekId) as { payable_net: number };
db.prepare("UPDATE payroll_weeks SET required_net=? WHERE id=?").run(payable.payable_net, weekId);
db.prepare(
`UPDATE payroll_week_lines SET required_net=payable_net
WHERE sheet_id IN (SELECT id FROM payroll_sheets WHERE week_id=?)`,
).run(weekId);
}
export function payWeek(db: Database, weekId: number) {
const week = db.prepare("SELECT * FROM payroll_weeks WHERE id=?").get(weekId) as {
id: number;
week_end: string;
status: string;
} | undefined;
if (!week) throw Object.assign(new Error("Semana no encontrada"), { status: 404 });
if (week.status === "paid") throw Object.assign(new Error("La semana ya está pagada"), { status: 400 });
if (week.status !== "assembled") {
assembleWeek(db, weekId);
}
recalcWeek(db, weekId);
const lines = db.prepare(
`SELECT l.loan_id, l.loan_discount, l.loan_label, l.worker_id
FROM payroll_week_lines l
JOIN payroll_sheets s ON s.id=l.sheet_id
WHERE s.week_id=? AND l.loan_id IS NOT NULL AND l.loan_discount > 0`,
).all(weekId) as { loan_id: number; loan_discount: number; loan_label: string; worker_id: number }[];
for (const line of lines) {
const loan = db.prepare("SELECT * FROM loans WHERE id=?").get(line.loan_id) as LoanRow | undefined;
if (!loan || loan.balance <= 0) continue;
const take = roundMoney(Math.min(line.loan_discount, loan.balance));
if (take <= 0) continue;
const n = paidInstallments(db, loan.id) + 1;
db.prepare(
`INSERT INTO loan_payments (loan_id, week_id, amount, installment_n, label) VALUES (?, ?, ?, ?, ?)`,
).run(loan.id, weekId, take, n, line.loan_label);
db.prepare("UPDATE loans SET balance = ROUND(balance - ?, 2) WHERE id=?").run(take, loan.id);
}
const period = db.prepare("SELECT id FROM destajo_periods WHERE week_id=?").get(weekId) as { id: number } | undefined;
if (period) {
const jobs = db.prepare("SELECT id, qty_total_estimated FROM destajo_jobs").all() as {
id: number;
qty_total_estimated: number;
}[];
for (const job of jobs) {
const paid = db.prepare(
`SELECT COALESCE(SUM(c.qty_actual + c.qty_extra), 0) AS n
FROM destajo_cut_lines c
JOIN destajo_periods p ON p.id=c.period_id
JOIN payroll_weeks w ON w.id=p.week_id
WHERE c.job_id=? AND (w.status='paid' OR w.id=?)`,
).get(job.id, weekId) as { n: number };
if (paid.n >= job.qty_total_estimated) {
db.prepare("UPDATE destajo_jobs SET status='done' WHERE id=?").run(job.id);
}
}
}
db.prepare(
"UPDATE payroll_weeks SET status='paid', paid_at=datetime('now') WHERE id=?",
).run(weekId);
}
export function addJornalWorker(db: Database, weekId: number, projectId: number, workerId: number) {
const week = db.prepare("SELECT * FROM payroll_weeks WHERE id=?").get(weekId) as { status: string } | undefined;
if (!week || week.status === "paid") throw Object.assign(new Error("No se puede editar"), { status: 400 });
const sheet = db.prepare(
"SELECT id FROM payroll_sheets WHERE week_id=? AND kind='obra' AND project_id=?",
).get(weekId, projectId) as { id: number } | undefined;
if (!sheet) throw Object.assign(new Error("Hoja de obra no encontrada"), { status: 404 });
sheetLine(db, sheet.id, workerId, null);
recalcWeek(db, weekId);
}
export function addAdminLine(
db: Database,
weekId: number,
body: { worker_id: number; amount: number; project_id?: number | null },
) {
const week = db.prepare("SELECT * FROM payroll_weeks WHERE id=?").get(weekId) as { status: string } | undefined;
if (!week || week.status === "paid") throw Object.assign(new Error("No se puede editar"), { status: 400 });
const sheet = db.prepare(
"SELECT id FROM payroll_sheets WHERE week_id=? AND kind='admin' AND project_id IS NULL",
).get(weekId) as { id: number } | undefined;
if (!sheet) throw Object.assign(new Error("Hoja administrativa no encontrada"), { status: 404 });
const lineId = sheetLine(db, sheet.id, body.worker_id, null);
db.prepare("UPDATE payroll_week_lines SET amount=?, project_id=?, gross=?, payable_net=? WHERE id=?").run(
roundMoney(body.amount),
body.project_id ?? null,
roundMoney(body.amount),
roundMoney(body.amount),
lineId,
);
recalcWeek(db, weekId);
}
export function updateAdminLine(db: Database, weekId: number, lineId: number, amount: number) {
const week = db.prepare("SELECT status FROM payroll_weeks WHERE id=?").get(weekId) as { status: string } | undefined;
if (!week || week.status === "paid") throw Object.assign(new Error("No se puede editar"), { status: 400 });
db.prepare("UPDATE payroll_week_lines SET amount=? WHERE id=?").run(roundMoney(amount), lineId);
recalcWeek(db, weekId);
}
export function removeAdminLine(db: Database, weekId: number, lineId: number) {
const week = db.prepare("SELECT status FROM payroll_weeks WHERE id=?").get(weekId) as { status: string } | undefined;
if (!week || week.status === "paid") throw Object.assign(new Error("No se puede editar"), { status: 400 });
db.prepare("DELETE FROM payroll_week_lines WHERE id=?").run(lineId);
recalcWeek(db, weekId);
}
export function jobRemainder(db: Database, jobId: number): number {
const job = db.prepare("SELECT qty_total_estimated FROM destajo_jobs WHERE id=?").get(jobId) as
| { qty_total_estimated: number }
| undefined;
if (!job) return 0;
const paid = db.prepare(
`SELECT COALESCE(SUM(c.qty_actual + c.qty_extra), 0) AS n
FROM destajo_cut_lines c
JOIN destajo_periods p ON p.id=c.period_id
JOIN payroll_weeks w ON w.id=p.week_id
WHERE c.job_id=? AND w.status='paid'`,
).get(jobId) as { n: number };
return roundMoney(Math.max(0, job.qty_total_estimated - paid.n));
}
export function createDestajoJob(
db: Database,
tenantId: number,
weekId: number,
body: {
project_id: number;
worker_id: number;
concepto: string;
unit_code: string;
qty_total_estimated: number;
unit_price: number;
qty_planned?: number;
qty_extra?: number;
},
): { id: number } {
const week = db.prepare("SELECT status FROM payroll_weeks WHERE id=?").get(weekId) as { status: string } | undefined;
if (!week || week.status === "paid") throw Object.assign(new Error("No se puede editar"), { status: 400 });
const concepto = body.concepto.trim();
if (!concepto) throw Object.assign(new Error("Concepto requerido"), { status: 400 });
db.prepare(
`INSERT INTO destajo_jobs
(project_id, worker_id, concepto, unit_code, qty_total_estimated, unit_price, tenant_id)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
).run(
body.project_id,
body.worker_id,
concepto,
body.unit_code,
body.qty_total_estimated,
body.unit_price,
tenantId,
);
const jobId = lid(db);
const period = db.prepare("SELECT id FROM destajo_periods WHERE week_id=?").get(weekId) as { id: number };
db.prepare(
`INSERT INTO destajo_cut_lines (period_id, job_id, qty_planned, qty_actual, qty_extra)
VALUES (?, ?, ?, 0, ?)`,
).run(period.id, jobId, body.qty_planned ?? 0, body.qty_extra ?? 0);
recalcWeek(db, weekId);
return { id: jobId };
}
export function patchDestajoCut(
db: Database,
weekId: number,
cutId: number,
patch: { qty_planned?: number; qty_actual?: number; qty_extra?: number },
) {
const week = db.prepare("SELECT status FROM payroll_weeks WHERE id=?").get(weekId) as { status: string } | undefined;
if (!week || week.status === "paid") throw Object.assign(new Error("No se puede editar"), { status: 400 });
const cut = db.prepare("SELECT * FROM destajo_cut_lines WHERE id=?").get(cutId) as {
qty_planned: number;
qty_actual: number;
qty_extra: number;
} | undefined;
if (!cut) throw Object.assign(new Error("Partida no encontrada"), { status: 404 });
const planned = patch.qty_planned ?? cut.qty_planned;
const actual = patch.qty_actual ?? cut.qty_actual;
const extra = patch.qty_extra ?? cut.qty_extra;
if (week.status === "assembled" && patch.qty_planned != null) {
// required already frozen; planned can still be edited for display but does not rewrite required
}
db.prepare("UPDATE destajo_cut_lines SET qty_planned=?, qty_actual=?, qty_extra=? WHERE id=?").run(
planned,
actual,
extra,
cutId,
);
recalcWeek(db, weekId);
}
export function ensureCutForOpenJobs(db: Database, weekId: number) {
const period = db.prepare("SELECT id, tenant_id FROM destajo_periods WHERE week_id=?").get(weekId) as
| { id: number; tenant_id: number }
| undefined;
if (!period) return;
const jobs = db.prepare(
"SELECT id FROM destajo_jobs WHERE tenant_id=? AND status='open'",
).all(period.tenant_id) as { id: number }[];
const ins = db.prepare(
`INSERT INTO destajo_cut_lines (period_id, job_id, qty_planned, qty_actual, qty_extra)
VALUES (?, ?, 0, 0, 0)
ON CONFLICT(period_id, job_id) DO NOTHING`,
);
for (const job of jobs) ins.run(period.id, job.id);
}
type AttCell = {
date: string;
present: boolean;
other_project_id: number | null;
other_project_name: string | null;
};
export function getWeekBundle(db: Database, tenantId: number, weekStart: string) {
const weekId = ensureWeek(db, tenantId, weekStart);
ensureCutForOpenJobs(db, weekId);
recalcWeek(db, weekId);
const week = db.prepare(
`SELECT * FROM payroll_weeks WHERE id=?`,
).get(weekId) as Record<string, unknown>;
const period = db.prepare("SELECT * FROM destajo_periods WHERE week_id=?").get(weekId);
const start = String(week.week_start);
const end = String(week.week_end);
const days = weekDays(start);
const sheetsRaw = db.prepare(
`SELECT s.*, p.name AS project_name, p.code AS project_code
FROM payroll_sheets s
LEFT JOIN projects p ON p.id=s.project_id
WHERE s.week_id=?
ORDER BY CASE s.kind WHEN 'obra' THEN 0 WHEN 'destajo' THEN 1 ELSE 2 END, p.name`,
).all(weekId) as Record<string, unknown>[];
const attRows = db.prepare(
`SELECT a.worker_id, a.project_id, a.work_date, a.present, p.name AS project_name
FROM attendance a JOIN projects p ON p.id=a.project_id
WHERE a.work_date BETWEEN ? AND ?`,
).all(start, end) as {
worker_id: number;
project_id: number;
work_date: string;
present: number;
project_name: string;
}[];
const sheets = sheetsRaw.map((sheet) => {
const lines = db.prepare(
`SELECT l.*, w.first_name, w.last_name_p, w.position, w.work_type
FROM payroll_week_lines l
JOIN workers w ON w.id=l.worker_id
WHERE l.sheet_id=?
ORDER BY w.last_name_p, w.first_name, l.id`,
).all(Number(sheet.id)) as Record<string, unknown>[];
const withCells = lines.map((line) => {
const cells: AttCell[] = days.map((date) => {
const mine = attRows.find((a) =>
a.worker_id === line.worker_id && a.project_id === sheet.project_id && a.work_date === date
);
const other = attRows.find((a) =>
a.worker_id === line.worker_id && a.present === 1 && a.project_id !== sheet.project_id && a.work_date === date
);
return {
date,
present: Boolean(mine?.present),
other_project_id: other && !mine?.present ? other.project_id : null,
other_project_name: other && !mine?.present ? other.project_name : null,
};
});
return { ...line, cells };
});
return { ...sheet, lines: withCells };
});
const jobs = db.prepare(
`SELECT j.*, w.first_name, w.last_name_p, p.name AS project_name
FROM destajo_jobs j
JOIN workers w ON w.id=j.worker_id
JOIN projects p ON p.id=j.project_id
WHERE j.tenant_id=?
ORDER BY j.status, j.id DESC`,
).all(tenantId) as Record<string, unknown>[];
const jobsOut = jobs.map((job) => ({ ...job, remainder: jobRemainder(db, Number(job.id)) }));
const cuts = period
? db.prepare(
`SELECT c.*, j.concepto, j.unit_code, j.unit_price, j.qty_total_estimated, j.worker_id, j.project_id,
j.status AS job_status, w.first_name, w.last_name_p, p.name AS project_name
FROM destajo_cut_lines c
JOIN destajo_jobs j ON j.id=c.job_id
JOIN workers w ON w.id=j.worker_id
JOIN projects p ON p.id=j.project_id
WHERE c.period_id=?
ORDER BY p.name, w.last_name_p`,
).all((period as { id: number }).id)
: [];
const settings = getSettings(db, tenantId);
const units = listUnits(db, tenantId);
const required = Number(week.required_net) || 0;
const payable = Number(week.payable_net) || 0;
return {
week,
destajo_period: period,
sheets,
destajo_jobs: jobsOut,
destajo_cuts: cuts,
settings,
units,
days,
board: {
required_net: required,
payable_net: payable,
delta: roundMoney(required - payable),
},
};
}
export function weekCsv(db: Database, weekId: number): string {
const lines = db.prepare(
`SELECT w.first_name, w.last_name_p, s.kind, p.name AS project_name,
l.days, l.daily_wage, l.concepto, l.qty_actual, l.qty_extra, l.unit_code,
l.gross, l.discounts, l.loan_discount, l.loan_label, l.payable_net, l.required_net
FROM payroll_week_lines l
JOIN payroll_sheets s ON s.id=l.sheet_id
JOIN workers w ON w.id=l.worker_id
LEFT JOIN projects p ON p.id=s.project_id
WHERE s.week_id=?
ORDER BY s.kind, p.name, w.last_name_p`,
).all(weekId) as Record<string, unknown>[];
const header =
"Nombre,Apellido,Hoja,Proyecto,Dias,Jornal,Concepto,Cantidad,Unidad,Bruto,Descuentos,Prestamo,EtiquetaPrestamo,Requerido,APagar";
const rows = lines.map((l) =>
[
l.first_name,
l.last_name_p,
l.kind,
l.project_name ?? "",
l.days,
l.daily_wage,
l.concepto ?? "",
Number(l.qty_actual || 0) + Number(l.qty_extra || 0),
l.unit_code ?? "",
l.gross,
l.discounts,
l.loan_discount,
String(l.loan_label ?? "").replaceAll(",", " "),
l.required_net,
l.payable_net,
].join(",")
);
return [header, ...rows].join("\n");
}
export function listOpenWeeks(db: Database, tenantId: number) {
return db.prepare(
`SELECT * FROM payroll_weeks WHERE tenant_id=? AND status != 'paid' ORDER BY week_start DESC`,
).all(tenantId);
}

454
api/payroll_http.ts Normal file
View file

@ -0,0 +1,454 @@
import type { Hono } from "hono";
import type { AuthUser } from "./auth.ts";
import { tenantScope, requireAuth } from "./auth.ts";
import { getDb, lastInsertId, projectById, projectMustBe } from "./db.ts";
import { generateLoanReceiptPdf } from "./pdf.ts";
import {
addAdminLine,
addJornalWorker,
addUnit,
assembleWeek,
createDestajoJob,
createLoan,
getSettings,
getWeekBundle,
listOpenWeeks,
listUnits,
moneyLabel,
payWeek,
patchDestajoCut,
removeAdminLine,
saveSettings,
setAttendance,
tenantKey,
todayIso,
updateAdminLine,
weekContaining,
weekCsv,
} from "./payroll.ts";
type App = Hono<{ Variables: { user: AuthUser } }>;
function tid(c: { get: (k: "user") => AuthUser }): number {
return tenantKey(tenantScope(c.get("user")));
}
async function pdfBody(
c: { header: (k: string, v: string) => void; body: (data: ArrayBuffer) => unknown },
bytes: Uint8Array,
filename: string,
) {
c.header("Content-Type", "application/pdf");
c.header("Content-Disposition", `attachment; filename="${filename}"`);
return c.body(bytes.buffer as ArrayBuffer);
}
export function registerPayrollRoutes(app: App) {
app.get("/v1/attendance", requireAuth, async (c) => {
const projectId = Number(c.req.query("project_id"));
const from = c.req.query("from") ?? "";
const to = c.req.query("to") ?? "";
const db = await getDb();
const rows = db.prepare(
`SELECT a.*, w.first_name, w.last_name_p FROM attendance a
JOIN workers w ON w.id=a.worker_id
WHERE a.project_id=? AND a.work_date BETWEEN ? AND ?
ORDER BY a.work_date, w.last_name_p`,
).all(projectId, from, to);
return c.json({ attendance: rows });
});
app.put("/v1/attendance", requireAuth, async (c) => {
const body = await c.req.json<{
project_id: number;
worker_id: number;
work_date: string;
present: boolean;
}>();
const db = await getDb();
const blocked = projectMustBe(
projectById(db, body.project_id),
["activo", "pausado"],
"No se registra asistencia en un proyecto concluido o cancelado",
);
if (blocked) return c.json({ error: blocked.error }, blocked.status);
const result = setAttendance(db, tid(c), body);
if ("error" in result) {
return c.json({ error: result.error, other: result.other }, result.status === 409 ? 409 : 400);
}
return c.json({ ok: true });
});
app.get("/v1/payroll/settings", requireAuth, async (c) => {
const db = await getDb();
const tenantId = tid(c);
return c.json({ settings: getSettings(db, tenantId), units: listUnits(db, tenantId) });
});
app.patch("/v1/payroll/settings", requireAuth, async (c) => {
const db = await getDb();
const body = await c.req.json<{
loan_commission_enabled?: number | boolean;
loan_commission_pct?: number;
loan_small_max?: number;
}>();
const settings = saveSettings(db, tid(c), {
loan_commission_enabled: body.loan_commission_enabled === undefined
? undefined
: body.loan_commission_enabled ? 1 : 0,
loan_commission_pct: body.loan_commission_pct,
loan_small_max: body.loan_small_max,
});
return c.json({ settings });
});
app.post("/v1/destajo/units", requireAuth, async (c) => {
const body = await c.req.json<{ code: string; label?: string }>();
const db = await getDb();
try {
const id = addUnit(db, tid(c), body.code, body.label ?? body.code);
return c.json({ id }, 201);
} catch (e) {
return c.json({ error: e instanceof Error ? e.message : "No se pudo guardar" }, 400);
}
});
app.get("/v1/loans", requireAuth, async (c) => {
const db = await getDb();
const workerId = c.req.query("worker_id");
const sql = workerId
? "SELECT l.*, w.first_name, w.last_name_p FROM loans l JOIN workers w ON w.id=l.worker_id WHERE l.worker_id=? ORDER BY l.id DESC"
: "SELECT l.*, w.first_name, w.last_name_p FROM loans l JOIN workers w ON w.id=l.worker_id ORDER BY l.id DESC";
const loans = workerId ? db.prepare(sql).all(Number(workerId)) : db.prepare(sql).all();
return c.json({ loans });
});
app.post("/v1/loans", requireAuth, async (c) => {
const body = await c.req.json<{
worker_id: number;
amount?: number;
delivered?: number;
weekly_payment?: number;
note?: string;
commission_enabled?: boolean;
commission_pct?: number;
plan?: "single" | "installments";
installments_n?: number;
}>();
const db = await getDb();
try {
const created = createLoan(db, tid(c), {
worker_id: body.worker_id,
delivered: Number(body.delivered ?? body.amount ?? 0),
commission_enabled: body.commission_enabled,
commission_pct: body.commission_pct,
plan: body.plan,
installments_n: body.installments_n,
note: body.note,
});
return c.json(created, 201);
} catch (e) {
const err = e as Error & { status?: number };
return c.json({ error: err.message }, err.status === 404 ? 404 : 400);
}
});
app.get("/v1/loans/:id/recibo", requireAuth, async (c) => {
const id = Number(c.req.param("id"));
const db = await getDb();
const loan = db.prepare(
`SELECT l.*, w.first_name, w.last_name_p FROM loans l JOIN workers w ON w.id=l.worker_id WHERE l.id=?`,
).get(id) as {
delivered: number;
commission_pct: number;
commission_amount: number;
balance: number;
weekly_payment: number;
plan: string;
installments_n: number;
first_due: string;
created_at: string;
first_name: string;
last_name_p: string;
note: string | null;
} | undefined;
if (!loan) return c.json({ error: "Préstamo no encontrado" }, 404);
const bytes = await generateLoanReceiptPdf({
title: "Recibo de aceptación de préstamo",
subtitle: `Registrado ${String(loan.created_at).slice(0, 10)}`,
person: `${loan.last_name_p} ${loan.first_name}`,
lines: [
{ label: "Entregado", value: moneyLabel(loan.delivered) },
{
label: "Comisión",
value: loan.commission_pct > 0
? `${loan.commission_pct}% · ${moneyLabel(loan.commission_amount)}`
: "Sin comisión",
},
{ label: "Saldo a descontar", value: moneyLabel(loan.delivered + loan.commission_amount) },
{
label: "Plan",
value: loan.plan === "installments" ? `${loan.installments_n} cuotas` : "1 exhibición",
},
{ label: "Primer descuento", value: loan.first_due || "—" },
{ label: "Abono", value: moneyLabel(loan.weekly_payment) },
{ label: "Nota", value: loan.note || "—" },
],
});
return pdfBody(c, bytes, `prestamo-aceptacion-${id}.pdf`);
});
app.get("/v1/loan-payments/:id/recibo", requireAuth, async (c) => {
const id = Number(c.req.param("id"));
const db = await getDb();
const pay = db.prepare(
`SELECT p.*, l.delivered, l.commission_pct, w.first_name, w.last_name_p, wk.week_end
FROM loan_payments p
JOIN loans l ON l.id=p.loan_id
JOIN workers w ON w.id=l.worker_id
JOIN payroll_weeks wk ON wk.id=p.week_id
WHERE p.id=?`,
).get(id) as {
amount: number;
installment_n: number;
label: string;
delivered: number;
commission_pct: number;
first_name: string;
last_name_p: string;
week_end: string;
} | undefined;
if (!pay) return c.json({ error: "Recibo no encontrado" }, 404);
const bytes = await generateLoanReceiptPdf({
title: "Recibo de pago de préstamo",
subtitle: `Descuento del sábado ${pay.week_end}`,
person: `${pay.last_name_p} ${pay.first_name}`,
lines: [
{ label: "Descuento", value: moneyLabel(pay.amount) },
{ label: "Exhibición", value: String(pay.installment_n) },
{ label: "Condiciones", value: pay.label || "—" },
],
});
return pdfBody(c, bytes, `prestamo-pago-${id}.pdf`);
});
app.get("/v1/payroll/weeks", requireAuth, async (c) => {
const db = await getDb();
const tenantId = tid(c);
const start = c.req.query("week_start") || weekContaining(todayIso()).weekStart;
const bundle = getWeekBundle(db, tenantId, start);
return c.json(bundle);
});
app.get("/v1/payroll/weeks/open", requireAuth, async (c) => {
const db = await getDb();
return c.json({ weeks: listOpenWeeks(db, tid(c)) });
});
app.post("/v1/payroll/weeks/:id/assemble", requireAuth, async (c) => {
const db = await getDb();
try {
assembleWeek(db, Number(c.req.param("id")));
return c.json({ ok: true });
} catch (e) {
const err = e as Error & { status?: number };
return c.json({ error: err.message }, err.status === 404 ? 404 : 400);
}
});
app.post("/v1/payroll/weeks/:id/pay", requireAuth, async (c) => {
const db = await getDb();
try {
payWeek(db, Number(c.req.param("id")));
return c.json({ ok: true });
} catch (e) {
const err = e as Error & { status?: number };
return c.json({ error: err.message }, err.status === 404 ? 404 : 400);
}
});
app.get("/v1/payroll/weeks/:id/csv", requireAuth, async (c) => {
const id = Number(c.req.param("id"));
const db = await getDb();
const csv = weekCsv(db, id);
c.header("Content-Type", "text/csv; charset=utf-8");
c.header("Content-Disposition", `attachment; filename="nomina-${id}.csv"`);
return c.body(csv);
});
app.post("/v1/payroll/weeks/:id/jornal", requireAuth, async (c) => {
const body = await c.req.json<{ project_id: number; worker_id: number }>();
const db = await getDb();
try {
addJornalWorker(db, Number(c.req.param("id")), body.project_id, body.worker_id);
return c.json({ ok: true });
} catch (e) {
const err = e as Error & { status?: number };
return c.json({ error: err.message }, err.status === 404 ? 404 : 400);
}
});
app.post("/v1/payroll/weeks/:id/admin", requireAuth, async (c) => {
const body = await c.req.json<{ worker_id: number; amount: number; project_id?: number | null }>();
const db = await getDb();
try {
addAdminLine(db, Number(c.req.param("id")), body);
return c.json({ ok: true });
} catch (e) {
const err = e as Error & { status?: number };
return c.json({ error: err.message }, err.status === 404 ? 404 : 400);
}
});
app.patch("/v1/payroll/weeks/:id/admin/:lineId", requireAuth, async (c) => {
const body = await c.req.json<{ amount: number }>();
const db = await getDb();
try {
updateAdminLine(db, Number(c.req.param("id")), Number(c.req.param("lineId")), body.amount);
return c.json({ ok: true });
} catch (e) {
const err = e as Error & { status?: number };
return c.json({ error: err.message }, err.status === 404 ? 404 : 400);
}
});
app.delete("/v1/payroll/weeks/:id/admin/:lineId", requireAuth, async (c) => {
const db = await getDb();
try {
removeAdminLine(db, Number(c.req.param("id")), Number(c.req.param("lineId")));
return c.json({ ok: true });
} catch (e) {
const err = e as Error & { status?: number };
return c.json({ error: err.message }, err.status === 404 ? 404 : 400);
}
});
app.post("/v1/destajo/jobs", requireAuth, async (c) => {
const body = await c.req.json<{
week_id: number;
project_id: number;
worker_id: number;
concepto: string;
unit_code: string;
qty_total_estimated: number;
unit_price: number;
qty_planned?: number;
qty_extra?: number;
}>();
const db = await getDb();
try {
const created = createDestajoJob(db, tid(c), body.week_id, body);
return c.json(created, 201);
} catch (e) {
const err = e as Error & { status?: number };
return c.json({ error: err.message }, err.status === 404 ? 404 : 400);
}
});
app.patch("/v1/destajo/cuts/:id", requireAuth, async (c) => {
const body = await c.req.json<{
week_id: number;
qty_planned?: number;
qty_actual?: number;
qty_extra?: number;
}>();
const db = await getDb();
try {
patchDestajoCut(db, body.week_id, Number(c.req.param("id")), body);
return c.json({ ok: true });
} catch (e) {
const err = e as Error & { status?: number };
return c.json({ error: err.message }, err.status === 404 ? 404 : 400);
}
});
app.post("/v1/payroll/periods", requireAuth, async (c) => {
const body = await c.req.json<{
project_id: number;
week_start: string;
week_end: string;
extra_discounts?: Record<number, number>;
}>();
const db = await getDb();
const blocked = projectMustBe(
projectById(db, body.project_id),
["activo", "pausado"],
"No se genera nómina de un proyecto concluido o cancelado",
);
if (blocked) return c.json({ error: blocked.error }, blocked.status);
db.prepare(
"INSERT INTO payroll_periods (project_id, week_start, week_end, status) VALUES (?, ?, ?, 'draft')",
).run(body.project_id, body.week_start, body.week_end);
const periodId = lastInsertId(db);
const workers = db.prepare(
`SELECT w.id, w.daily_wage FROM workers w
JOIN assignments a ON a.worker_id=w.id AND a.project_id=? AND a.active=1
WHERE w.status='activo'`,
).all(body.project_id) as { id: number; daily_wage: number }[];
const ins = db.prepare(
`INSERT INTO payroll_lines (period_id, worker_id, days, daily_wage, gross, discounts, loan_payment, net)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
);
for (const w of workers) {
const att = db.prepare(
`SELECT COUNT(*) AS n FROM attendance
WHERE worker_id=? AND project_id=? AND work_date BETWEEN ? AND ? AND present=1`,
).get(w.id, body.project_id, body.week_start, body.week_end) as { n: number };
const days = att.n;
const gross = days * w.daily_wage;
const extra = Number(body.extra_discounts?.[w.id] ?? 0);
const net = gross - extra;
ins.run(periodId, w.id, days, w.daily_wage, gross, extra, 0, net);
}
return c.json({ id: periodId });
});
app.get("/v1/payroll/periods", requireAuth, async (c) => {
const db = await getDb();
const projectId = c.req.query("project_id");
const sql = projectId
? `SELECT pe.*, p.name AS project_name FROM payroll_periods pe JOIN projects p ON p.id=pe.project_id WHERE pe.project_id=? ORDER BY pe.id DESC`
: `SELECT pe.*, p.name AS project_name FROM payroll_periods pe JOIN projects p ON p.id=pe.project_id ORDER BY pe.id DESC`;
const periods = projectId ? db.prepare(sql).all(Number(projectId)) : db.prepare(sql).all();
return c.json({ periods });
});
app.get("/v1/payroll/periods/:id", requireAuth, async (c) => {
const id = Number(c.req.param("id"));
const db = await getDb();
const period = db.prepare(
`SELECT pe.*, p.name AS project_name FROM payroll_periods pe JOIN projects p ON p.id=pe.project_id WHERE pe.id=?`,
).get(id);
if (!period) return c.json({ error: "Periodo no encontrado" }, 404);
const lines = db.prepare(
`SELECT l.*, w.first_name, w.last_name_p, w.position FROM payroll_lines l
JOIN workers w ON w.id=l.worker_id WHERE l.period_id=? ORDER BY w.last_name_p`,
).all(id);
return c.json({ period, lines });
});
app.get("/v1/payroll/periods/:id/csv", requireAuth, async (c) => {
const id = Number(c.req.param("id"));
const db = await getDb();
const lines = db.prepare(
`SELECT w.first_name, w.last_name_p, l.days, l.daily_wage, l.gross, l.discounts, l.loan_payment, l.net
FROM payroll_lines l JOIN workers w ON w.id=l.worker_id WHERE l.period_id=?`,
).all(id) as Record<string, unknown>[];
const header = "Nombre,Apellido,Dias,Jornal,Bruto,Descuentos,Prestamo,Neto";
const rows = lines.map((l) =>
[l.first_name, l.last_name_p, l.days, l.daily_wage, l.gross, l.discounts, l.loan_payment, l.net].join(",")
);
const csv = [header, ...rows].join("\n");
c.header("Content-Type", "text/csv; charset=utf-8");
c.header("Content-Disposition", `attachment; filename="nomina-${id}.csv"`);
return c.body(csv);
});
app.patch("/v1/payroll/periods/:id", requireAuth, async (c) => {
const id = Number(c.req.param("id"));
const { status } = await c.req.json<{ status: string }>();
const db = await getDb();
db.prepare("UPDATE payroll_periods SET status=? WHERE id=?").run(status, id);
return c.json({ ok: true });
});
}

280
api/payroll_test.ts Normal file
View file

@ -0,0 +1,280 @@
import { assertEquals, assertExists } from "jsr:@std/assert@1";
import { Database } from "@db/sqlite";
import {
addDays,
assembleWeek,
commissionAmount,
createDestajoJob,
createLoan,
destajoPeriodBounds,
ensureWeek,
firstDueForLoan,
getWeekBundle,
jobRemainder,
nextSaturdayAfterThisWeek,
patchDestajoCut,
payWeek,
roundMoney,
setAttendance,
thisSaturday,
weekContaining,
} from "./payroll.ts";
function mem() {
const db = new Database(":memory:");
db.exec("PRAGMA foreign_keys = ON");
db.exec(`
CREATE TABLE companies (id INTEGER PRIMARY KEY, name TEXT, tenant_id INTEGER);
CREATE TABLE projects (
id INTEGER PRIMARY KEY, code TEXT, name TEXT, status TEXT DEFAULT 'activo', tenant_id INTEGER
);
CREATE TABLE workers (
id INTEGER PRIMARY KEY,
first_name TEXT, last_name_p TEXT, position TEXT DEFAULT 'Ayudante',
work_type TEXT DEFAULT 'N', daily_wage REAL DEFAULT 0,
status TEXT DEFAULT 'activo', pipeline_status TEXT DEFAULT 'activo', tenant_id INTEGER
);
CREATE TABLE assignments (
id INTEGER PRIMARY KEY, worker_id INTEGER, project_id INTEGER, active INTEGER DEFAULT 1
);
CREATE TABLE attendance (
id INTEGER PRIMARY KEY,
worker_id INTEGER, project_id INTEGER, work_date TEXT, present INTEGER DEFAULT 1,
UNIQUE (worker_id, project_id, work_date)
);
CREATE TABLE loans (
id INTEGER PRIMARY KEY,
worker_id INTEGER, amount REAL, delivered REAL DEFAULT 0, balance REAL,
weekly_payment REAL DEFAULT 0, note TEXT,
commission_pct REAL DEFAULT 0, commission_amount REAL DEFAULT 0,
plan TEXT DEFAULT 'single', installments_n INTEGER DEFAULT 1, first_due TEXT,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE payroll_settings (
tenant_id INTEGER PRIMARY KEY,
loan_commission_enabled INTEGER DEFAULT 1,
loan_commission_pct REAL DEFAULT 10,
loan_small_max REAL DEFAULT 500
);
CREATE TABLE payroll_weeks (
id INTEGER PRIMARY KEY,
week_start TEXT, week_end TEXT, status TEXT DEFAULT 'draft',
required_net REAL DEFAULT 0, payable_net REAL DEFAULT 0,
assembled_at TEXT, paid_at TEXT, tenant_id INTEGER DEFAULT 0,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE UNIQUE INDEX idx_weeks ON payroll_weeks(tenant_id, week_start);
CREATE TABLE payroll_sheets (
id INTEGER PRIMARY KEY, week_id INTEGER, kind TEXT, project_id INTEGER
);
CREATE UNIQUE INDEX idx_sheets ON payroll_sheets(week_id, kind, IFNULL(project_id, 0));
CREATE TABLE payroll_week_lines (
id INTEGER PRIMARY KEY,
sheet_id INTEGER, worker_id INTEGER, destajo_cut_line_id INTEGER, project_id INTEGER,
days REAL DEFAULT 0, daily_wage REAL DEFAULT 0,
qty_planned REAL DEFAULT 0, qty_actual REAL DEFAULT 0, qty_extra REAL DEFAULT 0,
unit_price REAL DEFAULT 0, unit_code TEXT, concepto TEXT,
amount REAL DEFAULT 0, gross REAL DEFAULT 0, discounts REAL DEFAULT 0,
loan_id INTEGER, loan_discount REAL DEFAULT 0, loan_label TEXT,
required_net REAL DEFAULT 0, payable_net REAL DEFAULT 0
);
CREATE TABLE destajo_units (
id INTEGER PRIMARY KEY, tenant_id INTEGER, code TEXT, label TEXT, UNIQUE(tenant_id, code)
);
CREATE TABLE destajo_periods (
id INTEGER PRIMARY KEY, period_start TEXT, period_end TEXT, week_id INTEGER, tenant_id INTEGER,
UNIQUE(tenant_id, period_end)
);
CREATE TABLE destajo_jobs (
id INTEGER PRIMARY KEY, project_id INTEGER, worker_id INTEGER, concepto TEXT,
unit_code TEXT, qty_total_estimated REAL, unit_price REAL, status TEXT DEFAULT 'open',
tenant_id INTEGER DEFAULT 0
);
CREATE TABLE destajo_cut_lines (
id INTEGER PRIMARY KEY, period_id INTEGER, job_id INTEGER,
qty_planned REAL DEFAULT 0, qty_actual REAL DEFAULT 0, qty_extra REAL DEFAULT 0,
UNIQUE(period_id, job_id)
);
CREATE TABLE loan_payments (
id INTEGER PRIMARY KEY, loan_id INTEGER, week_id INTEGER, amount REAL,
installment_n INTEGER, label TEXT, created_at TEXT DEFAULT (datetime('now'))
);
`);
db.prepare("INSERT INTO projects (id, code, name, status, tenant_id) VALUES (1, 'PRY-0001', 'Obra Norte', 'activo', 1)").run();
db.prepare(
"INSERT INTO workers (id, first_name, last_name_p, work_type, daily_wage, tenant_id) VALUES (1, 'Juan', 'Perez', 'N', 500, 1)",
).run();
db.prepare("INSERT INTO assignments (worker_id, project_id, active) VALUES (1, 1, 1)").run();
return db;
}
Deno.test("semana lun-sáb y corte destajo jue-jue", () => {
const w = weekContaining("2026-08-26");
assertEquals(w.weekStart, "2026-08-24");
assertEquals(w.weekEnd, "2026-08-29");
const d = destajoPeriodBounds("2026-08-24");
assertEquals(d.periodEnd, "2026-08-27");
assertEquals(d.periodStart, "2026-08-20");
});
Deno.test("comisión suma al saldo", () => {
assertEquals(commissionAmount(500, 10), 50);
assertEquals(roundMoney(500 + 50), 550);
});
Deno.test("préstamo chico vence el sábado de la semana siguiente", () => {
assertEquals(thisSaturday("2026-08-18"), "2026-08-22");
assertEquals(nextSaturdayAfterThisWeek("2026-08-18"), "2026-08-29");
assertEquals(
firstDueForLoan({
delivered: 500,
smallMax: 500,
plan: "single",
grantIso: "2026-08-18",
weekAssembled: false,
}),
"2026-08-29",
);
assertEquals(
firstDueForLoan({
delivered: 2000,
smallMax: 500,
plan: "single",
grantIso: "2026-08-18",
weekAssembled: false,
}),
"2026-08-22",
);
assertEquals(
firstDueForLoan({
delivered: 2000,
smallMax: 500,
plan: "single",
grantIso: "2026-08-21",
weekAssembled: true,
}),
"2026-08-29",
);
});
Deno.test("armar jueves prellena jue-vie-sáb y congela requerida", () => {
const db = mem();
db.prepare("INSERT INTO attendance (worker_id, project_id, work_date, present) VALUES (1, 1, '2026-08-24', 1)").run();
db.prepare("INSERT INTO attendance (worker_id, project_id, work_date, present) VALUES (1, 1, '2026-08-25', 1)").run();
const weekId = ensureWeek(db, 1, "2026-08-24");
assembleWeek(db, weekId);
const week = db.prepare("SELECT * FROM payroll_weeks WHERE id=?").get(weekId) as {
status: string;
required_net: number;
payable_net: number;
};
assertEquals(week.status, "assembled");
assertEquals(week.required_net, 2500);
assertEquals(week.payable_net, 2500);
const thu = db.prepare(
"SELECT present FROM attendance WHERE worker_id=1 AND work_date='2026-08-27'",
).get() as { present: number };
assertEquals(thu.present, 1);
});
Deno.test("falta viernes baja a pagar y no toca requerida", () => {
const db = mem();
db.prepare("INSERT INTO attendance (worker_id, project_id, work_date, present) VALUES (1, 1, '2026-08-24', 1)").run();
db.prepare("INSERT INTO attendance (worker_id, project_id, work_date, present) VALUES (1, 1, '2026-08-25', 1)").run();
const weekId = ensureWeek(db, 1, "2026-08-24");
assembleWeek(db, weekId);
const r = setAttendance(db, 1, { project_id: 1, worker_id: 1, work_date: "2026-08-28", present: false });
assertEquals("ok" in r && r.ok, true);
const week = db.prepare("SELECT required_net, payable_net FROM payroll_weeks WHERE id=?").get(weekId) as {
required_net: number;
payable_net: number;
};
assertEquals(week.required_net, 2500);
assertEquals(week.payable_net, 2000);
});
Deno.test("préstamo no baja saldo al armar; sí al pagar", () => {
const db = mem();
db.prepare("INSERT INTO attendance (worker_id, project_id, work_date, present) VALUES (1, 1, '2026-08-24', 1)").run();
db.prepare("INSERT INTO attendance (worker_id, project_id, work_date, present) VALUES (1, 1, '2026-08-25', 1)").run();
db.prepare("INSERT INTO attendance (worker_id, project_id, work_date, present) VALUES (1, 1, '2026-08-26', 1)").run();
createLoan(db, 1, { worker_id: 1, delivered: 2000, commission_pct: 10, grantIso: "2026-08-24" });
const weekId = ensureWeek(db, 1, "2026-08-24");
assembleWeek(db, weekId);
const before = db.prepare("SELECT balance FROM loans WHERE worker_id=1").get() as { balance: number };
assertEquals(before.balance, 2200);
const week = db.prepare("SELECT required_net FROM payroll_weeks WHERE id=?").get(weekId) as { required_net: number };
assertEquals(week.required_net, 800);
payWeek(db, weekId);
const after = db.prepare("SELECT balance FROM loans WHERE worker_id=1").get() as { balance: number };
assertEquals(after.balance, 0);
});
Deno.test("un día no se marca en dos obras", () => {
const db = mem();
db.prepare("INSERT INTO projects (id, code, name, status, tenant_id) VALUES (2, 'PRY-0002', 'Obra Sur', 'activo', 1)").run();
setAttendance(db, 1, { project_id: 1, worker_id: 1, work_date: "2026-08-24", present: true });
const blocked = setAttendance(db, 1, { project_id: 2, worker_id: 1, work_date: "2026-08-24", present: true });
assertEquals("error" in blocked, true);
});
Deno.test("préstamo de personal: el día en otra obra no se prellena aquí", () => {
const db = mem();
db.prepare("INSERT INTO projects (id, code, name, status, tenant_id) VALUES (2, 'PRY-0002', 'Obra Sur', 'activo', 1)").run();
setAttendance(db, 1, { project_id: 2, worker_id: 1, work_date: "2026-08-27", present: true });
const weekId = ensureWeek(db, 1, "2026-08-24");
assembleWeek(db, weekId);
const onA = db.prepare(
"SELECT present FROM attendance WHERE worker_id=1 AND project_id=1 AND work_date='2026-08-27'",
).get() as { present: number } | undefined;
assertEquals(onA, undefined);
const onB = db.prepare(
"SELECT present FROM attendance WHERE worker_id=1 AND project_id=2 AND work_date='2026-08-27'",
).get() as { present: number };
assertEquals(onB.present, 1);
});
Deno.test("destajo: estimado del corte, realizado menor, remanente y extra viernes", () => {
const db = mem();
db.prepare("DELETE FROM assignments WHERE worker_id=1").run();
db.prepare(
"INSERT INTO workers (id, first_name, last_name_p, work_type, daily_wage, tenant_id) VALUES (2, 'Luis', 'Destajo', 'D', 0, 1)",
).run();
const weekId = ensureWeek(db, 1, "2026-08-24");
const job = createDestajoJob(db, 1, weekId, {
project_id: 1,
worker_id: 2,
concepto: "Muro tablaroca",
unit_code: "m2",
qty_total_estimated: 200,
unit_price: 100,
qty_planned: 80,
});
assembleWeek(db, weekId);
let week = db.prepare("SELECT required_net, payable_net FROM payroll_weeks WHERE id=?").get(weekId) as {
required_net: number;
payable_net: number;
};
assertEquals(week.required_net, 8000);
const cut = db.prepare("SELECT id FROM destajo_cut_lines WHERE job_id=?").get(job.id) as { id: number };
patchDestajoCut(db, weekId, cut.id, { qty_actual: 50 });
week = db.prepare("SELECT required_net, payable_net FROM payroll_weeks WHERE id=?").get(weekId) as {
required_net: number;
payable_net: number;
};
assertEquals(week.required_net, 8000);
assertEquals(week.payable_net, 5000);
patchDestajoCut(db, weekId, cut.id, { qty_extra: 10 });
week = db.prepare("SELECT required_net, payable_net FROM payroll_weeks WHERE id=?").get(weekId) as {
required_net: number;
payable_net: number;
};
assertEquals(week.payable_net, 6000);
payWeek(db, weekId);
assertEquals(jobRemainder(db, job.id), 140);
const nextId = ensureWeek(db, 1, addDays("2026-08-24", 7));
assertExists(nextId);
const bundle = getWeekBundle(db, 1, "2026-08-31");
assertEquals((bundle.destajo_jobs as { remainder: number }[])[0].remainder, 140);
});

View file

@ -348,3 +348,49 @@ export async function saveJobPdf(bytes: Uint8Array, jobId: number): Promise<stri
await Deno.writeFile(path, bytes);
return path;
}
function wrap(text: string, font: PDFFont, size: number, max: number): string[] {
const words = text.split(/\s+/);
const lines: string[] = [];
let cur = "";
for (const word of words) {
const next = cur ? `${cur} ${word}` : word;
if (font.widthOfTextAtSize(next, size) > max && cur) {
lines.push(cur);
cur = word;
} else {
cur = next;
}
}
if (cur) lines.push(cur);
return lines;
}
export async function generateLoanReceiptPdf(opts: {
title: string;
subtitle: string;
person: string;
lines: { label: string; value: string }[];
}): Promise<Uint8Array> {
const pdf = await PDFDocument.create();
const page = pdf.addPage([612, 792]);
const font = await pdf.embedFont(StandardFonts.Helvetica);
const bold = await pdf.embedFont(StandardFonts.HelveticaBold);
page.drawText("ARCTEC / PANEL OBRA", { x: 56, y: 740, size: 11, font: bold, color: rgb(0.12, 0.18, 0.28) });
page.drawText(opts.title, { x: 56, y: 710, size: 18, font: bold });
page.drawText(opts.subtitle, { x: 56, y: 688, size: 10, font, color: rgb(0.3, 0.3, 0.3) });
page.drawText(opts.person, { x: 56, y: 650, size: 14, font: bold });
let y = 610;
for (const row of opts.lines) {
page.drawText(row.label, { x: 56, y, size: 10, font, color: rgb(0.35, 0.35, 0.35) });
const valueLines = wrap(row.value, bold, 11, 280);
let vy = y;
for (const line of valueLines) {
page.drawText(line, { x: 280, y: vy, size: 11, font: bold });
vy -= 14;
}
y = Math.min(y, vy) - 18;
}
page.drawText("Recibo interno. No es un CFDI.", { x: 56, y: 80, size: 9, font, color: rgb(0.4, 0.4, 0.4) });
return await pdf.save();
}

View file

@ -125,7 +125,8 @@ CREATE TABLE IF NOT EXISTS document_types (
validity_mode TEXT NOT NULL DEFAULT 'none' CHECK (validity_mode IN ('none', 'freshness', 'expiry')),
freshness_days INTEGER,
requires_issued_at INTEGER NOT NULL DEFAULT 0,
requires_expires_at INTEGER NOT NULL DEFAULT 0
requires_expires_at INTEGER NOT NULL DEFAULT 0,
category TEXT NOT NULL DEFAULT 'identidad'
);
CREATE TABLE IF NOT EXISTS documents (
@ -151,7 +152,8 @@ CREATE TABLE IF NOT EXISTS documents (
CREATE TABLE IF NOT EXISTS project_document_types (
code TEXT PRIMARY KEY,
label TEXT NOT NULL,
required INTEGER NOT NULL DEFAULT 1
required INTEGER NOT NULL DEFAULT 1,
category TEXT NOT NULL DEFAULT 'contrato'
);
CREATE TABLE IF NOT EXISTS project_documents (
@ -170,6 +172,29 @@ CREATE TABLE IF NOT EXISTS project_documents (
uploaded_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS company_document_types (
code TEXT PRIMARY KEY,
label TEXT NOT NULL,
required INTEGER NOT NULL DEFAULT 0,
category TEXT NOT NULL DEFAULT 'otro'
);
CREATE TABLE IF NOT EXISTS company_documents (
id INTEGER PRIMARY KEY AUTOINCREMENT,
company_id INTEGER NOT NULL REFERENCES companies(id) ON DELETE CASCADE,
type_code TEXT NOT NULL REFERENCES company_document_types(code),
original_name TEXT NOT NULL,
mime TEXT NOT NULL,
size_bytes INTEGER NOT NULL,
sha256 TEXT NOT NULL,
iv TEXT NOT NULL,
storage_name TEXT NOT NULL,
is_current INTEGER NOT NULL DEFAULT 1,
parse_status TEXT NOT NULL DEFAULT 'manual',
uploaded_by INTEGER REFERENCES users(id),
uploaded_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS badge_jobs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id INTEGER NOT NULL REFERENCES projects(id),
@ -191,9 +216,15 @@ CREATE TABLE IF NOT EXISTS loans (
id INTEGER PRIMARY KEY AUTOINCREMENT,
worker_id INTEGER NOT NULL REFERENCES workers(id) ON DELETE CASCADE,
amount REAL NOT NULL,
delivered REAL NOT NULL DEFAULT 0,
balance REAL NOT NULL,
weekly_payment REAL NOT NULL,
note TEXT,
commission_pct REAL NOT NULL DEFAULT 0,
commission_amount REAL NOT NULL DEFAULT 0,
plan TEXT NOT NULL DEFAULT 'single',
installments_n INTEGER NOT NULL DEFAULT 1,
first_due TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
@ -227,6 +258,107 @@ CREATE TABLE IF NOT EXISTS payroll_lines (
net REAL NOT NULL
);
CREATE TABLE IF NOT EXISTS payroll_settings (
tenant_id INTEGER NOT NULL PRIMARY KEY,
loan_commission_enabled INTEGER NOT NULL DEFAULT 1,
loan_commission_pct REAL NOT NULL DEFAULT 10,
loan_small_max REAL NOT NULL DEFAULT 500
);
CREATE TABLE IF NOT EXISTS payroll_weeks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
week_start TEXT NOT NULL,
week_end TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'assembled', 'paid')),
required_net REAL NOT NULL DEFAULT 0,
payable_net REAL NOT NULL DEFAULT 0,
assembled_at TEXT,
paid_at TEXT,
tenant_id INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS payroll_sheets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
week_id INTEGER NOT NULL REFERENCES payroll_weeks(id) ON DELETE CASCADE,
kind TEXT NOT NULL CHECK (kind IN ('obra', 'destajo', 'admin')),
project_id INTEGER REFERENCES projects(id)
);
CREATE TABLE IF NOT EXISTS payroll_week_lines (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sheet_id INTEGER NOT NULL REFERENCES payroll_sheets(id) ON DELETE CASCADE,
worker_id INTEGER NOT NULL REFERENCES workers(id),
destajo_cut_line_id INTEGER,
project_id INTEGER REFERENCES projects(id),
days REAL NOT NULL DEFAULT 0,
daily_wage REAL NOT NULL DEFAULT 0,
qty_planned REAL NOT NULL DEFAULT 0,
qty_actual REAL NOT NULL DEFAULT 0,
qty_extra REAL NOT NULL DEFAULT 0,
unit_price REAL NOT NULL DEFAULT 0,
unit_code TEXT,
concepto TEXT,
amount REAL NOT NULL DEFAULT 0,
gross REAL NOT NULL DEFAULT 0,
discounts REAL NOT NULL DEFAULT 0,
loan_id INTEGER REFERENCES loans(id),
loan_discount REAL NOT NULL DEFAULT 0,
loan_label TEXT,
required_net REAL NOT NULL DEFAULT 0,
payable_net REAL NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS destajo_units (
id INTEGER PRIMARY KEY AUTOINCREMENT,
tenant_id INTEGER NOT NULL DEFAULT 0,
code TEXT NOT NULL,
label TEXT NOT NULL,
UNIQUE (tenant_id, code)
);
CREATE TABLE IF NOT EXISTS destajo_periods (
id INTEGER PRIMARY KEY AUTOINCREMENT,
period_start TEXT NOT NULL,
period_end TEXT NOT NULL,
week_id INTEGER NOT NULL REFERENCES payroll_weeks(id) ON DELETE CASCADE,
tenant_id INTEGER NOT NULL DEFAULT 0,
UNIQUE (tenant_id, period_end)
);
CREATE TABLE IF NOT EXISTS destajo_jobs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id INTEGER NOT NULL REFERENCES projects(id),
worker_id INTEGER NOT NULL REFERENCES workers(id),
concepto TEXT NOT NULL,
unit_code TEXT NOT NULL,
qty_total_estimated REAL NOT NULL,
unit_price REAL NOT NULL,
status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'done')),
tenant_id INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS destajo_cut_lines (
id INTEGER PRIMARY KEY AUTOINCREMENT,
period_id INTEGER NOT NULL REFERENCES destajo_periods(id) ON DELETE CASCADE,
job_id INTEGER NOT NULL REFERENCES destajo_jobs(id) ON DELETE CASCADE,
qty_planned REAL NOT NULL DEFAULT 0,
qty_actual REAL NOT NULL DEFAULT 0,
qty_extra REAL NOT NULL DEFAULT 0,
UNIQUE (period_id, job_id)
);
CREATE TABLE IF NOT EXISTS loan_payments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
loan_id INTEGER NOT NULL REFERENCES loans(id) ON DELETE CASCADE,
week_id INTEGER NOT NULL REFERENCES payroll_weeks(id),
amount REAL NOT NULL,
installment_n INTEGER NOT NULL DEFAULT 1,
label TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS budget_chapters (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,

View file

@ -8,4 +8,6 @@
<include file="changesets/001-baseline.sql" relativeToChangelogFile="true"/>
<include file="changesets/002-tenant-id.sql" relativeToChangelogFile="true"/>
<include file="changesets/003-must-change-password.sql" relativeToChangelogFile="true"/>
<include file="changesets/004-document-catalog.sql" relativeToChangelogFile="true"/>
<include file="changesets/005-payroll-week.sql" relativeToChangelogFile="true"/>
</databaseChangeLog>

View file

@ -0,0 +1,40 @@
--liquibase formatted sql
--changeset panel:app-004a-doc-type-category endDelimiter:;
--preconditions onFail:MARK_RAN
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM pragma_table_info('document_types') WHERE name='category'
ALTER TABLE document_types ADD COLUMN category TEXT NOT NULL DEFAULT 'identidad';
--changeset panel:app-004b-project-doc-category endDelimiter:;
--preconditions onFail:MARK_RAN
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM pragma_table_info('project_document_types') WHERE name='category'
ALTER TABLE project_document_types ADD COLUMN category TEXT NOT NULL DEFAULT 'contrato';
--changeset panel:app-004c-company-document-types endDelimiter:;
--preconditions onFail:MARK_RAN
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='company_document_types'
CREATE TABLE company_document_types (
code TEXT PRIMARY KEY,
label TEXT NOT NULL,
required INTEGER NOT NULL DEFAULT 0,
category TEXT NOT NULL DEFAULT 'otro'
);
--changeset panel:app-004d-company-documents endDelimiter:;
--preconditions onFail:MARK_RAN
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='company_documents'
CREATE TABLE company_documents (
id INTEGER PRIMARY KEY AUTOINCREMENT,
company_id INTEGER NOT NULL REFERENCES companies(id) ON DELETE CASCADE,
type_code TEXT NOT NULL REFERENCES company_document_types(code),
original_name TEXT NOT NULL,
mime TEXT NOT NULL,
size_bytes INTEGER NOT NULL,
sha256 TEXT NOT NULL,
iv TEXT NOT NULL,
storage_name TEXT NOT NULL,
is_current INTEGER NOT NULL DEFAULT 1,
parse_status TEXT NOT NULL DEFAULT 'manual',
uploaded_by INTEGER REFERENCES users(id),
uploaded_at TEXT NOT NULL DEFAULT (datetime('now'))
);

View file

@ -0,0 +1,164 @@
--liquibase formatted sql
--changeset panel:app-005a-payroll-settings endDelimiter:;
--preconditions onFail:MARK_RAN
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='payroll_settings'
CREATE TABLE payroll_settings (
tenant_id INTEGER NOT NULL PRIMARY KEY,
loan_commission_enabled INTEGER NOT NULL DEFAULT 1,
loan_commission_pct REAL NOT NULL DEFAULT 10,
loan_small_max REAL NOT NULL DEFAULT 500
);
--changeset panel:app-005b-payroll-weeks endDelimiter:;
--preconditions onFail:MARK_RAN
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='payroll_weeks'
CREATE TABLE payroll_weeks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
week_start TEXT NOT NULL,
week_end TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'assembled', 'paid')),
required_net REAL NOT NULL DEFAULT 0,
payable_net REAL NOT NULL DEFAULT 0,
assembled_at TEXT,
paid_at TEXT,
tenant_id INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE UNIQUE INDEX idx_payroll_weeks_tenant_start ON payroll_weeks(tenant_id, week_start);
--changeset panel:app-005c-payroll-sheets endDelimiter:;
--preconditions onFail:MARK_RAN
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='payroll_sheets'
CREATE TABLE payroll_sheets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
week_id INTEGER NOT NULL REFERENCES payroll_weeks(id) ON DELETE CASCADE,
kind TEXT NOT NULL CHECK (kind IN ('obra', 'destajo', 'admin')),
project_id INTEGER REFERENCES projects(id)
);
CREATE UNIQUE INDEX idx_payroll_sheets_week_kind_project ON payroll_sheets(week_id, kind, IFNULL(project_id, 0));
--changeset panel:app-005d-payroll-week-lines endDelimiter:;
--preconditions onFail:MARK_RAN
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='payroll_week_lines'
CREATE TABLE payroll_week_lines (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sheet_id INTEGER NOT NULL REFERENCES payroll_sheets(id) ON DELETE CASCADE,
worker_id INTEGER NOT NULL REFERENCES workers(id),
destajo_cut_line_id INTEGER,
project_id INTEGER REFERENCES projects(id),
days REAL NOT NULL DEFAULT 0,
daily_wage REAL NOT NULL DEFAULT 0,
qty_planned REAL NOT NULL DEFAULT 0,
qty_actual REAL NOT NULL DEFAULT 0,
qty_extra REAL NOT NULL DEFAULT 0,
unit_price REAL NOT NULL DEFAULT 0,
unit_code TEXT,
concepto TEXT,
amount REAL NOT NULL DEFAULT 0,
gross REAL NOT NULL DEFAULT 0,
discounts REAL NOT NULL DEFAULT 0,
loan_id INTEGER REFERENCES loans(id),
loan_discount REAL NOT NULL DEFAULT 0,
loan_label TEXT,
required_net REAL NOT NULL DEFAULT 0,
payable_net REAL NOT NULL DEFAULT 0
);
--changeset panel:app-005e-destajo-units endDelimiter:;
--preconditions onFail:MARK_RAN
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='destajo_units'
CREATE TABLE destajo_units (
id INTEGER PRIMARY KEY AUTOINCREMENT,
tenant_id INTEGER NOT NULL DEFAULT 0,
code TEXT NOT NULL,
label TEXT NOT NULL,
UNIQUE (tenant_id, code)
);
--changeset panel:app-005f-destajo-periods endDelimiter:;
--preconditions onFail:MARK_RAN
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='destajo_periods'
CREATE TABLE destajo_periods (
id INTEGER PRIMARY KEY AUTOINCREMENT,
period_start TEXT NOT NULL,
period_end TEXT NOT NULL,
week_id INTEGER NOT NULL REFERENCES payroll_weeks(id) ON DELETE CASCADE,
tenant_id INTEGER NOT NULL DEFAULT 0,
UNIQUE (tenant_id, period_end)
);
--changeset panel:app-005g-destajo-jobs endDelimiter:;
--preconditions onFail:MARK_RAN
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='destajo_jobs'
CREATE TABLE destajo_jobs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id INTEGER NOT NULL REFERENCES projects(id),
worker_id INTEGER NOT NULL REFERENCES workers(id),
concepto TEXT NOT NULL,
unit_code TEXT NOT NULL,
qty_total_estimated REAL NOT NULL,
unit_price REAL NOT NULL,
status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'done')),
tenant_id INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
--changeset panel:app-005h-destajo-cut-lines endDelimiter:;
--preconditions onFail:MARK_RAN
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='destajo_cut_lines'
CREATE TABLE destajo_cut_lines (
id INTEGER PRIMARY KEY AUTOINCREMENT,
period_id INTEGER NOT NULL REFERENCES destajo_periods(id) ON DELETE CASCADE,
job_id INTEGER NOT NULL REFERENCES destajo_jobs(id) ON DELETE CASCADE,
qty_planned REAL NOT NULL DEFAULT 0,
qty_actual REAL NOT NULL DEFAULT 0,
qty_extra REAL NOT NULL DEFAULT 0,
UNIQUE (period_id, job_id)
);
--changeset panel:app-005i-loan-payments endDelimiter:;
--preconditions onFail:MARK_RAN
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='loan_payments'
CREATE TABLE loan_payments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
loan_id INTEGER NOT NULL REFERENCES loans(id) ON DELETE CASCADE,
week_id INTEGER NOT NULL REFERENCES payroll_weeks(id),
amount REAL NOT NULL,
installment_n INTEGER NOT NULL DEFAULT 1,
label TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
--changeset panel:app-005j-loans-delivered endDelimiter:;
--preconditions onFail:MARK_RAN
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM pragma_table_info('loans') WHERE name='delivered'
ALTER TABLE loans ADD COLUMN delivered REAL NOT NULL DEFAULT 0;
--changeset panel:app-005k-loans-commission-pct endDelimiter:;
--preconditions onFail:MARK_RAN
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM pragma_table_info('loans') WHERE name='commission_pct'
ALTER TABLE loans ADD COLUMN commission_pct REAL NOT NULL DEFAULT 0;
--changeset panel:app-005l-loans-commission-amount endDelimiter:;
--preconditions onFail:MARK_RAN
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM pragma_table_info('loans') WHERE name='commission_amount'
ALTER TABLE loans ADD COLUMN commission_amount REAL NOT NULL DEFAULT 0;
--changeset panel:app-005m-loans-plan endDelimiter:;
--preconditions onFail:MARK_RAN
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM pragma_table_info('loans') WHERE name='plan'
ALTER TABLE loans ADD COLUMN plan TEXT NOT NULL DEFAULT 'single';
--changeset panel:app-005n-loans-installments endDelimiter:;
--preconditions onFail:MARK_RAN
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM pragma_table_info('loans') WHERE name='installments_n'
ALTER TABLE loans ADD COLUMN installments_n INTEGER NOT NULL DEFAULT 1;
--changeset panel:app-005o-loans-first-due endDelimiter:;
--preconditions onFail:MARK_RAN
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM pragma_table_info('loans') WHERE name='first_due'
ALTER TABLE loans ADD COLUMN first_due TEXT;
--changeset panel:app-005p-loans-migrate-delivered endDelimiter:;
UPDATE loans SET delivered = amount WHERE delivered = 0 AND amount > 0;

275
tmp/pdfs/build_estado_cuenta.py Executable file
View file

@ -0,0 +1,275 @@
from pathlib import Path
from reportlab.lib import colors
from reportlab.lib.enums import TA_LEFT
from reportlab.lib.pagesizes import letter
from reportlab.lib.styles import ParagraphStyle
from reportlab.pdfgen import canvas
from reportlab.platypus import Paragraph
from pypdf import PdfReader
WORK = Path(__file__).resolve().parent
OUTPUT = WORK / "estado_cuenta_comtecsa_2026-08-22.pdf"
PAGE_W, PAGE_H = letter
MARGIN = 42
CONTENT_W = PAGE_W - (2 * MARGIN)
INK = colors.HexColor("#172033")
MUTED = colors.HexColor("#64748B")
LINE = colors.HexColor("#D8E0EA")
NAVY = colors.HexColor("#163A5F")
BLUE = colors.HexColor("#2378A5")
PALE = colors.HexColor("#EEF5F9")
GREEN = colors.HexColor("#16805C")
GREEN_BG = colors.HexColor("#E8F7F1")
AMBER = colors.HexColor("#9A5C00")
AMBER_BG = colors.HexColor("#FFF3D6")
WHITE = colors.white
def money(value):
return "$" + f"{value:,.2f}"
def right_text(pdf, text, x, y, font="Helvetica", size=9, color=INK):
pdf.setFont(font, size)
pdf.setFillColor(color)
pdf.drawRightString(x, y, text)
def status_pill(pdf, text, x, y, width, fill, text_color):
pdf.setFillColor(fill)
pdf.roundRect(x, y, width, 16, 8, fill=1, stroke=0)
pdf.setFillColor(text_color)
pdf.setFont("Helvetica-Bold", 7.3)
pdf.drawCentredString(x + width / 2, y + 5, text)
def wrapped_paragraph(pdf, text, x, y, width, height, font_size=8.5, leading=11, color=INK):
style = ParagraphStyle(
name="body",
fontName="Helvetica",
fontSize=font_size,
leading=leading,
textColor=color,
alignment=TA_LEFT,
)
para = Paragraph(text, style)
_, used_h = para.wrap(width, height)
para.drawOn(pdf, x, y + height - used_h)
def draw_summary_box(pdf, x, y, w, h, label, amount, detail, dark=False):
if dark:
fill = NAVY
border = NAVY
label_color = colors.HexColor("#CFE4F2")
amount_color = WHITE
detail_color = colors.HexColor("#CFE4F2")
else:
fill = WHITE
border = LINE
label_color = MUTED
amount_color = INK
detail_color = MUTED
pdf.setFillColor(fill)
pdf.setStrokeColor(border)
pdf.setLineWidth(0.8)
pdf.roundRect(x, y, w, h, 8, fill=1, stroke=1)
pdf.setFillColor(label_color)
pdf.setFont("Helvetica-Bold", 7.2)
pdf.drawString(x + 11, y + h - 16, label.upper())
pdf.setFillColor(amount_color)
pdf.setFont("Helvetica-Bold", 16)
pdf.drawString(x + 11, y + h - 36, amount)
pdf.setFillColor(detail_color)
pdf.setFont("Helvetica", 7.2)
pdf.drawString(x + 11, y + 9, detail)
def build_pdf():
pdf = canvas.Canvas(str(OUTPUT), pagesize=letter)
pdf.setTitle("Estado de cuenta COMTECSA - 22 agosto 2026")
pdf.setAuthor("MRDEV")
pdf.setSubject("Conciliación de facturas pendientes y servicio por facturar")
pdf.setFillColor(NAVY)
pdf.rect(0, PAGE_H - 8, PAGE_W * 0.56, 8, fill=1, stroke=0)
pdf.setFillColor(BLUE)
pdf.rect(PAGE_W * 0.56, PAGE_H - 8, PAGE_W * 0.30, 8, fill=1, stroke=0)
pdf.setFillColor(colors.HexColor("#70B68D"))
pdf.rect(PAGE_W * 0.86, PAGE_H - 8, PAGE_W * 0.14, 8, fill=1, stroke=0)
pdf.setFillColor(NAVY)
pdf.roundRect(MARGIN, 704, 52, 52, 10, fill=1, stroke=0)
pdf.setFillColor(WHITE)
pdf.setFont("Helvetica-Bold", 15)
pdf.drawCentredString(MARGIN + 26, 724, "MR")
pdf.setFillColor(INK)
pdf.setFont("Helvetica-Bold", 15)
pdf.drawString(106, 738, "MRDEV")
pdf.setFillColor(MUTED)
pdf.setFont("Helvetica", 8.5)
pdf.drawString(106, 717, "Proveedor de servicios")
right_text(pdf, "ESTADO DE CUENTA", PAGE_W - MARGIN, 736, "Helvetica-Bold", 21, NAVY)
right_text(pdf, "Corte al 22 de agosto de 2026", PAGE_W - MARGIN, 717, "Helvetica", 8.5, MUTED)
right_text(pdf, "Referencia: EDC-COMTECSA-20260822", PAGE_W - MARGIN, 704, "Helvetica", 7.4, MUTED)
pdf.setStrokeColor(NAVY)
pdf.setLineWidth(1.4)
pdf.line(MARGIN, 686, PAGE_W - MARGIN, 686)
pdf.setFillColor(MUTED)
pdf.setFont("Helvetica-Bold", 7)
pdf.drawString(MARGIN, 667, "CLIENTE")
pdf.drawString(398, 667, "MONEDA")
pdf.setFillColor(INK)
pdf.setFont("Helvetica-Bold", 11.2)
pdf.drawString(MARGIN, 650, "COMERCIALIZADORA DE TECNOLOGÍAS (COMTECSA)")
pdf.setFont("Helvetica-Bold", 10.5)
pdf.drawString(398, 650, "PESOS MEXICANOS")
pdf.setFillColor(MUTED)
pdf.setFont("Helvetica", 7.6)
pdf.drawString(MARGIN, 637, "Atención: Contabilidad / Cuentas por pagar")
pdf.drawString(398, 637, "Importes en MXN")
gap = 9
box_w = (CONTENT_W - 2 * gap) / 3
card_y = 560
draw_summary_box(pdf, MARGIN, card_y, box_w, 62, "Facturado pendiente", money(23200), "Facturas A 113 y A 114")
draw_summary_box(pdf, MARGIN + box_w + gap, card_y, box_w, 62, "Por facturar", money(11600), "Servicio del 1 al 15 de agosto")
draw_summary_box(pdf, MARGIN + 2 * (box_w + gap), card_y, box_w, 62, "Saldo total abierto", money(34800), "Tres periodos pendientes", dark=True)
pdf.setFillColor(NAVY)
pdf.setFont("Helvetica-Bold", 10)
pdf.drawString(MARGIN, 540, "Detalle de movimientos pendientes")
table_top = 526
columns = [("DOCUMENTO", 64), ("PERIODO DEL SERVICIO", 132), ("SITUACIÓN", 116), ("CARGO", 72), ("ABONOS", 68), ("SALDO", 76)]
xs = [MARGIN]
for _, width in columns:
xs.append(xs[-1] + width)
header_h = 28
row_h = 44
total_h = 32
pdf.setFillColor(PALE)
pdf.rect(MARGIN, table_top - header_h, CONTENT_W, header_h, fill=1, stroke=0)
pdf.setStrokeColor(LINE)
pdf.setLineWidth(0.6)
pdf.line(MARGIN, table_top, PAGE_W - MARGIN, table_top)
pdf.line(MARGIN, table_top - header_h, PAGE_W - MARGIN, table_top - header_h)
for idx, (label, _) in enumerate(columns):
pdf.setFillColor(NAVY)
pdf.setFont("Helvetica-Bold", 6.6)
if idx >= 3:
pdf.drawRightString(xs[idx + 1] - 7, table_top - 18, label)
else:
pdf.drawString(xs[idx] + 7, table_top - 18, label)
rows = [
("A 113", "01-15 jul 2026", "Servicio facturado", "Pendiente de pago", 11600, 0, 11600, "pending"),
("A 114", "16-30 jul 2026", "Servicio facturado", "Pendiente de pago", 11600, 0, 11600, "pending"),
("Por emitir", "01-15 ago 2026", "Servicio por facturar", "Por confirmar", 11600, 0, 11600, "unbilled"),
]
y = table_top - header_h
for row in rows:
y_next = y - row_h
pdf.setStrokeColor(LINE)
pdf.line(MARGIN, y_next, PAGE_W - MARGIN, y_next)
pdf.setFillColor(NAVY)
pdf.setFont("Helvetica-Bold", 8.5)
pdf.drawString(xs[0] + 7, y_next + 26, row[0])
pdf.setFillColor(INK)
pdf.setFont("Helvetica-Bold", 8.2)
pdf.drawString(xs[1] + 7, y_next + 27, row[1])
pdf.setFillColor(MUTED)
pdf.setFont("Helvetica", 7)
pdf.drawString(xs[1] + 7, y_next + 14, row[2])
if row[7] == "pending":
status_pill(pdf, row[3], xs[2] + 7, y_next + 14, 91, AMBER_BG, AMBER)
else:
status_pill(pdf, row[3], xs[2] + 7, y_next + 14, 76, PALE, NAVY)
right_text(pdf, money(row[4]), xs[4] - 7, y_next + 21, "Helvetica", 8.2, INK)
right_text(pdf, money(row[5]), xs[5] - 7, y_next + 21, "Helvetica", 8.2, INK)
right_text(pdf, money(row[6]), xs[6] - 7, y_next + 21, "Helvetica-Bold", 8.2, INK)
y = y_next
pdf.setFillColor(colors.HexColor("#FBFCFD"))
pdf.rect(MARGIN, y - total_h, CONTENT_W, total_h, fill=1, stroke=0)
pdf.setStrokeColor(NAVY)
pdf.setLineWidth(1.2)
pdf.line(MARGIN, y - total_h, PAGE_W - MARGIN, y - total_h)
pdf.setFillColor(INK)
pdf.setFont("Helvetica-Bold", 8.4)
pdf.drawString(MARGIN + 7, y - 20, "TOTALES")
right_text(pdf, money(34800), xs[4] - 7, y - 20, "Helvetica-Bold", 8.4, INK)
right_text(pdf, money(0), xs[5] - 7, y - 20, "Helvetica-Bold", 8.4, INK)
right_text(pdf, money(34800), xs[6] - 7, y - 20, "Helvetica-Bold", 8.4, INK)
pdf.setFillColor(NAVY)
pdf.setFont("Helvetica-Bold", 10)
pdf.drawString(MARGIN, 304, "Pago aplicado recientemente")
paid_y = 253
pdf.setFillColor(GREEN_BG)
pdf.setStrokeColor(colors.HexColor("#BFE3D5"))
pdf.setLineWidth(0.8)
pdf.roundRect(MARGIN, paid_y, CONTENT_W, 40, 7, fill=1, stroke=1)
status_pill(pdf, "PAGADA", MARGIN + 10, paid_y + 12, 51, GREEN_BG, GREEN)
pdf.setFillColor(colors.HexColor("#0C5E43"))
pdf.setFont("Helvetica-Bold", 8.5)
pdf.drawString(MARGIN + 73, paid_y + 23, "Factura A 109")
pdf.setFillColor(MUTED)
pdf.setFont("Helvetica", 6.8)
pdf.drawString(MARGIN + 73, paid_y + 10, "Periodo 16-30 jun 2026")
for label, amount, x in [("CARGO", money(11600), 367), ("ABONO", money(11600), 457), ("SALDO", money(0), 554)]:
right_text(pdf, label, x, paid_y + 25, "Helvetica-Bold", 6.2, MUTED)
right_text(pdf, amount, x, paid_y + 11, "Helvetica-Bold", 7.8, colors.HexColor("#164E3C"))
note_y = 177
note_h = 58
pdf.setFillColor(PALE)
pdf.roundRect(MARGIN, note_y, CONTENT_W, note_h, 6, fill=1, stroke=0)
pdf.setFillColor(BLUE)
pdf.rect(MARGIN, note_y, 4, note_h, fill=1, stroke=0)
note_text = ("<b>Nota de conciliación.</b> La factura A 109 se reconoce como pagada y no forma parte del saldo abierto. "
"El importe del periodo del 1 al 15 de agosto de 2026 corresponde a un servicio pendiente de facturación; "
"aún no cuenta con número de factura ni CFDI emitido.")
wrapped_paragraph(pdf, note_text, MARGIN + 13, note_y + 7, CONTENT_W - 25, note_h - 14, 8.1, 10.2, colors.HexColor("#334155"))
req_y = 104
req_h = 56
pdf.setFillColor(WHITE)
pdf.setStrokeColor(LINE)
pdf.setLineWidth(0.8)
pdf.roundRect(MARGIN, req_y, CONTENT_W, req_h, 6, fill=1, stroke=1)
req_text = ("<b>Solicitud.</b> Agradecemos confirmar la fecha programada de pago de las facturas A 113 y A 114, "
"así como la procedencia de emitir la factura correspondiente al periodo del 1 al 15 de agosto de 2026.")
wrapped_paragraph(pdf, req_text, MARGIN + 12, req_y + 7, CONTENT_W - 24, req_h - 14, 8.2, 10.5, colors.HexColor("#334155"))
pdf.setStrokeColor(LINE)
pdf.setLineWidth(0.6)
pdf.line(MARGIN, 70, PAGE_W - MARGIN, 70)
pdf.setFillColor(MUTED)
pdf.setFont("Helvetica", 6.8)
pdf.drawString(MARGIN, 55, "Documento informativo para conciliación de saldos; no sustituye los CFDI correspondientes.")
right_text(pdf, "Página 1 de 1", PAGE_W - MARGIN, 55, "Helvetica", 6.8, MUTED)
pdf.showPage()
pdf.save()
reader = PdfReader(str(OUTPUT))
if len(reader.pages) != 1:
raise RuntimeError(f"Se esperaba 1 pagina, se obtuvieron {len(reader.pages)}")
extracted = reader.pages[0].extract_text() or ""
required = ["MRDEV", "COMERCIALIZADORA DE TECNOLOGÍAS", "A 113", "A 114", "Por emitir", "$34,800.00", "A 109"]
missing = [item for item in required if item not in extracted]
if missing:
raise RuntimeError(f"Faltan textos requeridos en el PDF: {missing}")
print(OUTPUT)
if __name__ == "__main__":
build_pdf()

View file

@ -495,6 +495,9 @@ html, body, #__nuxt {
.form-field .p-component,
.form-field .p-inputtext,
.form-field .p-inputnumber { width: 100%; }
.form-field .p-toggleswitch {
width: auto;
}
.toggle-field,
.risk-option {
min-height: 34px;

View file

@ -0,0 +1,152 @@
<template>
<div class="detail-section">
<div class="document-progress">
<div>
<div class="section-title">Expediente de empresa</div>
<div class="muted">{{ currentDocuments.length }} documentos guardados. Nada de esto es obligatorio.</div>
</div>
</div>
<Fieldset legend="Subir o reemplazar documento" class="mt-3">
<FormField label="Tipo de documento">
<Select
v-model="documentType"
:options="groupedTypes"
option-label="label"
option-value="code"
option-group-label="label"
option-group-children="items"
class="w-full"
/>
</FormField>
<FileUpload
mode="basic"
:auto="true"
choose-label="Seleccionar archivo"
accept=".pdf,.xlsx,.xls,.doc,.docx,.jpg,.jpeg,.png"
custom-upload
class="mt-2"
:disabled="uploading"
@uploader="uploadDocument"
/>
<div class="muted mt-2">REPSE, opiniones SAT/IMSS o actas se guardan aquí si la empresa los tiene.</div>
</Fieldset>
<Tabs v-model:value="docsTab" class="mt-3 document-list-tabs">
<TabList>
<Tab value="current">Vigentes ({{ currentDocuments.length }})</Tab>
<Tab value="history">Histórico ({{ historyDocuments.length }})</Tab>
</TabList>
<TabPanels>
<TabPanel value="current">
<DataTable :value="currentDocuments" size="small" empty-message="Aún no hay documentos">
<Column header="Tipo">
<template #body="{ data }">{{ typeLabel(data.type_code) }}</template>
</Column>
<Column field="original_name" header="Archivo" />
<Column header="">
<template #body="{ data }">
<Button icon="pi pi-eye" text rounded size="small" aria-label="Ver documento" @click="openDocument(data)" />
</template>
</Column>
</DataTable>
</TabPanel>
<TabPanel value="history">
<DataTable :value="historyDocuments" size="small" empty-message="Sin versiones anteriores">
<Column header="Tipo">
<template #body="{ data }">{{ typeLabel(data.type_code) }}</template>
</Column>
<Column field="original_name" header="Archivo" />
<Column header="Fecha">
<template #body="{ data }">{{ formatDate(data.uploaded_at) }}</template>
</Column>
<Column header="">
<template #body="{ data }">
<Button icon="pi pi-eye" text rounded size="small" aria-label="Ver documento" @click="openDocument(data)" />
</template>
</Column>
</DataTable>
</TabPanel>
</TabPanels>
</Tabs>
<DocumentPreviewDialog
v-model="previewOpen"
:path="previewPath"
:name="previewName"
:mime="previewMime"
/>
</div>
</template>
<script setup lang="ts">
import FormField from "~/components/padron/FormField.vue";
import DocumentPreviewDialog from "~/components/DocumentPreviewDialog.vue";
import { groupedDocumentOptions } from "~/types/padron";
type ChecklistItem = { code: string; label: string; required: boolean; present: boolean; category?: string };
type DocumentRow = {
id: number;
type_code: string;
original_name: string;
uploaded_at: string;
is_current: number;
mime?: string;
};
const props = defineProps<{ companyId: number; documents: DocumentRow[]; checklist: ChecklistItem[] }>();
const emit = defineEmits<{ changed: [] }>();
const { api } = useApi();
const toast = useToast();
const documentType = ref(props.checklist[0]?.code || "repse_federal");
const uploading = ref(false);
const docsTab = ref("current");
const previewOpen = ref(false);
const previewPath = ref("");
const previewName = ref("");
const previewMime = ref<string | null>(null);
const groupedTypes = computed(() => groupedDocumentOptions(props.checklist));
const currentDocuments = computed(() => props.documents.filter((doc) => doc.is_current));
const historyDocuments = computed(() => props.documents.filter((doc) => !doc.is_current));
function typeLabel(code: string) {
return props.checklist.find((item) => item.code === code)?.label || code;
}
function formatDate(value: string) {
if (!value) return "—";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleDateString("es-MX", { day: "2-digit", month: "short", year: "numeric" });
}
async function uploadDocument(event: { files: File[] }) {
const file = event.files?.[0];
if (!file || uploading.value) return;
uploading.value = true;
try {
const body = new FormData();
body.append("type", documentType.value);
body.append("file", file);
await api(`/v1/companies/${props.companyId}/documents`, { method: "POST", body });
toast.add({ severity: "success", summary: "Documento cifrado y guardado", life: 2200 });
docsTab.value = "current";
emit("changed");
} catch (error: unknown) {
toast.add({
severity: "error",
summary: error instanceof Error ? error.message : "No se pudo subir el archivo",
life: 3500,
});
} finally {
uploading.value = false;
}
}
function openDocument(row: DocumentRow) {
previewPath.value = `/v1/companies/${props.companyId}/documents/${row.id}`;
previewName.value = row.original_name;
previewMime.value = row.mime || null;
previewOpen.value = true;
}
watch(() => props.checklist, (items) => {
if (!items.some((item) => item.code === documentType.value) && items[0]) documentType.value = items[0].code;
}, { immediate: true });
</script>

View file

@ -17,17 +17,25 @@
</div>
</div>
<Panel v-if="optionalItems.length" header="Documentos opcionales" toggleable collapsed class="mt-3">
<Panel v-if="uploadedOptionalItems.length" header="También en el expediente" toggleable collapsed class="mt-3">
<div class="document-checklist">
<div v-for="item in optionalItems" :key="item.code" class="document-check" :class="{ complete: item.present }">
<i :class="item.present ? 'pi pi-check-circle' : 'pi pi-circle'" /><span>{{ item.label }}</span>
<div v-for="item in uploadedOptionalItems" :key="item.code" class="document-check complete">
<i class="pi pi-check-circle" /><span>{{ item.label }}</span>
</div>
</div>
</Panel>
<Fieldset legend="Subir o reemplazar documento" class="mt-3">
<FormField label="Tipo de documento">
<Select v-model="documentType" :options="documentTypes" option-label="label" option-value="code" class="w-full" />
<Select
v-model="documentType"
:options="groupedTypes"
option-label="label"
option-value="code"
option-group-label="label"
option-group-children="items"
class="w-full"
/>
</FormField>
<FileUpload
mode="basic"
@ -39,7 +47,7 @@
:disabled="uploading"
@uploader="uploadDocument"
/>
<div class="muted mt-2">Al elegir el archivo se sube solo. PDF, Excel o imagen.</div>
<div class="muted mt-2">Elige el tipo y el archivo se sube solo. Permisos y papeles extra no bloquean la obra.</div>
</Fieldset>
<Tabs v-model:value="docsTab" class="mt-3 document-list-tabs">
@ -92,8 +100,9 @@
<script setup lang="ts">
import FormField from "~/components/padron/FormField.vue";
import DocumentPreviewDialog from "~/components/DocumentPreviewDialog.vue";
import { groupedDocumentOptions } from "~/types/padron";
type ChecklistItem = { code: string; label: string; required: boolean; present: boolean };
type ChecklistItem = { code: string; label: string; required: boolean; present: boolean; category?: string };
type DocumentRow = {
id: number;
type_code: string;
@ -113,9 +122,10 @@ const previewOpen = ref(false);
const previewPath = ref("");
const previewName = ref("");
const previewMime = ref<string | null>(null);
const documentTypes = computed(() => props.checklist.map(({ code, label }) => ({ code, label })));
const documentTypes = computed(() => props.checklist.map(({ code, label, category }) => ({ code, label, category })));
const groupedTypes = computed(() => groupedDocumentOptions(documentTypes.value));
const requiredItems = computed(() => props.checklist.filter((item) => item.required));
const optionalItems = computed(() => props.checklist.filter((item) => !item.required));
const uploadedOptionalItems = computed(() => props.checklist.filter((item) => !item.required && item.present));
const requiredDone = computed(() => requiredItems.value.filter((item) => item.present).length);
const requiredProgress = computed(() => requiredItems.value.length ? Math.round(requiredDone.value / requiredItems.value.length * 100) : 0);
const currentDocuments = computed(() => props.documents.filter((doc) => doc.is_current));

View file

@ -135,14 +135,38 @@
<TabPanel value="nomina">
<Message :closable="false" class="nomina-jornal-msg">Jornal {{ money(Number(data.worker.daily_wage)) }} · {{ data.worker.work_type === "D" ? "Destajo" : "Normal" }}</Message>
<Fieldset legend="Registrar préstamo" class="nomina-loan-fieldset">
<FormField label="Monto"><InputNumber v-model="loanAmount" mode="currency" currency="MXN" locale="es-MX" class="w-full" /></FormField>
<FormField label="Abono semanal" class="mt-2"><InputNumber v-model="weeklyPayment" mode="currency" currency="MXN" locale="es-MX" class="w-full" /></FormField>
<FormField label="Monto a entregar">
<InputNumber v-model="loanAmount" mode="currency" currency="MXN" locale="es-MX" class="w-full" />
</FormField>
<div class="loan-switch">
<ToggleSwitch v-model="loanCommissionOn" />
<span>Comisión {{ loanCommissionPct }}%</span>
</div>
<FormField v-if="loanCommissionOn" label="Porcentaje" class="mt-2">
<InputNumber v-model="loanCommissionPct" suffix=" %" :min="0" :max="100" :max-fraction-digits="2" class="w-full" />
</FormField>
<Message v-if="loanIsSmall" severity="secondary" :closable="false" class="mt-2">
Préstamo chico (tope {{ money(loanSmallMax) }}): 1 exhibición el sábado de la semana siguiente. Recibe {{ money(loanAmount) }}; se descuenta {{ money(loanAmount + (loanCommissionOn ? loanAmount * loanCommissionPct / 100 : 0)) }}.
</Message>
<template v-else>
<FormField label="Plan" class="mt-2">
<Select v-model="loanPlan" :options="planOpts" option-label="label" option-value="value" class="w-full" />
</FormField>
<FormField v-if="loanPlan === 'installments'" label="Cuotas" class="mt-2">
<InputNumber v-model="loanInstallments" :min="2" :max="24" class="w-full" />
</FormField>
</template>
<Button label="Registrar préstamo" icon="pi pi-plus" class="w-full mt-2" :disabled="loanAmount <= 0" @click="addLoan" />
</Fieldset>
<DataTable :value="data.loans" size="small" class="mt-3" empty-message="Sin préstamos">
<Column header="Monto"><template #body="{ data: row }">{{ money(Number(row.amount)) }}</template></Column>
<Column header="Entregado"><template #body="{ data: row }">{{ money(Number(row.delivered || row.amount)) }}</template></Column>
<Column header="Saldo"><template #body="{ data: row }">{{ money(Number(row.balance)) }}</template></Column>
<Column header="Abono"><template #body="{ data: row }">{{ money(Number(row.weekly_payment)) }}</template></Column>
<Column header="Vence"><template #body="{ data: row }">{{ row.first_due || "—" }}</template></Column>
<Column header="">
<template #body="{ data: row }">
<Button icon="pi pi-file-pdf" text rounded size="small" aria-label="Recibo" @click="downloadLoan(row.id)" />
</template>
</Column>
</DataTable>
</TabPanel>
</TabPanels>
@ -185,7 +209,7 @@ type DetailData = {
imss_alta_at?: string | null;
}[];
assignments: { id: number; project_id: number; project_name: string; project_code: string; active: number; start_date: string; end_date?: string | null }[];
loans: { id: number; amount: number; balance: number; weekly_payment: number }[];
loans: { id: number; amount: number; delivered?: number; balance: number; weekly_payment: number; first_due?: string | null }[];
checklist: ChecklistItem[];
freshness_required?: boolean;
};
@ -193,7 +217,7 @@ type DetailData = {
const props = defineProps<{ workerId?: number; doc?: string }>();
const emit = defineEmits<{ edit: [] }>();
const { pipelineLabel, pipelineSeverity, initials, riskLevelLabel, riskLevelSeverity, hireSeverity } = useUi();
const { api } = useApi();
const { api, download } = useApi();
const toast = useToast();
const desk = useDesktop();
@ -206,7 +230,16 @@ const photoOk = ref(true);
const showAltaImss = ref(false);
const imssDialogTab = ref<"alta" | "baja" | "historial">("alta");
const loanAmount = ref(0);
const weeklyPayment = ref(0);
const loanCommissionOn = ref(true);
const loanCommissionPct = ref(10);
const loanSmallMax = ref(500);
const loanPlan = ref<"single" | "installments">("single");
const loanInstallments = ref(4);
const planOpts = [
{ label: "1 exhibición", value: "single" },
{ label: "Varias cuotas", value: "installments" },
];
const loanIsSmall = computed(() => loanAmount.value > 0 && loanAmount.value <= loanSmallMax.value);
const title = computed(() => data.value ? [data.value.worker.first_name, data.value.worker.middle_name, data.value.worker.last_name_p, data.value.worker.last_name_m].filter(Boolean).join(" ") : "Expediente");
const recordCode = computed(() => data.value ? `PER-${String(data.value.worker.id).padStart(4, "0")}` : "PER-0000");
const photoSrc = computed(() => `/v1/workers/${props.workerId}/photo?v=${desk.detailRev.value}`);
@ -262,15 +295,19 @@ async function load() {
if (!props.workerId) { data.value = null; return; }
loading.value = true;
try {
const [detail, projectResponse, companyResponse] = await Promise.all([
const [detail, projectResponse, companyResponse, payrollSettings] = await Promise.all([
api<DetailData>(`/v1/workers/${props.workerId}`),
api<{ projects: ProjectOption[] }>("/v1/projects?status=activo"),
api<{ companies: CompanyOption[] }>("/v1/companies"),
api<{ settings: { loan_commission_enabled: number; loan_commission_pct: number; loan_small_max: number } }>("/v1/payroll/settings"),
]);
data.value = detail;
projects.value = projectResponse.projects;
companies.value = companyResponse.companies.filter((c) => c.status === "activo");
photoOk.value = true;
loanCommissionOn.value = Boolean(payrollSettings.settings.loan_commission_enabled);
loanCommissionPct.value = Number(payrollSettings.settings.loan_commission_pct);
loanSmallMax.value = Number(payrollSettings.settings.loan_small_max);
} finally {
loading.value = false;
}
@ -292,11 +329,24 @@ function onImssSaved(kind: "alta" | "baja") {
}
async function addLoan() {
if (!props.workerId || loanAmount.value <= 0) return;
await api("/v1/loans", { method: "POST", json: { worker_id: props.workerId, amount: loanAmount.value, weekly_payment: weeklyPayment.value } });
const created = await api<{ id: number }>("/v1/loans", {
method: "POST",
json: {
worker_id: props.workerId,
delivered: loanAmount.value,
commission_enabled: loanCommissionOn.value,
commission_pct: loanCommissionPct.value,
plan: loanIsSmall.value ? "single" : loanPlan.value,
installments_n: loanIsSmall.value ? 1 : loanInstallments.value,
},
});
loanAmount.value = 0;
weeklyPayment.value = 0;
toast.add({ severity: "success", summary: "Préstamo registrado", life: 1800 });
refreshRelated();
if (created.id) download(`/v1/loans/${created.id}/recibo`, `prestamo-aceptacion-${created.id}.pdf`);
}
function downloadLoan(id: number) {
download(`/v1/loans/${id}/recibo`, `prestamo-aceptacion-${id}.pdf`);
}
watch(() => [props.workerId, props.doc, desk.detailRev.value] as const, ([, doc]) => {
@ -304,3 +354,13 @@ watch(() => [props.workerId, props.doc, desk.detailRev.value] as const, ([, doc]
load();
}, { immediate: true });
</script>
<style scoped>
.loan-switch {
display: flex;
align-items: center;
gap: 8px;
margin-top: 10px;
font-size: 0.9rem;
}
</style>

View file

@ -34,10 +34,10 @@
</div>
</div>
<Panel header="Documentos opcionales" toggleable collapsed class="mt-3">
<Panel v-if="uploadedOptionalItems.length" header="También en el expediente" toggleable collapsed class="mt-3">
<div class="document-checklist">
<div
v-for="item in optionalItems"
v-for="item in uploadedOptionalItems"
:key="item.code"
class="document-check"
:class="{ complete: item.present && item.valid }"
@ -52,16 +52,18 @@
<FormField label="Tipo de documento">
<Select
v-model="documentType"
:options="documentTypes"
:options="groupedTypes"
option-label="label"
option-value="code"
option-group-label="label"
option-group-children="items"
class="w-full"
/>
</FormField>
<FormField v-if="selectedPolicy?.requires_issued_at" label="Fecha de emisión" class="mt-2">
<FormField v-if="selectedPolicy?.requires_issued_at || selectedPolicy?.validity_mode === 'freshness'" label="Fecha de emisión" class="mt-2">
<DatePicker v-model="issuedAt" date-format="yy-mm-dd" show-icon class="w-full" />
</FormField>
<FormField v-if="selectedPolicy?.requires_expires_at" label="Vigencia / vencimiento" class="mt-2">
<FormField v-if="selectedPolicy?.requires_expires_at || selectedPolicy?.validity_mode === 'expiry'" label="Vigencia / vencimiento" class="mt-2">
<DatePicker v-model="expiresAt" date-format="yy-mm-dd" show-icon class="w-full" />
</FormField>
<FileUpload
@ -136,6 +138,7 @@
import FormField from "./FormField.vue";
import DocumentPreviewDialog from "~/components/DocumentPreviewDialog.vue";
import type { ChecklistItem, CompanyOption, ValidityStatus } from "~/types/padron";
import { groupedDocumentOptions } from "~/types/padron";
type DocumentRow = {
id: number;
@ -182,15 +185,17 @@ const documentTypes = computed(() =>
.map((item) => ({
code: item.code,
label: item.label,
category: item.category,
requires_issued_at: !!item.requires_issued_at,
requires_expires_at: !!item.requires_expires_at,
validity_mode: item.validity_mode,
})),
);
const groupedTypes = computed(() => groupedDocumentOptions(documentTypes.value));
const selectedPolicy = computed(() => documentTypes.value.find((item) => item.code === documentType.value));
const requiredItems = computed(() => props.checklist.filter((item) => item.required));
const optionalItems = computed(() =>
props.checklist.filter((item) => !item.required && item.code !== "alta_imss" && item.code !== "baja_imss"),
const uploadedOptionalItems = computed(() =>
props.checklist.filter((item) => !item.required && item.present && item.code !== "alta_imss" && item.code !== "baja_imss"),
);
const requiredDone = computed(() => requiredItems.value.filter((item) => item.present && item.valid !== false).length);
const requiredProgress = computed(() =>
@ -215,7 +220,8 @@ const canUpload = computed(() => {
const uploadHint = computed(() => {
if (selectedPolicy.value?.requires_issued_at) return "Indica la fecha de emisión del documento (máx. 3 meses si aplica alta/reingreso).";
if (selectedPolicy.value?.requires_expires_at) return "Indica la vigencia impresa en la INE.";
return "Al elegir el archivo se sube solo. El alta IMSS se registra con el botón del bloque IMSS.";
if (selectedPolicy.value?.validity_mode === "expiry") return "Si el documento tiene vencimiento, indícalo. No es obligatorio para operar.";
return "Elige el tipo y el archivo se sube solo. El catálogo extra no bloquea gafete ni alta IMSS.";
});
function typeLabel(code: string) {

View file

@ -44,7 +44,16 @@
</FormField>
<FormField label="Tipo de trabajo"><SelectButton v-model="form.work_type" :options="workTypes" option-label="label" option-value="value" :allow-empty="false" /></FormField>
<FormField label="Jornal diario" required :error="fieldErr.daily_wage"><InputNumber v-model="form.daily_wage" mode="currency" currency="MXN" locale="es-MX" :min="0" /></FormField>
<FormField label="Gafete"><div class="toggle-field"><ToggleSwitch v-model="form.needs_badge" /><span>{{ form.needs_badge ? "Sí lleva gafete" : "No lleva gafete" }}</span></div></FormField>
<FormField label="Gafete">
<SelectButton
:model-value="form.needs_badge ? 'si' : 'no'"
:options="badgeOpts"
option-label="label"
option-value="value"
:allow-empty="false"
@update:model-value="(value: string) => form.needs_badge = value === 'si'"
/>
</FormField>
</div>
</Fluid>
</TabPanel>
@ -90,6 +99,7 @@ const formError = computed(() => props.error || liveError.value);
const displayName = computed(() => [form.first_name, form.middle_name, form.last_name_p, form.last_name_m].filter(Boolean).join(" "));
const workTypes = [{ label: "Normal", value: "N" }, { label: "Destajo", value: "D" }];
const badgeOpts = [{ label: "Sí lleva", value: "si" }, { label: "No lleva", value: "no" }];
const bloodTypes = ["A+", "A-", "B+", "B-", "AB+", "AB-", "O+", "O-"];
const riskChoices = computed(() => props.risks.map((risk) => ({ ...risk, display: `${riskLevelLabel(risk.code)} · ${risk.label}` })));

View file

@ -3,12 +3,14 @@
<DeskTableBar v-model="tableQ" :placeholder="searchPlaceholder">
<Chip v-if="tab === 'empresas'" :label="`${listedCompanies.length} empresas`" icon="pi pi-briefcase" />
<Chip v-if="tab === 'empresas'" :label="`${subCompanies} subempresas`" icon="pi pi-th-large" />
<Chip v-if="tab === 'nomina'" :label="`${units.length} unidades`" icon="pi pi-percentage" />
<Chip v-if="tab === 'riesgos'" :label="`${listedRisks.length} riesgos`" icon="pi pi-exclamation-triangle" />
<Chip v-if="tab === 'temas'" :label="`${listedThemes.length} temas`" icon="pi pi-palette" />
</DeskTableBar>
<Tabs v-model:value="tab">
<TabList>
<Tab value="empresas">Empresas</Tab>
<Tab value="nomina">Nómina</Tab>
<Tab value="riesgos">Riesgos</Tab>
<Tab value="temas">Temas de gafete</Tab>
</TabList>
@ -52,6 +54,41 @@
</Column>
</DataTable>
</TabPanel>
<TabPanel value="nomina">
<Message severity="info" :closable="false">
Comisión de préstamos de la empresa. Al prestar se pueden cambiar; lo aceptado se congela en ese préstamo.
</Message>
<div class="company-form-grid mt-3" style="max-width: 36rem">
<FormField label="Comisión activa">
<ToggleSwitch v-model="payroll.loan_commission_enabled" />
</FormField>
<FormField label="Porcentaje">
<InputNumber v-model="payroll.loan_commission_pct" suffix=" %" :min="0" :max="100" :max-fraction-digits="2" class="w-full" />
</FormField>
<FormField label="Tope préstamo chico (entregado)">
<InputNumber v-model="payroll.loan_small_max" mode="currency" currency="MXN" locale="es-MX" class="w-full" />
</FormField>
<div class="span-2">
<Button label="Guardar nómina" icon="pi pi-check" :loading="savingPayroll" @click="savePayroll" />
</div>
</div>
<h3 class="mt-4">Unidades de destajo</h3>
<DataTable :value="units" class="mt-2" 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="unitForm.code" class="w-full" placeholder="ml" />
</FormField>
<FormField label="Nombre">
<InputText v-model="unitForm.label" class="w-full" placeholder="Metro lineal" />
</FormField>
<div>
<Button label="Agregar unidad" icon="pi pi-plus" outlined @click="addUnit" />
</div>
</div>
</TabPanel>
<TabPanel value="riesgos">
<DataTable :value="listedRisks" paginator :rows="15">
<Column field="code" header="Barra gafete" />
@ -87,12 +124,18 @@
:header="editing ? 'Editar empresa' : 'Nueva subempresa'"
modal
class="company-form-dialog"
:style="{ width: 'min(760px, 96vw)' }"
:style="{ width: 'min(820px, 96vw)' }"
>
<Message severity="secondary" :closable="false" class="mb-3">
<Tabs v-if="editing" v-model:value="formTab" class="mb-3">
<TabList>
<Tab value="datos">Datos</Tab>
<Tab value="docs">Expediente</Tab>
</TabList>
</Tabs>
<Message v-if="formTab === 'datos'" severity="secondary" :closable="false" class="mb-3">
Datos fiscales y patronales de la empresa. El nombre corto se usa en listados; la razón social y el RFC quedan para IMSS y facturación.
</Message>
<div class="company-form-grid">
<div v-show="formTab === 'datos'" class="company-form-grid">
<FormField label="Nombre comercial" required>
<InputText v-model="form.nombre_comercial" class="w-full" placeholder="Constructora Maya" />
</FormField>
@ -158,10 +201,17 @@
<Select v-model="form.status" :options="statusOpts" option-label="label" option-value="value" class="w-full" />
</FormField>
</div>
<CompanyDocuments
v-if="editing && formTab === 'docs'"
:company-id="editing.id"
:documents="companyDocs.documents"
:checklist="companyDocs.checklist"
@changed="loadCompanyDocs"
/>
<Message v-if="formError" severity="error" class="mt-3" :closable="false">{{ formError }}</Message>
<template #footer>
<Button label="Cancelar" severity="secondary" text @click="showForm = false" />
<Button :label="editing ? 'Guardar' : 'Crear'" icon="pi pi-check" :loading="saving" @click="save" />
<Button label="Cerrar" severity="secondary" text @click="showForm = false" />
<Button v-if="formTab === 'datos'" :label="editing ? 'Guardar' : 'Crear'" icon="pi pi-check" :loading="saving" @click="save" />
</template>
</Dialog>
</div>
@ -169,6 +219,7 @@
<script setup lang="ts">
import FormField from "~/components/padron/FormField.vue";
import CompanyDocuments from "~/components/CompanyDocuments.vue";
import type { CompanyOption } from "~/types/padron";
const emptyForm = () => ({
@ -198,10 +249,23 @@ const tab = ref("empresas");
const risks = ref<Record<string, string>[]>([]);
const themes = ref<Record<string, string>[]>([]);
const companies = ref<CompanyOption[]>([]);
const payroll = reactive({
loan_commission_enabled: true,
loan_commission_pct: 10,
loan_small_max: 500,
});
const units = ref<{ code: string; label: string }[]>([]);
const unitForm = reactive({ code: "", label: "" });
const savingPayroll = ref(false);
const showForm = ref(false);
const saving = ref(false);
const formError = ref("");
const editing = ref<CompanyOption | null>(null);
const formTab = ref("datos");
const companyDocs = reactive<{ documents: Array<{ id: number; type_code: string; original_name: string; uploaded_at: string; is_current: number; mime?: string }>; checklist: Array<{ code: string; label: string; required: boolean; present: boolean; category?: string }> }>({
documents: [],
checklist: [],
});
const form = reactive(emptyForm());
const statusOpts = [
{ label: "Activa", value: "activo" },
@ -227,7 +291,7 @@ const claseRiesgoOpts = [
const principal = computed(() => companies.value.find((company) => company.kind === "principal"));
const { tableQ, matches } = useTableSearch();
const searchPlaceholder = computed(() =>
tab.value === "riesgos" ? "Buscar riesgo o color" : tab.value === "temas" ? "Buscar tema" : "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" : "Buscar empresa, RFC o código",
);
const listedCompanies = computed(() =>
companies.value.filter((company) =>
@ -260,25 +324,70 @@ function resetForm() {
}
async function load() {
const c = await api<{
const [c, pay] = await Promise.all([
api<{
risks: Record<string, string>[];
themes: Record<string, string>[];
companies: CompanyOption[];
}>("/v1/catalogs");
}>("/v1/catalogs"),
api<{
settings: { loan_commission_enabled: number; loan_commission_pct: number; loan_small_max: number };
units: { code: string; label: string }[];
}>("/v1/payroll/settings"),
]);
risks.value = c.risks;
themes.value = c.themes;
companies.value = c.companies || [];
payroll.loan_commission_enabled = Boolean(pay.settings.loan_commission_enabled);
payroll.loan_commission_pct = Number(pay.settings.loan_commission_pct);
payroll.loan_small_max = Number(pay.settings.loan_small_max);
units.value = pay.units || [];
syncChrome();
}
async function savePayroll() {
savingPayroll.value = true;
try {
await api("/v1/payroll/settings", { method: "PATCH", json: payroll });
toast.add({ severity: "success", summary: "Nómina actualizada", life: 1800 });
await load();
} finally {
savingPayroll.value = false;
}
}
async function addUnit() {
if (!unitForm.code.trim()) return;
await api("/v1/destajo/units", { method: "POST", json: { code: unitForm.code, label: unitForm.label } });
unitForm.code = "";
unitForm.label = "";
toast.add({ severity: "success", summary: "Unidad agregada", life: 1600 });
await load();
}
function openNew() {
editing.value = null;
formTab.value = "datos";
resetForm();
formError.value = "";
showForm.value = true;
}
function openEdit(company: CompanyOption) {
async function loadCompanyDocs() {
if (!editing.value) {
companyDocs.documents = [];
companyDocs.checklist = [];
return;
}
const detail = await api<{
documents: typeof companyDocs.documents;
checklist: typeof companyDocs.checklist;
}>(`/v1/companies/${editing.value.id}`);
companyDocs.documents = detail.documents || [];
companyDocs.checklist = detail.checklist || [];
}
async function openEdit(company: CompanyOption) {
editing.value = company;
resetForm();
form.nombre_comercial = company.nombre_comercial || company.name || "";
@ -298,7 +407,9 @@ function openEdit(company: CompanyOption) {
form.representante_legal = company.representante_legal || "";
form.giro = company.giro || "";
formError.value = "";
formTab.value = "datos";
showForm.value = true;
await loadCompanyDocs();
}
function payload() {
@ -361,10 +472,11 @@ function syncChrome() {
showDetail: false,
treeKey: tab.value,
onTreeSelect: (key) => {
if (key === "empresas" || key === "riesgos" || key === "temas") tab.value = key;
if (key === "empresas" || key === "riesgos" || key === "temas" || key === "nomina") tab.value = key;
},
tree: [
{ key: "empresas", label: "Empresas", icon: "pi pi-briefcase", data: { count: companies.value.length } },
{ key: "nomina", label: "Nómina", icon: "pi pi-wallet" },
{ key: "riesgos", label: "Riesgos", icon: "pi pi-exclamation-triangle", data: { count: risks.value.length } },
{ key: "temas", label: "Temas de gafete", icon: "pi pi-palette", data: { count: themes.value.length } },
],

View file

@ -304,7 +304,11 @@ function syncChrome() {
}
async function generate() {
if (!projectId.value) return;
if (!projectId.value) {
toast.add({ severity: "warn", summary: "Seleccione un proyecto en el árbol", life: 2500 });
return;
}
try {
const r = await api<{ id: number; count: number }>(`/v1/projects/${projectId.value}/badge-jobs`, {
method: "POST",
json: {},
@ -312,6 +316,13 @@ async function generate() {
toast.add({ severity: "success", summary: `PDF ${r.id} (${r.count} gafetes)`, life: 2500 });
await load();
dl(r.id);
} catch (error: unknown) {
toast.add({
severity: "error",
summary: error instanceof Error ? error.message : "No se pudieron generar los gafetes",
life: 3500,
});
}
}
function dl(id: number) {

View file

@ -82,7 +82,7 @@ type JobRow = {
delivered?: number | boolean;
};
type PeriodRow = {
type WeekRow = {
status?: string;
};
@ -114,7 +114,7 @@ function buildHomeAlerts(input: {
workers: Worker[];
projects: ProjectRow[];
jobs: JobRow[];
periods: PeriodRow[];
weeks: WeekRow[];
imssReady: number;
}): DeskStatusMessage[] {
const active = input.workers.filter((w) => w.status === "activo" && w.pipeline_status !== "baja");
@ -129,7 +129,7 @@ function buildHomeAlerts(input: {
const proyDocs = input.projects.filter((p) => Number(p.missing_docs) > 0).length;
const proyPausados = input.projects.filter((p) => p.status === "pausado").length;
const jobsPendientes = input.jobs.filter((j) => !j.delivered).length;
const periodosAbiertos = input.periods.filter((p) => p.status !== "closed").length;
const periodosAbiertos = input.weeks.filter((p) => p.status !== "paid").length;
const messages: DeskStatusMessage[] = [];
@ -254,8 +254,8 @@ function buildHomeAlerts(input: {
messages.push({
key: "home_nomina",
severity: "info",
title: `${periodosAbiertos} periodos de nómina abiertos`,
detail: "Periodos sin cerrar en el módulo de nómina.",
title: `${periodosAbiertos} semanas de nómina abiertas`,
detail: "Hay una semana sin pagar. Ármala el jueves y págala el sábado.",
route: "/nomina",
});
}
@ -284,7 +284,7 @@ onMounted(async () => {
api<{ workers: Worker[]; imss_stats?: { imss_ready_count?: number } }>("/v1/workers"),
api<{ projects: ProjectRow[] }>("/v1/projects?status=activo,pausado"),
api<{ jobs: JobRow[] }>("/v1/badge-jobs"),
api<{ periods: PeriodRow[] }>("/v1/payroll/periods"),
api<{ weeks: { status?: string }[] }>("/v1/payroll/weeks/open"),
]);
workers.value = w.workers;
const activos = w.workers.filter((x) => x.status === "activo" && x.pipeline_status !== "baja").length;
@ -301,7 +301,7 @@ onMounted(async () => {
workers: w.workers,
projects: p.projects,
jobs: j.jobs,
periods: pe.periods,
weeks: pe.weeks,
imssReady: w.imss_stats?.imss_ready_count || 0,
}),
);

View file

@ -76,7 +76,7 @@
v-model="password"
:type="showPassword ? 'text' : 'password'"
name="password"
autocomplete="current-password"
autocomplete="off"
placeholder="Ingresa tu contraseña"
>
<button
@ -278,7 +278,7 @@ async function onSubmit() {
try {
if (remember.value) localStorage.setItem("panel-login-remember", username.value.trim());
else localStorage.removeItem("panel-login-remember");
await login(username.value.trim(), password.value);
await login(username.value.trim(), password.value.trim());
const { user } = useAuth();
const { api } = useApi();
if (user.value?.role === "platform_admin" && user.value?.realm === "platform") {

View file

@ -1,150 +1,399 @@
<template>
<div class="list-page">
<DeskTableBar v-model="tableQ" placeholder="Buscar periodo o proyecto">
<Chip :label="`${listedPeriods.length} periodos`" icon="pi pi-calendar" />
<Chip :label="`${openPeriods} abiertos`" icon="pi pi-unlock" />
<Chip :label="`${closedPeriods} cerrados`" icon="pi pi-lock" />
</DeskTableBar>
<div class="list-page nomina-page">
<div class="nomina-board">
<Card>
<template #content>
<div class="muted">Requerida (jueves)</div>
<div class="nomina-kpi">{{ money(board.required_net) }}</div>
</template>
</Card>
<Card>
<template #content>
<div class="muted">A pagar (sábado)</div>
<div class="nomina-kpi">{{ money(board.payable_net) }}</div>
</template>
</Card>
<Card>
<template #content>
<div class="muted">{{ board.delta >= 0 ? "Sobrante" : "Faltante" }}</div>
<div class="nomina-kpi" :class="board.delta >= 0 ? 'ok' : 'warn'">{{ money(Math.abs(board.delta)) }}</div>
</template>
</Card>
</div>
<Toolbar>
<template #start>
<DatePicker v-model="weekStart" date-format="yy-mm-dd" placeholder="Inicio semana" show-icon />
<DatePicker v-model="weekEnd" date-format="yy-mm-dd" placeholder="Fin semana" show-icon />
<Button icon="pi pi-chevron-left" text rounded @click="shiftWeek(-7)" />
<strong>{{ weekLabel }}</strong>
<Button icon="pi pi-chevron-right" text rounded @click="shiftWeek(7)" />
<Tag :value="statusLabel" :severity="statusSeverity" />
<span v-if="destajoPeriod" class="muted destajo-cut">Corte destajo {{ destajoPeriod.period_start }} → {{ destajoPeriod.period_end }}</span>
</template>
</Toolbar>
<Toolbar>
<template #start>
<Select v-model="attWorker" :options="workers" option-label="label" option-value="id" placeholder="Persona" />
<DatePicker v-model="attDate" date-format="yy-mm-dd" placeholder="Día" show-icon />
<ToggleSwitch v-model="attPresent" />
<Tag :value="attPresent ? 'Presente' : 'Falta'" :severity="attPresent ? 'success' : 'danger'" />
<Button label="Registrar" icon="pi pi-check" @click="saveAtt" />
</template>
</Toolbar>
<Tabs v-model:value="tab">
<TabList>
<Tab value="obra">Jornal</Tab>
<Tab value="destajo">Destajo</Tab>
<Tab value="admin">Administrativa</Tab>
</TabList>
<TabPanels>
<TabPanel value="obra">
<Message severity="secondary" :closable="false">
El neto es días presentes × jornal, menos descuentos y abono de préstamo. Solo personal activo del proyecto.
Lun–sáb. El jueves se prellenan jue–vie–sáb. Un día marcado en otra obra viaja con esa obra (préstamo de personal).
</Message>
<DataTable :value="listedPeriods" paginator :rows="12" :rows-per-page-options="[12, 25, 50]" scrollable scroll-height="flex" empty-message="Sin periodos" @row-click="(e: { data: { id: number } }) => open(e.data.id)">
<Column field="id" header="#" />
<Column field="project_name" header="Proyecto" />
<Column field="week_start" header="Desde" />
<Column field="week_end" header="Hasta" />
<Column header="Estado">
<DataTable :value="obraLines" scrollable scroll-height="flex" empty-message="Sin personal de jornal en esta obra">
<Column header="Persona" frozen>
<template #body="{ data }">{{ data.last_name_p }} {{ data.first_name }}</template>
</Column>
<Column
v-for="(day, idx) in days"
:key="day"
:header="dayHeaders[idx]"
style="width: 5.5rem; text-align: center"
>
<template #body="{ data }">
<Tag :value="String(data.status)" :severity="data.status === 'closed' ? 'success' : 'warn'" />
<Tag
v-if="cell(data, idx)?.other_project_name"
:value="cell(data, idx)?.other_project_name"
severity="info"
class="staff-loan-tag"
:title="`Préstamo de personal · ${cell(data, idx)?.other_project_name}`"
/>
<Checkbox
v-else
:model-value="Boolean(cell(data, idx)?.present)"
binary
:disabled="locked"
@update:model-value="(v: boolean) => toggleDay(data, day, v)"
/>
</template>
</Column>
<Column header="Días"><template #body="{ data }">{{ data.days }}</template></Column>
<Column header="Jornal"><template #body="{ data }">{{ money(Number(data.daily_wage)) }}</template></Column>
<Column header="A pagar"><template #body="{ data }">{{ money(Number(data.payable_net)) }}</template></Column>
<Column header="Préstamo">
<template #body="{ data }">
<span v-if="data.loan_label" class="loan-label">{{ data.loan_label }}</span>
<span v-else class="muted">—</span>
</template>
</Column>
</DataTable>
</TabPanel>
<Dialog v-model:visible="show" header="Detalle de nómina" modal :style="{ width: '920px' }">
<DeskTableBar v-model="linesQ" placeholder="Buscar persona">
<Chip :label="`${listedLines.length} líneas`" icon="pi pi-users" />
</DeskTableBar>
<DataTable :value="listedLines" class="mt-3">
<Column field="last_name_p" header="Apellido" />
<Column field="first_name" header="Nombre" />
<Column field="days" header="Días" />
<Column field="daily_wage" header="Jornal" />
<Column field="gross" header="Bruto" />
<Column field="discounts" header="Desc." />
<Column field="loan_payment" header="Préstamo" />
<Column field="net" header="Neto" />
<TabPanel value="destajo">
<Message severity="secondary" :closable="false">
Trabajo largo, corte jueves a jueves. El jueves pide el estimado de este corte; el sábado paga lo hecho más adicionales del viernes.
</Message>
<DataTable :value="destajoCuts" empty-message="Sin destajo en este corte">
<Column header="Obra" field="project_name" />
<Column header="Persona">
<template #body="{ data }">{{ data.last_name_p }} {{ data.first_name }}</template>
</Column>
<Column header="Concepto" field="concepto" />
<Column header="Unidad" field="unit_code" />
<Column header="Total">
<template #body="{ data }">{{ data.qty_total_estimated }}</template>
</Column>
<Column header="Estimado corte">
<template #body="{ data }">
<InputNumber
:model-value="Number(data.qty_planned)"
:min="0"
:max-fraction-digits="2"
:disabled="paid"
class="cut-input"
@update:model-value="(v: number) => patchCut(data.id, { qty_planned: Number(v || 0) })"
/>
</template>
</Column>
<Column header="Realizado">
<template #body="{ data }">
<InputNumber
:model-value="Number(data.qty_actual)"
:min="0"
:max-fraction-digits="2"
:disabled="paid"
class="cut-input"
@update:model-value="(v: number) => patchCut(data.id, { qty_actual: Number(v || 0) })"
/>
</template>
</Column>
<Column header="Adicional vie">
<template #body="{ data }">
<InputNumber
:model-value="Number(data.qty_extra)"
:min="0"
:max-fraction-digits="2"
:disabled="paid"
class="cut-input"
@update:model-value="(v: number) => patchCut(data.id, { qty_extra: Number(v || 0) })"
/>
</template>
</Column>
<Column header="Precio">
<template #body="{ data }">{{ money(Number(data.unit_price)) }}</template>
</Column>
</DataTable>
</TabPanel>
<TabPanel value="admin">
<Message severity="secondary" :closable="false">Lista aparte, sueldo semanal, sin asistencia. Proyecto opcional.</Message>
<DataTable :value="adminLines" empty-message="Sin personal administrativo">
<Column header="Persona">
<template #body="{ data }">{{ data.last_name_p }} {{ data.first_name }}</template>
</Column>
<Column header="Sueldo">
<template #body="{ data }">
<InputNumber
:model-value="Number(data.amount)"
mode="currency"
currency="MXN"
locale="es-MX"
:disabled="paid"
class="cut-input"
@update:model-value="(v: number) => saveAdmin(data.id, Number(v || 0))"
/>
</template>
</Column>
<Column header="A pagar"><template #body="{ data }">{{ money(Number(data.payable_net)) }}</template></Column>
<Column header="">
<template #body="{ data }">
<Button icon="pi pi-times" text rounded severity="danger" :disabled="paid" @click="removeAdmin(data.id)" />
</template>
</Column>
</DataTable>
</TabPanel>
</TabPanels>
</Tabs>
<Dialog v-model:visible="showDestajo" header="Alta de destajo" modal :style="{ width: '28rem' }">
<div class="loan-form">
<Select v-model="jobForm.project_id" :options="projects" option-label="name" option-value="id" placeholder="Obra" class="w-full" />
<Select v-model="jobForm.worker_id" :options="destajoWorkers" option-label="label" option-value="id" placeholder="Quién cobra" class="w-full" filter />
<InputText v-model="jobForm.concepto" placeholder="Concepto" class="w-full" />
<Select v-model="jobForm.unit_code" :options="units" option-label="label" option-value="code" placeholder="Unidad" class="w-full" />
<InputNumber v-model="jobForm.qty_total_estimated" placeholder="Cantidad total" :min="0" class="w-full" />
<InputNumber v-model="jobForm.qty_planned" placeholder="Estimado de este corte" :min="0" class="w-full" />
<InputNumber v-model="jobForm.unit_price" mode="currency" currency="MXN" locale="es-MX" placeholder="Precio unitario" class="w-full" />
</div>
<template #footer>
<Button label="CSV" icon="pi pi-download" outlined @click="csv" />
<Button label="Cerrar periodo" icon="pi pi-lock" @click="closeP" />
<Button label="Cancelar" text @click="showDestajo = false" />
<Button label="Dar de alta" icon="pi pi-check" @click="createJob" />
</template>
</Dialog>
<Dialog v-model:visible="showAdmin" header="Agregar administrativa" modal :style="{ width: '24rem' }">
<div class="loan-form">
<Select v-model="adminForm.worker_id" :options="allWorkers" option-label="label" option-value="id" placeholder="Persona" class="w-full" filter />
<InputNumber v-model="adminForm.amount" mode="currency" currency="MXN" locale="es-MX" placeholder="Sueldo semanal" class="w-full" />
<Select v-model="adminForm.project_id" :options="projects" option-label="name" option-value="id" placeholder="Proyecto (opcional)" show-clear class="w-full" />
</div>
<template #footer>
<Button label="Cancelar" text @click="showAdmin = false" />
<Button label="Agregar" icon="pi pi-check" @click="createAdmin" />
</template>
</Dialog>
<Dialog v-model:visible="showAddJornal" header="Agregar a jornal" modal :style="{ width: '22rem' }">
<Select v-model="jornalWorkerId" :options="allWorkers" option-label="label" option-value="id" placeholder="Persona" class="w-full" filter />
<template #footer>
<Button label="Cancelar" text @click="showAddJornal = false" />
<Button label="Agregar" icon="pi pi-check" @click="addJornal" />
</template>
</Dialog>
</div>
</template>
<script setup lang="ts">
type Cell = { date: string; present: boolean; other_project_id: number | null; other_project_name: string | null };
type Line = {
id: number;
worker_id: number;
first_name: string;
last_name_p: string;
days: number;
daily_wage: number;
payable_net: number;
amount: number;
loan_label?: string | null;
cells?: Cell[];
};
type Sheet = { id: number; kind: string; project_id: number | null; project_name?: string; lines: Line[] };
type Cut = {
id: number;
project_name: string;
first_name: string;
last_name_p: string;
concepto: string;
unit_code: string;
qty_total_estimated: number;
qty_planned: number;
qty_actual: number;
qty_extra: number;
unit_price: number;
};
const { api, download } = useApi();
const desk = useDesktop();
const toast = useToast();
const projects = ref<{ id: number; name: string }[]>([]);
const confirm = useConfirm();
const weekStart = ref(mondayOf(new Date()));
const tab = ref("obra");
const projectId = ref<number | null>(null);
const weekStart = ref<Date | null>(null);
const weekEnd = ref<Date | null>(null);
const periods = ref<Record<string, unknown>[]>([]);
const workers = ref<{ id: number; label: string }[]>([]);
const attWorker = ref<number | null>(null);
const attDate = ref<Date | null>(null);
const attPresent = ref(true);
const show = ref(false);
const lines = ref<Record<string, unknown>[]>([]);
const periodId = ref(0);
const { tableQ, matches } = useTableSearch();
const linesQ = ref("");
const listedPeriods = computed(() =>
periods.value.filter((period) => matches(period.id, period.project_name, period.week_start, period.week_end, period.status)),
const projects = ref<{ id: number; name: string }[]>([]);
const allWorkers = ref<{ id: number; label: string; work_type?: string }[]>([]);
const units = ref<{ code: string; label: string }[]>([]);
const week = ref<Record<string, unknown> | null>(null);
const destajoPeriod = ref<{ period_start: string; period_end: string } | null>(null);
const sheets = ref<Sheet[]>([]);
const destajoCuts = ref<Cut[]>([]);
const days = ref<string[]>([]);
const board = ref({ required_net: 0, payable_net: 0, delta: 0 });
const showDestajo = ref(false);
const showAdmin = ref(false);
const showAddJornal = ref(false);
const jornalWorkerId = ref<number | null>(null);
const jobForm = reactive({
project_id: null as number | null,
worker_id: null as number | null,
concepto: "",
unit_code: "m",
qty_total_estimated: 0,
qty_planned: 0,
unit_price: 0,
});
const adminForm = reactive({ worker_id: null as number | null, amount: 0, project_id: null as number | null });
const dayHeaders = ["L", "M", "X", "J", "V", "S"];
const paid = computed(() => week.value?.status === "paid");
const locked = computed(() => paid.value);
const statusLabel = computed(() => {
const s = String(week.value?.status || "draft");
if (s === "assembled") return "Armada";
if (s === "paid") return "Pagada";
return "Borrador";
});
const statusSeverity = computed(() => {
const s = String(week.value?.status || "draft");
if (s === "paid") return "success";
if (s === "assembled") return "info";
return "warn";
});
const weekLabel = computed(() => {
const a = week.value?.week_start || weekStart.value;
const b = week.value?.week_end || "";
return b ? `${fmt(String(a))} – ${fmt(String(b))}` : fmt(weekStart.value);
});
const obraSheet = computed(() =>
sheets.value.find((s) => s.kind === "obra" && s.project_id === projectId.value) ||
sheets.value.find((s) => s.kind === "obra") ||
null,
);
const openPeriods = computed(() => listedPeriods.value.filter((period) => period.status !== "closed").length);
const closedPeriods = computed(() => listedPeriods.value.filter((period) => period.status === "closed").length);
const listedLines = computed(() => {
const q = linesQ.value.trim().toLowerCase();
if (!q) return lines.value;
return lines.value.filter((row) =>
[row.last_name_p, row.first_name].some((part) => String(part ?? "").toLowerCase().includes(q)),
);
const obraLines = computed(() => obraSheet.value?.lines || []);
const adminLines = computed(() => sheets.value.find((s) => s.kind === "admin")?.lines || []);
const destajoWorkers = computed(() => {
const d = allWorkers.value.filter((w) => w.work_type === "D");
return d.length ? d : allWorkers.value;
});
function iso(d: Date | null) {
if (!d) return "";
return d.toISOString().slice(0, 10);
function mondayOf(d: Date) {
const iso = d.toLocaleDateString("en-CA", { timeZone: "America/Cancun" });
const [y, m, day] = iso.split("-").map(Number);
const utc = new Date(Date.UTC(y, m - 1, day));
const mon0 = (utc.getUTCDay() + 6) % 7;
utc.setUTCDate(utc.getUTCDate() - mon0);
return utc.toISOString().slice(0, 10);
}
function addDaysIso(iso: string, n: number) {
const [y, m, d] = iso.split("-").map(Number);
const utc = new Date(Date.UTC(y, m - 1, d + n));
return utc.toISOString().slice(0, 10);
}
function fmt(iso: string) {
const [y, m, d] = iso.split("-").map(Number);
return new Date(Date.UTC(y, m - 1, d)).toLocaleDateString("es-MX", { day: "numeric", month: "short", timeZone: "UTC" });
}
function money(n: number) {
return (Number(n) || 0).toLocaleString("es-MX", { style: "currency", currency: "MXN", maximumFractionDigits: 0 });
}
function cell(line: Line, idx: number) {
return line.cells?.[idx];
}
async function load() {
const p = await api<{ projects: { id: number; name: string }[] }>("/v1/projects?status=activo,pausado");
const [p, w, bundle] = await Promise.all([
api<{ projects: { id: number; name: string }[] }>("/v1/projects?status=activo,pausado"),
api<{ workers: { id: number; first_name: string; last_name_p: string; work_type: string }[] }>("/v1/workers?status=activo"),
api<{
week: Record<string, unknown>;
destajo_period: { period_start: string; period_end: string } | null;
sheets: Sheet[];
destajo_cuts: Cut[];
days: string[];
board: { required_net: number; payable_net: number; delta: number };
units: { code: string; label: string }[];
}>(`/v1/payroll/weeks?week_start=${weekStart.value}`),
]);
projects.value = p.projects;
if (!projectId.value && p.projects[0]) projectId.value = p.projects[0].id;
const pe = await api<{ periods: Record<string, unknown>[] }>(
`/v1/payroll/periods${projectId.value ? `?project_id=${projectId.value}` : ""}`,
);
periods.value = pe.periods;
const w = await api<{ workers: { id: number; first_name: string; last_name_p: string }[] }>(
`/v1/workers?status=activo${projectId.value ? `&project_id=${projectId.value}` : ""}`,
);
workers.value = w.workers.map((x) => ({ id: x.id, label: `${x.last_name_p} ${x.first_name}` }));
allWorkers.value = w.workers.map((x) => ({
id: x.id,
label: `${x.last_name_p} ${x.first_name}`,
work_type: x.work_type,
}));
week.value = bundle.week;
destajoPeriod.value = bundle.destajo_period;
sheets.value = bundle.sheets;
destajoCuts.value = bundle.destajo_cuts || [];
days.value = bundle.days || [];
board.value = bundle.board;
units.value = bundle.units || [];
syncChrome();
}
function syncChrome() {
const messages = [];
if (openPeriods.value > 0) {
messages.push({
key: "nom_abiertos",
severity: "info" as const,
title: `${openPeriods.value} periodos de nómina abiertos`,
detail: "Genera CSV o cierra el periodo cuando esté listo.",
const st = String(week.value?.status || "draft");
desk.setStatusMessages([
{
key: "nom_week",
severity: st === "paid" ? "info" : board.value.delta < 0 ? "warn" : "info",
title: st === "paid" ? "Semana pagada" : `Caja ${money(board.value.required_net)} · a pagar ${money(board.value.payable_net)}`,
detail: board.value.delta < 0 ? "Hay faltante respecto a lo armado el jueves." : "Jueves se arma, sábado se paga.",
route: "/nomina",
});
}
desk.setStatusMessages(messages);
},
]);
desk.configure({
tab: "Nómina",
treeTitle: "Proyectos",
treeTitle: "Obras",
showTree: true,
showDetail: false,
treeKey: projectId.value ? `p:${projectId.value}` : "",
countLabel: `${periods.value.length} periodos`,
countLabel: weekLabel.value,
onTreeSelect: (key) => {
if (!key.startsWith("p:")) return;
projectId.value = Number(key.slice(2));
load();
},
tree: projects.value.map((p) => ({
key: `p:${p.id}`,
label: p.name,
icon: "pi pi-folder",
})),
tree: projects.value.map((p) => ({ key: `p:${p.id}`, label: p.name, icon: "pi pi-folder" })),
ribbon: [
{
key: "acciones",
label: "Nómina",
items: [{ key: "gen", label: "Periodo", icon: "pi pi-calculator", command: createPeriod }],
items: [
{ key: "assemble", label: "Armar jueves", icon: "pi pi-box", disabled: paid.value || st === "assembled", command: assemble },
{ key: "pay", label: "Pagar sábado", icon: "pi pi-wallet", disabled: paid.value, command: pay },
{ key: "csv", label: "CSV", icon: "pi pi-download", command: csv },
],
},
{
key: "alta",
label: "Alta",
items: [
{ key: "destajo", label: "Destajo", icon: "pi pi-plus", disabled: paid.value, command: () => { jobForm.project_id = projectId.value; showDestajo.value = true; } },
{ key: "admin", label: "Administrativa", icon: "pi pi-user-plus", disabled: paid.value, command: () => { showAdmin.value = true; } },
{ key: "jornal", label: "Agregar a jornal", icon: "pi pi-users", disabled: paid.value, command: () => { showAddJornal.value = true; } },
],
},
{
key: "vista",
@ -155,49 +404,134 @@ function syncChrome() {
});
}
async function saveAtt() {
if (!projectId.value || !attWorker.value || !attDate.value) return;
await api("/v1/attendance", {
method: "PUT",
json: {
project_id: projectId.value,
worker_id: attWorker.value,
work_date: iso(attDate.value),
present: attPresent.value,
},
});
toast.add({ severity: "success", summary: "Asistencia", life: 1500 });
function shiftWeek(n: number) {
weekStart.value = addDaysIso(weekStart.value, n);
load();
}
async function createPeriod() {
if (!projectId.value || !weekStart.value || !weekEnd.value) return;
await api("/v1/payroll/periods", {
method: "POST",
json: { project_id: projectId.value, week_start: iso(weekStart.value), week_end: iso(weekEnd.value) },
async function toggleDay(line: Line, date: string, present: boolean) {
if (!obraSheet.value?.project_id || paid.value) return;
try {
await api("/v1/attendance", {
method: "PUT",
json: { project_id: obraSheet.value.project_id, worker_id: line.worker_id, work_date: date, present },
});
toast.add({ severity: "success", summary: "Periodo generado", life: 2000 });
await load();
} catch (error) {
const detail = error as { data?: { error?: string } };
toast.add({ severity: "warn", summary: detail.data?.error || "No se pudo marcar el día", life: 2800 });
}
}
async function assemble() {
if (!week.value) return;
await api(`/v1/payroll/weeks/${week.value.id}/assemble`, { method: "POST" });
toast.add({ severity: "success", summary: "Nómina armada", detail: "Requerida congelada", life: 2200 });
await load();
}
async function open(id: number) {
periodId.value = id;
const r = await api<{ lines: Record<string, unknown>[] }>(`/v1/payroll/periods/${id}`);
lines.value = r.lines;
show.value = true;
async function pay() {
if (!week.value) return;
confirm.require({
header: "Pagar sábado",
message: `Se descontarán préstamos y se congelará el gasto de ${money(board.value.payable_net)}.`,
acceptLabel: "Pagar",
rejectLabel: "Cancelar",
accept: async () => {
await api(`/v1/payroll/weeks/${week.value!.id}/pay`, { method: "POST" });
toast.add({ severity: "success", summary: "Semana pagada", life: 2200 });
await load();
},
});
}
function csv() {
download(`/v1/payroll/periods/${periodId.value}/csv`, `nomina-${periodId.value}.csv`).then(() => {
toast.add({ severity: "success", summary: "CSV descargado", life: 2500 });
if (!week.value) return;
download(`/v1/payroll/weeks/${week.value.id}/csv`, `nomina-${week.value.id}.csv`).then(() => {
toast.add({ severity: "success", summary: "CSV descargado", life: 2000 });
});
}
async function closeP() {
await api(`/v1/payroll/periods/${periodId.value}`, { method: "PATCH", json: { status: "closed" } });
show.value = false;
let cutTimer: number | undefined;
async function patchCut(id: number, patch: Record<string, number>) {
if (!week.value) return;
window.clearTimeout(cutTimer);
cutTimer = window.setTimeout(async () => {
await api(`/v1/destajo/cuts/${id}`, { method: "PATCH", json: { week_id: week.value!.id, ...patch } });
await load();
}, 450);
}
async function createJob() {
if (!week.value || !jobForm.project_id || !jobForm.worker_id || !jobForm.concepto.trim()) return;
await api("/v1/destajo/jobs", { method: "POST", json: { week_id: week.value.id, ...jobForm } });
showDestajo.value = false;
jobForm.concepto = "";
jobForm.qty_total_estimated = 0;
jobForm.qty_planned = 0;
jobForm.unit_price = 0;
toast.add({ severity: "success", summary: "Destajo dado de alta", life: 1800 });
await load();
}
async function createAdmin() {
if (!week.value || !adminForm.worker_id || adminForm.amount <= 0) return;
await api(`/v1/payroll/weeks/${week.value.id}/admin`, { method: "POST", json: adminForm });
showAdmin.value = false;
adminForm.worker_id = null;
adminForm.amount = 0;
adminForm.project_id = null;
await load();
}
async function saveAdmin(lineId: number, amount: number) {
if (!week.value) return;
await api(`/v1/payroll/weeks/${week.value.id}/admin/${lineId}`, { method: "PATCH", json: { amount } });
await load();
}
async function removeAdmin(lineId: number) {
if (!week.value) return;
await api(`/v1/payroll/weeks/${week.value.id}/admin/${lineId}`, { method: "DELETE" });
await load();
}
async function addJornal() {
if (!week.value || !projectId.value || !jornalWorkerId.value) return;
await api(`/v1/payroll/weeks/${week.value.id}/jornal`, {
method: "POST",
json: { project_id: projectId.value, worker_id: jornalWorkerId.value },
});
showAddJornal.value = false;
jornalWorkerId.value = null;
await load();
}
watch(projectId, syncChrome);
onMounted(load);
onBeforeUnmount(() => desk.clearStatusMessages());
</script>
<style scoped>
.nomina-board {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
margin-bottom: 12px;
}
.nomina-kpi {
font-size: 1.45rem;
font-weight: 700;
margin-top: 4px;
}
.nomina-kpi.ok { color: var(--p-green-600); }
.nomina-kpi.warn { color: var(--p-orange-600); }
.destajo-cut { margin-left: 10px; }
.staff-loan-tag { max-width: 5rem; overflow: hidden; text-overflow: ellipsis; }
.loan-label { font-size: 0.8rem; }
.cut-input { width: 7.5rem; }
.loan-form { display: flex; flex-direction: column; gap: 10px; }
@media (max-width: 800px) {
.nomina-board { grid-template-columns: 1fr; }
}
</style>

View file

@ -58,6 +58,7 @@ export type DocumentTypePolicy = {
freshness_days?: number | null;
requires_issued_at?: boolean | number;
requires_expires_at?: boolean | number;
category?: string;
};
export type ChecklistItem = DocumentTypePolicy & {
@ -68,6 +69,40 @@ export type ChecklistItem = DocumentTypePolicy & {
expires_at?: string | null;
};
export const DOCUMENT_CATEGORY_LABEL: Record<string, string> = {
identidad: "Identidad",
laboral: "Laboral",
sst: "Seguridad",
oficio: "Oficio",
contrato: "Contrato",
permisos: "Permisos",
ambiental: "Ambiental",
imss: "IMSS",
tecnico: "Técnico",
repse: "REPSE",
fiscal: "Opiniones fiscales",
legal: "Legal",
otro: "Otros",
};
const CATEGORY_ORDER = Object.keys(DOCUMENT_CATEGORY_LABEL);
export function groupedDocumentOptions(
items: Array<{ code: string; label: string; category?: string }>,
) {
const buckets = new Map<string, { code: string; label: string }[]>();
for (const item of items) {
const cat = item.category && DOCUMENT_CATEGORY_LABEL[item.category] ? item.category : "otro";
const list = buckets.get(cat) ?? [];
list.push({ code: item.code, label: item.label });
buckets.set(cat, list);
}
return CATEGORY_ORDER.filter((cat) => buckets.has(cat)).map((cat) => ({
label: DOCUMENT_CATEGORY_LABEL[cat],
items: buckets.get(cat) ?? [],
}));
}
export type ImssStats = {
with_imss: number;
without_imss: number;