mirror of
https://origin.cursor.com/mrdevmx/panels.git
synced 2026-10-09 11:23:18 +00:00
Nominas
This commit is contained in:
parent
6a0c6a4a49
commit
085366c287
28 changed files with 3560 additions and 387 deletions
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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"),
|
||||||
|
|
|
||||||
84
api/db.ts
84
api/db.ts
|
|
@ -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),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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" },
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
|
|
||||||
30
api/excel.ts
30
api/excel.ts
|
|
@ -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 {
|
||||||
|
|
|
||||||
234
api/main.ts
234
api/main.ts
|
|
@ -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
997
api/payroll.ts
Normal 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
454
api/payroll_http.ts
Normal 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
280
api/payroll_test.ts
Normal 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);
|
||||||
|
});
|
||||||
46
api/pdf.ts
46
api/pdf.ts
|
|
@ -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();
|
||||||
|
}
|
||||||
|
|
|
||||||
136
api/schema.sql
136
api/schema.sql
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
40
db/app/changesets/004-document-catalog.sql
Normal file
40
db/app/changesets/004-document-catalog.sql
Normal 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'))
|
||||||
|
);
|
||||||
164
db/app/changesets/005-payroll-week.sql
Normal file
164
db/app/changesets/005-payroll-week.sql
Normal 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
275
tmp/pdfs/build_estado_cuenta.py
Executable 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()
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
152
web-panel/components/CompanyDocuments.vue
Normal file
152
web-panel/components/CompanyDocuments.vue
Normal 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>
|
||||||
|
|
@ -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));
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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}` })));
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 } },
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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") {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue