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> { export async function login(username: string, password: string): Promise<AuthUser | null> {
const user = username.trim(); const user = username.trim();
if (!user) return null; const pass = password.trim();
if (!user || !pass) return null;
const db = await getDb(); const db = await getDb();
const appRow = db.prepare( 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 FROM users u LEFT JOIN companies c ON c.id = u.company_id
WHERE u.username = ?`, WHERE u.username = ?`,
).get(user) as (Record<string, unknown> & { password_hash: string }) | undefined; ).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 authUser = asAppUser(appRow);
const { tenantAccessBlocked } = await import("./saas.ts"); const { tenantAccessBlocked } = await import("./saas.ts");
const pdb = await getPlatformDb(); const pdb = await getPlatformDb();
@ -189,7 +190,7 @@ export async function login(username: string, password: string): Promise<AuthUse
).get(user) as ).get(user) as
| { id: number; username: string; display_name: string; password_hash: string } | { id: number; username: string; display_name: string; password_hash: string }
| undefined; | undefined;
if (plat && await verifyPassword(password, plat.password_hash)) { if (plat && await verifyPassword(pass, plat.password_hash)) {
return asPlatformUser(plat); return asPlatformUser(plat);
} }
return null; 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 PDFS_DIR = join(DATA_DIR, "pdfs");
export const LOGOS_DIR = join(DATA_DIR, "logos"); export const LOGOS_DIR = join(DATA_DIR, "logos");
export const PROJECTS_DIR = join(DATA_DIR, "proyectos"); export const PROJECTS_DIR = join(DATA_DIR, "proyectos");
export const COMPANIES_DIR = join(DATA_DIR, "empresas");
export const config = { export const config = {
port: Number(Deno.env.get("PORT") ?? "8000"), port: Number(Deno.env.get("PORT") ?? "8000"),

View file

@ -1,10 +1,10 @@
import { Database } from "@db/sqlite"; import { Database } from "@db/sqlite";
import { mkdir } from "node:fs/promises"; import { mkdir } from "node:fs/promises";
import { join } from "node:path"; 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 { config } from "./config.ts";
import { hashPassword } from "./crypto.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"; import { runLiquibase } from "./liquibase.ts";
export type Db = Database; export type Db = Database;
@ -18,6 +18,7 @@ export async function getDb(): Promise<Database> {
await mkdir(PDFS_DIR, { recursive: true }); await mkdir(PDFS_DIR, { recursive: true });
await mkdir(LOGOS_DIR, { recursive: true }); await mkdir(LOGOS_DIR, { recursive: true });
await mkdir(PROJECTS_DIR, { recursive: true }); await mkdir(PROJECTS_DIR, { recursive: true });
await mkdir(COMPANIES_DIR, { recursive: true });
await runLiquibase(); await runLiquibase();
db = new Database(DB_PATH); db = new Database(DB_PATH);
db.exec("PRAGMA foreign_keys = ON;"); db.exec("PRAGMA foreign_keys = ON;");
@ -28,15 +29,16 @@ export async function getDb(): Promise<Database> {
export function seedDocumentTypes(database: Database) { export function seedDocumentTypes(database: Database) {
const upsert = database.prepare( const upsert = database.prepare(
`INSERT INTO document_types `INSERT INTO document_types
(code, label, required, validity_mode, freshness_days, requires_issued_at, requires_expires_at) (code, label, required, validity_mode, freshness_days, requires_issued_at, requires_expires_at, category)
VALUES (?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(code) DO UPDATE SET ON CONFLICT(code) DO UPDATE SET
label = excluded.label, label = excluded.label,
required = excluded.required, required = excluded.required,
validity_mode = excluded.validity_mode, validity_mode = excluded.validity_mode,
freshness_days = excluded.freshness_days, freshness_days = excluded.freshness_days,
requires_issued_at = excluded.requires_issued_at, 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) { for (const d of DOCUMENT_TYPE_SEED) {
upsert.run( upsert.run(
@ -47,10 +49,39 @@ export function seedDocumentTypes(database: Database) {
d.freshness_days, d.freshness_days,
d.requires_issued_at, d.requires_issued_at,
d.requires_expires_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 { export function nextProjectCode(database: Database): string {
const row = database.prepare( const row = database.prepare(
`SELECT COALESCE(MAX(CAST(substr(code, 5) AS INTEGER)), 0) + 1 AS n `SELECT COALESCE(MAX(CAST(substr(code, 5) AS INTEGER)), 0) + 1 AS n
@ -114,17 +145,8 @@ async function seed(database: Database) {
); );
seedDocumentTypes(database); seedDocumentTypes(database);
seedProjectDocumentTypes(database);
const projectDocs = [ seedCompanyDocumentTypes(database);
["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);
const defaultTenantId = 1; const defaultTenantId = 1;
let principal = database.prepare( let principal = database.prepare(
@ -210,7 +232,7 @@ export function workerImssContext(database: Database, workerId: number): WorkerI
export function checklistItemsFor(database: Database, workerId: number) { export function checklistItemsFor(database: Database, workerId: number) {
const types = database.prepare( const types = database.prepare(
`SELECT code, label, required, validity_mode, freshness_days, `SELECT code, label, required, validity_mode, freshness_days,
requires_issued_at, requires_expires_at requires_issued_at, requires_expires_at, category
FROM document_types`, FROM document_types`,
).all() as { ).all() as {
code: string; code: string;
@ -220,6 +242,7 @@ export function checklistItemsFor(database: Database, workerId: number) {
freshness_days: number | null; freshness_days: number | null;
requires_issued_at: number; requires_issued_at: number;
requires_expires_at: number; requires_expires_at: number;
category: string;
}[]; }[];
const currentDocs = database.prepare( const currentDocs = database.prepare(
`SELECT type_code, issued_at, expires_at, uploaded_at, imss_alta_at `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, freshness_days: t.freshness_days,
requires_issued_at: policy.requires_issued_at, requires_issued_at: policy.requires_issued_at,
requires_expires_at: policy.requires_expires_at, requires_expires_at: policy.requires_expires_at,
category: t.category || "otro",
present: evaled.present, present: evaled.present,
valid: evaled.valid, valid: evaled.valid,
validity_status: evaled.validity_status, validity_status: evaled.validity_status,
@ -328,10 +352,14 @@ export function projectDir(projectId: number): string {
return join(PROJECTS_DIR, String(projectId)); 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) { export function projectChecklistFor(database: Database, projectId: number) {
const types = database.prepare( const types = database.prepare(
"SELECT code, label, required FROM project_document_types", "SELECT code, label, required, category FROM project_document_types",
).all() as { code: string; label: string; required: number }[]; ).all() as { code: string; label: string; required: number; category: string }[];
const current = database.prepare( const current = database.prepare(
`SELECT type_code FROM project_documents WHERE project_id = ? AND is_current = 1`, `SELECT type_code FROM project_documents WHERE project_id = ? AND is_current = 1`,
).all(projectId) as { type_code: string }[]; ).all(projectId) as { type_code: string }[];
@ -340,6 +368,24 @@ export function projectChecklistFor(database: Database, projectId: number) {
code: t.code, code: t.code,
label: t.label, label: t.label,
required: !!t.required, 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), 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; code: string;
label: string; label: string;
required: number; required: number;
category: string;
validity_mode: ValidityMode; validity_mode: ValidityMode;
freshness_days: number | null; freshness_days: number | null;
requires_issued_at: number; requires_issued_at: number;
requires_expires_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 }, /** Núcleo = required 1. El resto es catálogo: se sube si se tiene, no cuenta como faltante. */
{ code: "curp", label: "Constancia CURP", required: 1, validity_mode: "freshness", freshness_days: 90, requires_issued_at: 1, requires_expires_at: 0 }, export const DOCUMENT_TYPE_SEED: CatalogDocType[] = [
{ code: "nss", label: "Constancia NSS", required: 1, validity_mode: "freshness", freshness_days: 90, requires_issued_at: 1, requires_expires_at: 0 }, { code: "foto", label: "Foto", required: 1, category: "identidad", validity_mode: "none", freshness_days: null, requires_issued_at: 0, 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: "ine", label: "INE", required: 1, category: "identidad", validity_mode: "expiry", freshness_days: null, requires_issued_at: 0, requires_expires_at: 1 },
{ code: "domicilio", label: "Comprobante de domicilio", required: 1, validity_mode: "freshness", freshness_days: 90, requires_issued_at: 1, requires_expires_at: 0 }, { code: "curp", label: "Constancia CURP", 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, validity_mode: "none", freshness_days: null, requires_issued_at: 0, 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: "baja_imss", label: "Baja IMSS", required: 0, validity_mode: "none", freshness_days: null, requires_issued_at: 0, 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: "contrato", label: "Contrato", required: 0, validity_mode: "none", freshness_days: null, requires_issued_at: 0, 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: "otro", label: "Otro", required: 0, validity_mode: "none", freshness_days: null, requires_issued_at: 0, 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 { import {
evaluateDocumentValidity, evaluateDocumentValidity,
freshnessRequired, freshnessRequired,
DOCUMENT_TYPE_SEED,
PROJECT_DOCUMENT_TYPE_SEED,
COMPANY_DOCUMENT_TYPE_SEED,
type DocTypePolicy, type DocTypePolicy,
type WorkerImssContext, type WorkerImssContext,
} from "./document_validity.ts"; } from "./document_validity.ts";
@ -122,3 +125,17 @@ Deno.test("missing issued_at for freshness", () => {
); );
assertEquals(result.validity_status, "missing_dates"); 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 { encryptBytes } from "./docs_crypto.ts";
import { sha256Hex } from "./crypto.ts"; import { sha256Hex } from "./crypto.ts";
import { canonicalRiskCode, normalizeWorker, validateWorkerFields, formatNss, normUpper, type WorkerInput } from "./mx.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"; import { resolveCompany } from "./companies.ts";
export const IMPORT_COLUMNS = [ export const IMPORT_COLUMNS = [
@ -325,6 +325,34 @@ export async function storeProjectDocument(
).run(projectId, type, filename, mime, bytes.byteLength, hash, iv, storage, userId); ).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> { async function fetchPhoto(url: string): Promise<Uint8Array | null> {
if (!url.startsWith("http")) return null; if (!url.startsWith("http")) return null;
try { try {

View file

@ -1,6 +1,6 @@
import { Hono } from "hono"; import { Hono } from "hono";
import { cors } from "hono/cors"; 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 { config } from "./config.ts";
import { clearSession, createSessionCookie, login, changePassword, requireAuth, requirePlatformAdmin, tenantScope, type AuthUser } from "./auth.ts"; import { clearSession, createSessionCookie, login, changePassword, requireAuth, requirePlatformAdmin, tenantScope, type AuthUser } from "./auth.ts";
import { getPlatformDb } from "./platform_db.ts"; import { getPlatformDb } from "./platform_db.ts";
@ -8,7 +8,7 @@ import { createTenant, getTenantDetail, issueTenantAdminAccess, listTenants, upd
import { smtpConfigured, testSmtp } from "./mail.ts"; import { smtpConfigured, testSmtp } from "./mail.ts";
import { saveSmtpSettings, smtpPublicView } from "./smtp.ts"; import { saveSmtpSettings, smtpPublicView } from "./smtp.ts";
import { decryptBytes } from "./docs_crypto.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 { import {
normalizeWorker, normalizeWorker,
validateCurp, validateCurp,
@ -23,7 +23,8 @@ import {
type WorkerInput, type WorkerInput,
} from "./mx.ts"; } from "./mx.ts";
import { generateBadgePdf, saveJobPdf, loadCurrentPhoto, badgeQrPng } from "./pdf.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 { import {
buildBudgetTemplate, buildBudgetTemplate,
exportBudgetWorkbook, exportBudgetWorkbook,
@ -36,6 +37,17 @@ import { join } from "node:path";
const app = new Hono<{ Variables: { user: AuthUser } }>(); 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[] { function corsOrigins(): string[] {
const defaults = [ const defaults = [
"http://localhost:3000", "http://localhost:3000",
@ -199,6 +211,7 @@ app.get("/v1/catalogs", requireAuth, async (c) => {
themes: db.prepare("SELECT * FROM badge_themes").all(), themes: db.prepare("SELECT * FROM badge_themes").all(),
document_types: db.prepare("SELECT * FROM document_types").all(), document_types: db.prepare("SELECT * FROM document_types").all(),
project_document_types: db.prepare("SELECT * FROM project_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, project_statuses: PROJECT_STATUS_CATALOG,
companies: listCompanies(db, tid), companies: listCompanies(db, tid),
}); });
@ -228,6 +241,59 @@ app.patch("/v1/companies/:id", requireAuth, async (c) => {
return c.json({ company: result.company }); 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) => { app.get("/v1/projects", requireAuth, async (c) => {
const db = await getDb(); const db = await getDb();
const tid = tenantScope(c.get("user")); 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 { items, freshness_required } = checklistItemsFor(db, id);
const flags = imssFlagsFor(db, id); const flags = imssFlagsFor(db, id);
const docTypes = db.prepare( 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`, FROM document_types ORDER BY required DESC, label`,
).all(); ).all();
return c.json({ return c.json({
@ -1094,165 +1160,7 @@ app.patch("/v1/badge-jobs/:id/people/:workerId", requireAuth, async (c) => {
return c.json({ ok: true }); return c.json({ ok: true });
}); });
app.get("/v1/attendance", requireAuth, async (c) => { registerPayrollRoutes(app);
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 });
});
const port = config.port; const port = config.port;
await getDb(); 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); await Deno.writeFile(path, bytes);
return path; 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')), validity_mode TEXT NOT NULL DEFAULT 'none' CHECK (validity_mode IN ('none', 'freshness', 'expiry')),
freshness_days INTEGER, freshness_days INTEGER,
requires_issued_at INTEGER NOT NULL DEFAULT 0, 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 ( CREATE TABLE IF NOT EXISTS documents (
@ -151,7 +152,8 @@ CREATE TABLE IF NOT EXISTS documents (
CREATE TABLE IF NOT EXISTS project_document_types ( CREATE TABLE IF NOT EXISTS project_document_types (
code TEXT PRIMARY KEY, code TEXT PRIMARY KEY,
label TEXT NOT NULL, 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 ( 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')) 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 ( CREATE TABLE IF NOT EXISTS badge_jobs (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id INTEGER NOT NULL REFERENCES projects(id), project_id INTEGER NOT NULL REFERENCES projects(id),
@ -191,9 +216,15 @@ CREATE TABLE IF NOT EXISTS loans (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
worker_id INTEGER NOT NULL REFERENCES workers(id) ON DELETE CASCADE, worker_id INTEGER NOT NULL REFERENCES workers(id) ON DELETE CASCADE,
amount REAL NOT NULL, amount REAL NOT NULL,
delivered REAL NOT NULL DEFAULT 0,
balance REAL NOT NULL, balance REAL NOT NULL,
weekly_payment REAL NOT NULL, weekly_payment REAL NOT NULL,
note TEXT, 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')) created_at TEXT NOT NULL DEFAULT (datetime('now'))
); );
@ -227,6 +258,107 @@ CREATE TABLE IF NOT EXISTS payroll_lines (
net REAL NOT NULL 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 ( CREATE TABLE IF NOT EXISTS budget_chapters (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE, 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/001-baseline.sql" relativeToChangelogFile="true"/>
<include file="changesets/002-tenant-id.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/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> </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-component,
.form-field .p-inputtext, .form-field .p-inputtext,
.form-field .p-inputnumber { width: 100%; } .form-field .p-inputnumber { width: 100%; }
.form-field .p-toggleswitch {
width: auto;
}
.toggle-field, .toggle-field,
.risk-option { .risk-option {
min-height: 34px; 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>
</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 class="document-checklist">
<div v-for="item in optionalItems" :key="item.code" class="document-check" :class="{ complete: item.present }"> <div v-for="item in uploadedOptionalItems" :key="item.code" class="document-check complete">
<i :class="item.present ? 'pi pi-check-circle' : 'pi pi-circle'" /><span>{{ item.label }}</span> <i class="pi pi-check-circle" /><span>{{ item.label }}</span>
</div> </div>
</div> </div>
</Panel> </Panel>
<Fieldset legend="Subir o reemplazar documento" class="mt-3"> <Fieldset legend="Subir o reemplazar documento" class="mt-3">
<FormField label="Tipo de documento"> <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> </FormField>
<FileUpload <FileUpload
mode="basic" mode="basic"
@ -39,7 +47,7 @@
:disabled="uploading" :disabled="uploading"
@uploader="uploadDocument" @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> </Fieldset>
<Tabs v-model:value="docsTab" class="mt-3 document-list-tabs"> <Tabs v-model:value="docsTab" class="mt-3 document-list-tabs">
@ -92,8 +100,9 @@
<script setup lang="ts"> <script setup lang="ts">
import FormField from "~/components/padron/FormField.vue"; import FormField from "~/components/padron/FormField.vue";
import DocumentPreviewDialog from "~/components/DocumentPreviewDialog.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 = { type DocumentRow = {
id: number; id: number;
type_code: string; type_code: string;
@ -113,9 +122,10 @@ const previewOpen = ref(false);
const previewPath = ref(""); const previewPath = ref("");
const previewName = ref(""); const previewName = ref("");
const previewMime = ref<string | null>(null); 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 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 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 requiredProgress = computed(() => requiredItems.value.length ? Math.round(requiredDone.value / requiredItems.value.length * 100) : 0);
const currentDocuments = computed(() => props.documents.filter((doc) => doc.is_current)); const currentDocuments = computed(() => props.documents.filter((doc) => doc.is_current));

View file

@ -135,14 +135,38 @@
<TabPanel value="nomina"> <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> <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"> <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="Monto a entregar">
<FormField label="Abono semanal" class="mt-2"><InputNumber v-model="weeklyPayment" mode="currency" currency="MXN" locale="es-MX" class="w-full" /></FormField> <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" /> <Button label="Registrar préstamo" icon="pi pi-plus" class="w-full mt-2" :disabled="loanAmount <= 0" @click="addLoan" />
</Fieldset> </Fieldset>
<DataTable :value="data.loans" size="small" class="mt-3" empty-message="Sin préstamos"> <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="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> </DataTable>
</TabPanel> </TabPanel>
</TabPanels> </TabPanels>
@ -185,7 +209,7 @@ type DetailData = {
imss_alta_at?: string | null; 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 }[]; 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[]; checklist: ChecklistItem[];
freshness_required?: boolean; freshness_required?: boolean;
}; };
@ -193,7 +217,7 @@ type DetailData = {
const props = defineProps<{ workerId?: number; doc?: string }>(); const props = defineProps<{ workerId?: number; doc?: string }>();
const emit = defineEmits<{ edit: [] }>(); const emit = defineEmits<{ edit: [] }>();
const { pipelineLabel, pipelineSeverity, initials, riskLevelLabel, riskLevelSeverity, hireSeverity } = useUi(); const { pipelineLabel, pipelineSeverity, initials, riskLevelLabel, riskLevelSeverity, hireSeverity } = useUi();
const { api } = useApi(); const { api, download } = useApi();
const toast = useToast(); const toast = useToast();
const desk = useDesktop(); const desk = useDesktop();
@ -206,7 +230,16 @@ const photoOk = ref(true);
const showAltaImss = ref(false); const showAltaImss = ref(false);
const imssDialogTab = ref<"alta" | "baja" | "historial">("alta"); const imssDialogTab = ref<"alta" | "baja" | "historial">("alta");
const loanAmount = ref(0); 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 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 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}`); 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; } if (!props.workerId) { data.value = null; return; }
loading.value = true; loading.value = true;
try { try {
const [detail, projectResponse, companyResponse] = await Promise.all([ const [detail, projectResponse, companyResponse, payrollSettings] = await Promise.all([
api<DetailData>(`/v1/workers/${props.workerId}`), api<DetailData>(`/v1/workers/${props.workerId}`),
api<{ projects: ProjectOption[] }>("/v1/projects?status=activo"), api<{ projects: ProjectOption[] }>("/v1/projects?status=activo"),
api<{ companies: CompanyOption[] }>("/v1/companies"), 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; data.value = detail;
projects.value = projectResponse.projects; projects.value = projectResponse.projects;
companies.value = companyResponse.companies.filter((c) => c.status === "activo"); companies.value = companyResponse.companies.filter((c) => c.status === "activo");
photoOk.value = true; 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 { } finally {
loading.value = false; loading.value = false;
} }
@ -292,11 +329,24 @@ function onImssSaved(kind: "alta" | "baja") {
} }
async function addLoan() { async function addLoan() {
if (!props.workerId || loanAmount.value <= 0) return; 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; loanAmount.value = 0;
weeklyPayment.value = 0;
toast.add({ severity: "success", summary: "Préstamo registrado", life: 1800 }); toast.add({ severity: "success", summary: "Préstamo registrado", life: 1800 });
refreshRelated(); 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]) => { 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(); load();
}, { immediate: true }); }, { immediate: true });
</script> </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>
</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 class="document-checklist">
<div <div
v-for="item in optionalItems" v-for="item in uploadedOptionalItems"
:key="item.code" :key="item.code"
class="document-check" class="document-check"
:class="{ complete: item.present && item.valid }" :class="{ complete: item.present && item.valid }"
@ -52,16 +52,18 @@
<FormField label="Tipo de documento"> <FormField label="Tipo de documento">
<Select <Select
v-model="documentType" v-model="documentType"
:options="documentTypes" :options="groupedTypes"
option-label="label" option-label="label"
option-value="code" option-value="code"
option-group-label="label"
option-group-children="items"
class="w-full" class="w-full"
/> />
</FormField> </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" /> <DatePicker v-model="issuedAt" date-format="yy-mm-dd" show-icon class="w-full" />
</FormField> </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" /> <DatePicker v-model="expiresAt" date-format="yy-mm-dd" show-icon class="w-full" />
</FormField> </FormField>
<FileUpload <FileUpload
@ -136,6 +138,7 @@
import FormField from "./FormField.vue"; import FormField from "./FormField.vue";
import DocumentPreviewDialog from "~/components/DocumentPreviewDialog.vue"; import DocumentPreviewDialog from "~/components/DocumentPreviewDialog.vue";
import type { ChecklistItem, CompanyOption, ValidityStatus } from "~/types/padron"; import type { ChecklistItem, CompanyOption, ValidityStatus } from "~/types/padron";
import { groupedDocumentOptions } from "~/types/padron";
type DocumentRow = { type DocumentRow = {
id: number; id: number;
@ -182,15 +185,17 @@ const documentTypes = computed(() =>
.map((item) => ({ .map((item) => ({
code: item.code, code: item.code,
label: item.label, label: item.label,
category: item.category,
requires_issued_at: !!item.requires_issued_at, requires_issued_at: !!item.requires_issued_at,
requires_expires_at: !!item.requires_expires_at, requires_expires_at: !!item.requires_expires_at,
validity_mode: item.validity_mode, validity_mode: item.validity_mode,
})), })),
); );
const groupedTypes = computed(() => groupedDocumentOptions(documentTypes.value));
const selectedPolicy = computed(() => documentTypes.value.find((item) => item.code === documentType.value)); const selectedPolicy = computed(() => documentTypes.value.find((item) => item.code === documentType.value));
const requiredItems = computed(() => props.checklist.filter((item) => item.required)); const requiredItems = computed(() => props.checklist.filter((item) => item.required));
const optionalItems = computed(() => const uploadedOptionalItems = computed(() =>
props.checklist.filter((item) => !item.required && item.code !== "alta_imss" && item.code !== "baja_imss"), 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 requiredDone = computed(() => requiredItems.value.filter((item) => item.present && item.valid !== false).length);
const requiredProgress = computed(() => const requiredProgress = computed(() =>
@ -215,7 +220,8 @@ const canUpload = computed(() => {
const uploadHint = 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_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."; 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) { function typeLabel(code: string) {

View file

@ -44,7 +44,16 @@
</FormField> </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="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="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> </div>
</Fluid> </Fluid>
</TabPanel> </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 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 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 bloodTypes = ["A+", "A-", "B+", "B-", "AB+", "AB-", "O+", "O-"];
const riskChoices = computed(() => props.risks.map((risk) => ({ ...risk, display: `${riskLevelLabel(risk.code)} · ${risk.label}` }))); 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"> <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="`${listedCompanies.length} empresas`" icon="pi pi-briefcase" />
<Chip v-if="tab === 'empresas'" :label="`${subCompanies} subempresas`" icon="pi pi-th-large" /> <Chip v-if="tab === 'empresas'" :label="`${subCompanies} subempresas`" icon="pi pi-th-large" />
<Chip v-if="tab === 'nomina'" :label="`${units.length} unidades`" icon="pi pi-percentage" />
<Chip v-if="tab === 'riesgos'" :label="`${listedRisks.length} riesgos`" icon="pi pi-exclamation-triangle" /> <Chip v-if="tab === 'riesgos'" :label="`${listedRisks.length} riesgos`" icon="pi pi-exclamation-triangle" />
<Chip v-if="tab === 'temas'" :label="`${listedThemes.length} temas`" icon="pi pi-palette" /> <Chip v-if="tab === 'temas'" :label="`${listedThemes.length} temas`" icon="pi pi-palette" />
</DeskTableBar> </DeskTableBar>
<Tabs v-model:value="tab"> <Tabs v-model:value="tab">
<TabList> <TabList>
<Tab value="empresas">Empresas</Tab> <Tab value="empresas">Empresas</Tab>
<Tab value="nomina">Nómina</Tab>
<Tab value="riesgos">Riesgos</Tab> <Tab value="riesgos">Riesgos</Tab>
<Tab value="temas">Temas de gafete</Tab> <Tab value="temas">Temas de gafete</Tab>
</TabList> </TabList>
@ -52,6 +54,41 @@
</Column> </Column>
</DataTable> </DataTable>
</TabPanel> </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"> <TabPanel value="riesgos">
<DataTable :value="listedRisks" paginator :rows="15"> <DataTable :value="listedRisks" paginator :rows="15">
<Column field="code" header="Barra gafete" /> <Column field="code" header="Barra gafete" />
@ -87,12 +124,18 @@
:header="editing ? 'Editar empresa' : 'Nueva subempresa'" :header="editing ? 'Editar empresa' : 'Nueva subempresa'"
modal modal
class="company-form-dialog" 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. 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> </Message>
<div class="company-form-grid"> <div v-show="formTab === 'datos'" class="company-form-grid">
<FormField label="Nombre comercial" required> <FormField label="Nombre comercial" required>
<InputText v-model="form.nombre_comercial" class="w-full" placeholder="Constructora Maya" /> <InputText v-model="form.nombre_comercial" class="w-full" placeholder="Constructora Maya" />
</FormField> </FormField>
@ -158,10 +201,17 @@
<Select v-model="form.status" :options="statusOpts" option-label="label" option-value="value" class="w-full" /> <Select v-model="form.status" :options="statusOpts" option-label="label" option-value="value" class="w-full" />
</FormField> </FormField>
</div> </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> <Message v-if="formError" severity="error" class="mt-3" :closable="false">{{ formError }}</Message>
<template #footer> <template #footer>
<Button label="Cancelar" severity="secondary" text @click="showForm = false" /> <Button label="Cerrar" severity="secondary" text @click="showForm = false" />
<Button :label="editing ? 'Guardar' : 'Crear'" icon="pi pi-check" :loading="saving" @click="save" /> <Button v-if="formTab === 'datos'" :label="editing ? 'Guardar' : 'Crear'" icon="pi pi-check" :loading="saving" @click="save" />
</template> </template>
</Dialog> </Dialog>
</div> </div>
@ -169,6 +219,7 @@
<script setup lang="ts"> <script setup lang="ts">
import FormField from "~/components/padron/FormField.vue"; import FormField from "~/components/padron/FormField.vue";
import CompanyDocuments from "~/components/CompanyDocuments.vue";
import type { CompanyOption } from "~/types/padron"; import type { CompanyOption } from "~/types/padron";
const emptyForm = () => ({ const emptyForm = () => ({
@ -198,10 +249,23 @@ const tab = ref("empresas");
const risks = ref<Record<string, string>[]>([]); const risks = ref<Record<string, string>[]>([]);
const themes = ref<Record<string, string>[]>([]); const themes = ref<Record<string, string>[]>([]);
const companies = ref<CompanyOption[]>([]); 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 showForm = ref(false);
const saving = ref(false); const saving = ref(false);
const formError = ref(""); const formError = ref("");
const editing = ref<CompanyOption | null>(null); 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 form = reactive(emptyForm());
const statusOpts = [ const statusOpts = [
{ label: "Activa", value: "activo" }, { label: "Activa", value: "activo" },
@ -227,7 +291,7 @@ const claseRiesgoOpts = [
const principal = computed(() => companies.value.find((company) => company.kind === "principal")); const principal = computed(() => companies.value.find((company) => company.kind === "principal"));
const { tableQ, matches } = useTableSearch(); const { tableQ, matches } = useTableSearch();
const searchPlaceholder = computed(() => const searchPlaceholder = computed(() =>
tab.value === "riesgos" ? "Buscar riesgo o color" : tab.value === "temas" ? "Buscar tema" : "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(() => const listedCompanies = computed(() =>
companies.value.filter((company) => companies.value.filter((company) =>
@ -260,25 +324,70 @@ function resetForm() {
} }
async function load() { async function load() {
const c = await api<{ const [c, pay] = await Promise.all([
api<{
risks: Record<string, string>[]; risks: Record<string, string>[];
themes: Record<string, string>[]; themes: Record<string, string>[];
companies: CompanyOption[]; 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; risks.value = c.risks;
themes.value = c.themes; themes.value = c.themes;
companies.value = c.companies || []; 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(); 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() { function openNew() {
editing.value = null; editing.value = null;
formTab.value = "datos";
resetForm(); resetForm();
formError.value = ""; formError.value = "";
showForm.value = true; 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; editing.value = company;
resetForm(); resetForm();
form.nombre_comercial = company.nombre_comercial || company.name || ""; form.nombre_comercial = company.nombre_comercial || company.name || "";
@ -298,7 +407,9 @@ function openEdit(company: CompanyOption) {
form.representante_legal = company.representante_legal || ""; form.representante_legal = company.representante_legal || "";
form.giro = company.giro || ""; form.giro = company.giro || "";
formError.value = ""; formError.value = "";
formTab.value = "datos";
showForm.value = true; showForm.value = true;
await loadCompanyDocs();
} }
function payload() { function payload() {
@ -361,10 +472,11 @@ function syncChrome() {
showDetail: false, showDetail: false,
treeKey: tab.value, treeKey: tab.value,
onTreeSelect: (key) => { 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: [ tree: [
{ key: "empresas", label: "Empresas", icon: "pi pi-briefcase", data: { count: companies.value.length } }, { key: "empresas", label: "Empresas", icon: "pi pi-briefcase", data: { count: companies.value.length } },
{ key: "nomina", label: "Nómina", icon: "pi pi-wallet" },
{ key: "riesgos", label: "Riesgos", icon: "pi pi-exclamation-triangle", data: { count: risks.value.length } }, { key: "riesgos", label: "Riesgos", icon: "pi pi-exclamation-triangle", data: { count: risks.value.length } },
{ key: "temas", label: "Temas de gafete", icon: "pi pi-palette", data: { count: themes.value.length } }, { key: "temas", label: "Temas de gafete", icon: "pi pi-palette", data: { count: themes.value.length } },
], ],

View file

@ -304,7 +304,11 @@ function syncChrome() {
} }
async function generate() { 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`, { const r = await api<{ id: number; count: number }>(`/v1/projects/${projectId.value}/badge-jobs`, {
method: "POST", method: "POST",
json: {}, json: {},
@ -312,6 +316,13 @@ async function generate() {
toast.add({ severity: "success", summary: `PDF ${r.id} (${r.count} gafetes)`, life: 2500 }); toast.add({ severity: "success", summary: `PDF ${r.id} (${r.count} gafetes)`, life: 2500 });
await load(); await load();
dl(r.id); 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) { function dl(id: number) {

View file

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

View file

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

View file

@ -1,150 +1,399 @@
<template> <template>
<div class="list-page"> <div class="list-page nomina-page">
<DeskTableBar v-model="tableQ" placeholder="Buscar periodo o proyecto"> <div class="nomina-board">
<Chip :label="`${listedPeriods.length} periodos`" icon="pi pi-calendar" /> <Card>
<Chip :label="`${openPeriods} abiertos`" icon="pi pi-unlock" /> <template #content>
<Chip :label="`${closedPeriods} cerrados`" icon="pi pi-lock" /> <div class="muted">Requerida (jueves)</div>
</DeskTableBar> <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> <Toolbar>
<template #start> <template #start>
<DatePicker v-model="weekStart" date-format="yy-mm-dd" placeholder="Inicio semana" show-icon /> <Button icon="pi pi-chevron-left" text rounded @click="shiftWeek(-7)" />
<DatePicker v-model="weekEnd" date-format="yy-mm-dd" placeholder="Fin semana" show-icon /> <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> </template>
</Toolbar> </Toolbar>
<Toolbar> <Tabs v-model:value="tab">
<template #start> <TabList>
<Select v-model="attWorker" :options="workers" option-label="label" option-value="id" placeholder="Persona" /> <Tab value="obra">Jornal</Tab>
<DatePicker v-model="attDate" date-format="yy-mm-dd" placeholder="Día" show-icon /> <Tab value="destajo">Destajo</Tab>
<ToggleSwitch v-model="attPresent" /> <Tab value="admin">Administrativa</Tab>
<Tag :value="attPresent ? 'Presente' : 'Falta'" :severity="attPresent ? 'success' : 'danger'" /> </TabList>
<Button label="Registrar" icon="pi pi-check" @click="saveAtt" /> <TabPanels>
</template> <TabPanel value="obra">
</Toolbar>
<Message severity="secondary" :closable="false"> <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> </Message>
<DataTable :value="obraLines" scrollable scroll-height="flex" empty-message="Sin personal de jornal en esta obra">
<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 header="Persona" frozen>
<Column field="id" header="#" /> <template #body="{ data }">{{ data.last_name_p }} {{ data.first_name }}</template>
<Column field="project_name" header="Proyecto" /> </Column>
<Column field="week_start" header="Desde" /> <Column
<Column field="week_end" header="Hasta" /> v-for="(day, idx) in days"
<Column header="Estado"> :key="day"
:header="dayHeaders[idx]"
style="width: 5.5rem; text-align: center"
>
<template #body="{ data }"> <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> </template>
</Column> </Column>
</DataTable> </DataTable>
</TabPanel>
<Dialog v-model:visible="show" header="Detalle de nómina" modal :style="{ width: '920px' }"> <TabPanel value="destajo">
<DeskTableBar v-model="linesQ" placeholder="Buscar persona"> <Message severity="secondary" :closable="false">
<Chip :label="`${listedLines.length} líneas`" icon="pi pi-users" /> 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.
</DeskTableBar> </Message>
<DataTable :value="listedLines" class="mt-3"> <DataTable :value="destajoCuts" empty-message="Sin destajo en este corte">
<Column field="last_name_p" header="Apellido" /> <Column header="Obra" field="project_name" />
<Column field="first_name" header="Nombre" /> <Column header="Persona">
<Column field="days" header="Días" /> <template #body="{ data }">{{ data.last_name_p }} {{ data.first_name }}</template>
<Column field="daily_wage" header="Jornal" /> </Column>
<Column field="gross" header="Bruto" /> <Column header="Concepto" field="concepto" />
<Column field="discounts" header="Desc." /> <Column header="Unidad" field="unit_code" />
<Column field="loan_payment" header="Préstamo" /> <Column header="Total">
<Column field="net" header="Neto" /> <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> </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> <template #footer>
<Button label="CSV" icon="pi pi-download" outlined @click="csv" /> <Button label="Cancelar" text @click="showDestajo = false" />
<Button label="Cerrar periodo" icon="pi pi-lock" @click="closeP" /> <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> </template>
</Dialog> </Dialog>
</div> </div>
</template> </template>
<script setup lang="ts"> <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 { api, download } = useApi();
const desk = useDesktop(); const desk = useDesktop();
const toast = useToast(); 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 projectId = ref<number | null>(null);
const weekStart = ref<Date | null>(null); const projects = ref<{ id: number; name: string }[]>([]);
const weekEnd = ref<Date | null>(null); const allWorkers = ref<{ id: number; label: string; work_type?: string }[]>([]);
const periods = ref<Record<string, unknown>[]>([]); const units = ref<{ code: string; label: string }[]>([]);
const workers = ref<{ id: number; label: string }[]>([]); const week = ref<Record<string, unknown> | null>(null);
const attWorker = ref<number | null>(null); const destajoPeriod = ref<{ period_start: string; period_end: string } | null>(null);
const attDate = ref<Date | null>(null); const sheets = ref<Sheet[]>([]);
const attPresent = ref(true); const destajoCuts = ref<Cut[]>([]);
const show = ref(false); const days = ref<string[]>([]);
const lines = ref<Record<string, unknown>[]>([]); const board = ref({ required_net: 0, payable_net: 0, delta: 0 });
const periodId = ref(0); const showDestajo = ref(false);
const { tableQ, matches } = useTableSearch(); const showAdmin = ref(false);
const linesQ = ref(""); const showAddJornal = ref(false);
const listedPeriods = computed(() => const jornalWorkerId = ref<number | null>(null);
periods.value.filter((period) => matches(period.id, period.project_name, period.week_start, period.week_end, period.status)), 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 obraLines = computed(() => obraSheet.value?.lines || []);
const closedPeriods = computed(() => listedPeriods.value.filter((period) => period.status === "closed").length); const adminLines = computed(() => sheets.value.find((s) => s.kind === "admin")?.lines || []);
const listedLines = computed(() => { const destajoWorkers = computed(() => {
const q = linesQ.value.trim().toLowerCase(); const d = allWorkers.value.filter((w) => w.work_type === "D");
if (!q) return lines.value; return d.length ? d : allWorkers.value;
return lines.value.filter((row) =>
[row.last_name_p, row.first_name].some((part) => String(part ?? "").toLowerCase().includes(q)),
);
}); });
function iso(d: Date | null) { function mondayOf(d: Date) {
if (!d) return ""; const iso = d.toLocaleDateString("en-CA", { timeZone: "America/Cancun" });
return d.toISOString().slice(0, 10); 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() { 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; projects.value = p.projects;
if (!projectId.value && p.projects[0]) projectId.value = p.projects[0].id; if (!projectId.value && p.projects[0]) projectId.value = p.projects[0].id;
const pe = await api<{ periods: Record<string, unknown>[] }>( allWorkers.value = w.workers.map((x) => ({
`/v1/payroll/periods${projectId.value ? `?project_id=${projectId.value}` : ""}`, id: x.id,
); label: `${x.last_name_p} ${x.first_name}`,
periods.value = pe.periods; work_type: x.work_type,
const w = await api<{ workers: { id: number; first_name: string; last_name_p: string }[] }>( }));
`/v1/workers?status=activo${projectId.value ? `&project_id=${projectId.value}` : ""}`, week.value = bundle.week;
); destajoPeriod.value = bundle.destajo_period;
workers.value = w.workers.map((x) => ({ id: x.id, label: `${x.last_name_p} ${x.first_name}` })); sheets.value = bundle.sheets;
destajoCuts.value = bundle.destajo_cuts || [];
days.value = bundle.days || [];
board.value = bundle.board;
units.value = bundle.units || [];
syncChrome(); syncChrome();
} }
function syncChrome() { function syncChrome() {
const messages = []; const st = String(week.value?.status || "draft");
if (openPeriods.value > 0) { desk.setStatusMessages([
messages.push({ {
key: "nom_abiertos", key: "nom_week",
severity: "info" as const, severity: st === "paid" ? "info" : board.value.delta < 0 ? "warn" : "info",
title: `${openPeriods.value} periodos de nómina abiertos`, title: st === "paid" ? "Semana pagada" : `Caja ${money(board.value.required_net)} · a pagar ${money(board.value.payable_net)}`,
detail: "Genera CSV o cierra el periodo cuando esté listo.", detail: board.value.delta < 0 ? "Hay faltante respecto a lo armado el jueves." : "Jueves se arma, sábado se paga.",
route: "/nomina", route: "/nomina",
}); },
} ]);
desk.setStatusMessages(messages);
desk.configure({ desk.configure({
tab: "Nómina", tab: "Nómina",
treeTitle: "Proyectos", treeTitle: "Obras",
showTree: true, showTree: true,
showDetail: false, showDetail: false,
treeKey: projectId.value ? `p:${projectId.value}` : "", treeKey: projectId.value ? `p:${projectId.value}` : "",
countLabel: `${periods.value.length} periodos`, countLabel: weekLabel.value,
onTreeSelect: (key) => { onTreeSelect: (key) => {
if (!key.startsWith("p:")) return; if (!key.startsWith("p:")) return;
projectId.value = Number(key.slice(2)); projectId.value = Number(key.slice(2));
load();
}, },
tree: projects.value.map((p) => ({ tree: projects.value.map((p) => ({ key: `p:${p.id}`, label: p.name, icon: "pi pi-folder" })),
key: `p:${p.id}`,
label: p.name,
icon: "pi pi-folder",
})),
ribbon: [ ribbon: [
{ {
key: "acciones", key: "acciones",
label: "Nómina", 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", key: "vista",
@ -155,49 +404,134 @@ function syncChrome() {
}); });
} }
async function saveAtt() { function shiftWeek(n: number) {
if (!projectId.value || !attWorker.value || !attDate.value) return; weekStart.value = addDaysIso(weekStart.value, n);
await api("/v1/attendance", { load();
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 });
} }
async function createPeriod() { async function toggleDay(line: Line, date: string, present: boolean) {
if (!projectId.value || !weekStart.value || !weekEnd.value) return; if (!obraSheet.value?.project_id || paid.value) return;
await api("/v1/payroll/periods", { try {
method: "POST", await api("/v1/attendance", {
json: { project_id: projectId.value, week_start: iso(weekStart.value), week_end: iso(weekEnd.value) }, 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(); await load();
} }
async function open(id: number) { async function pay() {
periodId.value = id; if (!week.value) return;
const r = await api<{ lines: Record<string, unknown>[] }>(`/v1/payroll/periods/${id}`); confirm.require({
lines.value = r.lines; header: "Pagar sábado",
show.value = true; 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() { function csv() {
download(`/v1/payroll/periods/${periodId.value}/csv`, `nomina-${periodId.value}.csv`).then(() => { if (!week.value) return;
toast.add({ severity: "success", summary: "CSV descargado", life: 2500 }); 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() { let cutTimer: number | undefined;
await api(`/v1/payroll/periods/${periodId.value}`, { method: "PATCH", json: { status: "closed" } }); async function patchCut(id: number, patch: Record<string, number>) {
show.value = false; 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(); 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); onMounted(load);
onBeforeUnmount(() => desk.clearStatusMessages()); onBeforeUnmount(() => desk.clearStatusMessages());
</script> </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; freshness_days?: number | null;
requires_issued_at?: boolean | number; requires_issued_at?: boolean | number;
requires_expires_at?: boolean | number; requires_expires_at?: boolean | number;
category?: string;
}; };
export type ChecklistItem = DocumentTypePolicy & { export type ChecklistItem = DocumentTypePolicy & {
@ -68,6 +69,40 @@ export type ChecklistItem = DocumentTypePolicy & {
expires_at?: string | null; 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 = { export type ImssStats = {
with_imss: number; with_imss: number;
without_imss: number; without_imss: number;