commit 70d89609f449aa42b8c88fc6b654f0d045ea8f80 Author: Alberto Martinez Date: Fri Aug 21 16:55:01 2026 -0500 first commit diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..10bc53d --- /dev/null +++ b/.dockerignore @@ -0,0 +1,19 @@ +.git +.github +.cursor +.env +data/ +**/*.enc +**/.DS_Store + +node_modules/ +web-panel/node_modules/ +web-saas/node_modules/ +web-panel/.nuxt/ +web-saas/.nuxt/ +web-panel/.output/ +web-saas/.output/ +web-panel/dist/ +web-saas/dist/ + +db/tools/ diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..acb4d07 --- /dev/null +++ b/.env.example @@ -0,0 +1,33 @@ +# ============================================================================= +# Copiar a `.env` (gitignored) para local / Coolify. +# NUNCA subas `.env` ni `data/*.db` al repositorio. +# ============================================================================= + +# --- OBLIGATORIAS (producción) --- +# Generar ejemplos: +# SESSION_SECRET: openssl rand -hex 32 +# DOCS_KEY: openssl rand -hex 32 (exactamente 64 hex) +SESSION_SECRET= +DOCS_KEY= +SEED_PASSWORD= +# Si la contraseña tiene `$`, en Docker Compose escríbelo como `$$` (ej. `p$$ass` → `p$ass`). +# Opcional one-shot: SEED_SYNC_PASSWORD=true (actualiza password de admin al arrancar; luego quítalo) + +PANEL_LOGIN_URL=https://app.tudominio/login +PORT=8000 + +# --- Recomendadas en HTTPS (Coolify) --- +COOKIE_SECURE=true +# Solo si expones la API en un host distinto (con proxy /v1 en nginx suele ir vacío) +CORS_ORIGINS= + +# --- Opcionales --- +API_KEY= +VCARD_BASE=https://vcard.arctec.com.mx?info= + +# SMTP (opcional; también se puede configurar en SaaS → /smtp y queda en platform.db) +SMTP_HOST= +SMTP_PORT=587 +SMTP_USER= +SMTP_PASS= +SMTP_FROM=PANELS diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cd347b8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,64 @@ +# --- Secrets & local env --- +.env +.env.* +!.env.example +api/.env +**/.env +!**/.env.example + +# --- Runtime data (SQLite + archivos) --- +data/ +*.db +*.db-shm +*.db-wal +*.enc + +# --- Liquibase CLI (descargado por bootstrap) --- +db/tools/ + +# --- Dependencies --- +node_modules/ +**/node_modules/ + +# --- Nuxt / Vite / Nitro (web-panel + web-saas; web/ reservado para website) --- +.nuxt/ +.output/ +.nitro/ +.cache/ +.data/ +dist/ +**/.nuxt/ +**/.output/ +**/.nitro/ +**/.cache/ +**/dist/ + +# --- Deno (lock raíz accidental; el de api/ sí se versiona) --- +/deno.lock + +# --- Logs & debug --- +logs/ +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# --- Test / coverage --- +coverage/ +.nyc_output/ + +# --- OS / editors --- +.DS_Store +Thumbs.db +*:Zone.Identifier +.idea/ +.vscode/ +!.vscode/extensions.json +.fleet/ +*.swp +*~ + +# --- Docker / local overrides --- +docker-compose.override.yml +.docker/ diff --git a/Dockerfile.api b/Dockerfile.api new file mode 100644 index 0000000..392c58d --- /dev/null +++ b/Dockerfile.api @@ -0,0 +1,34 @@ +# API PANELS — Deno + SQLite FFI + Liquibase/JRE (Debian slim, no Alpine) +FROM denoland/deno:2.9.5 + +USER root +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + openjdk-21-jre-headless \ + curl \ + unzip \ + bash \ + ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +COPY db/ ./db/ +COPY api/ ./api/ + +RUN chmod +x ./db/bootstrap-tools.sh \ + && ./db/bootstrap-tools.sh \ + && mkdir -p /app/data \ + && chown -R deno:deno /app + +USER deno +WORKDIR /app/api +RUN deno cache main.ts + +ENV PORT=8000 +EXPOSE 8000 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=45s --retries=3 \ + CMD ["deno", "eval", "const r=await fetch('http://127.0.0.1:'+(Deno.env.get('PORT')||'8000')+'/v1/health'); if(!r.ok) Deno.exit(1)"] + +CMD ["deno", "run", "--allow-net", "--allow-read", "--allow-write", "--allow-env", "--allow-ffi", "--allow-run", "main.ts"] diff --git a/Dockerfile.web-panel b/Dockerfile.web-panel new file mode 100644 index 0000000..315fa7b --- /dev/null +++ b/Dockerfile.web-panel @@ -0,0 +1,14 @@ +# PANELS (obra) — Nuxt SPA → nginx Alpine +FROM node:22-alpine AS build +WORKDIR /app +COPY web-panel/package.json web-panel/package-lock.json ./ +RUN npm install --no-audit --no-fund +COPY web-panel/ ./ +RUN npm run generate + +FROM nginx:1.27-alpine +COPY web-panel/nginx.conf /etc/nginx/conf.d/default.conf +COPY --from=build /app/.output/public /usr/share/nginx/html +EXPOSE 80 +HEALTHCHECK --interval=30s --timeout=3s --retries=3 \ + CMD wget -qO- http://127.0.0.1/ >/dev/null || exit 1 diff --git a/Dockerfile.web-saas b/Dockerfile.web-saas new file mode 100644 index 0000000..83a3810 --- /dev/null +++ b/Dockerfile.web-saas @@ -0,0 +1,14 @@ +# PANELS SaaS — Nuxt SPA → nginx Alpine +FROM node:22-alpine AS build +WORKDIR /app +COPY web-saas/package.json web-saas/package-lock.json ./ +RUN npm install --no-audit --no-fund +COPY web-saas/ ./ +RUN npm run generate + +FROM nginx:1.27-alpine +COPY web-saas/nginx.conf /etc/nginx/conf.d/default.conf +COPY --from=build /app/.output/public /usr/share/nginx/html +EXPOSE 80 +HEALTHCHECK --interval=30s --timeout=3s --retries=3 \ + CMD wget -qO- http://127.0.0.1/ >/dev/null || exit 1 diff --git a/api/auth.ts b/api/auth.ts new file mode 100644 index 0000000..74a49e2 --- /dev/null +++ b/api/auth.ts @@ -0,0 +1,216 @@ +import type { Context, Next } from "hono"; +import { deleteCookie, getCookie, setCookie } from "hono/cookie"; +import { getDb } from "./db.ts"; +import { getPlatformDb } from "./platform_db.ts"; +import { config } from "./config.ts"; +import { b64urlJson, b64urlJsonParse, hmacSign, hmacVerify, hashPassword, verifyPassword } from "./crypto.ts"; + +export type AuthRealm = "app" | "platform"; +export type AuthRole = "platform_admin" | "tenant_admin" | "user"; + +export type AuthUser = { + id: number; + username: string; + display_name: string; + company_id: number | null; + company_code: string | null; + company_name: string | null; + company_kind: string | null; + tenant_id: number | null; + role: AuthRole; + realm: AuthRealm; + must_change_password: boolean; +}; + +const USER_SELECT = `u.id, u.username, u.display_name, u.company_id, u.tenant_id, u.role, + COALESCE(u.must_change_password, 0) AS must_change_password, + c.code AS company_code, c.name AS company_name, c.kind AS company_kind`; + +type SessionPayload = { uid: number; exp: number; realm?: AuthRealm }; + +const COOKIE = "po_session"; +const TTL = 60 * 60 * 24 * 7; + +export async function createSessionCookie(c: Context, userId: number, realm: AuthRealm = "app") { + const payload: SessionPayload = { uid: userId, exp: Date.now() + TTL * 1000, realm }; + const body = b64urlJson(payload); + const sig = await hmacSign(config.sessionSecret, body); + setCookie(c, COOKIE, `${body}.${sig}`, { + httpOnly: true, + path: "/", + sameSite: "Lax", + secure: config.cookieSecure, + maxAge: TTL, + }); +} + +export function clearSession(c: Context) { + deleteCookie(c, COOKIE, { path: "/" }); +} + +function asAppUser(row: Record): AuthUser { + const role = (row.role as string) || "user"; + return { + id: Number(row.id), + username: String(row.username), + display_name: String(row.display_name), + company_id: row.company_id == null ? null : Number(row.company_id), + company_code: (row.company_code as string | null) ?? null, + company_name: (row.company_name as string | null) ?? null, + company_kind: (row.company_kind as string | null) ?? null, + tenant_id: row.tenant_id == null ? null : Number(row.tenant_id), + role: role === "tenant_admin" ? "tenant_admin" : "user", + realm: "app", + must_change_password: Boolean(row.must_change_password), + }; +} + +function asPlatformUser(row: { id: number; username: string; display_name: string }): AuthUser { + return { + id: row.id, + username: row.username, + display_name: row.display_name, + company_id: null, + company_code: null, + company_name: null, + company_kind: null, + tenant_id: null, + role: "platform_admin", + realm: "platform", + must_change_password: false, + }; +} + +async function userFromCookie(c: Context): Promise { + const raw = getCookie(c, COOKIE); + if (!raw || !raw.includes(".")) return null; + const [body, sig] = raw.split("."); + if (!await hmacVerify(config.sessionSecret, body, sig)) return null; + let payload: SessionPayload; + try { + payload = b64urlJsonParse(body); + } catch { + return null; + } + if (payload.exp < Date.now()) return null; + const realm: AuthRealm = payload.realm === "platform" ? "platform" : "app"; + + if (realm === "platform") { + const pdb = await getPlatformDb(); + const row = pdb.prepare( + `SELECT id, username, display_name FROM platform_users + WHERE id = ? AND status = 'activo'`, + ).get(payload.uid) as { id: number; username: string; display_name: string } | undefined; + return row ? asPlatformUser(row) : null; + } + + const db = await getDb(); + const row = db.prepare( + `SELECT ${USER_SELECT} + FROM users u LEFT JOIN companies c ON c.id = u.company_id + WHERE u.id = ?`, + ).get(payload.uid) as Record | undefined; + return row ? asAppUser(row) : null; +} + +function apiKeyOk(c: Context): boolean { + if (!config.apiKey) return false; + const header = c.req.header("x-api-key") ?? ""; + if (header.length !== config.apiKey.length) return false; + let diff = 0; + for (let i = 0; i < header.length; i++) diff |= header.charCodeAt(i) ^ config.apiKey.charCodeAt(i); + return diff === 0; +} + +export async function requireAuth(c: Context, next: Next) { + if (apiKeyOk(c)) { + c.set("user", { + id: 0, + username: "api", + display_name: "API Key", + company_id: null, + company_code: null, + company_name: null, + company_kind: null, + tenant_id: null, + role: "tenant_admin", + realm: "app", + must_change_password: false, + } satisfies AuthUser); + await next(); + return; + } + const user = await userFromCookie(c); + if (!user) return c.json({ error: "No autenticado" }, 401); + c.set("user", user); + await next(); +} + +export async function requirePlatformAdmin(c: Context, next: Next) { + const user = await userFromCookie(c); + if (!user) return c.json({ error: "No autenticado" }, 401); + if (user.realm !== "platform" || user.role !== "platform_admin") { + return c.json({ error: "Solo administradores SaaS" }, 403); + } + c.set("user", user); + await next(); +} + +export function tenantScope(user: AuthUser): number | null { + if (user.realm === "platform") return null; + return user.tenant_id; +} + +export async function login(username: string, password: string): Promise { + const user = username.trim(); + if (!user) return null; + + const db = await getDb(); + const appRow = db.prepare( + `SELECT ${USER_SELECT}, u.password_hash + FROM users u LEFT JOIN companies c ON c.id = u.company_id + WHERE u.username = ?`, + ).get(user) as (Record & { password_hash: string }) | undefined; + if (appRow && await verifyPassword(password, appRow.password_hash)) { + const authUser = asAppUser(appRow); + const { tenantAccessBlocked } = await import("./saas.ts"); + const pdb = await getPlatformDb(); + const blocked = tenantAccessBlocked(pdb, authUser.tenant_id); + if (blocked) { + throw Object.assign(new Error(blocked), { code: "TENANT_BLOCKED" }); + } + return authUser; + } + + const pdb = await getPlatformDb(); + const plat = pdb.prepare( + `SELECT id, username, display_name, password_hash FROM platform_users + WHERE username = ? AND status = 'activo'`, + ).get(user) as + | { id: number; username: string; display_name: string; password_hash: string } + | undefined; + if (plat && await verifyPassword(password, plat.password_hash)) { + return asPlatformUser(plat); + } + return null; +} + +export async function changePassword( + userId: number, + currentPassword: string, + newPassword: string, +): Promise<{ error?: string }> { + const next = (newPassword ?? "").trim(); + if (next.length < 8) return { error: "La nueva contraseña debe tener al menos 8 caracteres" }; + const db = await getDb(); + const row = db.prepare("SELECT password_hash FROM users WHERE id = ?").get(userId) as + | { password_hash: string } + | undefined; + if (!row) return { error: "Usuario no encontrado" }; + if (!await verifyPassword(currentPassword, row.password_hash)) { + return { error: "Contraseña actual incorrecta" }; + } + const hash = await hashPassword(next); + db.prepare("UPDATE users SET password_hash = ?, must_change_password = 0 WHERE id = ?").run(hash, userId); + return {}; +} diff --git a/api/budget.ts b/api/budget.ts new file mode 100644 index 0000000..95e3e8f --- /dev/null +++ b/api/budget.ts @@ -0,0 +1,963 @@ +import * as XLSX from "xlsx"; +import type { Database } from "@db/sqlite"; + +function lastId(database: Database): number { + return Number((database.prepare("SELECT last_insert_rowid() AS id").get() as { id: number }).id); +} + +export const BUDGET_IVA = 0.16; + +export const BUDGET_COLUMNS = [ + "WBS", + "TIPO", + "CODIGO", + "DESCRIPCION", + "UNIDAD", + "CANTIDAD", + "PRECIO", +] as const; + +const GROUP_TYPES = new Set(["lote", "capitulo", "capítulo", "partida", "titulo", "título", "group", "capitulo"]); +const ITEM_TYPES = new Set(["concepto", "item", "partida-concepto"]); + +const HEADER_ALIASES: Record = { + WBS: "WBS", + NIVEL: "WBS", + TIPO: "TIPO", + TYPE: "TIPO", + KIND: "TIPO", + CAPITULO: "CAPITULO", + CAPÍTULO: "CAPITULO", + CAP: "CAPITULO", + CLAVE: "CLAVE", + CODIGO: "CLAVE", + CÓDIGO: "CLAVE", + CODE: "CLAVE", + DESCRIPCION: "DESCRIPCION", + DESCRIPCIÓN: "DESCRIPCION", + CONCEPT: "DESCRIPCION", + CONCEPTO: "DESCRIPCION", + PARTIDA: "DESCRIPCION", + UNIDAD: "UNIDAD", + UD: "UNIDAD", + U: "UNIDAD", + CANTIDAD: "CANTIDAD", + CANT: "CANTIDAD", + QTY: "CANTIDAD", + PRECIO: "PRECIO", + "PRECIO U": "PRECIO", + "PRECIO U.": "PRECIO", + "P.U.": "PRECIO", + "P.U": "PRECIO", + PU: "PRECIO", + "PRECIO UNITARIO": "PRECIO", + "P. UNITARIO": "PRECIO", + "P UNITARIO": "PRECIO", + IMPORTE: "IMPORTE", + TOTAL: "IMPORTE", + MONTO: "IMPORTE", +}; + +export type ParsedGroup = { + kind: "lote" | "capitulo" | "partida"; + wbs: string; + parentWbs: string; + code: string; + name: string; +}; + +export type ParsedBudgetItem = { + chapter: string; + chapterCode: string; + wbs: string; + parentWbs: string; + code: string; + description: string; + unit: string; + quantity: number; + unit_price: number; + amount: number; +}; + +export type ParsedBudget = { + format: "wbs" | "opus" | "flat" | "none"; + groups: ParsedGroup[]; + items: ParsedBudgetItem[]; + skipped: number; +}; + +export type BudgetPreviewNode = { + wbs: string; + code: string; + name: string; + kind: ParsedGroup["kind"]; + amount: number; + item_count: number; + children: BudgetPreviewNode[]; +}; + +export type BudgetPreview = { + format: ParsedBudget["format"]; + format_label: string; + sheet: string; + tree: BudgetPreviewNode[]; + items: { + code: string; + description: string; + unit: string; + quantity: number; + unit_price: number; + amount: number; + chapter: string; + parentWbs: string; + }[]; + totals: { subtotal: number; iva: number; total: number; item_count: number }; + skipped: number; + errors: { row: number; messages: string[] }[]; +}; + +export type BudgetImportReport = { + replaced: boolean; + chapters: number; + inserted: number; + skipped: number; + errors: { row: number; messages: string[] }[]; + totals?: { subtotal: number; iva: number; total: number; item_count: number }; + contract_amount?: number; +}; + +export type BudgetTreeNode = { + id: number; + parent_id: number | null; + code: string; + name: string; + wbs: string; + amount: number; + item_count: number; + children: BudgetTreeNode[]; +}; + +export type BudgetItemRow = { + id: number; + project_id: number; + chapter_id: number | null; + code: string; + description: string; + unit: string; + quantity: number; + unit_price: number; + amount: number; + sort_order: number; + wbs: string; + chapter_name: string | null; + chapter_code: string | null; + parent_path: string; + ancestor_ids: number[]; +}; + +function normHeader(raw: unknown): string { + const k = String(raw ?? "") + .normalize("NFD") + .replace(/\p{M}/gu, "") + .toUpperCase() + .replace(/\s+/g, " ") + .replace(/\.$/, "") + .trim(); + return HEADER_ALIASES[k] || k; +} + +function fold(value: string) { + return value.normalize("NFD").replace(/\p{M}/gu, "").toUpperCase().replace(/\s+/g, " ").trim(); +} + +export function parseMoney(value: unknown): number { + if (typeof value === "number" && Number.isFinite(value)) return value; + const raw = String(value ?? "").trim(); + if (!raw) return 0; + const cleaned = raw.replace(/[$%\s]/g, ""); + if (cleaned.includes(",") && cleaned.includes(".")) { + return Number(cleaned.replace(/,/g, "")) || 0; + } + if (cleaned.includes(",")) return Number(cleaned.replace(",", ".")) || 0; + return Number(cleaned) || 0; +} + +function isSummaryLabel(value: string) { + const t = fold(value); + if (!t) return false; + if (/^\(.*\*.*\)$/.test(t) && /(PESOS|MILLON|M\.?\s*N\.?)/.test(t)) return true; + return /(SUB\s*TOTAL|^TOTAL\b|GRAN TOTAL|\bIVA\b|SIN IVA)/.test(t); +} + +function rowHasNumbers(row: unknown[]) { + return row.slice(0, 6).some((cell, i) => i >= 2 && parseMoney(cell) !== 0); +} + +function isGroupCode(code: string) { + const t = fold(code); + return /^[A-Z]$/.test(t) || /^[A-Z]\d{2,4}$/.test(t); +} + +function padChild(parent: string, index: number) { + const part = String(index).padStart(parent ? 2 : 1, "0"); + if (!parent) return String(index); + return `${parent}.${part}`; +} + +function parentOfWbs(wbs: string) { + const i = wbs.lastIndexOf("."); + return i < 0 ? "" : wbs.slice(0, i); +} + +function tipoOf(raw: string): "group" | "item" | "" { + const t = fold(raw).toLowerCase(); + if (!t) return ""; + if (ITEM_TYPES.has(t) || t === "concepto") return "item"; + if (GROUP_TYPES.has(t) || t === "lote" || t === "capitulo" || t === "partida") return "group"; + return ""; +} + +function groupKind(raw: string, depth: number): ParsedGroup["kind"] { + const t = fold(raw).toLowerCase(); + if (t === "lote") return "lote"; + if (t === "partida") return "partida"; + if (depth <= 1) return "lote"; + if (depth === 2) return "capitulo"; + return "partida"; +} + +type ColMap = Record; + +function findHeader(rows: unknown[][]): { headerAt: number; map: ColMap } { + for (let i = 0; i < rows.length; i++) { + const headers = rows[i].map(normHeader); + const map: ColMap = { + WBS: headers.indexOf("WBS"), + TIPO: headers.indexOf("TIPO"), + CAPITULO: headers.indexOf("CAPITULO"), + CLAVE: headers.indexOf("CLAVE"), + DESCRIPCION: headers.indexOf("DESCRIPCION"), + UNIDAD: headers.indexOf("UNIDAD"), + CANTIDAD: headers.indexOf("CANTIDAD"), + PRECIO: headers.indexOf("PRECIO"), + IMPORTE: headers.indexOf("IMPORTE"), + }; + if (map.WBS >= 0 && (map.TIPO >= 0 || map.DESCRIPCION >= 0)) return { headerAt: i, map }; + if (map.DESCRIPCION >= 0 && (map.CLAVE >= 0 || map.CANTIDAD >= 0 || map.PRECIO >= 0)) { + return { headerAt: i, map }; + } + } + return { headerAt: -1, map: {} }; +} + +function cell(row: unknown[], map: ColMap, key: string) { + const i = map[key]; + return i >= 0 ? row[i] : ""; +} + +function looksOpus(rows: unknown[][], headerAt: number, map: ColMap) { + for (let r = headerAt + 1; r < Math.min(rows.length, headerAt + 40); r++) { + const code = String(cell(rows[r] || [], map, "CLAVE") ?? "").trim(); + const unit = String(cell(rows[r] || [], map, "UNIDAD") ?? "").trim(); + const qty = parseMoney(cell(rows[r] || [], map, "CANTIDAD")); + const price = parseMoney(cell(rows[r] || [], map, "PRECIO")); + if (isGroupCode(code) && !unit && qty === 0 && price === 0) return true; + } + return false; +} + +function parseWbsRows(rows: unknown[][], headerAt: number, map: ColMap): ParsedBudget { + const groups: ParsedGroup[] = []; + const items: ParsedBudgetItem[] = []; + let skipped = 0; + const names = new Map(); + + for (let r = headerAt + 1; r < rows.length; r++) { + const row = rows[r] || []; + const values = row.map((v) => String(v ?? "").trim()); + if (values.every((v) => !v)) continue; + if (values.some((value) => isSummaryLabel(value))) { + skipped++; + continue; + } + const wbs = String(cell(row, map, "WBS") ?? "").trim(); + const tipoRaw = String(cell(row, map, "TIPO") ?? "").trim(); + const code = String(cell(row, map, "CLAVE") ?? "").trim(); + const description = String(cell(row, map, "DESCRIPCION") ?? "").trim(); + const unit = String(cell(row, map, "UNIDAD") ?? "").trim(); + const qty = parseMoney(cell(row, map, "CANTIDAD")); + const price = parseMoney(cell(row, map, "PRECIO")); + const importe = parseMoney(cell(row, map, "IMPORTE")); + if (!wbs && !code && !description) { + skipped++; + continue; + } + const depth = wbs ? wbs.split(".").length : 1; + let kind = tipoOf(tipoRaw); + if (!kind) kind = unit || qty || price ? "item" : "group"; + const parentWbs = wbs ? parentOfWbs(wbs) : ""; + if (kind === "group") { + const name = description || code || "Capítulo"; + const node: ParsedGroup = { + kind: groupKind(tipoRaw, depth), + wbs: wbs || String(groups.length + 1), + parentWbs, + code, + name, + }; + groups.push(node); + names.set(node.wbs, { name, code }); + continue; + } + const parent = names.get(parentWbs); + items.push({ + chapter: parent?.name || "General", + chapterCode: parent?.code || "", + wbs: wbs || padChild(parentWbs, items.length + 1), + parentWbs, + code, + description: description || code, + unit, + quantity: qty, + unit_price: price, + amount: Math.round((qty * price || importe) * 100) / 100, + }); + } + return { groups, items, skipped }; +} + +function parseOpusRows(rows: unknown[][], headerAt: number, map: ColMap): ParsedBudget { + const groups: ParsedGroup[] = []; + const items: ParsedBudgetItem[] = []; + let skipped = 0; + let loteWbs = ""; + let chapterWbs = ""; + let loteIndex = 0; + let chapterIndex = 0; + let itemIndex = 0; + let lastItem: ParsedBudgetItem | null = null; + const groupNames = new Map(); + + for (let r = headerAt + 1; r < rows.length; r++) { + const row = rows[r] || []; + const values = row.map((v) => String(v ?? "").trim()); + if (values.every((v) => !v)) continue; + const code = String(cell(row, map, "CLAVE") ?? "").trim(); + const description = String(cell(row, map, "DESCRIPCION") ?? "").trim(); + const unit = String(cell(row, map, "UNIDAD") ?? "").trim(); + const qty = parseMoney(cell(row, map, "CANTIDAD")); + const price = parseMoney(cell(row, map, "PRECIO")); + const importe = parseMoney(cell(row, map, "IMPORTE")); + if ([code, description].some((value) => isSummaryLabel(value)) || values.some((value) => isSummaryLabel(value))) { + skipped++; + lastItem = null; + continue; + } + if (!code && description && !unit && qty === 0 && price === 0 && importe === 0) { + if (lastItem) { + lastItem.description = `${lastItem.description} ${description}`.replace(/\s+/g, " ").trim(); + } else { + skipped++; + } + continue; + } + if (code && !unit && qty === 0 && price === 0 && importe === 0 && (isGroupCode(code) || !rowHasNumbers(row))) { + lastItem = null; + if (isGroupCode(code) && /^[A-Z]$/i.test(fold(code))) { + loteIndex += 1; + chapterIndex = 0; + itemIndex = 0; + loteWbs = String(loteIndex); + chapterWbs = loteWbs; + const node: ParsedGroup = { + kind: "lote", + wbs: loteWbs, + parentWbs: "", + code, + name: description || code, + }; + groups.push(node); + groupNames.set(node.wbs, { name: node.name, code: node.code }); + } else { + chapterIndex += 1; + itemIndex = 0; + const parent = loteWbs || ""; + if (!loteWbs) { + loteIndex += 1; + loteWbs = String(loteIndex); + } + chapterWbs = padChild(parent || loteWbs, chapterIndex); + const node: ParsedGroup = { + kind: "capitulo", + wbs: chapterWbs, + parentWbs: loteWbs && chapterWbs !== loteWbs ? loteWbs : "", + code, + name: description || code, + }; + groups.push(node); + groupNames.set(node.wbs, { name: node.name, code: node.code }); + } + continue; + } + if (!description && !code) { + skipped++; + continue; + } + if (!code && !unit && qty === 0 && price === 0) { + skipped++; + continue; + } + itemIndex += 1; + const parentWbs = chapterWbs || loteWbs; + const parent = groupNames.get(parentWbs); + const item: ParsedBudgetItem = { + chapter: parent?.name || "General", + chapterCode: parent?.code || "", + wbs: padChild(parentWbs, itemIndex), + parentWbs, + code, + description: description || code, + unit, + quantity: qty, + unit_price: price, + amount: Math.round((qty * price || importe) * 100) / 100, + }; + items.push(item); + lastItem = item; + } + return { groups, items, skipped }; +} + +function parseFlatRows(rows: unknown[][], headerAt: number, map: ColMap): ParsedBudget { + const groups: ParsedGroup[] = []; + const items: ParsedBudgetItem[] = []; + let skipped = 0; + let chapter = ""; + let chapterCode = ""; + let chapterWbs = ""; + let chapterIndex = 0; + let itemIndex = 0; + const cache = new Map(); + + function ensureChapter(name: string, code = "") { + const key = name.trim() || "General"; + const hit = cache.get(key.toLowerCase()); + if (hit) { + chapter = hit.name; + chapterCode = hit.code; + chapterWbs = hit.wbs; + return hit; + } + chapterIndex += 1; + itemIndex = 0; + const node: ParsedGroup = { + kind: "capitulo", + wbs: String(chapterIndex), + parentWbs: "", + code, + name: key, + }; + groups.push(node); + cache.set(key.toLowerCase(), node); + chapter = node.name; + chapterCode = node.code; + chapterWbs = node.wbs; + return node; + } + + for (let r = 0; r < headerAt; r++) { + const first = String(rows[r]?.[0] ?? "").trim(); + const second = String(rows[r]?.[1] ?? "").trim(); + if (/^obra:?$/i.test(first) && second) ensureChapter(second); + const restEmpty = (rows[r] || []).slice(1).every((value) => !String(value ?? "").trim()); + if (first && restEmpty && !/^(cliente|concurso|obra|lugar|ciudad|fecha):?$/i.test(first) && !isSummaryLabel(first)) { + ensureChapter(first); + } + } + + for (let r = headerAt + 1; r < rows.length; r++) { + const row = rows[r] || []; + const values = row.map((v) => String(v ?? "").trim()); + if (values.every((v) => !v)) continue; + if (values.some((value) => isSummaryLabel(value))) { + skipped++; + continue; + } + const code = String(cell(row, map, "CLAVE") ?? "").trim(); + const description = String(cell(row, map, "DESCRIPCION") ?? "").trim(); + const unit = String(cell(row, map, "UNIDAD") ?? "").trim(); + const chapterCell = String(cell(row, map, "CAPITULO") ?? "").trim(); + const qty = parseMoney(cell(row, map, "CANTIDAD")); + const price = parseMoney(cell(row, map, "PRECIO")); + const importe = parseMoney(cell(row, map, "IMPORTE")); + const first = values[0]; + const label = chapterCell || description || first; + const looksChapter = !code && !unit && qty === 0 && price === 0 && !rowHasNumbers(row) && !!label; + if (looksChapter) { + ensureChapter(label); + continue; + } + if (chapterCell) ensureChapter(chapterCell); + if (!chapterWbs) ensureChapter("General"); + if (!description && !code) { + skipped++; + continue; + } + itemIndex += 1; + items.push({ + chapter: chapter || "General", + chapterCode, + wbs: padChild(chapterWbs, itemIndex), + parentWbs: chapterWbs, + code, + description: description || code, + unit, + quantity: qty, + unit_price: price, + amount: Math.round((qty * price || importe) * 100) / 100, + }); + } + return { groups, items, skipped }; +} + +export function parseBudgetRows(rows: unknown[][]): ParsedBudget { + const empty: ParsedBudget = { format: "none", groups: [], items: [], skipped: 0 }; + const { headerAt, map } = findHeader(rows); + if (headerAt < 0) return empty; + if (map.WBS >= 0) return { format: "wbs", ...parseWbsRows(rows, headerAt, map) }; + if (looksOpus(rows, headerAt, map)) return { format: "opus", ...parseOpusRows(rows, headerAt, map) }; + return { format: "flat", ...parseFlatRows(rows, headerAt, map) }; +} + +function xlsxBytes(wb: XLSX.WorkBook): Uint8Array { + const out = XLSX.write(wb, { type: "array", bookType: "xlsx" }); + const bytes = out instanceof Uint8Array ? out : new Uint8Array(out); + return bytes.byteOffset === 0 && bytes.byteLength === bytes.buffer.byteLength + ? bytes + : bytes.slice(); +} + +export function buildBudgetTemplate(): Uint8Array { + const wb = XLSX.utils.book_new(); + const instructions = [ + ["Plantilla de presupuesto — Panel de proyectos"], + [""], + ["1. Llene la hoja PRESUPUESTO. No cambie los nombres de las columnas."], + ["2. WBS es el árbol: 1 = lote, 1.01 = capítulo, 1.01.01 = concepto."], + ["3. TIPO: lote, capitulo o partida para agrupadores; concepto para renglones con unidad y precio."], + ["4. El padre se infiere del WBS (1.01.01 cuelga de 1.01). No capture importes, IVA ni totales."], + ["5. Una fila = un nodo. Ponga la descripción completa en una sola celda."], + ["6. También se acepta un Excel Opus/Neodata (Código, Concepto, Unidad, Cantidad, P. Unitario) o uno plano con CLAVE y DESCRIPCION."], + ]; + XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet(instructions), "INSTRUCCIONES"); + const sheet = [ + [...BUDGET_COLUMNS], + ["1", "lote", "A", "LOCAL L224", "", "", ""], + ["1.01", "capitulo", "A001", "Preliminares y albañilería", "", "", ""], + ["1.01.01", "concepto", "10301-001", "Trazo y nivelación manual para establecer ejes, banco de nivel y referencias.", "M2", 61.9, 15.53], + ["1.01.02", "concepto", "10606-002", "Firme de 5 cm acabado común, de concreto F'c= 150 kg/cm2.", "M2", 61.9, 342.55], + ["1.02", "capitulo", "A002", "Instalación hidrosanitaria", "", "", ""], + ["1.02.01", "concepto", "11402-2996C200MX.020", "Inodoro alargado de una pieza, cerámica porcelanizada.", "PZA", 1, 6422.87], + ["1.02.02", "concepto", "11403-E928-1.9", "Monomando de lavabo, negro mate.", "PZA", 2, 4272.4], + ]; + const ws = XLSX.utils.aoa_to_sheet(sheet); + ws["!cols"] = [{ wch: 10 }, { wch: 12 }, { wch: 24 }, { wch: 70 }, { wch: 10 }, { wch: 12 }, { wch: 14 }]; + XLSX.utils.book_append_sheet(wb, ws, "PRESUPUESTO"); + return xlsxBytes(wb); +} + +type ExportChapter = { id: number; parent_id: number | null; code: string; name: string; wbs: string; sort_order: number }; +type ExportItem = { + chapter_id?: number | null; + wbs?: string; + code: string; + description: string; + unit: string; + quantity: number; + unit_price: number; +}; + +export function exportBudgetWorkbook( + projectName: string, + budget: { tree: BudgetTreeNode[]; chapters?: ExportChapter[]; items: ExportItem[] }, +): Uint8Array { + const wb = XLSX.utils.book_new(); + const rows: unknown[][] = [ + ["Obra", projectName], + [], + [...BUDGET_COLUMNS], + ]; + const itemsByChapter = new Map(); + for (const item of budget.items) { + const id = Number(item.chapter_id || 0); + const list = itemsByChapter.get(id) || []; + list.push(item); + itemsByChapter.set(id, list); + } + + function walk(nodes: BudgetTreeNode[], depth: number) { + for (const node of nodes) { + const tipo = depth <= 0 ? "lote" : depth === 1 ? "capitulo" : "partida"; + rows.push([node.wbs || "", tipo, node.code || "", node.name, "", "", ""]); + for (const item of itemsByChapter.get(node.id) || []) { + rows.push([ + item.wbs || "", + "concepto", + item.code, + item.description, + item.unit, + item.quantity, + item.unit_price, + ]); + } + if (node.children?.length) walk(node.children, depth + 1); + } + } + walk(budget.tree, 0); + const unassigned = itemsByChapter.get(0) || []; + for (const item of unassigned) { + rows.push([item.wbs || "", "concepto", item.code, item.description, item.unit, item.quantity, item.unit_price]); + } + const ws = XLSX.utils.aoa_to_sheet(rows); + ws["!cols"] = [{ wch: 10 }, { wch: 12 }, { wch: 24 }, { wch: 70 }, { wch: 10 }, { wch: 12 }, { wch: 14 }]; + XLSX.utils.book_append_sheet(wb, ws, "PRESUPUESTO"); + return xlsxBytes(wb); +} + +type ChapterRow = { + id: number; + project_id: number; + parent_id: number | null; + code: string; + name: string; + wbs: string; + sort_order: number; +}; + +function compareWbs(a: string, b: string) { + const as = a.split(".").map(Number); + const bs = b.split(".").map(Number); + const n = Math.max(as.length, bs.length); + for (let i = 0; i < n; i++) { + const d = (as[i] || 0) - (bs[i] || 0); + if (d) return d; + } + return a.localeCompare(b, undefined, { numeric: true }); +} + +export function listBudget(database: Database, projectId: number) { + const chapterCols = (database.prepare("PRAGMA table_info(budget_chapters)").all() as { name: string }[]).map((c) => c.name); + const wbsExpr = chapterCols.includes("wbs") ? "COALESCE(c.wbs, '') AS wbs" : "'' AS wbs"; + const chapters = database.prepare( + `SELECT c.id, c.project_id, c.parent_id, c.code, c.name, ${wbsExpr}, c.sort_order + FROM budget_chapters c WHERE c.project_id = ? ORDER BY c.sort_order, c.id`, + ).all(projectId) as ChapterRow[]; + const rawItems = database.prepare( + `SELECT i.*, ch.name AS chapter_name, ch.code AS chapter_code + FROM budget_items i + LEFT JOIN budget_chapters ch ON ch.id = i.chapter_id + WHERE i.project_id = ? + ORDER BY COALESCE(ch.sort_order, 9999), i.sort_order, i.id`, + ).all(projectId) as (BudgetItemRow & { chapter_name: string | null; chapter_code: string | null })[]; + + const byParent = new Map(); + const byId = new Map(); + for (const chapter of chapters) { + byId.set(chapter.id, chapter); + const key = chapter.parent_id || null; + const list = byParent.get(key) || []; + list.push(chapter); + byParent.set(key, list); + } + + function ancestorsOf(id: number | null): ChapterRow[] { + const chain: ChapterRow[] = []; + let cur = id ? byId.get(id) : undefined; + const seen = new Set(); + while (cur && !seen.has(cur.id)) { + seen.add(cur.id); + chain.unshift(cur); + cur = cur.parent_id ? byId.get(cur.parent_id) : undefined; + } + return chain; + } + + function descendantIds(id: number): number[] { + const ids = [id]; + for (const child of byParent.get(id) || []) ids.push(...descendantIds(child.id)); + return ids; + } + + const amounts = new Map(); + for (const chapter of chapters) { + const ids = new Set(descendantIds(chapter.id)); + const owned = rawItems.filter((item) => item.chapter_id && ids.has(Number(item.chapter_id))); + amounts.set(chapter.id, { + amount: Math.round(owned.reduce((sum, item) => sum + Number(item.amount || 0), 0) * 100) / 100, + item_count: owned.length, + }); + } + + function toTree(nodes: ChapterRow[]): BudgetTreeNode[] { + return nodes.map((chapter) => { + const stats = amounts.get(chapter.id) || { amount: 0, item_count: 0 }; + return { + id: chapter.id, + parent_id: chapter.parent_id, + code: chapter.code || "", + name: chapter.name, + wbs: chapter.wbs || "", + amount: stats.amount, + item_count: stats.item_count, + children: toTree(byParent.get(chapter.id) || []), + }; + }); + } + + const tree = toTree(byParent.get(null) || chapters.filter((chapter) => !chapter.parent_id)); + const items: BudgetItemRow[] = rawItems.map((item) => { + const chain = ancestorsOf(item.chapter_id); + return { + ...item, + wbs: item.wbs || "", + parent_path: chain.map((chapter) => [chapter.code, chapter.name].filter(Boolean).join(" ").trim()).join(" > "), + ancestor_ids: chain.map((chapter) => chapter.id), + }; + }); + + const subtotal = items.reduce((sum, item) => sum + Number(item.amount || 0), 0); + const iva = Math.round(subtotal * BUDGET_IVA * 100) / 100; + const chapterView = chapters.map((chapter) => { + const stats = amounts.get(chapter.id) || { amount: 0, item_count: 0 }; + const chain = ancestorsOf(chapter.id); + return { + ...chapter, + ...stats, + path: chain.map((node) => [node.code, node.name].filter(Boolean).join(" ").trim()).join(" > "), + }; + }); + + return { + tree, + chapters: chapterView, + items, + totals: { + subtotal: Math.round(subtotal * 100) / 100, + iva, + total: Math.round((subtotal + iva) * 100) / 100, + item_count: items.length, + }, + }; +} + +function insertChapter( + database: Database, + projectId: number, + parentId: number | null, + code: string, + name: string, + wbs: string, + sort: number, +) { + const cols = (database.prepare("PRAGMA table_info(budget_chapters)").all() as { name: string }[]).map((c) => c.name); + if (cols.includes("wbs")) { + database.prepare( + "INSERT INTO budget_chapters (project_id, parent_id, code, name, wbs, sort_order) VALUES (?, ?, ?, ?, ?, ?)", + ).run(projectId, parentId, code, name, wbs, sort); + } else { + database.prepare( + "INSERT INTO budget_chapters (project_id, parent_id, code, name, sort_order) VALUES (?, ?, ?, ?, ?)", + ).run(projectId, parentId, code, name, sort); + } + return lastId(database); +} + +export function replaceBudgetFromParsed(database: Database, projectId: number, parsed: ParsedBudget) { + database.prepare("DELETE FROM budget_items WHERE project_id = ?").run(projectId); + database.prepare("DELETE FROM budget_chapters WHERE project_id = ?").run(projectId); + const groups = [...parsed.groups].sort((a, b) => compareWbs(a.wbs, b.wbs)); + const ids = new Map(); + let sort = 1; + for (const group of groups) { + const parentId = group.parentWbs ? ids.get(group.parentWbs) || null : null; + const id = insertChapter(database, projectId, parentId, group.code, group.name, group.wbs, sort); + ids.set(group.wbs, id); + sort++; + } + const itemCols = (database.prepare("PRAGMA table_info(budget_items)").all() as { name: string }[]).map((c) => c.name); + const hasWbs = itemCols.includes("wbs"); + let itemSort = 1; + for (const item of parsed.items) { + const chapterId = item.parentWbs ? ids.get(item.parentWbs) || null : null; + const amount = Math.round((item.quantity * item.unit_price || item.amount) * 100) / 100; + if (hasWbs) { + database.prepare( + `INSERT INTO budget_items + (project_id, chapter_id, code, description, unit, quantity, unit_price, amount, sort_order, wbs) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ).run(projectId, chapterId, item.code, item.description, item.unit, item.quantity, item.unit_price, amount, itemSort, item.wbs); + } else { + database.prepare( + `INSERT INTO budget_items + (project_id, chapter_id, code, description, unit, quantity, unit_price, amount, sort_order) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ).run(projectId, chapterId, item.code, item.description, item.unit, item.quantity, item.unit_price, amount, itemSort); + } + itemSort++; + } +} + +export function replaceBudgetFromItems(database: Database, projectId: number, parsed: ParsedBudgetItem[]) { + const groups: ParsedGroup[] = []; + const seen = new Set(); + for (const item of parsed) { + const wbs = item.parentWbs || "1"; + if (seen.has(wbs)) continue; + seen.add(wbs); + groups.push({ + kind: "capitulo", + wbs, + parentWbs: parentOfWbs(wbs), + code: item.chapterCode || "", + name: item.chapter || "General", + }); + } + replaceBudgetFromParsed(database, projectId, { format: "flat", groups, items: parsed, skipped: 0 }); +} + +export function lineAmount(quantity: number, unitPrice: number) { + return Math.round(quantity * unitPrice * 100) / 100; +} + +const FORMAT_LABEL: Record = { + wbs: "Plantilla del panel", + opus: "Opus / Neodata", + flat: "Excel plano", + none: "No reconocido", +}; + +function readBudgetSheet(bytes: Uint8Array): { sheet: string; rows: unknown[][] } | { error: string } { + const wb = XLSX.read(bytes, { type: "array" }); + const sheet = wb.SheetNames.find((name) => /presupuesto/i.test(name)) || wb.SheetNames[0]; + if (!sheet) return { error: "El archivo no tiene hojas" }; + return { sheet, rows: XLSX.utils.sheet_to_json(wb.Sheets[sheet], { header: 1, defval: "" }) as unknown[][] }; +} + +function previewTreeOf(parsed: ParsedBudget): BudgetPreviewNode[] { + const byParent = new Map(); + for (const group of parsed.groups) { + const key = group.parentWbs || ""; + const list = byParent.get(key) || []; + list.push(group); + byParent.set(key, list); + } + function stats(wbs: string): { amount: number; item_count: number } { + const direct = parsed.items.filter((item) => item.parentWbs === wbs); + const children = byParent.get(wbs) || []; + const childStats = children.map((child) => stats(child.wbs)); + return { + amount: Math.round( + (direct.reduce((sum, item) => sum + Number(item.amount || 0), 0) + + childStats.reduce((sum, child) => sum + child.amount, 0)) * 100, + ) / 100, + item_count: direct.length + childStats.reduce((sum, child) => sum + child.item_count, 0), + }; + } + function node(group: ParsedGroup): BudgetPreviewNode { + const roll = stats(group.wbs); + return { + wbs: group.wbs, + code: group.code, + name: group.name, + kind: group.kind, + amount: roll.amount, + item_count: roll.item_count, + children: (byParent.get(group.wbs) || []).map(node), + }; + } + const roots = byParent.get("") || parsed.groups.filter((group) => !group.parentWbs); + return roots.map(node); +} + +export function previewBudgetExcel(bytes: Uint8Array): BudgetPreview { + const read = readBudgetSheet(bytes); + if ("error" in read) { + return { + format: "none", + format_label: FORMAT_LABEL.none, + sheet: "", + tree: [], + items: [], + totals: { subtotal: 0, iva: 0, total: 0, item_count: 0 }, + skipped: 0, + errors: [{ row: 0, messages: [read.error] }], + }; + } + const parsed = parseBudgetRows(read.rows); + const subtotal = Math.round(parsed.items.reduce((sum, item) => sum + Number(item.amount || 0), 0) * 100) / 100; + const iva = Math.round(subtotal * BUDGET_IVA * 100) / 100; + const errors = parsed.items.length + ? [] + : [{ row: 0, messages: ["No se encontraron partidas. Revise que el Excel tenga Código/Concepto o la plantilla WBS."] }]; + return { + format: parsed.format, + format_label: FORMAT_LABEL[parsed.format], + sheet: read.sheet, + tree: previewTreeOf(parsed), + items: parsed.items.map((item) => ({ + code: item.code, + description: item.description, + unit: item.unit, + quantity: item.quantity, + unit_price: item.unit_price, + amount: item.amount, + chapter: item.chapter, + parentWbs: item.parentWbs, + })), + totals: { + subtotal, + iva, + total: Math.round((subtotal + iva) * 100) / 100, + item_count: parsed.items.length, + }, + skipped: parsed.skipped, + errors, + }; +} + +export function importBudgetExcel(database: Database, projectId: number, bytes: Uint8Array): BudgetImportReport { + const read = readBudgetSheet(bytes); + if ("error" in read) { + return { replaced: false, chapters: 0, inserted: 0, skipped: 0, errors: [{ row: 0, messages: [read.error] }] }; + } + const parsed = parseBudgetRows(read.rows); + if (!parsed.items.length) { + return { + replaced: false, + chapters: 0, + inserted: 0, + skipped: parsed.skipped, + errors: [{ row: 0, messages: ["No se encontraron partidas. Use la plantilla o un Excel con CLAVE, DESCRIPCION, UNIDAD, CANTIDAD y PRECIO."] }], + }; + } + replaceBudgetFromParsed(database, projectId, parsed); + const subtotal = Math.round(parsed.items.reduce((sum, item) => sum + Number(item.amount || 0), 0) * 100) / 100; + const iva = Math.round(subtotal * BUDGET_IVA * 100) / 100; + const totals = { + subtotal, + iva, + total: Math.round((subtotal + iva) * 100) / 100, + item_count: parsed.items.length, + }; + // El monto de contrato del proyecto refleja el subtotal del presupuesto importado. + database.prepare("UPDATE projects SET contract_amount = ? WHERE id = ?").run(subtotal, projectId); + return { + replaced: true, + chapters: parsed.groups.length, + inserted: parsed.items.length, + skipped: parsed.skipped, + errors: [], + totals, + contract_amount: subtotal, + }; +} diff --git a/api/budget_test.ts b/api/budget_test.ts new file mode 100644 index 0000000..b728114 --- /dev/null +++ b/api/budget_test.ts @@ -0,0 +1,98 @@ +import { assertEquals, assertStringIncludes } from "jsr:@std/assert@1"; +import * as XLSX from "xlsx"; +import { buildBudgetTemplate, parseBudgetRows, parseMoney, previewBudgetExcel } from "./budget.ts"; + +Deno.test("parsea partidas estilo CLAVE DESCRIPCION UNIDAD", () => { + const parsed = parseBudgetRows([ + ["Obra:", "Barda perimetral"], + ["BARDAS PERIMETRALES", "", "", "", "", ""], + ["CLAVE", "DESCRIPCION", "UNIDAD", "CANTIDAD", "PRECIO U", "IMPORTE"], + ["PRE01", "TRAZO Y NIVELACION", "ml", 92, 10, 920], + ["EXC-001", "EXCAVACION", "M3", 8, 850, 6800], + ["", "", "", "", "SUB TOTAL", 398545.97], + ["", "", "", "", "16% IVA", 63767.36], + ["", "", "", "", "TOTAL", 462313.33], + ]); + assertEquals(parsed.items.length, 2); + assertEquals(parsed.items[0].code, "PRE01"); + assertEquals(parsed.items[0].quantity, 92); + assertEquals(parsed.items[0].amount, 920); + assertEquals(parsed.items[0].chapter, "BARDAS PERIMETRALES"); + assertEquals(parsed.items[1].code, "EXC-001"); +}); + +Deno.test("parsea plantilla con columna CAPITULO", () => { + const parsed = parseBudgetRows([ + ["CAPITULO", "CLAVE", "DESCRIPCION", "UNIDAD", "CANTIDAD", "PRECIO", "IMPORTE"], + ["Acabados", "ACA01", "Aplanado", "M2", 10, 20, 200], + ["Cimentacion", "CIM01", "Zapata", "PZA", 2, 100, 200], + ]); + assertEquals(parsed.items.map((item) => item.chapter), ["Acabados", "Cimentacion"]); + assertEquals(parsed.items[0].amount, 200); +}); + +Deno.test("lee importes con coma o signo", () => { + assertEquals(parseMoney("$ 1,500.00"), 1500); + assertEquals(parseMoney("201.47"), 201.47); +}); + +Deno.test("parsea Opus/Neodata: agrupadores, continuaciones y totales ignorados", () => { + const parsed = parseBudgetRows([ + ["Código", "Concepto", "Unidad", "Cantidad", "P. Unitario", "Importe", "%"], + ["A", "LOCAL L224", "", "", "", "", ""], + ["A001", "PRELIMINARES Y ALBAÑILERÍA", "", "", "", "", ""], + ["10301-001", "Trazo y nivelación manual para establecer ejes, banco", "M2", 61.9, 15.53, 961.31, 0.000847], + ["", "de nivel y referencias, incluye: materiales, mano de", "", 0, 0, 0, 0], + ["", "obra, equipo y herramienta.", "", 0, 0, 0, 0], + ["A001", "TOTAL PRELIMINARES Y ALBAÑILERÍA", "", "", "", 123219.19, 0.1], + ["", "IVA 16.00%", "", "", "", 181433.44, ""], + ["", "TOTAL DEL PRESUPUESTO MOSTRADO:", "", "", "", 1315392.47, ""], + ]); + assertEquals(parsed.items.length, 1); + assertEquals(parsed.groups.map((group) => group.code), ["A", "A001"]); + assertEquals(parsed.groups[0].kind, "lote"); + assertEquals(parsed.groups[1].parentWbs, parsed.groups[0].wbs); + assertEquals(parsed.items[0].code, "10301-001"); + assertEquals(parsed.items[0].chapter, "PRELIMINARES Y ALBAÑILERÍA"); + assertEquals(parsed.items[0].parentWbs, parsed.groups[1].wbs); + assertStringIncludes(parsed.items[0].description, "Trazo y nivelación manual"); + assertStringIncludes(parsed.items[0].description, "banco de nivel y referencias"); + assertStringIncludes(parsed.items[0].description, "equipo y herramienta"); +}); + +Deno.test("parsea plantilla WBS lote-capitulo-concepto", () => { + const parsed = parseBudgetRows([ + ["WBS", "TIPO", "CODIGO", "DESCRIPCION", "UNIDAD", "CANTIDAD", "PRECIO"], + ["1", "lote", "A", "LOCAL L224", "", "", ""], + ["1.01", "capitulo", "A001", "Preliminares y albañilería", "", "", ""], + ["1.01.01", "concepto", "10301-001", "Trazo y nivelación manual", "M2", 61.9, 15.53], + ["1.02", "capitulo", "A002", "Instalación hidrosanitaria", "", "", ""], + ["1.02.01", "concepto", "11402-001", "Inodoro", "PZA", 1, 100], + ]); + assertEquals(parsed.groups.length, 3); + assertEquals(parsed.groups[0].kind, "lote"); + assertEquals(parsed.groups[1].parentWbs, "1"); + assertEquals(parsed.items.length, 2); + assertEquals(parsed.items[0].parentWbs, "1.01"); + assertEquals(parsed.items[0].chapter, "Preliminares y albañilería"); + assertEquals(parsed.items[1].parentWbs, "1.02"); + assertEquals(parsed.items[0].amount, 961.31); +}); + +Deno.test("la plantilla es un xlsx valido", () => { + const bytes = buildBudgetTemplate(); + assertEquals(bytes[0], 0x50); + assertEquals(bytes[1], 0x4b); + const wb = XLSX.read(bytes, { type: "array" }); + assertEquals(wb.SheetNames.includes("PRESUPUESTO"), true); + assertEquals(wb.SheetNames.includes("INSTRUCCIONES"), true); +}); + +Deno.test("preview de plantilla no guarda y arma arbol", () => { + const preview = previewBudgetExcel(buildBudgetTemplate()); + assertEquals(preview.format, "wbs"); + assertEquals(preview.errors.length, 0); + assertEquals(preview.tree[0]?.code, "A"); + assertEquals(preview.tree[0]?.children.length, 2); + assertEquals(preview.totals.item_count, 4); +}); diff --git a/api/companies.ts b/api/companies.ts new file mode 100644 index 0000000..c7cda4b --- /dev/null +++ b/api/companies.ts @@ -0,0 +1,334 @@ +import type { Database } from "@db/sqlite"; + +function lastId(database: Database): number { + return Number((database.prepare("SELECT last_insert_rowid() AS id").get() as { id: number }).id); +} + +export type Company = { + id: number; + code: string; + name: string; + parent_id: number | null; + kind: "principal" | "sub"; + status: "activo" | "inactivo"; + tenant_id?: number | null; + registro_patronal?: string; + razon_social?: string; + nombre_comercial?: string; + rfc?: string; + regimen_fiscal?: string; + clase_riesgo?: string; + domicilio_fiscal?: string; + codigo_postal?: string; + ciudad?: string; + estado?: string; + telefono?: string; + email?: string; + representante_legal?: string; + giro?: string; + created_at?: string; + parent_code?: string | null; + parent_name?: string | null; + worker_count?: number; +}; + +export type CompanyProfileInput = { + name?: string; + code?: string; + status?: string; + parent_id?: number | null; + registro_patronal?: string; + razon_social?: string; + nombre_comercial?: string; + rfc?: string; + regimen_fiscal?: string; + clase_riesgo?: string; + domicilio_fiscal?: string; + codigo_postal?: string; + ciudad?: string; + estado?: string; + telefono?: string; + email?: string; + representante_legal?: string; + giro?: string; +}; + +const CODE_RE = /^[A-Z0-9][A-Z0-9_-]{1,15}$/; +const RFC_RE = /^[A-ZÑ&]{3,4}\d{6}[A-Z0-9]{3}$/; + +function trimText(value: unknown): string { + return (value ?? "").toString().trim(); +} + +function normRfc(value: unknown): string { + return trimText(value).toUpperCase().replace(/\s+/g, ""); +} + +export function listCompanies(database: Database, tenantId?: number | null): Company[] { + const where = tenantId != null ? "WHERE c.tenant_id = ?" : ""; + const params = tenantId != null ? [tenantId] : []; + return database.prepare( + `SELECT c.*, p.code AS parent_code, p.name AS parent_name, + (SELECT COUNT(*) FROM workers w WHERE w.company_id = c.id) AS worker_count + FROM companies c + LEFT JOIN companies p ON p.id = c.parent_id + ${where} + ORDER BY CASE c.kind WHEN 'principal' THEN 0 ELSE 1 END, c.name COLLATE NOCASE`, + ).all(...params) as Company[]; +} + +export function companyById(database: Database, id: number): Company | undefined { + return database.prepare("SELECT * FROM companies WHERE id = ?").get(id) as Company | undefined; +} + +export function companyByCode(database: Database, code: string): Company | undefined { + return database.prepare("SELECT * FROM companies WHERE code = ?").get(normCompanyCode(code)) as + | Company + | undefined; +} + +export function principalCompany(database: Database, tenantId?: number | null): Company | undefined { + if (tenantId != null) { + return database.prepare( + "SELECT * FROM companies WHERE kind = 'principal' AND tenant_id = ? ORDER BY id LIMIT 1", + ).get(tenantId) as Company | undefined; + } + return database.prepare( + "SELECT * FROM companies WHERE kind = 'principal' ORDER BY id LIMIT 1", + ).get() as Company | undefined; +} + +export function normCompanyCode(value: string | null | undefined): string { + return (value ?? "").toString().trim().toUpperCase().replace(/\s+/g, ""); +} + +export function companyCodeFromName(name: string): string { + const slug = name + .normalize("NFD") + .replace(/\p{M}/gu, "") + .toUpperCase() + .replace(/[^A-Z0-9]+/g, "") + .slice(0, 12); + return slug || "EMP"; +} + +export function nextCompanyCode(database: Database, name: string): string { + const base = companyCodeFromName(name); + if (!companyByCode(database, base)) return base; + const row = database.prepare( + `SELECT COALESCE(MAX(CAST(substr(code, 5) AS INTEGER)), 0) + 1 AS n + FROM companies WHERE code GLOB 'EMP-[0-9][0-9][0-9][0-9]*'`, + ).get() as { n: number }; + return `EMP-${String(row.n).padStart(4, "0")}`; +} + +export function resolveCompany( + database: Database, + body: { company_id?: number | null; hire_type?: string | null }, +): Company | undefined { + if (body.company_id) { + const byId = companyById(database, Number(body.company_id)); + if (byId) return byId; + } + const code = normCompanyCode(body.hire_type); + if (code) return companyByCode(database, code); + return undefined; +} + +export function validateCompanyCode(code: string): string | null { + if (!CODE_RE.test(code)) { + return "Código de 2 a 16 caracteres (letras, números, _ o -)"; + } + return null; +} + +export function validateCompanyProfile(input: CompanyProfileInput, opts: { requireLegal?: boolean } = {}): string | null { + const rfc = normRfc(input.rfc); + if (rfc && !RFC_RE.test(rfc)) { + return "RFC inválido (formato mexicano de 12 o 13 caracteres)"; + } + if (opts.requireLegal) { + if (!trimText(input.razon_social) && !trimText(input.name) && !trimText(input.nombre_comercial)) { + return "Indique razón social o nombre comercial"; + } + } + const clase = trimText(input.clase_riesgo).toUpperCase(); + if (clase && !["I", "II", "III", "IV", "V"].includes(clase)) { + return "Clase de riesgo IMSS debe ser I, II, III, IV o V"; + } + return null; +} + +export function normalizeCompanyProfile(input: CompanyProfileInput, fallbackName = "") { + const nombreComercial = trimText(input.nombre_comercial) || trimText(input.name) || fallbackName; + const razonSocial = trimText(input.razon_social) || nombreComercial || fallbackName; + const name = trimText(input.name) || nombreComercial || razonSocial || fallbackName; + return { + name, + nombre_comercial: nombreComercial, + razon_social: razonSocial, + rfc: normRfc(input.rfc), + regimen_fiscal: trimText(input.regimen_fiscal), + registro_patronal: trimText(input.registro_patronal).toUpperCase(), + clase_riesgo: trimText(input.clase_riesgo).toUpperCase(), + domicilio_fiscal: trimText(input.domicilio_fiscal), + codigo_postal: trimText(input.codigo_postal), + ciudad: trimText(input.ciudad), + estado: trimText(input.estado), + telefono: trimText(input.telefono), + email: trimText(input.email).toLowerCase(), + representante_legal: trimText(input.representante_legal), + giro: trimText(input.giro), + }; +} + +function validateProfile(input: CompanyProfileInput, opts: { requireLegal?: boolean } = {}): string | null { + return validateCompanyProfile(input, opts); +} + +function profileFromInput(input: CompanyProfileInput, fallbackName = "") { + return normalizeCompanyProfile(input, fallbackName); +} + +export function createSubcompany( + database: Database, + input: CompanyProfileInput, + tenantId?: number | null, +): { company?: Company; error?: string } { + const profileErr = validateProfile(input, { requireLegal: true }); + if (profileErr) return { error: profileErr }; + const profile = profileFromInput(input); + if (!profile.name) return { error: "Nombre de empresa obligatorio" }; + + const principal = principalCompany(database, tenantId); + if (!principal) return { error: "No hay empresa principal" }; + const parentId = input.parent_id ? Number(input.parent_id) : principal.id; + const parent = companyById(database, parentId); + if (!parent) return { error: "Empresa padre no encontrada" }; + if (tenantId != null && parent.tenant_id != null && parent.tenant_id !== tenantId) { + return { error: "Empresa padre de otro tenant" }; + } + + let code = normCompanyCode(input.code); + if (!code) code = nextCompanyCode(database, profile.name); + const codeErr = validateCompanyCode(code); + if (codeErr) return { error: codeErr }; + if (companyByCode(database, code)) return { error: "Ya existe una empresa con ese código" }; + + const tid = tenantId ?? principal.tenant_id ?? null; + database.prepare( + `INSERT INTO companies ( + code, name, parent_id, kind, status, tenant_id, + registro_patronal, razon_social, nombre_comercial, rfc, regimen_fiscal, clase_riesgo, + domicilio_fiscal, codigo_postal, ciudad, estado, telefono, email, representante_legal, giro + ) VALUES (?, ?, ?, 'sub', 'activo', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ).run( + code, + profile.name, + parent.id, + tid, + profile.registro_patronal, + profile.razon_social, + profile.nombre_comercial, + profile.rfc, + profile.regimen_fiscal, + profile.clase_riesgo, + profile.domicilio_fiscal, + profile.codigo_postal, + profile.ciudad, + profile.estado, + profile.telefono, + profile.email, + profile.representante_legal, + profile.giro, + ); + return { company: companyById(database, lastId(database)) }; +} + +export function updateCompany( + database: Database, + id: number, + input: CompanyProfileInput, +): { company?: Company; error?: string; status?: 400 | 404 } { + const current = companyById(database, id); + if (!current) return { error: "Empresa no encontrada", status: 404 }; + + const profileErr = validateProfile(input); + if (profileErr) return { error: profileErr, status: 400 }; + + const merged: CompanyProfileInput = { + name: input.name !== undefined ? input.name : current.name, + code: input.code !== undefined ? input.code : current.code, + status: input.status !== undefined ? input.status : current.status, + registro_patronal: input.registro_patronal !== undefined ? input.registro_patronal : current.registro_patronal, + razon_social: input.razon_social !== undefined ? input.razon_social : current.razon_social, + nombre_comercial: input.nombre_comercial !== undefined ? input.nombre_comercial : current.nombre_comercial, + rfc: input.rfc !== undefined ? input.rfc : current.rfc, + regimen_fiscal: input.regimen_fiscal !== undefined ? input.regimen_fiscal : current.regimen_fiscal, + clase_riesgo: input.clase_riesgo !== undefined ? input.clase_riesgo : current.clase_riesgo, + domicilio_fiscal: input.domicilio_fiscal !== undefined ? input.domicilio_fiscal : current.domicilio_fiscal, + codigo_postal: input.codigo_postal !== undefined ? input.codigo_postal : current.codigo_postal, + ciudad: input.ciudad !== undefined ? input.ciudad : current.ciudad, + estado: input.estado !== undefined ? input.estado : current.estado, + telefono: input.telefono !== undefined ? input.telefono : current.telefono, + email: input.email !== undefined ? input.email : current.email, + representante_legal: input.representante_legal !== undefined + ? input.representante_legal + : current.representante_legal, + giro: input.giro !== undefined ? input.giro : current.giro, + }; + const profile = profileFromInput(merged, current.name); + if (!profile.name) return { error: "Nombre de empresa obligatorio", status: 400 }; + + let code = current.code; + if (input.code !== undefined) { + code = normCompanyCode(input.code); + const codeErr = validateCompanyCode(code); + if (codeErr) return { error: codeErr, status: 400 }; + const clash = companyByCode(database, code); + if (clash && clash.id !== id) return { error: "Ya existe una empresa con ese código", status: 400 }; + } + + let status = current.status; + if (input.status !== undefined) { + if (!["activo", "inactivo"].includes(input.status)) { + return { error: "Estatus debe ser activo o inactivo", status: 400 }; + } + if (current.kind === "principal" && input.status === "inactivo") { + return { error: "La empresa principal no se puede inactivar", status: 400 }; + } + status = input.status as Company["status"]; + } + + database.prepare( + `UPDATE companies SET + name = ?, code = ?, status = ?, + registro_patronal = ?, razon_social = ?, nombre_comercial = ?, rfc = ?, regimen_fiscal = ?, clase_riesgo = ?, + domicilio_fiscal = ?, codigo_postal = ?, ciudad = ?, estado = ?, telefono = ?, email = ?, + representante_legal = ?, giro = ? + WHERE id = ?`, + ).run( + profile.name, + code, + status, + profile.registro_patronal, + profile.razon_social, + profile.nombre_comercial, + profile.rfc, + profile.regimen_fiscal, + profile.clase_riesgo, + profile.domicilio_fiscal, + profile.codigo_postal, + profile.ciudad, + profile.estado, + profile.telefono, + profile.email, + profile.representante_legal, + profile.giro, + id, + ); + if (code !== current.code) { + database.prepare("UPDATE workers SET hire_type = ? WHERE company_id = ?").run(code, id); + } + return { company: companyById(database, id) }; +} diff --git a/api/companies_test.ts b/api/companies_test.ts new file mode 100644 index 0000000..5485f4f --- /dev/null +++ b/api/companies_test.ts @@ -0,0 +1,10 @@ +import { assertEquals } from "jsr:@std/assert@1"; +import { companyCodeFromName, normCompanyCode, validateCompanyCode } from "./companies.ts"; + +Deno.test("normaliza código de empresa", () => { + assertEquals(normCompanyCode(" maya "), "MAYA"); + assertEquals(companyCodeFromName("Constructora Maya"), "CONSTRUCTORA"); + assertEquals(companyCodeFromName(" "), "EMP"); + assertEquals(validateCompanyCode("MAYA"), null); + assertEquals(typeof validateCompanyCode("x"), "string"); +}); diff --git a/api/config.ts b/api/config.ts new file mode 100644 index 0000000..b636731 --- /dev/null +++ b/api/config.ts @@ -0,0 +1,33 @@ +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const here = dirname(fileURLToPath(import.meta.url)); +export const ROOT = join(here, ".."); +export const DATA_DIR = join(ROOT, "data"); +export const DB_PATH = join(DATA_DIR, "app.db"); +export const PLATFORM_DB_PATH = join(DATA_DIR, "platform.db"); +export const EXPEDIENTES_DIR = join(DATA_DIR, "expedientes"); +export const PDFS_DIR = join(DATA_DIR, "pdfs"); +export const LOGOS_DIR = join(DATA_DIR, "logos"); +export const PROJECTS_DIR = join(DATA_DIR, "proyectos"); + +export const config = { + port: Number(Deno.env.get("PORT") ?? "8000"), + sessionSecret: Deno.env.get("SESSION_SECRET") ?? "dev-session-secret-change-me", + apiKey: Deno.env.get("API_KEY") ?? "", + docsKeyHex: Deno.env.get("DOCS_KEY") ?? + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + /** Solo vía env. Compose exige SEED_PASSWORD; no hay default en código. */ + seedPassword: Deno.env.get("SEED_PASSWORD") ?? "", + vcardBase: Deno.env.get("VCARD_BASE") ?? "https://vcard.arctec.com.mx?info=", + panelLoginUrl: Deno.env.get("PANEL_LOGIN_URL") ?? "http://localhost:3000/login", + smtpHost: Deno.env.get("SMTP_HOST") ?? "", + smtpPort: Number(Deno.env.get("SMTP_PORT") ?? "587"), + smtpUser: Deno.env.get("SMTP_USER") ?? "", + smtpPass: Deno.env.get("SMTP_PASS") ?? "", + smtpFrom: Deno.env.get("SMTP_FROM") ?? "PANELS ", + /** En HTTPS (Coolify) poner COOKIE_SECURE=true */ + cookieSecure: ["1", "true", "yes"].includes( + (Deno.env.get("COOKIE_SECURE") ?? "").toLowerCase(), + ), +}; diff --git a/api/crypto.ts b/api/crypto.ts new file mode 100644 index 0000000..259dec4 --- /dev/null +++ b/api/crypto.ts @@ -0,0 +1,109 @@ +const encoder = new TextEncoder(); +const decoder = new TextDecoder(); + +function toHex(buf: ArrayBuffer | Uint8Array): string { + const u8 = buf instanceof Uint8Array ? buf : new Uint8Array(buf); + return [...u8].map((b) => b.toString(16).padStart(2, "0")).join(""); +} + +function fromHex(hex: string): Uint8Array { + const clean = hex.replace(/^0x/, ""); + const out = new Uint8Array(clean.length / 2); + for (let i = 0; i < out.length; i++) { + out[i] = parseInt(clean.slice(i * 2, i * 2 + 2), 16); + } + return out; +} + +export async function hashPassword(password: string): Promise { + const salt = crypto.getRandomValues(new Uint8Array(16)); + const key = await crypto.subtle.importKey( + "raw", + encoder.encode(password), + "PBKDF2", + false, + ["deriveBits"], + ); + const bits = await crypto.subtle.deriveBits( + { name: "PBKDF2", salt, iterations: 120_000, hash: "SHA-256" }, + key, + 256, + ); + return `pbkdf2$${toHex(salt)}$${toHex(bits)}`; +} + +/** Contraseña temporal segura (16 chars, sin ambiguos). */ +export function generateSecurePassword(length = 16): string { + const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789!@$%*?"; + const bytes = crypto.getRandomValues(new Uint8Array(length)); + let out = ""; + for (let i = 0; i < length; i++) out += alphabet[bytes[i]! % alphabet.length]; + return out; +} + +export async function verifyPassword(password: string, stored: string): Promise { + const parts = stored.split("$"); + if (parts.length !== 3 || parts[0] !== "pbkdf2") return false; + const salt = fromHex(parts[1]); + const expected = fromHex(parts[2]); + const key = await crypto.subtle.importKey( + "raw", + encoder.encode(password), + "PBKDF2", + false, + ["deriveBits"], + ); + const bits = new Uint8Array( + await crypto.subtle.deriveBits( + { name: "PBKDF2", salt, iterations: 120_000, hash: "SHA-256" }, + key, + 256, + ), + ); + if (bits.length !== expected.length) return false; + let diff = 0; + for (let i = 0; i < bits.length; i++) diff |= bits[i] ^ expected[i]; + return diff === 0; +} + +export async function hmacSign(secret: string, payload: string): Promise { + const key = await crypto.subtle.importKey( + "raw", + encoder.encode(secret), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"], + ); + const sig = await crypto.subtle.sign("HMAC", key, encoder.encode(payload)); + return btoa(String.fromCharCode(...new Uint8Array(sig))) + .replaceAll("+", "-") + .replaceAll("/", "_") + .replaceAll("=", ""); +} + +export async function hmacVerify(secret: string, payload: string, sig: string): Promise { + const expected = await hmacSign(secret, payload); + if (expected.length !== sig.length) return false; + let diff = 0; + for (let i = 0; i < expected.length; i++) { + diff |= expected.charCodeAt(i) ^ sig.charCodeAt(i); + } + return diff === 0; +} + +export function b64urlJson(obj: unknown): string { + const json = JSON.stringify(obj); + return btoa(json).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", ""); +} + +export function b64urlJsonParse(s: string): T { + const pad = s.replaceAll("-", "+").replaceAll("_", "/"); + const padded = pad + "=".repeat((4 - (pad.length % 4)) % 4); + return JSON.parse(atob(padded)) as T; +} + +export async function sha256Hex(data: Uint8Array): Promise { + return toHex(await crypto.subtle.digest("SHA-256", data)); +} + +export { toHex, fromHex, encoder, decoder }; diff --git a/api/db.ts b/api/db.ts new file mode 100644 index 0000000..8e2b4f7 --- /dev/null +++ b/api/db.ts @@ -0,0 +1,345 @@ +import { Database } from "@db/sqlite"; +import { mkdir } from "node:fs/promises"; +import { join } from "node:path"; +import { DATA_DIR, DB_PATH, EXPEDIENTES_DIR, PDFS_DIR, LOGOS_DIR, PROJECTS_DIR } from "./config.ts"; +import { config } from "./config.ts"; +import { hashPassword } from "./crypto.ts"; +import { DOCUMENT_TYPE_SEED, evaluateDocumentValidity, freshnessRequired, type ImssStatus, type WorkerImssContext } from "./document_validity.ts"; +import { runLiquibase } from "./liquibase.ts"; + +export type Db = Database; + +let db: Database | null = null; + +export async function getDb(): Promise { + if (db) return db; + await mkdir(DATA_DIR, { recursive: true }); + await mkdir(EXPEDIENTES_DIR, { recursive: true }); + await mkdir(PDFS_DIR, { recursive: true }); + await mkdir(LOGOS_DIR, { recursive: true }); + await mkdir(PROJECTS_DIR, { recursive: true }); + await runLiquibase(); + db = new Database(DB_PATH); + db.exec("PRAGMA foreign_keys = ON;"); + await seed(db); + return db; +} + +export function seedDocumentTypes(database: Database) { + const upsert = database.prepare( + `INSERT INTO document_types + (code, label, required, validity_mode, freshness_days, requires_issued_at, requires_expires_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(code) DO UPDATE SET + label = excluded.label, + required = excluded.required, + validity_mode = excluded.validity_mode, + freshness_days = excluded.freshness_days, + requires_issued_at = excluded.requires_issued_at, + requires_expires_at = excluded.requires_expires_at`, + ); + for (const d of DOCUMENT_TYPE_SEED) { + upsert.run( + d.code, + d.label, + d.required, + d.validity_mode, + d.freshness_days, + d.requires_issued_at, + d.requires_expires_at, + ); + } +} + +export function nextProjectCode(database: Database): string { + const row = database.prepare( + `SELECT COALESCE(MAX(CAST(substr(code, 5) AS INTEGER)), 0) + 1 AS n + FROM projects WHERE code GLOB 'PRY-[0-9][0-9][0-9][0-9]*' OR code GLOB 'OBR-[0-9][0-9][0-9][0-9]*'`, + ).get() as { n: number }; + return `PRY-${String(row.n).padStart(4, "0")}`; +} + +export const PROJECT_STATUSES = ["activo", "pausado", "concluido", "cancelado"] as const; +export type ProjectStatus = (typeof PROJECT_STATUSES)[number]; + +export const PROJECT_STATUS_CATALOG = [ + { code: "activo", label: "Activo" }, + { code: "pausado", label: "Pausado" }, + { code: "concluido", label: "Concluido" }, + { code: "cancelado", label: "Cancelado" }, +] as const; + +export function isProjectStatus(value: string): value is ProjectStatus { + return (PROJECT_STATUSES as readonly string[]).includes(value); +} + +export function projectById(database: Database, id: number) { + return database.prepare("SELECT * FROM projects WHERE id = ?").get(id) as + | { id: number; code: string; name: string; status: string } + | undefined; +} + +export function projectMustBe( + project: { status: string } | undefined, + allowed: readonly ProjectStatus[], + closedMessage: string, +): { error: string; status: 400 | 404 } | null { + if (!project) return { error: "Proyecto no encontrado", status: 404 }; + if (!allowed.includes(project.status as ProjectStatus)) { + return { error: closedMessage, status: 400 }; + } + return null; +} + +async function seed(database: Database) { + const risks = [ + ["rojo", "Rojo", "#A20000", "#FFFFFF"], + ["amarillo", "Amarillo", "#EEEE3C", "#000000"], + ["azul", "Azul", "#001485", "#FFFFFF"], + ["verde", "Verde", "#008514", "#FFFFFF"], + ["negro", "Negro", "#000000", "#FFFFFF"], + ["naranja", "Naranja", "#FF5733", "#FFFFFF"], + ]; + const insRisk = database.prepare( + "INSERT OR IGNORE INTO risk_levels (code, label, color, text_color) VALUES (?, ?, ?, ?)", + ); + for (const r of risks) insRisk.run(...r); + + database.prepare( + "INSERT OR IGNORE INTO badge_themes (id, name, layout) VALUES (?, ?, ?)", + ).run( + "arctec-dos-logos-fold", + "Arctec dos logos (doblez carta)", + "letter-landscape-4-fold", + ); + + seedDocumentTypes(database); + + const projectDocs = [ + ["contrato", "Contrato", 1], + ["presupuesto", "Presupuesto", 1], + ["planos", "Planos", 0], + ["otro", "Otro", 0], + ]; + const insPDoc = database.prepare( + "INSERT OR IGNORE INTO project_document_types (code, label, required) VALUES (?, ?, ?)", + ); + for (const d of projectDocs) insPDoc.run(...d); + + const defaultTenantId = 1; + let principal = database.prepare( + "SELECT id FROM companies WHERE kind = 'principal' AND tenant_id = ? ORDER BY id LIMIT 1", + ).get(defaultTenantId) as { id: number } | undefined; + if (!principal) { + database.prepare( + `INSERT INTO companies (code, name, parent_id, kind, tenant_id, nombre_comercial, razon_social) + VALUES ('ARCT2608', 'ARCTEC', NULL, 'principal', ?, 'ARCTEC', 'ARCTEC')`, + ).run(defaultTenantId); + principal = database.prepare( + "SELECT id FROM companies WHERE code = 'ARCT2608'", + ).get() as { id: number }; + } + for (const [code, name] of [["FISICA", "FISICA"], ["ARCOTEC", "ARCOTEC"]] as const) { + const exists = database.prepare("SELECT id FROM companies WHERE code = ?").get(code); + if (!exists) { + database.prepare( + `INSERT INTO companies (code, name, parent_id, kind, tenant_id, nombre_comercial, razon_social) + VALUES (?, ?, ?, 'sub', ?, ?, ?)`, + ).run(code, name, principal.id, defaultTenantId, name, name); + } + } + + const users = [ + ["arct2608", "Administrador"], + ]; + // Migración suave de usuarios legacy → código puro + database.prepare( + "UPDATE users SET username = 'arct2608' WHERE username IN ('papa', 'adm.arctec') AND NOT EXISTS (SELECT 1 FROM users WHERE username = 'arct2608')", + ).run(); + for (const [username, display] of users) { + const exists = database.prepare("SELECT id FROM users WHERE username = ?").get(username); + if (!exists) { + const hash = await hashPassword(config.seedPassword); + database.prepare( + `INSERT INTO users (username, password_hash, display_name, company_id, tenant_id, role) + VALUES (?, ?, ?, ?, ?, 'tenant_admin')`, + ).run(username, hash, display, principal.id, defaultTenantId); + } + } + database.prepare( + "UPDATE users SET company_id = ?, tenant_id = COALESCE(tenant_id, ?), role = COALESCE(NULLIF(role, ''), 'tenant_admin') WHERE company_id IS NULL OR tenant_id IS NULL", + ).run(principal.id, defaultTenantId); + + const proj = database.prepare("SELECT id FROM projects LIMIT 1").get(); + if (!proj) { + database.prepare( + `INSERT INTO projects (code, name, address, theme_id, company_id, tenant_id) + VALUES (?, ?, ?, ?, ?, ?)`, + ).run( + nextProjectCode(database), + "ZENDALA CANCUN", + "", + "arctec-dos-logos-fold", + principal.id, + defaultTenantId, + ); + } +} + +export function workerImssContext(database: Database, workerId: number): WorkerImssContext { + const w = database.prepare( + "SELECT imss_status, imss_alta_at, last_rehire_at FROM workers WHERE id = ?", + ).get(workerId) as { + imss_status: ImssStatus; + imss_alta_at: string | null; + last_rehire_at: string | null; + } | undefined; + const altaDoc = database.prepare( + `SELECT imss_alta_at, uploaded_at FROM documents + WHERE worker_id = ? AND type_code = 'alta_imss' AND is_current = 1 + LIMIT 1`, + ).get(workerId) as { imss_alta_at: string | null; uploaded_at: string } | undefined; + return { + imss_status: (w?.imss_status as ImssStatus) || "sin_alta", + last_rehire_at: w?.last_rehire_at ?? null, + current_alta_at: altaDoc?.imss_alta_at || w?.imss_alta_at || altaDoc?.uploaded_at || null, + }; +} + +/** Checklist con vigencia; contexto IMSS vía imssFlagsFor / checklistItemsFor. */ +export function checklistItemsFor(database: Database, workerId: number) { + const types = database.prepare( + `SELECT code, label, required, validity_mode, freshness_days, + requires_issued_at, requires_expires_at + FROM document_types`, + ).all() as { + code: string; + label: string; + required: number; + validity_mode: string; + freshness_days: number | null; + requires_issued_at: number; + requires_expires_at: number; + }[]; + const currentDocs = database.prepare( + `SELECT type_code, issued_at, expires_at, uploaded_at, imss_alta_at + FROM documents WHERE worker_id = ? AND is_current = 1`, + ).all(workerId) as { + type_code: string; + issued_at: string | null; + expires_at: string | null; + uploaded_at: string; + imss_alta_at: string | null; + }[]; + const byType = new Map(currentDocs.map((d) => [d.type_code, d])); + const ctx = workerImssContext(database, workerId); + + return { + ctx, + freshness_required: freshnessRequired(ctx), + items: types.map((t) => { + const policy = { + code: t.code, + label: t.label, + required: !!t.required, + validity_mode: (t.validity_mode || "none") as "none" | "freshness" | "expiry", + freshness_days: t.freshness_days, + requires_issued_at: !!t.requires_issued_at, + requires_expires_at: !!t.requires_expires_at, + }; + const evaled = evaluateDocumentValidity(policy, byType.get(t.code), ctx); + return { + code: t.code, + label: t.label, + required: !!t.required, + validity_mode: policy.validity_mode, + freshness_days: t.freshness_days, + requires_issued_at: policy.requires_issued_at, + requires_expires_at: policy.requires_expires_at, + present: evaled.present, + valid: evaled.valid, + validity_status: evaled.validity_status, + issued_at: evaled.issued_at, + expires_at: evaled.expires_at, + }; + }), + }; +} + +export function imssFlagsFor(database: Database, workerId: number) { + const { ctx, freshness_required: needFresh, items } = checklistItemsFor(database, workerId); + const requiredOk = items.filter((i) => i.required).every((i) => i.present && i.valid); + const inProject = !!database.prepare( + "SELECT 1 FROM assignments WHERE worker_id = ? AND active = 1 LIMIT 1", + ).get(workerId); + const hasImss = ctx.imss_status === "alta"; + return { + imss_status: ctx.imss_status, + freshness_required: needFresh, + imss_ready: requiredOk && !hasImss, + in_project_without_imss: inProject && !hasImss, + expediente_ok: requiredOk, + }; +} + +export function checklistFor(database: Database, workerId: number) { + return checklistItemsFor(database, workerId).items; +} + +export function refreshPipeline(database: Database, workerId: number) { + const w = database.prepare("SELECT status FROM workers WHERE id = ?").get(workerId) as + | { status: string } + | undefined; + if (!w) return; + if (w.status === "baja") { + database.prepare("UPDATE workers SET pipeline_status = 'baja' WHERE id = ?").run(workerId); + return; + } + const items = checklistFor(database, workerId); + const photo = items.find((i) => i.code === "foto"); + const photoOk = photo?.present && photo?.valid; + const requiredOk = items.filter((i) => i.required).every((i) => i.present && i.valid); + const printed = database.prepare( + `SELECT 1 FROM badge_job_people p + JOIN badge_jobs j ON j.id = p.job_id + WHERE p.worker_id = ? LIMIT 1`, + ).get(workerId); + let pipeline = "incompleto"; + if (requiredOk && photoOk) pipeline = printed ? "impreso" : "listo_gafete"; + const assigned = database.prepare( + "SELECT 1 FROM assignments WHERE worker_id = ? AND active = 1 LIMIT 1", + ).get(workerId); + if (pipeline !== "incompleto" && assigned) { + pipeline = printed ? "activo" : "listo_gafete"; + } + database.prepare("UPDATE workers SET pipeline_status = ? WHERE id = ?").run(pipeline, workerId); +} + +export function lastInsertId(database: Database): number { + const row = database.prepare("SELECT last_insert_rowid() AS id").get() as { id: number }; + return Number(row.id); +} + +export function workerDir(workerId: number): string { + return join(EXPEDIENTES_DIR, String(workerId)); +} + +export function projectDir(projectId: number): string { + return join(PROJECTS_DIR, String(projectId)); +} + +export function projectChecklistFor(database: Database, projectId: number) { + const types = database.prepare( + "SELECT code, label, required FROM project_document_types", + ).all() as { code: string; label: string; required: number }[]; + const current = database.prepare( + `SELECT type_code FROM project_documents WHERE project_id = ? AND is_current = 1`, + ).all(projectId) 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, + present: have.has(t.code), + })); +} diff --git a/api/deno.json b/api/deno.json new file mode 100644 index 0000000..6653b44 --- /dev/null +++ b/api/deno.json @@ -0,0 +1,22 @@ +{ + "name": "panel-obra-api", + "exports": "./main.ts", + "tasks": { + "dev": "deno run --allow-net --allow-read --allow-write --allow-env --allow-ffi --allow-run --env-file=../.env main.ts", + "start": "deno run --allow-net --allow-read --allow-write --allow-env --allow-ffi --allow-run --env-file=../.env main.ts", + "migrate": "cd .. && ./db/update.sh all", + "check": "deno check main.ts", + "test": "deno test --allow-read --allow-write --allow-env --allow-ffi --allow-run" + }, + "imports": { + "@db/sqlite": "jsr:@db/sqlite@0.12", + "hono": "jsr:@hono/hono@4", + "xlsx": "npm:xlsx@0.18.5", + "pdf-lib": "npm:pdf-lib@1.17.1", + "qrcode": "npm:qrcode@1.5.4" + }, + "compilerOptions": { + "strict": true, + "lib": ["deno.ns", "dom"] + } +} diff --git a/api/deno.lock b/api/deno.lock new file mode 100644 index 0000000..8c445f0 --- /dev/null +++ b/api/deno.lock @@ -0,0 +1,358 @@ +{ + "version": "5", + "specifiers": { + "jsr:@db/sqlite@0.12": "0.12.0", + "jsr:@denosaurs/plug@1": "1.1.0", + "jsr:@hono/hono@4": "4.13.2", + "jsr:@std/assert@0.217": "0.217.0", + "jsr:@std/assert@1": "1.0.19", + "jsr:@std/encoding@1": "1.0.11", + "jsr:@std/fmt@1": "1.0.10", + "jsr:@std/fs@1": "1.0.24", + "jsr:@std/internal@^1.0.12": "1.0.14", + "jsr:@std/internal@^1.0.14": "1.0.14", + "jsr:@std/path@0.217": "0.217.0", + "jsr:@std/path@1": "1.1.6", + "jsr:@std/path@^1.1.5": "1.1.6", + "npm:@types/node@*": "22.15.15", + "npm:nodemailer@6.9.16": "6.9.16", + "npm:pdf-lib@1.17.1": "1.17.1", + "npm:qrcode@1.5.4": "1.5.4", + "npm:xlsx@0.18.5": "0.18.5" + }, + "jsr": { + "@db/sqlite@0.12.0": { + "integrity": "dd1ef7f621ad50fc1e073a1c3609c4470bd51edc0994139c5bf9851de7a6d85f", + "dependencies": [ + "jsr:@denosaurs/plug", + "jsr:@std/path@0.217" + ] + }, + "@denosaurs/plug@1.1.0": { + "integrity": "eb2f0b7546c7bca2000d8b0282c54d50d91cf6d75cb26a80df25a6de8c4bc044", + "dependencies": [ + "jsr:@std/encoding", + "jsr:@std/fmt", + "jsr:@std/fs", + "jsr:@std/path@1" + ] + }, + "@hono/hono@4.13.2": { + "integrity": "715d8cc1b6b5d6b9e6a7519059778d775b2d79fffdc026ac0ccdd9971e75809c" + }, + "@std/assert@0.217.0": { + "integrity": "c98e279362ca6982d5285c3b89517b757c1e3477ee9f14eb2fdf80a45aaa9642" + }, + "@std/assert@1.0.19": { + "integrity": "eaada96ee120cb980bc47e040f82814d786fe8162ecc53c91d8df60b8755991e", + "dependencies": [ + "jsr:@std/internal@^1.0.12" + ] + }, + "@std/encoding@1.0.11": { + "integrity": "e7cef2f0b3153bccc17431e7c864a1de03a4b6d9647389c43e08aaa775d37385" + }, + "@std/fmt@1.0.10": { + "integrity": "90dfba288802ac6de82fb31d0917eb9e4450b9925b954d5e51fc29ac07419db5" + }, + "@std/fs@1.0.24": { + "integrity": "f3061b45b81673a2bece689da041df32d174be064c89eb6397fb5718d3fb7877", + "dependencies": [ + "jsr:@std/internal@^1.0.14", + "jsr:@std/path@^1.1.5" + ] + }, + "@std/internal@1.0.14": { + "integrity": "291516b3d4c35024d6ffbc0a9df5bf4c64116e05b50012cf846710152d2ffdf7" + }, + "@std/path@0.217.0": { + "integrity": "1217cc25534bca9a2f672d7fe7c6f356e4027df400c0e85c0ef3e4343bc67d11", + "dependencies": [ + "jsr:@std/assert@0.217" + ] + }, + "@std/path@1.1.6": { + "integrity": "c68485c2a4dfbb5ae3cc74fae4e8c4e5d874cf8a8ed12927917235c758b46cbe", + "dependencies": [ + "jsr:@std/internal@^1.0.14" + ] + } + }, + "npm": { + "@pdf-lib/standard-fonts@1.0.0": { + "integrity": "sha512-hU30BK9IUN/su0Mn9VdlVKsWBS6GyhVfqjwl1FjZN4TxP6cCw0jP2w7V3Hf5uX7M0AZJ16vey9yE0ny7Sa59ZA==", + "dependencies": [ + "pako" + ] + }, + "@pdf-lib/upng@1.0.1": { + "integrity": "sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ==", + "dependencies": [ + "pako" + ] + }, + "@types/node@22.15.15": { + "integrity": "sha512-R5muMcZob3/Jjchn5LcO8jdKwSCbzqmPB6ruBxMcf9kbxtniZHP327s6C37iOfuw8mbKK3cAQa7sEl7afLrQ8A==", + "dependencies": [ + "undici-types" + ] + }, + "adler-32@1.3.1": { + "integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==" + }, + "ansi-regex@5.0.1": { + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==" + }, + "ansi-styles@4.3.0": { + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dependencies": [ + "color-convert" + ] + }, + "camelcase@5.3.1": { + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==" + }, + "cfb@1.2.2": { + "integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==", + "dependencies": [ + "adler-32", + "crc-32" + ] + }, + "cliui@6.0.0": { + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "dependencies": [ + "string-width", + "strip-ansi", + "wrap-ansi" + ] + }, + "codepage@1.15.0": { + "integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==" + }, + "color-convert@2.0.1": { + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dependencies": [ + "color-name" + ] + }, + "color-name@1.1.4": { + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + }, + "crc-32@1.2.2": { + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "bin": true + }, + "decamelize@1.2.0": { + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==" + }, + "dijkstrajs@1.0.3": { + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==" + }, + "emoji-regex@8.0.0": { + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" + }, + "find-up@4.1.0": { + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dependencies": [ + "locate-path", + "path-exists" + ] + }, + "frac@1.1.2": { + "integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==" + }, + "get-caller-file@2.0.5": { + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==" + }, + "is-fullwidth-code-point@3.0.0": { + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==" + }, + "locate-path@5.0.0": { + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dependencies": [ + "p-locate" + ] + }, + "nodemailer@6.9.16": { + "integrity": "sha512-psAuZdTIRN08HKVd/E8ObdV6NO7NTBY3KsC30F7M4H1OnmLCUNaS56FpYxyb26zWLSyYF9Ozch9KYHhHegsiOQ==" + }, + "p-limit@2.3.0": { + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dependencies": [ + "p-try" + ] + }, + "p-locate@4.1.0": { + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dependencies": [ + "p-limit" + ] + }, + "p-try@2.2.0": { + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==" + }, + "pako@1.0.11": { + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==" + }, + "path-exists@4.0.0": { + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==" + }, + "pdf-lib@1.17.1": { + "integrity": "sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw==", + "dependencies": [ + "@pdf-lib/standard-fonts", + "@pdf-lib/upng", + "pako", + "tslib" + ] + }, + "pngjs@5.0.0": { + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==" + }, + "qrcode@1.5.4": { + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "dependencies": [ + "dijkstrajs", + "pngjs", + "yargs" + ], + "bin": true + }, + "require-directory@2.1.1": { + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==" + }, + "require-main-filename@2.0.0": { + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==" + }, + "set-blocking@2.0.0": { + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==" + }, + "ssf@0.11.2": { + "integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==", + "dependencies": [ + "frac" + ] + }, + "string-width@4.2.3": { + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dependencies": [ + "emoji-regex", + "is-fullwidth-code-point", + "strip-ansi" + ] + }, + "strip-ansi@6.0.1": { + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dependencies": [ + "ansi-regex" + ] + }, + "tslib@1.14.1": { + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" + }, + "undici-types@6.21.0": { + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==" + }, + "which-module@2.0.1": { + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==" + }, + "wmf@1.0.2": { + "integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==" + }, + "word@0.3.0": { + "integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==" + }, + "wrap-ansi@6.2.0": { + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "dependencies": [ + "ansi-styles", + "string-width", + "strip-ansi" + ] + }, + "xlsx@0.18.5": { + "integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==", + "dependencies": [ + "adler-32", + "cfb", + "codepage", + "crc-32", + "ssf", + "wmf", + "word" + ], + "bin": true + }, + "y18n@4.0.3": { + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==" + }, + "yargs-parser@18.1.3": { + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "dependencies": [ + "camelcase", + "decamelize" + ] + }, + "yargs@15.4.1": { + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "dependencies": [ + "cliui", + "decamelize", + "find-up", + "get-caller-file", + "require-directory", + "require-main-filename", + "set-blocking", + "string-width", + "which-module", + "y18n", + "yargs-parser" + ] + } + }, + "remote": { + "https://deno.land/std@0.224.0/assert/_constants.ts": "a271e8ef5a573f1df8e822a6eb9d09df064ad66a4390f21b3e31f820a38e0975", + "https://deno.land/std@0.224.0/assert/assert.ts": "09d30564c09de846855b7b071e62b5974b001bb72a4b797958fe0660e7849834", + "https://deno.land/std@0.224.0/assert/assert_almost_equals.ts": "9e416114322012c9a21fa68e187637ce2d7df25bcbdbfd957cd639e65d3cf293", + "https://deno.land/std@0.224.0/assert/assert_array_includes.ts": "14c5094471bc8e4a7895fc6aa5a184300d8a1879606574cb1cd715ef36a4a3c7", + "https://deno.land/std@0.224.0/assert/assert_equals.ts": "3bbca947d85b9d374a108687b1a8ba3785a7850436b5a8930d81f34a32cb8c74", + "https://deno.land/std@0.224.0/assert/assert_exists.ts": "43420cf7f956748ae6ed1230646567b3593cb7a36c5a5327269279c870c5ddfd", + "https://deno.land/std@0.224.0/assert/assert_false.ts": "3e9be8e33275db00d952e9acb0cd29481a44fa0a4af6d37239ff58d79e8edeff", + "https://deno.land/std@0.224.0/assert/assert_greater.ts": "5e57b201fd51b64ced36c828e3dfd773412c1a6120c1a5a99066c9b261974e46", + "https://deno.land/std@0.224.0/assert/assert_greater_or_equal.ts": "9870030f997a08361b6f63400273c2fb1856f5db86c0c3852aab2a002e425c5b", + "https://deno.land/std@0.224.0/assert/assert_instance_of.ts": "e22343c1fdcacfaea8f37784ad782683ec1cf599ae9b1b618954e9c22f376f2c", + "https://deno.land/std@0.224.0/assert/assert_is_error.ts": "f856b3bc978a7aa6a601f3fec6603491ab6255118afa6baa84b04426dd3cc491", + "https://deno.land/std@0.224.0/assert/assert_less.ts": "60b61e13a1982865a72726a5fa86c24fad7eb27c3c08b13883fb68882b307f68", + "https://deno.land/std@0.224.0/assert/assert_less_or_equal.ts": "d2c84e17faba4afe085e6c9123a63395accf4f9e00150db899c46e67420e0ec3", + "https://deno.land/std@0.224.0/assert/assert_match.ts": "ace1710dd3b2811c391946954234b5da910c5665aed817943d086d4d4871a8b7", + "https://deno.land/std@0.224.0/assert/assert_not_equals.ts": "78d45dd46133d76ce624b2c6c09392f6110f0df9b73f911d20208a68dee2ef29", + "https://deno.land/std@0.224.0/assert/assert_not_instance_of.ts": "3434a669b4d20cdcc5359779301a0588f941ffdc2ad68803c31eabdb4890cf7a", + "https://deno.land/std@0.224.0/assert/assert_not_match.ts": "df30417240aa2d35b1ea44df7e541991348a063d9ee823430e0b58079a72242a", + "https://deno.land/std@0.224.0/assert/assert_not_strict_equals.ts": "37f73880bd672709373d6dc2c5f148691119bed161f3020fff3548a0496f71b8", + "https://deno.land/std@0.224.0/assert/assert_object_match.ts": "411450fd194fdaabc0089ae68f916b545a49d7b7e6d0026e84a54c9e7eed2693", + "https://deno.land/std@0.224.0/assert/assert_rejects.ts": "4bee1d6d565a5b623146a14668da8f9eb1f026a4f338bbf92b37e43e0aa53c31", + "https://deno.land/std@0.224.0/assert/assert_strict_equals.ts": "b4f45f0fd2e54d9029171876bd0b42dd9ed0efd8f853ab92a3f50127acfa54f5", + "https://deno.land/std@0.224.0/assert/assert_string_includes.ts": "496b9ecad84deab72c8718735373feb6cdaa071eb91a98206f6f3cb4285e71b8", + "https://deno.land/std@0.224.0/assert/assert_throws.ts": "c6508b2879d465898dab2798009299867e67c570d7d34c90a2d235e4553906eb", + "https://deno.land/std@0.224.0/assert/assertion_error.ts": "ba8752bd27ebc51f723702fac2f54d3e94447598f54264a6653d6413738a8917", + "https://deno.land/std@0.224.0/assert/equal.ts": "bddf07bb5fc718e10bb72d5dc2c36c1ce5a8bdd3b647069b6319e07af181ac47", + "https://deno.land/std@0.224.0/assert/fail.ts": "0eba674ffb47dff083f02ced76d5130460bff1a9a68c6514ebe0cdea4abadb68", + "https://deno.land/std@0.224.0/assert/mod.ts": "48b8cb8a619ea0b7958ad7ee9376500fe902284bb36f0e32c598c3dc34cbd6f3", + "https://deno.land/std@0.224.0/assert/unimplemented.ts": "8c55a5793e9147b4f1ef68cd66496b7d5ba7a9e7ca30c6da070c1a58da723d73", + "https://deno.land/std@0.224.0/assert/unreachable.ts": "5ae3dbf63ef988615b93eb08d395dda771c96546565f9e521ed86f6510c29e19", + "https://deno.land/std@0.224.0/fmt/colors.ts": "508563c0659dd7198ba4bbf87e97f654af3c34eb56ba790260f252ad8012e1c5", + "https://deno.land/std@0.224.0/internal/diff.ts": "6234a4b493ebe65dc67a18a0eb97ef683626a1166a1906232ce186ae9f65f4e6", + "https://deno.land/std@0.224.0/internal/format.ts": "0a98ee226fd3d43450245b1844b47003419d34d210fa989900861c79820d21c2", + "https://deno.land/std@0.224.0/internal/mod.ts": "534125398c8e7426183e12dc255bb635d94e06d0f93c60a297723abe69d3b22e" + }, + "workspace": { + "dependencies": [ + "jsr:@db/sqlite@0.12", + "jsr:@hono/hono@4", + "npm:pdf-lib@1.17.1", + "npm:qrcode@1.5.4", + "npm:xlsx@0.18.5" + ] + } +} diff --git a/api/docs_crypto.ts b/api/docs_crypto.ts new file mode 100644 index 0000000..349549c --- /dev/null +++ b/api/docs_crypto.ts @@ -0,0 +1,27 @@ +import { fromHex, toHex } from "./crypto.ts"; +import { config } from "./config.ts"; + +async function docsKey(): Promise { + const raw = fromHex(config.docsKeyHex); + if (raw.length !== 32) { + throw new Error("DOCS_KEY must be 64 hex chars (32 bytes)"); + } + return await crypto.subtle.importKey("raw", raw, "AES-GCM", false, ["encrypt", "decrypt"]); +} + +export async function encryptBytes(plain: Uint8Array): Promise<{ iv: string; cipher: Uint8Array }> { + const key = await docsKey(); + const iv = crypto.getRandomValues(new Uint8Array(12)); + const cipher = new Uint8Array( + await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, plain), + ); + return { iv: toHex(iv), cipher }; +} + +export async function decryptBytes(ivHex: string, cipher: Uint8Array): Promise { + const key = await docsKey(); + const iv = fromHex(ivHex); + return new Uint8Array( + await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, cipher), + ); +} diff --git a/api/document_validity.ts b/api/document_validity.ts new file mode 100644 index 0000000..e02bf9a --- /dev/null +++ b/api/document_validity.ts @@ -0,0 +1,170 @@ +/** Vigencia documental y contexto de alta IMSS. */ + +export type ValidityMode = "none" | "freshness" | "expiry"; +export type ValidityStatus = "ok" | "missing" | "expired" | "stale_for_alta" | "missing_dates"; +export type ImssStatus = "sin_alta" | "alta" | "baja_imss"; + +export type DocTypePolicy = { + code: string; + label: string; + required: boolean; + validity_mode: ValidityMode; + freshness_days: number | null; + requires_issued_at: boolean; + requires_expires_at: boolean; +}; + +export type CurrentDocDates = { + type_code: string; + issued_at: string | null; + expires_at: string | null; + uploaded_at: string; + imss_alta_at?: string | null; +}; + +export type WorkerImssContext = { + imss_status: ImssStatus; + last_rehire_at: string | null; + /** Fecha de la alta IMSS vigente (documento o worker.imss_alta_at). */ + current_alta_at: string | null; +}; + +function parseDay(value: string | null | undefined): Date | null { + if (!value) return null; + const raw = String(value).trim().slice(0, 10); + if (!/^\d{4}-\d{2}-\d{2}$/.test(raw)) return null; + const d = new Date(`${raw}T12:00:00`); + return Number.isNaN(d.getTime()) ? null : d; +} + +function daysBetween(from: Date, to: Date): number { + return Math.floor((to.getTime() - from.getTime()) / 86_400_000); +} + +/** True cuando el expediente debe exigir frescor (alta o reingreso pendiente de alta). */ +export function freshnessRequired(ctx: WorkerImssContext): boolean { + if (ctx.imss_status !== "alta") return true; + if (!ctx.last_rehire_at) return false; + const rehire = parseDay(ctx.last_rehire_at); + const alta = parseDay(ctx.current_alta_at); + if (!rehire) return false; + if (!alta) return true; + return alta < rehire; +} + +export function evaluateDocumentValidity( + policy: DocTypePolicy, + doc: CurrentDocDates | null | undefined, + ctx: WorkerImssContext, + today = new Date(), +): { + present: boolean; + valid: boolean; + validity_status: ValidityStatus; + issued_at: string | null; + expires_at: string | null; +} { + if (!doc) { + return { + present: false, + valid: false, + validity_status: "missing", + issued_at: null, + expires_at: null, + }; + } + + const issuedAt = doc.issued_at; + const expiresAt = doc.expires_at; + const needFresh = freshnessRequired(ctx); + + if (policy.validity_mode === "expiry") { + if (policy.requires_expires_at && !expiresAt) { + return { + present: true, + valid: false, + validity_status: "missing_dates", + issued_at: issuedAt, + expires_at: expiresAt, + }; + } + const exp = parseDay(expiresAt); + if (exp && exp < new Date(today.toISOString().slice(0, 10) + "T12:00:00")) { + return { + present: true, + valid: false, + validity_status: "expired", + issued_at: issuedAt, + expires_at: expiresAt, + }; + } + return { + present: true, + valid: true, + validity_status: "ok", + issued_at: issuedAt, + expires_at: expiresAt, + }; + } + + if (policy.validity_mode === "freshness") { + if (policy.requires_issued_at && !issuedAt) { + return { + present: true, + valid: false, + validity_status: "missing_dates", + issued_at: issuedAt, + expires_at: expiresAt, + }; + } + if (needFresh && issuedAt) { + const issued = parseDay(issuedAt); + const days = policy.freshness_days ?? 90; + if (issued && daysBetween(issued, today) > days) { + return { + present: true, + valid: false, + validity_status: "stale_for_alta", + issued_at: issuedAt, + expires_at: expiresAt, + }; + } + } + return { + present: true, + valid: true, + validity_status: "ok", + issued_at: issuedAt, + expires_at: expiresAt, + }; + } + + return { + present: true, + valid: true, + validity_status: "ok", + issued_at: issuedAt, + expires_at: expiresAt, + }; +} + +export const DOCUMENT_TYPE_SEED: Array<{ + code: string; + label: string; + required: number; + validity_mode: ValidityMode; + freshness_days: number | null; + requires_issued_at: number; + requires_expires_at: number; +}> = [ + { code: "foto", label: "Foto", required: 1, validity_mode: "none", freshness_days: null, requires_issued_at: 0, requires_expires_at: 0 }, + { code: "ine", label: "INE", required: 1, validity_mode: "expiry", freshness_days: null, requires_issued_at: 0, requires_expires_at: 1 }, + { code: "curp", label: "Constancia CURP", required: 1, validity_mode: "freshness", freshness_days: 90, requires_issued_at: 1, requires_expires_at: 0 }, + { code: "nss", label: "Constancia NSS", required: 1, validity_mode: "freshness", freshness_days: 90, requires_issued_at: 1, requires_expires_at: 0 }, + { code: "rfc", label: "Constancia RFC", required: 1, validity_mode: "freshness", freshness_days: 90, requires_issued_at: 1, requires_expires_at: 0 }, + { code: "domicilio", label: "Comprobante de domicilio", required: 1, validity_mode: "freshness", freshness_days: 90, requires_issued_at: 1, requires_expires_at: 0 }, + { code: "alta_imss", label: "Alta IMSS", required: 0, validity_mode: "none", freshness_days: null, requires_issued_at: 0, requires_expires_at: 0 }, + { code: "baja_imss", label: "Baja IMSS", required: 0, validity_mode: "none", freshness_days: null, requires_issued_at: 0, requires_expires_at: 0 }, + { code: "contrato", label: "Contrato", required: 0, validity_mode: "none", freshness_days: null, requires_issued_at: 0, requires_expires_at: 0 }, + { code: "otro", label: "Otro", required: 0, validity_mode: "none", freshness_days: null, requires_issued_at: 0, requires_expires_at: 0 }, +]; diff --git a/api/document_validity_test.ts b/api/document_validity_test.ts new file mode 100644 index 0000000..65bd8d7 --- /dev/null +++ b/api/document_validity_test.ts @@ -0,0 +1,124 @@ +import { assertEquals } from "https://deno.land/std@0.224.0/assert/mod.ts"; +import { + evaluateDocumentValidity, + freshnessRequired, + type DocTypePolicy, + type WorkerImssContext, +} from "./document_validity.ts"; + +const curpPolicy: DocTypePolicy = { + code: "curp", + label: "CURP", + required: true, + validity_mode: "freshness", + freshness_days: 90, + requires_issued_at: true, + requires_expires_at: false, +}; + +const inePolicy: DocTypePolicy = { + code: "ine", + label: "INE", + required: true, + validity_mode: "expiry", + freshness_days: null, + requires_issued_at: false, + requires_expires_at: true, +}; + +Deno.test("freshnessRequired: sin alta exige frescor", () => { + assertEquals( + freshnessRequired({ imss_status: "sin_alta", last_rehire_at: null, current_alta_at: null }), + true, + ); +}); + +Deno.test("freshnessRequired: alta continua no exige frescor", () => { + assertEquals( + freshnessRequired({ + imss_status: "alta", + last_rehire_at: null, + current_alta_at: "2024-01-01", + }), + false, + ); +}); + +Deno.test("freshnessRequired: rehire sin alta posterior exige frescor", () => { + assertEquals( + freshnessRequired({ + imss_status: "alta", + last_rehire_at: "2026-06-01", + current_alta_at: "2025-01-01", + }), + true, + ); +}); + +Deno.test("freshnessRequired: rehire con alta posterior no exige frescor", () => { + assertEquals( + freshnessRequired({ + imss_status: "alta", + last_rehire_at: "2026-01-01", + current_alta_at: "2026-02-01", + }), + false, + ); +}); + +Deno.test("CURP stale when freshness required", () => { + const ctx: WorkerImssContext = { + imss_status: "sin_alta", + last_rehire_at: null, + current_alta_at: null, + }; + const result = evaluateDocumentValidity( + curpPolicy, + { type_code: "curp", issued_at: "2025-01-01", expires_at: null, uploaded_at: "2025-01-02" }, + ctx, + new Date("2026-08-20T12:00:00"), + ); + assertEquals(result.validity_status, "stale_for_alta"); + assertEquals(result.valid, false); +}); + +Deno.test("CURP old but ok when already alta", () => { + const ctx: WorkerImssContext = { + imss_status: "alta", + last_rehire_at: null, + current_alta_at: "2025-02-01", + }; + const result = evaluateDocumentValidity( + curpPolicy, + { type_code: "curp", issued_at: "2025-01-01", expires_at: null, uploaded_at: "2025-01-02" }, + ctx, + new Date("2026-08-20T12:00:00"), + ); + assertEquals(result.validity_status, "ok"); + assertEquals(result.valid, true); +}); + +Deno.test("INE expired always invalid", () => { + const ctx: WorkerImssContext = { + imss_status: "alta", + last_rehire_at: null, + current_alta_at: "2026-01-01", + }; + const result = evaluateDocumentValidity( + inePolicy, + { type_code: "ine", issued_at: null, expires_at: "2025-12-31", uploaded_at: "2025-01-01" }, + ctx, + new Date("2026-08-20T12:00:00"), + ); + assertEquals(result.validity_status, "expired"); + assertEquals(result.valid, false); +}); + +Deno.test("missing issued_at for freshness", () => { + const result = evaluateDocumentValidity( + curpPolicy, + { type_code: "curp", issued_at: null, expires_at: null, uploaded_at: "2026-08-01" }, + { imss_status: "sin_alta", last_rehire_at: null, current_alta_at: null }, + ); + assertEquals(result.validity_status, "missing_dates"); +}); diff --git a/api/excel.ts b/api/excel.ts new file mode 100644 index 0000000..a6382e5 --- /dev/null +++ b/api/excel.ts @@ -0,0 +1,546 @@ +import * as XLSX from "xlsx"; +import type { Database } from "@db/sqlite"; +import { mkdir } from "node:fs/promises"; +import { join } from "node:path"; +import { encryptBytes } from "./docs_crypto.ts"; +import { sha256Hex } from "./crypto.ts"; +import { canonicalRiskCode, normalizeWorker, validateWorkerFields, formatNss, normUpper, type WorkerInput } from "./mx.ts"; +import { refreshPipeline, workerDir, lastInsertId, projectDir } from "./db.ts"; +import { resolveCompany } from "./companies.ts"; + +export const IMPORT_COLUMNS = [ + "NOMBRE", + "2 NOMBRE", + "APELLIDO PATERNO", + "APELLIDO MATERNO", + "CURP", + "RFC", + "NSS", + "TELEFONO", + "CORREO", + "DIRECCION", + "TIPO SANGRE", + "ALTA", + "CARGO", + "RIESGO", + "TIPO TRABAJO", + "JORNAL", + "GAFETE", + "ESTATUS", + "URL FOTO", +] as const; + +const FIELD_LABEL: Record = { + first_name: "Nombre", + last_name_p: "Apellido paterno", + last_name_m: "Apellido materno", + curp: "CURP", + rfc: "RFC", + nss: "NSS", + phone: "Teléfono", + email: "Correo", + address: "Dirección", + hire_type: "Empresa", + position: "Cargo", + risk_code: "Riesgo", + work_type: "Tipo de trabajo", + daily_wage: "Jornal", +}; + +const KEY_ALIAS: Record = { + "SEGUNDO NOMBRE": "2 NOMBRE", + "2NOMBRE": "2 NOMBRE", + TELEFONO: "TELEFONO", + TEL: "TELEFONO", + EMAIL: "CORREO", + MAIL: "CORREO", + "TIPO DE SANGRE": "TIPO SANGRE", + "TIPO TRABAJO": "TIPO TRABAJO", + "TIPO DE TRABAJO": "TIPO TRABAJO", + "JORNAL DIARIO": "JORNAL", + SALARIO: "JORNAL", + STATUS: "ESTATUS", + ESTADO: "ESTATUS", + FOTO: "URL FOTO", +}; + +function normHeader(raw: string) { + const k = raw + .normalize("NFD") + .replace(/\p{M}/gu, "") + .toUpperCase() + .replace(/\s+/g, " ") + .trim(); + return KEY_ALIAS[k] || k; +} + +function normalizeRow(r: Record): Record { + const out: Record = {}; + for (const [k, v] of Object.entries(r)) out[normHeader(k)] = String(v ?? "").trim(); + return out; +} + +function sheetRows(wb: XLSX.WorkBook, name: string): { row: number; data: Record }[] { + const sheet = wb.Sheets[name]; + if (!sheet) return []; + const rows = XLSX.utils.sheet_to_json>(sheet, { defval: "" }); + return rows.map((r, i) => ({ row: i + 2, data: normalizeRow(r) })); +} + +function parseWage(r: Record): number | undefined { + if (!("JORNAL" in r)) return undefined; + const raw = r["JORNAL"].replace(/[$\s]/g, "").replace(",", "."); + if (!raw) return NaN; + return Number(raw); +} + +function yesNo(v: string) { + return ["si", "sí", "yes", "1", "true"].includes(v.toLowerCase()); +} + +function rowToInput(r: Record, fallbackStatus: "activo" | "baja"): WorkerInput { + const est = (r["ESTATUS"] || fallbackStatus).toLowerCase(); + const wage = parseWage(r); + return { + first_name: r["NOMBRE"], + middle_name: r["2 NOMBRE"] || null, + last_name_p: r["APELLIDO PATERNO"], + last_name_m: r["APELLIDO MATERNO"], + curp: r["CURP"], + rfc: r["RFC"], + nss: r["NSS"], + phone: r["TELEFONO"], + email: r["CORREO"], + address: r["DIRECCION"], + blood_type: r["TIPO SANGRE"] || null, + hire_type: (r["ALTA"] || "ARCT2608").toUpperCase(), + position: r["CARGO"], + risk_code: canonicalRiskCode(r["RIESGO"] || ""), + work_type: (r["TIPO TRABAJO"] || "N").toUpperCase(), + daily_wage: wage === undefined ? 0 : wage, + needs_badge: r["GAFETE"] ? yesNo(r["GAFETE"]) : true, + status: est.startsWith("baja") ? "baja" : "activo", + }; +} + +function isEmptyRow(r: Record) { + return IMPORT_COLUMNS.every((c) => !r[c]); +} + +function errorMessages(errors: Record): string[] { + return Object.entries(errors).map(([k, v]) => `${FIELD_LABEL[k] || k}: ${v}`); +} + +export function buildImportTemplate(companies: { code: string; name: string }[] = []): Uint8Array { + const wb = XLSX.utils.book_new(); + const companyRows = companies.length + ? companies.map((company) => ["ALTA", company.code, company.name]) + : [ + ["ALTA", "ARCT2608", "Empresa principal"], + ["ALTA", "FISICA", "Persona física"], + ["ALTA", "ARCOTEC", "Subempresa"], + ]; + + const instructions = [ + ["Plantilla de carga masiva — Panel de proyectos Arctec"], + [""], + ["1. Llene la hoja PERSONAL. No cambie los nombres de las columnas."], + ["2. CURP, RFC y NSS deben ser únicos. Si ya existen en el sistema, esa fila no se inserta (queda como 'ya existía')."], + ["3. Obligatorio: nombre, apellidos, CURP, RFC, NSS, teléfono, correo, dirección, alta, cargo, riesgo, tipo trabajo, jornal."], + ["4. Opcional: segundo nombre, tipo de sangre, URL foto."], + ["5. Valores permitidos: vea la hoja CATALOGOS."], + ["6. ESTATUS: activo o baja. Si deja vacío, se toma activo."], + ["7. GAFETE: SI o NO."], + ["8. Suba este archivo en Padrón → Importar Excel."], + ["9. También se aceptan las hojas ACTUALES e HISTORICO del Excel anterior."], + ]; + const wsI = XLSX.utils.aoa_to_sheet(instructions); + wsI["!cols"] = [{ wch: 110 }]; + XLSX.utils.book_append_sheet(wb, wsI, "INSTRUCCIONES"); + + const personal = [IMPORT_COLUMNS.slice() as string[]]; + const wsP = XLSX.utils.aoa_to_sheet(personal); + wsP["!cols"] = IMPORT_COLUMNS.map((c) => ({ wch: Math.max(14, c.length + 2) })); + XLSX.utils.book_append_sheet(wb, wsP, "PERSONAL"); + + const catalogs = [ + ["Campo", "Valor", "Notas"], + ...companyRows, + ["RIESGO", "alto", "Alto — barra gafete roja"], + ["RIESGO", "medio", "Medio — barra amarilla"], + ["RIESGO", "bajo", "Bajo — barra verde"], + ["RIESGO", "rojo", "También se acepta el color de barra"], + ["RIESGO", "amarillo", "Color de barra"], + ["RIESGO", "azul", "Color de barra (se trata como bajo)"], + ["RIESGO", "verde", "Color de barra"], + ["RIESGO", "negro", "Color de barra (se trata como alto)"], + ["RIESGO", "naranja", "Color de barra (se trata como alto)"], + ["TIPO TRABAJO", "N", "Normal"], + ["TIPO TRABAJO", "D", "Destajo"], + ["GAFETE", "SI", "Se imprime gafete"], + ["GAFETE", "NO", "No lleva gafete"], + ["ESTATUS", "activo", "Alta en padrón"], + ["ESTATUS", "baja", "Histórico"], + ]; + const wsC = XLSX.utils.aoa_to_sheet(catalogs); + wsC["!cols"] = [{ wch: 16 }, { wch: 14 }, { wch: 36 }]; + XLSX.utils.book_append_sheet(wb, wsC, "CATALOGOS"); + + const out = XLSX.write(wb, { type: "array", bookType: "xlsx" }); + return out instanceof Uint8Array ? out : new Uint8Array(out); +} + +function findExisting(db: Database, curp: string, rfc: string, nss: string) { + return db.prepare( + `SELECT id, first_name, last_name_p, curp, rfc, nss FROM workers + WHERE curp = ? OR rfc = ? OR nss = ? LIMIT 1`, + ).get(curp, rfc, nss) as + | { id: number; first_name: string; last_name_p: string; curp: string; rfc: string; nss: string } + | undefined; +} + +export async function storeDocument( + db: Database, + workerId: number, + type: string, + filename: string, + mime: string, + bytes: Uint8Array, + userId: number | null, + meta: { + issued_at?: string | null; + expires_at?: string | null; + imss_company_id?: number | null; + imss_alta_at?: string | null; + imss_baja_at?: string | null; + } = {}, +) { + const allowed = db.prepare("SELECT code FROM 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 = workerDir(workerId); + await mkdir(dir, { recursive: true }); + await Deno.writeFile(join(dir, storage), cipher); + db.prepare("UPDATE documents SET is_current = 0 WHERE worker_id = ? AND type_code = ?").run( + workerId, + type, + ); + const issuedAt = meta.issued_at || null; + const expiresAt = meta.expires_at || null; + const imssCompanyId = meta.imss_company_id || null; + const today = new Date().toISOString().slice(0, 10); + const imssAltaAt = meta.imss_alta_at || (type === "alta_imss" ? today : null); + const imssBajaAt = meta.imss_baja_at || (type === "baja_imss" ? today : null); + const movementDate = type === "baja_imss" ? imssBajaAt : imssAltaAt; + + db.prepare( + `INSERT INTO documents + (worker_id, type_code, original_name, mime, size_bytes, sha256, iv, storage_name, is_current, + parse_status, issued_at, expires_at, imss_company_id, imss_alta_at, uploaded_by) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, 'manual', ?, ?, ?, ?, ?)`, + ).run( + workerId, + type, + filename, + mime, + bytes.byteLength, + hash, + iv, + storage, + issuedAt || movementDate, + expiresAt, + imssCompanyId, + movementDate, + userId, + ); + + if (type === "alta_imss") { + const companyId = imssCompanyId || + (db.prepare("SELECT company_id FROM workers WHERE id = ?").get(workerId) as { company_id: number } | undefined) + ?.company_id || + null; + const company = companyId + ? db.prepare("SELECT id, code FROM companies WHERE id = ?").get(companyId) as + | { id: number; code: string } + | undefined + : undefined; + if (!company) throw new Error("Empresa patrón no válida"); + db.prepare( + `UPDATE workers SET + imss_status = 'alta', + imss_company_id = ?, + imss_alta_at = ?, + imss_baja_at = NULL, + company_id = ?, + hire_type = ?, + updated_at = datetime('now') + WHERE id = ?`, + ).run(company.id, imssAltaAt, company.id, company.code, workerId); + } + + if (type === "baja_imss") { + db.prepare( + `UPDATE workers SET + imss_status = 'baja_imss', + imss_baja_at = ?, + imss_company_id = NULL, + company_id = NULL, + hire_type = '', + updated_at = datetime('now') + WHERE id = ?`, + ).run(imssBajaAt, workerId); + } + + refreshPipeline(db, workerId); +} + +export async function storeProjectDocument( + db: Database, + projectId: number, + type: string, + filename: string, + mime: string, + bytes: Uint8Array, + userId: number | null, +) { + const allowed = db.prepare("SELECT code FROM project_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 = projectDir(projectId); + await mkdir(dir, { recursive: true }); + await Deno.writeFile(join(dir, storage), cipher); + db.prepare("UPDATE project_documents SET is_current = 0 WHERE project_id = ? AND type_code = ?").run( + projectId, + type, + ); + db.prepare( + `INSERT INTO project_documents + (project_id, type_code, original_name, mime, size_bytes, sha256, iv, storage_name, is_current, parse_status, uploaded_by) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, 'manual', ?)`, + ).run(projectId, type, filename, mime, bytes.byteLength, hash, iv, storage, userId); +} + +async function fetchPhoto(url: string): Promise { + if (!url.startsWith("http")) return null; + try { + const res = await fetch(url); + if (!res.ok) return null; + return new Uint8Array(await res.arrayBuffer()); + } catch { + return null; + } +} + +function upsertWorker( + db: Database, + input: ReturnType & { company_id: number; tenant_id?: number | null }, + projectId: number | null, +) { + const existing = findExisting(db, input.curp, input.rfc, input.nss); + if (existing) { + db.prepare( + `UPDATE workers SET + first_name=?, middle_name=?, last_name_p=?, last_name_m=?, + curp=?, rfc=?, nss=?, phone=?, email=?, address=?, blood_type=?, + hire_type=?, company_id=?, tenant_id=COALESCE(?, tenant_id), position=?, risk_code=?, work_type=?, daily_wage=?, + needs_badge=?, status=?, updated_at=datetime('now') + WHERE id=?`, + ).run( + input.first_name, + input.middle_name, + input.last_name_p, + input.last_name_m, + input.curp, + input.rfc, + input.nss, + input.phone, + input.email, + input.address, + input.blood_type, + input.hire_type, + input.company_id, + input.tenant_id ?? null, + input.position, + input.risk_code, + input.work_type, + input.daily_wage, + input.needs_badge, + input.status, + existing.id, + ); + if (projectId) assign(db, existing.id, projectId); + refreshPipeline(db, existing.id); + const matched = existing.curp === input.curp ? "CURP" : existing.rfc === input.rfc ? "RFC" : "NSS"; + return { id: existing.id, action: "existed" as const, matched }; + } + db.prepare( + `INSERT INTO workers + (first_name, middle_name, last_name_p, last_name_m, curp, rfc, nss, phone, email, address, + blood_type, hire_type, company_id, position, risk_code, work_type, daily_wage, needs_badge, status, tenant_id) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, + ).run( + input.first_name, + input.middle_name, + input.last_name_p, + input.last_name_m, + input.curp, + input.rfc, + input.nss, + input.phone, + input.email, + input.address, + input.blood_type, + input.hire_type, + input.company_id, + input.position, + input.risk_code, + input.work_type, + input.daily_wage, + input.needs_badge, + input.status, + input.tenant_id ?? null, + ); + const id = lastInsertId(db); + if (projectId) assign(db, id, projectId); + refreshPipeline(db, id); + return { id, action: "inserted" as const, matched: null as string | null }; +} + +function assign(db: Database, workerId: number, projectId: number) { + db.prepare( + `INSERT INTO assignments (worker_id, project_id, active, start_date) + VALUES (?, ?, 1, date('now')) + ON CONFLICT(worker_id, project_id) DO UPDATE SET + active=1, + start_date=CASE WHEN assignments.active=0 THEN excluded.start_date ELSE assignments.start_date END, + end_date=NULL`, + ).run(workerId, projectId); +} + +export type ImportReport = { + inserted: number; + existed: number; + errors: number; + photos: number; + existed_rows: { sheet: string; row: number; nombre: string; curp: string; matched: string }[]; + error_rows: { sheet: string; row: number; nombre: string; curp: string; messages: string[] }[]; +}; + +export async function importExcel( + db: Database, + bytes: Uint8Array, + projectId: number | null, + userId: number | null, +): Promise { + const wb = XLSX.read(bytes, { type: "array" }); + const risks = new Set( + (db.prepare("SELECT code FROM risk_levels").all() as { code: string }[]).map((r) => r.code), + ); + const report: ImportReport = { + inserted: 0, + existed: 0, + errors: 0, + photos: 0, + existed_rows: [], + error_rows: [], + }; + const seenCurp = new Map(); + const seenRfc = new Map(); + const seenNss = new Map(); + + const jobs: { sheet: string; fallback: "activo" | "baja"; rows: { row: number; data: Record }[] }[] = []; + if (wb.SheetNames.includes("PERSONAL")) { + jobs.push({ sheet: "PERSONAL", fallback: "activo", rows: sheetRows(wb, "PERSONAL") }); + } + if (wb.SheetNames.includes("ACTUALES")) { + jobs.push({ sheet: "ACTUALES", fallback: "activo", rows: sheetRows(wb, "ACTUALES") }); + } + if (wb.SheetNames.includes("HISTORICO")) { + jobs.push({ sheet: "HISTORICO", fallback: "baja", rows: sheetRows(wb, "HISTORICO") }); + } + if (!jobs.length) { + report.error_rows.push({ + sheet: "-", + row: 0, + nombre: "", + curp: "", + messages: ["El archivo no tiene hoja PERSONAL, ACTUALES ni HISTORICO. Descargue la plantilla."], + }); + report.errors = 1; + return report; + } + + for (const job of jobs) { + for (const { row, data } of job.rows) { + if (isEmptyRow(data)) continue; + const nombre = [data["NOMBRE"], data["APELLIDO PATERNO"], data["APELLIDO MATERNO"]].filter(Boolean).join(" "); + const input = rowToInput(data, job.fallback); + const errors = validateWorkerFields(input); + const company = resolveCompany(db, input); + if (!company) errors.hire_type = `Empresa no encontrada (${data["ALTA"] || "—"})`; + if (input.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(input.email)) { + errors.email = "Correo inválido"; + } + if (input.risk_code && !risks.has(input.risk_code)) { + errors.risk_code = `Riesgo inválido (${data["RIESGO"]}). Use alto, medio o bajo`; + } + const curp = normUpper(input.curp); + const rfc = normUpper(input.rfc); + const nss = formatNss(input.nss ?? ""); + if (!errors.curp && seenCurp.has(curp)) errors.curp = `CURP duplicada en el archivo (fila ${seenCurp.get(curp)})`; + if (!errors.rfc && seenRfc.has(rfc)) errors.rfc = `RFC duplicado en el archivo (fila ${seenRfc.get(rfc)})`; + if (!errors.nss && seenNss.has(nss)) errors.nss = `NSS duplicado en el archivo (fila ${seenNss.get(nss)})`; + + if (Object.keys(errors).length) { + report.error_rows.push({ + sheet: job.sheet, + row, + nombre, + curp: data["CURP"] || "", + messages: errorMessages(errors), + }); + report.errors++; + continue; + } + + seenCurp.set(curp, row); + seenRfc.set(rfc, row); + seenNss.set(nss, row); + + const n = { + ...normalizeWorker(input), + hire_type: company!.code, + company_id: company!.id, + tenant_id: company!.tenant_id ?? 1, + }; + const res = upsertWorker(db, n, n.status === "activo" ? projectId : null); + if (res.action === "inserted") report.inserted++; + else { + report.existed++; + report.existed_rows.push({ + sheet: job.sheet, + row, + nombre, + curp: n.curp, + matched: res.matched || "CURP", + }); + } + const url = data["URL FOTO"]; + if (url) { + const photo = await fetchPhoto(url); + if (photo) { + await storeDocument(db, res.id, "foto", "foto-import.jpg", "image/jpeg", photo, userId); + report.photos++; + } + } + } + } + return report; +} + +export { findExisting, upsertWorker, assign }; diff --git a/api/liquibase.ts b/api/liquibase.ts new file mode 100644 index 0000000..a080270 --- /dev/null +++ b/api/liquibase.ts @@ -0,0 +1,67 @@ +import { join } from "node:path"; +import { ROOT, DATA_DIR } from "./config.ts"; + +const LIQUIBASE = join(ROOT, "db", "tools", "liquibase", "liquibase"); +const SQLITE_JDBC = join(ROOT, "db", "tools", "sqlite-jdbc.jar"); +const BOOTSTRAP = join(ROOT, "db", "bootstrap-tools.sh"); + +async function ensureTools(): Promise { + try { + await Deno.stat(LIQUIBASE); + await Deno.stat(SQLITE_JDBC); + return true; + } catch { + const cmd = new Deno.Command("bash", { + args: [BOOTSTRAP], + stdout: "piped", + stderr: "piped", + }); + const { code, stdout, stderr } = await cmd.output(); + if (code !== 0) { + console.warn( + "[liquibase] bootstrap failed:\n", + new TextDecoder().decode(stdout), + new TextDecoder().decode(stderr), + ); + return false; + } + return true; + } +} + +async function updateOne(name: "app" | "platform"): Promise { + const dir = join(ROOT, "db", name); + const dbPath = join(DATA_DIR, `${name}.db`); + const cmd = new Deno.Command(LIQUIBASE, { + args: [ + "--defaultsFile=liquibase.properties", + `--classpath=${SQLITE_JDBC}`, + `--url=jdbc:sqlite:${dbPath}`, + "update", + ], + cwd: dir, + stdout: "piped", + stderr: "piped", + }); + const { code, stdout, stderr } = await cmd.output(); + const out = new TextDecoder().decode(stdout); + const err = new TextDecoder().decode(stderr); + if (code !== 0) { + throw new Error(`Liquibase update failed for ${name}:\n${out}\n${err}`); + } +} + +let ran = false; + +/** Apply Liquibase changelogs for app.db and platform.db (idempotent). */ +export async function runLiquibase(): Promise { + if (ran) return; + if (!await ensureTools()) { + console.warn("[liquibase] CLI missing; run ./db/update.sh"); + ran = true; + return; + } + await updateOne("app"); + await updateOne("platform"); + ran = true; +} diff --git a/api/mail.ts b/api/mail.ts new file mode 100644 index 0000000..e31f61f --- /dev/null +++ b/api/mail.ts @@ -0,0 +1,100 @@ +import type { PlatformDb } from "./platform_db.ts"; +import { config } from "./config.ts"; +import { resolveSmtp, smtpConfigured as smtpConfiguredFromDb } from "./smtp.ts"; + +export type MailPayload = { + to: string; + subject: string; + text: string; +}; + +export function smtpConfigured(platformDb: PlatformDb): boolean { + return smtpConfiguredFromDb(platformDb); +} + +/** Envía correo por SMTP. Si no hay SMTP, no falla: sent=false. */ +export async function sendMail( + platformDb: PlatformDb, + payload: MailPayload, +): Promise<{ sent: boolean; error?: string; message_id?: string }> { + if (!smtpConfigured(platformDb)) { + return { sent: false, error: "SMTP no configurado o deshabilitado" }; + } + + const s = resolveSmtp(platformDb); + try { + const nodemailer = await import("npm:nodemailer@6.9.16"); + const transporter = nodemailer.createTransport({ + host: s.host, + port: s.port, + secure: s.port === 465, + requireTLS: s.port === 587, + auth: s.username ? { user: s.username, pass: s.password } : undefined, + connectionTimeout: 20000, + greetingTimeout: 20000, + socketTimeout: 30000, + tls: { + // Algunos hosting (cPanel) usan certs que fallan la validación estricta en Deno + rejectUnauthorized: false, + servername: s.host, + }, + }); + const info = await transporter.sendMail({ + from: s.from_address, + to: payload.to, + subject: payload.subject, + text: payload.text, + }); + const messageId = typeof info?.messageId === "string" ? info.messageId : undefined; + console.log(`[smtp] sent to=${payload.to} id=${messageId ?? "—"} host=${s.host}:${s.port}`); + return { sent: true, message_id: messageId }; + } catch (e) { + const msg = e instanceof Error ? e.message : "Error al enviar correo"; + console.error(`[smtp] fail to=${payload.to}:`, msg); + return { sent: false, error: msg }; + } +} + +/** Prueba de conexión / envío a un destinatario. */ +export async function testSmtp( + platformDb: PlatformDb, + to: string, +): Promise<{ sent: boolean; error?: string; message_id?: string }> { + const dest = (to ?? "").trim(); + if (!dest || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(dest)) { + return { sent: false, error: "Indique un correo de prueba válido" }; + } + return sendMail(platformDb, { + to: dest, + subject: "PANELS — prueba SMTP", + text: `Este es un correo de prueba de la configuración SMTP de PANELS. + +Si recibió este mensaje, el SMTP está funcionando. + +Enviado: ${new Date().toISOString()} +Destinatario: ${dest} +`, + }); +} + +export function accessEmailBody(opts: { + companyName: string; + username: string; + password: string; + contactName?: string; +}): string { + const hi = opts.contactName ? `Hola ${opts.contactName},` : "Hola,"; + return `${hi} + +Su empresa "${opts.companyName}" ya tiene acceso a PANELS. + +Usuario (administrador): ${opts.username} +Contraseña temporal: ${opts.password} + +Inicie sesión en: ${config.panelLoginUrl} + +Por seguridad, al entrar por primera vez deberá cambiar la contraseña. + +— Equipo PANELS +`; +} diff --git a/api/main.ts b/api/main.ts new file mode 100644 index 0000000..733278a --- /dev/null +++ b/api/main.ts @@ -0,0 +1,1260 @@ +import { Hono } from "hono"; +import { cors } from "hono/cors"; +import { getDb, checklistFor, refreshPipeline, workerDir, lastInsertId, nextProjectCode, isProjectStatus, projectById, projectMustBe, PROJECT_STATUS_CATALOG, projectDir, projectChecklistFor, imssFlagsFor, checklistItemsFor } from "./db.ts"; +import { config } from "./config.ts"; +import { clearSession, createSessionCookie, login, changePassword, requireAuth, requirePlatformAdmin, tenantScope, type AuthUser } from "./auth.ts"; +import { getPlatformDb } from "./platform_db.ts"; +import { createTenant, getTenantDetail, issueTenantAdminAccess, listTenants, updateTenant } from "./saas.ts"; +import { smtpConfigured, testSmtp } from "./mail.ts"; +import { saveSmtpSettings, smtpPublicView } from "./smtp.ts"; +import { decryptBytes } from "./docs_crypto.ts"; +import { importExcel, storeDocument, storeProjectDocument, findExisting, assign, buildImportTemplate } from "./excel.ts"; +import { + normalizeWorker, + validateCurp, + validateNss, + validateRfc, + validateWorkerFields, + fullName, + formatNss, + normUpper, + titleCase, + sentenceCase, + type WorkerInput, +} from "./mx.ts"; +import { generateBadgePdf, saveJobPdf, loadCurrentPhoto, badgeQrPng } from "./pdf.ts"; +import { createSubcompany, listCompanies, resolveCompany, updateCompany } from "./companies.ts"; +import { + buildBudgetTemplate, + exportBudgetWorkbook, + previewBudgetExcel, + importBudgetExcel, + lineAmount, + listBudget, +} from "./budget.ts"; +import { join } from "node:path"; + +const app = new Hono<{ Variables: { user: AuthUser } }>(); + +function corsOrigins(): string[] { + const defaults = [ + "http://localhost:3000", + "http://127.0.0.1:3000", + "http://localhost:3001", + "http://127.0.0.1:3001", + ]; + const extra = (Deno.env.get("CORS_ORIGINS") ?? "") + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + return [...new Set([...defaults, ...extra])]; +} + +app.use( + "/v1/*", + cors({ + origin: corsOrigins(), + credentials: true, + allowHeaders: ["Content-Type", "X-API-Key"], + }), +); + +app.get("/v1/health", (c) => c.json({ ok: true })); + +app.post("/v1/auth/login", async (c) => { + const body = await c.req.json<{ username?: string; password?: string }>(); + try { + const user = await login(body.username ?? "", body.password ?? ""); + if (!user) return c.json({ error: "Usuario o contraseña incorrectos" }, 401); + await createSessionCookie(c, user.id, user.realm); + return c.json({ user }); + } catch (e: unknown) { + if (e instanceof Error && (e as { code?: string }).code === "TENANT_BLOCKED") { + return c.json({ error: e.message }, 403); + } + throw e; + } +}); + +app.post("/v1/auth/logout", (c) => { + clearSession(c); + return c.json({ ok: true }); +}); + +app.get("/v1/auth/me", requireAuth, (c) => c.json({ user: c.get("user") })); + +app.post("/v1/auth/change-password", requireAuth, async (c) => { + const user = c.get("user"); + if (user.realm !== "app") return c.json({ error: "No aplica" }, 400); + const body = await c.req.json<{ current_password?: string; new_password?: string }>(); + const result = await changePassword(user.id, body.current_password ?? "", body.new_password ?? ""); + if (result.error) return c.json({ error: result.error }, 400); + const db = await getDb(); + const row = db.prepare( + `SELECT u.id, u.username, u.display_name, u.company_id, u.tenant_id, u.role, + COALESCE(u.must_change_password, 0) AS must_change_password, + c.code AS company_code, c.name AS company_name, c.kind AS company_kind + FROM users u LEFT JOIN companies c ON c.id = u.company_id WHERE u.id = ?`, + ).get(user.id) as Record; + return c.json({ + ok: true, + user: { + ...row, + must_change_password: false, + role: row.role === "tenant_admin" ? "tenant_admin" : "user", + realm: "app", + }, + }); +}); + +app.get("/v1/saas/tenants", requirePlatformAdmin, async (c) => { + const pdb = await getPlatformDb(); + return c.json({ tenants: listTenants(pdb), smtp_configured: smtpConfigured(pdb) }); +}); + +app.get("/v1/saas/tenants/:id", requirePlatformAdmin, async (c) => { + const id = Number(c.req.param("id")); + const pdb = await getPlatformDb(); + const db = await getDb(); + const detail = getTenantDetail(pdb, db, id); + if (!detail) return c.json({ error: "Empresa no encontrada" }, 404); + return c.json({ tenant: detail, smtp_configured: smtpConfigured(pdb) }); +}); + +app.get("/v1/saas/smtp", requirePlatformAdmin, async (c) => { + const pdb = await getPlatformDb(); + return c.json({ smtp: smtpPublicView(pdb) }); +}); + +app.put("/v1/saas/smtp", requirePlatformAdmin, async (c) => { + const body = await c.req.json(); + const pdb = await getPlatformDb(); + const result = saveSmtpSettings(pdb, { + ...body, + keep_password: body.password === undefined || body.password === "", + }); + if (result.error) return c.json({ error: result.error }, 400); + return c.json({ smtp: result.settings }); +}); + +app.post("/v1/saas/smtp/test", requirePlatformAdmin, async (c) => { + const body = await c.req.json<{ to?: string }>().catch(() => ({})); + const pdb = await getPlatformDb(); + if (!smtpConfigured(pdb)) { + return c.json({ error: "Guarde y habilite SMTP antes de probar" }, 400); + } + const result = await testSmtp(pdb, body.to ?? ""); + if (!result.sent) return c.json({ error: result.error || "No se pudo enviar" }, 400); + return c.json({ ok: true, sent: true, to: body.to, message_id: result.message_id }); +}); + +app.post("/v1/saas/tenants", requirePlatformAdmin, async (c) => { + const body = await c.req.json(); + const pdb = await getPlatformDb(); + const db = await getDb(); + const result = await createTenant(pdb, db, body); + if (result.error || !result.tenant) return c.json({ error: result.error }, 400); + return c.json({ + tenant: result.tenant, + admin: result.admin, + email: result.email, + }, 201); +}); + +app.patch("/v1/saas/tenants/:id", requirePlatformAdmin, async (c) => { + const id = Number(c.req.param("id")); + const body = await c.req.json(); + const pdb = await getPlatformDb(); + const db = await getDb(); + const result = updateTenant(pdb, db, id, body); + if (result.error) return c.json({ error: result.error }, 400); + const detail = getTenantDetail(pdb, db, id); + return c.json({ tenant: detail }); +}); + +/** Regenera contraseña temporal del admin y opcionalmente la envía por correo. */ +app.post("/v1/saas/tenants/:id/send-access", requirePlatformAdmin, async (c) => { + const id = Number(c.req.param("id")); + const body = await c.req.json<{ user_id?: number; send_email?: boolean }>().catch(() => ({})); + const pdb = await getPlatformDb(); + const db = await getDb(); + const result = await issueTenantAdminAccess(pdb, db, id, { + userId: body.user_id, + send_email: body.send_email !== false, + }); + if (result.error) return c.json({ error: result.error }, 400); + return c.json({ + admin: result.admin, + email: result.email, + smtp_configured: smtpConfigured(pdb), + }); +}); + +app.get("/v1/catalogs", requireAuth, async (c) => { + const db = await getDb(); + const user = c.get("user"); + const tid = tenantScope(user); + return c.json({ + risks: db.prepare("SELECT * FROM risk_levels").all(), + themes: db.prepare("SELECT * FROM badge_themes").all(), + document_types: db.prepare("SELECT * FROM document_types").all(), + project_document_types: db.prepare("SELECT * FROM project_document_types").all(), + project_statuses: PROJECT_STATUS_CATALOG, + companies: listCompanies(db, tid), + }); +}); + +app.get("/v1/companies", requireAuth, async (c) => { + const db = await getDb(); + const tid = tenantScope(c.get("user")); + return c.json({ companies: listCompanies(db, tid) }); +}); + +app.post("/v1/companies", requireAuth, async (c) => { + const body = await c.req.json(); + const db = await getDb(); + const tid = tenantScope(c.get("user")); + const result = createSubcompany(db, body, tid); + if (result.error || !result.company) return c.json({ error: result.error }, 400); + return c.json({ company: result.company }, 201); +}); + +app.patch("/v1/companies/:id", requireAuth, async (c) => { + const id = Number(c.req.param("id")); + const body = await c.req.json(); + const db = await getDb(); + const result = updateCompany(db, id, body); + if (result.error) return c.json({ error: result.error }, (result.status ?? 400) as 400 | 404); + return c.json({ company: result.company }); +}); + +app.get("/v1/projects", requireAuth, async (c) => { + const db = await getDb(); + const tid = tenantScope(c.get("user")); + const status = (c.req.query("status") ?? "").trim(); + const allowed = status ? status.split(",").map((s) => s.trim()).filter(isProjectStatus) : []; + const clauses: string[] = []; + const params: (string | number)[] = []; + if (tid != null) { + clauses.push("p.tenant_id = ?"); + params.push(tid); + } + if (allowed.length) { + clauses.push(`p.status IN (${allowed.map(() => "?").join(",")})`); + params.push(...allowed); + } + const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : ""; + return c.json({ + projects: db.prepare( + `SELECT p.*, c.name AS company_name, c.code AS company_code, + (SELECT COUNT(*) FROM assignments a WHERE a.project_id = p.id AND a.active = 1) AS active_count, + (SELECT COUNT(*) FROM project_document_types t + WHERE t.required = 1 AND NOT EXISTS ( + SELECT 1 FROM project_documents d + WHERE d.project_id = p.id AND d.type_code = t.code AND d.is_current = 1 + )) AS missing_docs, + (SELECT COUNT(*) FROM budget_items b WHERE b.project_id = p.id) AS budget_count + FROM projects p + LEFT JOIN companies c ON c.id = p.company_id + ${where} + ORDER BY CASE p.status WHEN 'activo' THEN 0 WHEN 'pausado' THEN 1 WHEN 'concluido' THEN 2 ELSE 3 END, p.id`, + ).all(...params), + }); +}); + +type ProjectInput = { + name?: string; + address?: string; + theme_id?: string; + status?: string; + company_id?: number | null; + contract_amount?: number | null; + start_date?: string | null; + end_date?: string | null; + resident_name?: string | null; + siroc?: string | null; + payroll_tax_pct?: number | null; +}; + +function isoDate(value: unknown): string | null { + const raw = String(value ?? "").trim(); + if (!raw) return null; + const match = raw.match(/^(\d{4}-\d{2}-\d{2})/); + return match ? match[1] : null; +} + +function numOrNull(value: unknown): number | null { + if (value === undefined || value === null || value === "") return null; + const n = Number(value); + return Number.isNaN(n) ? null : n; +} + +function projectFields(body: ProjectInput, cur?: Record) { + const start = isoDate(body.start_date !== undefined ? body.start_date : cur?.start_date); + const end = isoDate(body.end_date !== undefined ? body.end_date : cur?.end_date); + const payroll = numOrNull(body.payroll_tax_pct !== undefined ? body.payroll_tax_pct : cur?.payroll_tax_pct); + const amount = numOrNull(body.contract_amount !== undefined ? body.contract_amount : cur?.contract_amount); + const companyRaw = body.company_id !== undefined ? body.company_id : cur?.company_id; + return { + name: titleCase(String(body.name ?? cur?.name ?? "")), + address: sentenceCase(String(body.address ?? cur?.address ?? "")), + theme_id: String(body.theme_id ?? cur?.theme_id ?? "arctec-dos-logos-fold"), + company_id: companyRaw ? Number(companyRaw) : null, + contract_amount: amount, + start_date: start, + end_date: end, + resident_name: titleCase(String(body.resident_name ?? cur?.resident_name ?? "")), + siroc: normUpper(String(body.siroc ?? cur?.siroc ?? "")), + payroll_tax_pct: payroll ?? 4, + }; +} + +app.post("/v1/projects", requireAuth, async (c) => { + const body = await c.req.json(); + if (!body.name?.trim()) return c.json({ error: "Nombre de proyecto obligatorio" }, 400); + const status = body.status && isProjectStatus(body.status) ? body.status : "activo"; + const db = await getDb(); + const fields = projectFields(body); + if (!fields.company_id) return c.json({ error: "Seleccione la empresa del proyecto" }, 400); + if (!resolveCompany(db, { company_id: fields.company_id })) { + return c.json({ error: "Empresa no encontrada" }, 400); + } + if (fields.start_date && fields.end_date && fields.end_date < fields.start_date) { + return c.json({ error: "La fecha de término no puede ser anterior al inicio" }, 400); + } + const code = nextProjectCode(db); + const tid = tenantScope(c.get("user")); + db.prepare( + `INSERT INTO projects ( + code, name, address, theme_id, status, company_id, contract_amount, + start_date, end_date, resident_name, siroc, payroll_tax_pct, tenant_id + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ).run( + code, + fields.name, + fields.address, + fields.theme_id, + status, + fields.company_id, + fields.contract_amount, + fields.start_date, + fields.end_date, + fields.resident_name, + fields.siroc, + fields.payroll_tax_pct, + tid, + ); + const id = lastInsertId(db); + return c.json({ id, code, status }, 201); +}); + +app.patch("/v1/projects/:id", requireAuth, async (c) => { + const id = Number(c.req.param("id")); + const body = await c.req.json(); + const db = await getDb(); + const cur = db.prepare("SELECT * FROM projects WHERE id = ?").get(id) as Record | undefined; + if (!cur) return c.json({ error: "Proyecto no encontrado" }, 404); + const status = body.status && isProjectStatus(body.status) ? body.status : String(cur.status || "activo"); + const fields = projectFields(body, cur); + if (!fields.company_id) return c.json({ error: "Seleccione la empresa del proyecto" }, 400); + if (!resolveCompany(db, { company_id: fields.company_id })) { + return c.json({ error: "Empresa no encontrada" }, 400); + } + if (fields.start_date && fields.end_date && fields.end_date < fields.start_date) { + return c.json({ error: "La fecha de término no puede ser anterior al inicio" }, 400); + } + db.prepare( + `UPDATE projects SET name=?, address=?, theme_id=?, status=?, company_id=?, contract_amount=?, + start_date=?, end_date=?, resident_name=?, siroc=?, payroll_tax_pct=? WHERE id=?`, + ).run( + fields.name, + fields.address, + fields.theme_id, + status, + fields.company_id, + fields.contract_amount, + fields.start_date, + fields.end_date, + fields.resident_name, + fields.siroc, + fields.payroll_tax_pct, + id, + ); + return c.json({ ok: true, id, code: cur.code, status }); +}); + +app.get("/v1/projects/:id", requireAuth, async (c) => { + const id = Number(c.req.param("id")); + const db = await getDb(); + const project = db.prepare( + `SELECT p.*, c.name AS company_name, c.code AS company_code, + (SELECT COUNT(*) FROM assignments a WHERE a.project_id = p.id AND a.active = 1) AS active_count + FROM projects p + LEFT JOIN companies c ON c.id = p.company_id + WHERE p.id = ?`, + ).get(id); + if (!project) return c.json({ error: "Proyecto no encontrado" }, 404); + const documents = db.prepare( + "SELECT id, type_code, original_name, mime, size_bytes, is_current, uploaded_at FROM project_documents WHERE project_id = ? ORDER BY uploaded_at DESC", + ).all(id); + return c.json({ + project, + documents, + checklist: projectChecklistFor(db, id), + }); +}); + +app.post("/v1/projects/: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 (!projectById(db, id)) return c.json({ error: "Proyecto no encontrado" }, 404); + const bytes = new Uint8Array(await file.arrayBuffer()); + try { + await storeProjectDocument(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: projectChecklistFor(db, id) }); +}); + +app.get("/v1/projects/:id/documents/:docId", requireAuth, async (c) => { + const projectId = Number(c.req.param("id")); + const docId = Number(c.req.param("docId")); + const db = await getDb(); + const doc = db.prepare( + "SELECT * FROM project_documents WHERE id=? AND project_id=?", + ).get(docId, projectId) 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(projectDir(projectId), 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/:id/budget", requireAuth, async (c) => { + const id = Number(c.req.param("id")); + const db = await getDb(); + if (!projectById(db, id)) return c.json({ error: "Proyecto no encontrado" }, 404); + return c.json(listBudget(db, id)); +}); + +app.get("/v1/projects/:id/budget/template", requireAuth, (c) => { + const bytes = buildBudgetTemplate(); + c.header("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); + c.header("Content-Disposition", 'attachment; filename="plantilla-presupuesto.xlsx"'); + return c.body(bytes.slice()); +}); + +app.get("/v1/projects/:id/budget/export", requireAuth, async (c) => { + const id = Number(c.req.param("id")); + const db = await getDb(); + const project = projectById(db, id); + if (!project) return c.json({ error: "Proyecto no encontrado" }, 404); + const budget = listBudget(db, id); + const bytes = exportBudgetWorkbook(project.name, budget); + c.header("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); + c.header("Content-Disposition", `attachment; filename="presupuesto-${project.code}.xlsx"`); + return c.body(bytes.slice()); +}); + +app.post("/v1/budget/preview", requireAuth, async (c) => { + const form = await c.req.formData(); + const file = form.get("file"); + if (!(file instanceof File)) return c.json({ error: "Archivo Excel requerido" }, 400); + const bytes = new Uint8Array(await file.arrayBuffer()); + const preview = previewBudgetExcel(bytes); + if (preview.errors.length && !preview.items.length) return c.json(preview, 400); + return c.json(preview); +}); + +app.post("/v1/projects/:id/budget/preview", requireAuth, async (c) => { + const id = Number(c.req.param("id")); + const form = await c.req.formData(); + const file = form.get("file"); + if (!(file instanceof File)) return c.json({ error: "Archivo Excel requerido" }, 400); + const db = await getDb(); + if (!projectById(db, id)) return c.json({ error: "Proyecto no encontrado" }, 404); + const bytes = new Uint8Array(await file.arrayBuffer()); + const preview = previewBudgetExcel(bytes); + if (preview.errors.length && !preview.items.length) return c.json(preview, 400); + return c.json(preview); +}); + +app.post("/v1/projects/:id/budget/import", requireAuth, async (c) => { + const id = Number(c.req.param("id")); + const form = await c.req.formData(); + const file = form.get("file"); + if (!(file instanceof File)) return c.json({ error: "Archivo Excel requerido" }, 400); + const db = await getDb(); + if (!projectById(db, id)) return c.json({ error: "Proyecto no encontrado" }, 404); + const bytes = new Uint8Array(await file.arrayBuffer()); + const report = importBudgetExcel(db, id, bytes); + if (report.errors.length && !report.inserted) return c.json(report, 400); + return c.json(report); +}); + +app.post("/v1/projects/:id/budget/chapters", requireAuth, async (c) => { + const id = Number(c.req.param("id")); + const body = await c.req.json<{ name?: string; code?: string; parent_id?: number | null }>(); + if (!body.name?.trim()) return c.json({ error: "Nombre de capítulo obligatorio" }, 400); + const db = await getDb(); + if (!projectById(db, id)) return c.json({ error: "Proyecto no encontrado" }, 404); + const sort = (db.prepare("SELECT COALESCE(MAX(sort_order), 0) + 1 AS n FROM budget_chapters WHERE project_id = ?").get(id) as { n: number }).n; + db.prepare("INSERT INTO budget_chapters (project_id, parent_id, code, name, sort_order) VALUES (?, ?, ?, ?, ?)").run( + id, + body.parent_id || null, + (body.code || "").trim(), + body.name.trim(), + sort, + ); + return c.json({ id: lastInsertId(db) }, 201); +}); + +app.patch("/v1/projects/:id/budget/chapters/:cid", requireAuth, async (c) => { + const id = Number(c.req.param("id")); + const cid = Number(c.req.param("cid")); + const body = await c.req.json<{ name?: string; code?: string }>(); + const db = await getDb(); + const cur = db.prepare("SELECT * FROM budget_chapters WHERE id = ? AND project_id = ?").get(cid, id) as { name: string; code: string } | undefined; + if (!cur) return c.json({ error: "Capítulo no encontrado" }, 404); + db.prepare("UPDATE budget_chapters SET name = ?, code = ? WHERE id = ?").run( + (body.name ?? cur.name).trim(), + (body.code ?? cur.code).trim(), + cid, + ); + return c.json({ ok: true }); +}); + +app.delete("/v1/projects/:id/budget/chapters/:cid", requireAuth, async (c) => { + const id = Number(c.req.param("id")); + const cid = Number(c.req.param("cid")); + const db = await getDb(); + const found = db.prepare("SELECT id FROM budget_chapters WHERE id = ? AND project_id = ?").get(cid, id); + if (!found) return c.json({ error: "Capítulo no encontrado" }, 404); + db.prepare("UPDATE budget_items SET chapter_id = NULL WHERE project_id = ? AND chapter_id = ?").run(id, cid); + db.prepare("DELETE FROM budget_chapters WHERE id = ? AND project_id = ?").run(cid, id); + return c.json({ ok: true }); +}); + +type BudgetItemInput = { + chapter_id?: number | null; + code?: string; + description?: string; + unit?: string; + quantity?: number; + unit_price?: number; +}; + +app.post("/v1/projects/:id/budget/items", requireAuth, async (c) => { + const id = Number(c.req.param("id")); + const body = await c.req.json(); + if (!body.description?.trim()) return c.json({ error: "Descripción de partida obligatoria" }, 400); + const db = await getDb(); + if (!projectById(db, id)) return c.json({ error: "Proyecto no encontrado" }, 404); + const qty = Number(body.quantity ?? 0); + const price = Number(body.unit_price ?? 0); + const sort = (db.prepare("SELECT COALESCE(MAX(sort_order), 0) + 1 AS n FROM budget_items WHERE project_id = ?").get(id) as { n: number }).n; + db.prepare( + `INSERT INTO budget_items (project_id, chapter_id, code, description, unit, quantity, unit_price, amount, sort_order) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ).run( + id, + body.chapter_id || null, + (body.code || "").trim().toUpperCase(), + body.description.trim(), + (body.unit || "").trim().toUpperCase(), + qty, + price, + lineAmount(qty, price), + sort, + ); + return c.json({ id: lastInsertId(db) }, 201); +}); + +app.patch("/v1/projects/:id/budget/items/:iid", requireAuth, async (c) => { + const id = Number(c.req.param("id")); + const iid = Number(c.req.param("iid")); + const body = await c.req.json(); + const db = await getDb(); + const cur = db.prepare("SELECT * FROM budget_items WHERE id = ? AND project_id = ?").get(iid, id) as Record | undefined; + if (!cur) return c.json({ error: "Partida no encontrada" }, 404); + const qty = body.quantity !== undefined ? Number(body.quantity) : Number(cur.quantity); + const price = body.unit_price !== undefined ? Number(body.unit_price) : Number(cur.unit_price); + db.prepare( + `UPDATE budget_items SET chapter_id=?, code=?, description=?, unit=?, quantity=?, unit_price=?, amount=? WHERE id=?`, + ).run( + body.chapter_id !== undefined ? (body.chapter_id || null) : cur.chapter_id, + (body.code ?? String(cur.code ?? "")).trim().toUpperCase(), + (body.description ?? String(cur.description ?? "")).trim(), + (body.unit ?? String(cur.unit ?? "")).trim().toUpperCase(), + qty, + price, + lineAmount(qty, price), + iid, + ); + return c.json({ ok: true }); +}); + +app.delete("/v1/projects/:id/budget/items/:iid", requireAuth, async (c) => { + const id = Number(c.req.param("id")); + const iid = Number(c.req.param("iid")); + const db = await getDb(); + const found = db.prepare("SELECT id FROM budget_items WHERE id = ? AND project_id = ?").get(iid, id); + if (!found) return c.json({ error: "Partida no encontrada" }, 404); + db.prepare("DELETE FROM budget_items WHERE id = ? AND project_id = ?").run(iid, id); + return c.json({ ok: true }); +}); + +app.post("/v1/workers/validate", requireAuth, async (c) => { + const body = await c.req.json(); + const errors = validateWorkerFields(body); + const db = await getDb(); + const exclude = body.id ?? 0; + const checks: { field: string; value: string; err: string | null }[] = [ + { field: "curp", value: normUpper(body.curp), err: validateCurp(body.curp ?? "") }, + { field: "rfc", value: normUpper(body.rfc), err: validateRfc(body.rfc ?? "") }, + { field: "nss", value: formatNss(body.nss ?? ""), err: validateNss(body.nss ?? "") }, + ]; + for (const ch of checks) { + if (ch.err) continue; + const row = db.prepare( + `SELECT id, first_name, last_name_p FROM workers WHERE ${ch.field} = ? AND id != ?`, + ).get(ch.value, exclude) as { id: number; first_name: string; last_name_p: string } | undefined; + if (row) { + errors[ch.field] = + `Este ${ch.field.toUpperCase()} ya pertenece a ${row.first_name} ${row.last_name_p}`; + } + } + return c.json({ ok: Object.keys(errors).length === 0, errors }); +}); + +app.get("/v1/workers", requireAuth, async (c) => { + const db = await getDb(); + const tid = tenantScope(c.get("user")); + const q = (c.req.query("q") ?? "").trim(); + const status = c.req.query("status"); + const projectId = c.req.query("project_id"); + let sql = `SELECT w.*, r.label AS risk_label, r.color AS risk_color, r.text_color AS risk_text, + c.name AS company_name, c.kind AS company_kind, + ic.name AS imss_company_name, ic.code AS imss_company_code, ic.registro_patronal AS imss_registro_patronal, + (SELECT GROUP_CONCAT(p.name, ', ') FROM assignments a JOIN projects p ON p.id = a.project_id + WHERE a.worker_id = w.id AND a.active = 1) AS proyectos, + (SELECT GROUP_CONCAT(p.code || '|' || replace(p.name, '|', '/'), ';;') FROM assignments a JOIN projects p ON p.id = a.project_id + WHERE a.worker_id = w.id AND a.active = 1) AS proyecto_pairs, + (SELECT GROUP_CONCAT(p.id, ',') FROM assignments a JOIN projects p ON p.id = a.project_id + WHERE a.worker_id = w.id AND a.active = 1) AS project_ids, + (SELECT COUNT(*) FROM document_types t + WHERE t.required = 1 AND NOT EXISTS ( + SELECT 1 FROM documents d + WHERE d.worker_id = w.id AND d.type_code = t.code AND d.is_current = 1 + )) AS missing_docs, + (SELECT COALESCE(SUM(l.balance), 0) FROM loans l WHERE l.worker_id = w.id AND l.balance > 0) AS loan_balance, + CASE WHEN EXISTS (SELECT 1 FROM assignments a WHERE a.worker_id = w.id AND a.active = 1) THEN 1 ELSE 0 END AS in_project + FROM workers w + JOIN risk_levels r ON r.code = w.risk_code + LEFT JOIN companies c ON c.id = w.company_id + LEFT JOIN companies ic ON ic.id = w.imss_company_id + WHERE 1=1`; + const params: (string | number)[] = []; + if (tid != null) { + sql += " AND w.tenant_id = ?"; + params.push(tid); + } + if (status) { + sql += " AND w.status = ?"; + params.push(status); + if (status === "activo") sql += " AND w.pipeline_status != 'baja'"; + } + if (projectId) { + sql += + " AND EXISTS (SELECT 1 FROM assignments a WHERE a.worker_id = w.id AND a.project_id = ? AND a.active = 1)"; + params.push(Number(projectId)); + } + if (q) { + sql += + " AND (w.first_name LIKE ? OR w.last_name_p LIKE ? OR w.curp LIKE ? OR w.rfc LIKE ? OR w.nss LIKE ?)"; + const like = `%${q}%`; + params.push(like, like, like, like, like); + } + sql += " ORDER BY w.last_name_p, w.first_name"; + const rows = db.prepare(sql).all(...params) as Array & { + id: number; + status: string; + pipeline_status: string; + imss_status?: string; + in_project?: number; + }>; + const workers = rows.map((row) => { + const flags = imssFlagsFor(db, row.id); + return { + ...row, + imss_status: flags.imss_status, + imss_ready: flags.imss_ready, + in_project_without_imss: flags.in_project_without_imss, + expediente_ok: flags.expediente_ok, + freshness_required: flags.freshness_required, + }; + }); + const active = workers.filter((w) => w.status === "activo" && w.pipeline_status !== "baja"); + const withImss = active.filter((w) => w.imss_status === "alta").length; + const withoutImss = active.length - withImss; + const inProjectWithoutImss = active.filter((w) => w.in_project_without_imss).length; + const imssReady = active.filter((w) => w.imss_ready).length; + return c.json({ + workers, + imss_stats: { + with_imss: withImss, + without_imss: withoutImss, + in_project_without_imss: inProjectWithoutImss, + imss_ready_count: imssReady, + }, + }); +}); + +app.get("/v1/workers/:id", requireAuth, async (c) => { + const id = Number(c.req.param("id")); + const db = await getDb(); + const worker = db.prepare( + `SELECT w.*, r.label AS risk_label, r.color AS risk_color, r.text_color AS risk_text, + c.name AS company_name, c.kind AS company_kind, + ic.name AS imss_company_name, ic.code AS imss_company_code, ic.registro_patronal AS imss_registro_patronal + FROM workers w + JOIN risk_levels r ON r.code = w.risk_code + LEFT JOIN companies c ON c.id = w.company_id + LEFT JOIN companies ic ON ic.id = w.imss_company_id + WHERE w.id = ?`, + ).get(id); + if (!worker) return c.json({ error: "No encontrado" }, 404); + const documents = db.prepare( + `SELECT id, type_code, original_name, mime, size_bytes, is_current, uploaded_at, + issued_at, expires_at, imss_company_id, imss_alta_at + FROM documents WHERE worker_id = ? ORDER BY uploaded_at DESC`, + ).all(id); + const assignments = db.prepare( + `SELECT a.*, p.name AS project_name, p.code AS project_code FROM assignments a + JOIN projects p ON p.id = a.project_id WHERE a.worker_id = ? + ORDER BY a.active DESC, a.start_date DESC, a.id DESC`, + ).all(id); + const loans = db.prepare("SELECT * FROM loans WHERE worker_id = ? ORDER BY id DESC").all(id); + const { items, freshness_required } = checklistItemsFor(db, id); + const flags = imssFlagsFor(db, id); + const docTypes = db.prepare( + `SELECT code, label, required, validity_mode, freshness_days, requires_issued_at, requires_expires_at + FROM document_types ORDER BY required DESC, label`, + ).all(); + return c.json({ + worker: { ...worker, full_name: fullName(worker as never), ...flags }, + documents, + assignments, + loans, + checklist: items, + document_types: docTypes, + freshness_required, + }); +}); + +function conflict(db: Awaited>, n: ReturnType, excludeId = 0) { + const row = findExisting(db, n.curp, n.rfc, n.nss); + if (row && row.id !== excludeId) { + const field = row.curp === n.curp ? "CURP" : row.rfc === n.rfc ? "RFC" : "NSS"; + return { + status: 409 as const, + body: { + error: `Este ${field} ya pertenece a ${row.first_name} ${row.last_name_p}`, + worker_id: row.id, + }, + }; + } + return null; +} + +app.post("/v1/workers", requireAuth, async (c) => { + const body = await c.req.json(); + const errors = validateWorkerFields(body); + const db = await getDb(); + if (Object.keys(errors).length) return c.json({ errors }, 400); + const n = normalizeWorker({ ...body, hire_type: "" }); + const cf = conflict(db, n); + if (cf) return c.json(cf.body, cf.status); + if (body.project_id) { + const blocked = projectMustBe( + projectById(db, body.project_id), + ["activo"], + "Solo se asigna personal a proyectos activos", + ); + if (blocked) return c.json({ error: blocked.error }, blocked.status); + } + db.prepare( + `INSERT INTO workers + (first_name, middle_name, last_name_p, last_name_m, curp, rfc, nss, phone, email, address, + blood_type, hire_type, company_id, position, risk_code, work_type, daily_wage, needs_badge, status, tenant_id) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, + ).run( + n.first_name, n.middle_name, n.last_name_p, n.last_name_m, n.curp, n.rfc, n.nss, + n.phone, n.email, n.address, n.blood_type, "", null, n.position, n.risk_code, + n.work_type, n.daily_wage, n.needs_badge, n.status, tenantScope(c.get("user")), + ); + const id = lastInsertId(db); + if (body.project_id) { + assign(db, id, body.project_id); + } + refreshPipeline(db, id); + return c.json({ id }, 201); +}); + +app.patch("/v1/workers/:id", requireAuth, async (c) => { + const id = Number(c.req.param("id")); + const db = await getDb(); + const cur = db.prepare("SELECT * FROM workers WHERE id = ?").get(id) as Record | undefined; + if (!cur) return c.json({ error: "No encontrado" }, 404); + const body = await c.req.json(); + const merged = { ...cur, ...body } as WorkerInput; + const errors = validateWorkerFields(merged); + if (Object.keys(errors).length) return c.json({ errors }, 400); + // Empresa/patrón solo cambia con alta/baja IMSS; no se sobrescribe desde el formulario. + const hireType = String(cur.hire_type || ""); + const companyId = (cur.company_id as number | null) ?? null; + const n = normalizeWorker({ ...merged, hire_type: hireType }); + const cf = conflict(db, n, id); + if (cf) return c.json(cf.body, cf.status); + db.prepare( + `UPDATE workers SET + first_name=?, middle_name=?, last_name_p=?, last_name_m=?, curp=?, rfc=?, nss=?, + phone=?, email=?, address=?, blood_type=?, hire_type=?, company_id=?, position=?, risk_code=?, + work_type=?, daily_wage=?, needs_badge=?, status=?, updated_at=datetime('now') + WHERE id=?`, + ).run( + n.first_name, n.middle_name, n.last_name_p, n.last_name_m, n.curp, n.rfc, n.nss, + n.phone, n.email, n.address, n.blood_type, hireType, companyId, n.position, n.risk_code, + n.work_type, n.daily_wage, n.needs_badge, n.status, id, + ); + refreshPipeline(db, id); + return c.json({ ok: true }); +}); + +app.patch("/v1/workers/:id/pipeline", requireAuth, async (c) => { + const id = Number(c.req.param("id")); + const { pipeline_status } = await c.req.json<{ pipeline_status: string }>(); + const allowed = ["incompleto", "listo_gafete", "impreso", "activo", "baja"]; + if (!allowed.includes(pipeline_status)) return c.json({ error: "Estado inválido" }, 400); + const db = await getDb(); + const prev = db.prepare("SELECT status FROM workers WHERE id=?").get(id) as { status: string } | undefined; + if (pipeline_status === "baja") { + db.prepare("UPDATE workers SET status='baja', pipeline_status='baja' WHERE id=?").run(id); + } else { + // Reactivar: si venía de baja, marca rehire para exigir docs frescos hasta nueva alta IMSS. + if (prev?.status === "baja") { + db.prepare( + "UPDATE workers SET status='activo', last_rehire_at=date('now') WHERE id=?", + ).run(id); + } else { + db.prepare("UPDATE workers SET status='activo' WHERE id=?").run(id); + } + refreshPipeline(db, id); + } + const worker = db.prepare("SELECT status, pipeline_status, last_rehire_at, imss_status FROM workers WHERE id=?").get(id); + return c.json({ ok: true, worker }); +}); + +app.post("/v1/workers/:id/assign", requireAuth, async (c) => { + const id = Number(c.req.param("id")); + const { project_id, active } = await c.req.json<{ project_id: number; active?: boolean }>(); + const db = await getDb(); + if (active === false) { + db.prepare( + "UPDATE assignments SET active=0, end_date=date('now') WHERE worker_id=? AND project_id=?", + ).run(id, project_id); + } else { + const project = projectById(db, project_id); + if (!project) return c.json({ error: "Proyecto no encontrado" }, 404); + if (project.status !== "activo") { + return c.json({ error: "Solo se asigna personal a proyectos activos" }, 400); + } + assign(db, id, project_id); + } + refreshPipeline(db, id); + return c.json({ ok: true }); +}); + +app.get("/v1/workers/import/template", requireAuth, async (c) => { + const db = await getDb(); + const bytes = buildImportTemplate(listCompanies(db)); + c.header("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); + c.header("Content-Disposition", 'attachment; filename="plantilla-padron-arctec.xlsx"'); + return c.body(bytes.buffer as ArrayBuffer); +}); + +app.post("/v1/workers/import", requireAuth, async (c) => { + const form = await c.req.formData(); + const file = form.get("file"); + const projectId = Number(form.get("project_id") || 0) || null; + if (!(file instanceof File)) return c.json({ error: "Archivo Excel requerido" }, 400); + const bytes = new Uint8Array(await file.arrayBuffer()); + const db = await getDb(); + if (projectId) { + const blocked = projectMustBe( + projectById(db, projectId), + ["activo"], + "Solo se importan altas a proyectos activos", + ); + if (blocked) return c.json({ error: blocked.error }, blocked.status); + } + const report = await importExcel(db, bytes, projectId, c.get("user").id || null); + return c.json(report); +}); + +app.post("/v1/workers/: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(); + const exists = db.prepare("SELECT id FROM workers WHERE id=?").get(id); + if (!exists) return c.json({ error: "No encontrado" }, 404); + + const policy = db.prepare( + `SELECT validity_mode, requires_issued_at, requires_expires_at FROM document_types WHERE code = ?`, + ).get(type) as { + validity_mode: string; + requires_issued_at: number; + requires_expires_at: number; + } | undefined; + if (!policy) return c.json({ error: "Tipo de documento no válido" }, 400); + + const issuedAt = String(form.get("issued_at") || "").trim() || null; + const expiresAt = String(form.get("expires_at") || "").trim() || null; + const imssCompanyId = Number(form.get("imss_company_id") || 0) || null; + const imssAltaAt = String(form.get("imss_alta_at") || "").trim() || null; + const imssBajaAt = String(form.get("imss_baja_at") || "").trim() || null; + + if (policy.requires_issued_at && !issuedAt) { + return c.json({ error: "Indique la fecha de emisión del documento" }, 400); + } + if (policy.requires_expires_at && !expiresAt) { + return c.json({ error: "Indique la fecha de vigencia / vencimiento" }, 400); + } + if (type === "alta_imss" && !imssCompanyId) { + return c.json({ error: "Seleccione la empresa patrón del alta IMSS" }, 400); + } + if (type === "alta_imss" && !imssAltaAt) { + return c.json({ error: "Indique la fecha de alta IMSS" }, 400); + } + if (type === "baja_imss" && !imssBajaAt) { + return c.json({ error: "Indique la fecha de baja IMSS" }, 400); + } + + const bytes = new Uint8Array(await file.arrayBuffer()); + try { + await storeDocument(db, id, type, file.name, file.type || "application/octet-stream", bytes, c.get("user").id, { + issued_at: issuedAt, + expires_at: expiresAt, + imss_company_id: imssCompanyId, + imss_alta_at: imssAltaAt, + imss_baja_at: imssBajaAt, + }); + } catch (error) { + return c.json({ error: error instanceof Error ? error.message : "No se pudo guardar" }, 400); + } + return c.json({ + ok: true, + checklist: checklistFor(db, id), + ...imssFlagsFor(db, id), + }); +}); + +app.get("/v1/workers/:id/documents/:docId", requireAuth, async (c) => { + const workerId = Number(c.req.param("id")); + const docId = Number(c.req.param("docId")); + const db = await getDb(); + const doc = db.prepare( + "SELECT * FROM documents WHERE id=? AND worker_id=?", + ).get(docId, workerId) 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(workerDir(workerId), 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/workers/:id/photo", requireAuth, async (c) => { + const id = Number(c.req.param("id")); + const db = await getDb(); + const bytes = await loadCurrentPhoto(db, id); + if (!bytes) return c.json({ error: "Sin foto" }, 404); + const jpeg = bytes[0] === 0xff && bytes[1] === 0xd8; + c.header("Content-Type", jpeg ? "image/jpeg" : "image/png"); + c.header("Cache-Control", "private, max-age=30"); + return c.body(bytes.buffer as ArrayBuffer); +}); + +app.get("/v1/badge-qr", requireAuth, async (c) => { + const curp = (c.req.query("curp") ?? "").trim().toUpperCase(); + if (curp.length < 10) return c.json({ error: "CURP requerida" }, 400); + const png = await badgeQrPng(curp); + c.header("Content-Type", "image/png"); + c.header("Cache-Control", "private, max-age=60"); + return c.body(png.buffer as ArrayBuffer); +}); + +app.post("/v1/projects/:id/badge-jobs", requireAuth, async (c) => { + const projectId = Number(c.req.param("id")); + const body = await c.req.json<{ worker_ids?: number[] }>().catch(() => ({ worker_ids: [] as number[] })); + const db = await getDb(); + const blocked = projectMustBe( + projectById(db, projectId), + ["activo"], + "Solo se generan gafetes de proyectos activos", + ); + if (blocked) return c.json({ error: blocked.error }, blocked.status); + let ids = body.worker_ids ?? []; + if (!ids.length) { + ids = (db.prepare( + `SELECT w.id FROM workers w + JOIN assignments a ON a.worker_id = w.id AND a.project_id = ? AND a.active = 1 + WHERE w.status = 'activo' AND w.needs_badge = 1 + AND EXISTS (SELECT 1 FROM documents d WHERE d.worker_id=w.id AND d.type_code='foto' AND d.is_current=1)`, + ).all(projectId) as { id: number }[]).map((r) => r.id); + } + if (!ids.length) return c.json({ error: "No hay personal activo con foto para imprimir" }, 400); + const bytes = await generateBadgePdf(db, projectId, ids); + db.prepare( + "INSERT INTO badge_jobs (project_id, status, created_by) VALUES (?, 'done', ?)", + ).run(projectId, c.get("user").id); + const jobId = lastInsertId(db); + const path = await saveJobPdf(bytes, jobId); + db.prepare("UPDATE badge_jobs SET pdf_path=? WHERE id=?").run(path, jobId); + const ins = db.prepare("INSERT INTO badge_job_people (job_id, worker_id) VALUES (?, ?)"); + for (const wid of ids) { + ins.run(jobId, wid); + refreshPipeline(db, wid); + } + return c.json({ id: jobId, count: ids.length }); +}); + +app.get("/v1/badge-jobs", requireAuth, async (c) => { + const db = await getDb(); + const jobs = db.prepare( + `SELECT j.*, p.name AS project_name, + (SELECT COUNT(*) FROM badge_job_people x WHERE x.job_id=j.id) AS people, + (SELECT COUNT(*) FROM badge_job_people x WHERE x.job_id=j.id AND x.delivered=1) AS delivered + FROM badge_jobs j JOIN projects p ON p.id=j.project_id ORDER BY j.id DESC`, + ).all(); + return c.json({ jobs }); +}); + +app.get("/v1/badge-jobs/:id", requireAuth, async (c) => { + const id = Number(c.req.param("id")); + const db = await getDb(); + const job = db.prepare("SELECT * FROM badge_jobs WHERE id=?").get(id); + if (!job) return c.json({ error: "Job no encontrado" }, 404); + const people = db.prepare( + `SELECT p.*, w.first_name, w.last_name_p, w.position FROM badge_job_people p + JOIN workers w ON w.id = p.worker_id WHERE p.job_id=?`, + ).all(id); + return c.json({ job, people }); +}); + +app.get("/v1/badge-jobs/:id/pdf", requireAuth, async (c) => { + const id = Number(c.req.param("id")); + const db = await getDb(); + const job = db.prepare("SELECT pdf_path FROM badge_jobs WHERE id=?").get(id) as { pdf_path: string } | undefined; + if (!job?.pdf_path) return c.json({ error: "PDF no encontrado" }, 404); + const bytes = await Deno.readFile(job.pdf_path); + c.header("Content-Type", "application/pdf"); + c.header("Content-Disposition", `attachment; filename="gafetes-${id}.pdf"`); + return c.body(bytes.buffer as ArrayBuffer); +}); + +app.patch("/v1/badge-jobs/:id/people/:workerId", requireAuth, async (c) => { + const jobId = Number(c.req.param("id")); + const workerId = Number(c.req.param("workerId")); + const { delivered } = await c.req.json<{ delivered: boolean }>(); + const db = await getDb(); + db.prepare( + `UPDATE badge_job_people SET delivered=?, delivered_at=CASE WHEN ? THEN datetime('now') ELSE NULL END + WHERE job_id=? AND worker_id=?`, + ).run(delivered ? 1 : 0, delivered ? 1 : 0, jobId, workerId); + return c.json({ ok: true }); +}); + +app.get("/v1/attendance", requireAuth, async (c) => { + const projectId = Number(c.req.query("project_id")); + const from = c.req.query("from") ?? ""; + const to = c.req.query("to") ?? ""; + const db = await getDb(); + const rows = db.prepare( + `SELECT a.*, w.first_name, w.last_name_p FROM attendance a + JOIN workers w ON w.id=a.worker_id + WHERE a.project_id=? AND a.work_date BETWEEN ? AND ? + ORDER BY a.work_date, w.last_name_p`, + ).all(projectId, from, to); + return c.json({ attendance: rows }); +}); + +app.put("/v1/attendance", requireAuth, async (c) => { + const body = await c.req.json<{ + project_id: number; + worker_id: number; + work_date: string; + present: boolean; + }>(); + const db = await getDb(); + const blocked = projectMustBe( + projectById(db, body.project_id), + ["activo", "pausado"], + "No se registra asistencia en un proyecto concluido o cancelado", + ); + if (blocked) return c.json({ error: blocked.error }, blocked.status); + db.prepare( + `INSERT INTO attendance (worker_id, project_id, work_date, present) + VALUES (?, ?, ?, ?) + ON CONFLICT(worker_id, project_id, work_date) DO UPDATE SET present=excluded.present`, + ).run(body.worker_id, body.project_id, body.work_date, body.present ? 1 : 0); + return c.json({ ok: true }); +}); + +app.get("/v1/loans", requireAuth, async (c) => { + const db = await getDb(); + const workerId = c.req.query("worker_id"); + const sql = workerId + ? "SELECT l.*, w.first_name, w.last_name_p FROM loans l JOIN workers w ON w.id=l.worker_id WHERE l.worker_id=? ORDER BY l.id DESC" + : "SELECT l.*, w.first_name, w.last_name_p FROM loans l JOIN workers w ON w.id=l.worker_id ORDER BY l.id DESC"; + const loans = workerId ? db.prepare(sql).all(Number(workerId)) : db.prepare(sql).all(); + return c.json({ loans }); +}); + +app.post("/v1/loans", requireAuth, async (c) => { + const body = await c.req.json<{ + worker_id: number; + amount: number; + weekly_payment: number; + note?: string; + }>(); + if (!body.worker_id || !body.amount) return c.json({ error: "worker_id y amount requeridos" }, 400); + const db = await getDb(); + db.prepare( + "INSERT INTO loans (worker_id, amount, balance, weekly_payment, note) VALUES (?, ?, ?, ?, ?)", + ).run(body.worker_id, body.amount, body.amount, body.weekly_payment ?? 0, body.note ?? null); + return c.json({ id: lastInsertId(db) }, 201); +}); + +app.post("/v1/payroll/periods", requireAuth, async (c) => { + const body = await c.req.json<{ + project_id: number; + week_start: string; + week_end: string; + extra_discounts?: Record; + }>(); + 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[]; + 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; +await getDb(); +Deno.serve({ port, hostname: "0.0.0.0" }, app.fetch); +console.log(`API panel-obra en http://127.0.0.1:${port}`); diff --git a/api/mx.ts b/api/mx.ts new file mode 100644 index 0000000..4a7c030 --- /dev/null +++ b/api/mx.ts @@ -0,0 +1,195 @@ +const CURP_RE = + /^[A-Z][AEIOUX][A-Z]{2}\d{2}(?:0[1-9]|1[0-2])(?:0[1-9]|[12]\d|3[01])[HMX](?:AS|BC|BS|CC|CS|CH|CL|CM|DF|DG|GT|GR|HG|JC|MC|MN|MS|NT|NL|OC|PL|QT|QR|SP|SL|SR|TC|TS|TL|VZ|YN|ZS|NE)[B-DF-HJ-NP-TV-Z]{3}[A-Z\d]\d$/; + +const RFC_PF = /^[A-ZÑ&]{4}\d{6}[A-Z0-9]{3}$/; +const RFC_PM = /^[A-ZÑ&]{3}\d{6}[A-Z0-9]{3}$/; + +export function normUpper(value: string | null | undefined): string { + return (value ?? "").toString().trim().toUpperCase().replace(/\s+/g, ""); +} + +export function normName(value: string | null | undefined): string { + return (value ?? "").toString().trim().replace(/\s+/g, " "); +} + +const NAME_PARTICLES = new Set([ + "de", "del", "la", "las", "los", "el", "y", "e", "da", "das", "do", "dos", "di", "du", "le", "van", "von", +]); + +function capPiece(piece: string): string { + if (!piece) return piece; + return piece.replace(/(^|['’])(\p{L})/gu, (_m, sep: string, ch: string) => sep + ch.toLocaleUpperCase("es-MX")); +} + +/** Nombre propio: Héctor de la Cruz. No guarda TODO EN MAYÚSCULAS. */ +export function titleCase(value: string | null | undefined): string { + const raw = normName(value); + if (!raw) return ""; + return raw + .toLocaleLowerCase("es-MX") + .split(" ") + .map((word, i) => { + if (!word) return word; + if (i > 0 && NAME_PARTICLES.has(word)) return word; + return word.split("-").map(capPiece).join("-"); + }) + .join(" "); +} + +/** Oración: primera letra y después de . ? ! */ +export function sentenceCase(value: string | null | undefined): string { + const raw = normName(value); + if (!raw) return ""; + return raw + .toLocaleLowerCase("es-MX") + .replace(/(^|[.!?]\s+)(\p{L})/gu, (_m, sep: string, ch: string) => sep + ch.toLocaleUpperCase("es-MX")); +} + +export function looksAllCaps(value: string | null | undefined): boolean { + const letters = [...(value ?? "")].filter((ch) => /\p{L}/u.test(ch)).join(""); + if (letters.length < 2) return false; + const up = letters.toLocaleUpperCase("es-MX"); + const low = letters.toLocaleLowerCase("es-MX"); + return letters === up && letters !== low; +} + +export function validateCurp(raw: string): string | null { + const curp = normUpper(raw); + if (!CURP_RE.test(curp)) return "CURP inválida (18 caracteres, formato oficial)"; + return null; +} + +export function validateRfc(raw: string): string | null { + const rfc = normUpper(raw).replace(/Ñ/g, "Ñ"); + if (!RFC_PF.test(rfc) && !RFC_PM.test(rfc)) { + return "RFC inválido (12 o 13 caracteres, formato SAT)"; + } + return null; +} + +/** Dígito verificador IMSS (11 dígitos). */ +export function validateNss(raw: string): string | null { + const nss = (raw ?? "").toString().replace(/\D/g, ""); + if (nss.length !== 11) return "NSS inválido (11 dígitos)"; + const digits = nss.split("").map(Number); + let sum = 0; + for (let i = 0; i < 10; i++) { + let n = digits[i] * (i % 2 === 1 ? 2 : 1); + if (n > 9) n -= 9; + sum += n; + } + const check = (10 - (sum % 10)) % 10; + if (check !== digits[10]) return "NSS inválido (dígito verificador)"; + return null; +} + +export function formatNss(raw: string): string { + return (raw ?? "").toString().replace(/\D/g, ""); +} + +export type WorkerInput = { + first_name?: string; + middle_name?: string | null; + last_name_p?: string; + last_name_m?: string; + curp?: string; + rfc?: string; + nss?: string; + phone?: string; + email?: string; + address?: string; + blood_type?: string | null; + hire_type?: string; + company_id?: number | null; + position?: string; + risk_code?: string; + work_type?: string; + daily_wage?: number; + needs_badge?: boolean | number; + status?: string; +}; + +export function validateWorkerFields(body: WorkerInput): Record { + const errors: Record = {}; + if (!normName(body.first_name)) errors.first_name = "Nombre obligatorio"; + if (!normName(body.last_name_p)) errors.last_name_p = "Apellido paterno obligatorio"; + if (!normName(body.last_name_m)) errors.last_name_m = "Apellido materno obligatorio"; + + const curpErr = validateCurp(body.curp ?? ""); + if (curpErr) errors.curp = curpErr; + const rfcErr = validateRfc(body.rfc ?? ""); + if (rfcErr) errors.rfc = rfcErr; + const nssErr = validateNss(body.nss ?? ""); + if (nssErr) errors.nss = nssErr; + + if (!normName(body.phone)) errors.phone = "Teléfono obligatorio"; + if (!normName(body.email)) errors.email = "Correo obligatorio"; + if (!normName(body.address)) errors.address = "Dirección obligatoria"; + + // Empresa / patrón se asigna solo con el alta IMSS; no es obligatoria en el padrón. + if (!normName(body.position)) errors.position = "Cargo obligatorio"; + if (!normName(body.risk_code)) errors.risk_code = "Riesgo obligatorio"; + const wt = (body.work_type ?? "").toUpperCase(); + if (!["N", "D"].includes(wt)) errors.work_type = "Tipo de trabajo N o D"; + if (body.daily_wage === undefined || body.daily_wage === null || Number.isNaN(Number(body.daily_wage))) { + errors.daily_wage = "Jornal obligatorio"; + } + return errors; +} + +export function canonicalRiskCode(raw?: string) { + const c = (raw ?? "").toLowerCase().trim(); + if (c === "alto") return "rojo"; + if (c === "medio") return "amarillo"; + if (c === "bajo") return "verde"; + return c; +} + +export function pipelineLabelFor(status: string): string { + const labels: Record = { + incompleto: "Falta expediente", + listo_gafete: "Listo gafete", + impreso: "Gafete impreso", + activo: "Activo en proyecto", + baja: "Baja", + }; + return labels[status] ?? status; +} + +export function normalizeWorker(body: WorkerInput) { + return { + first_name: titleCase(body.first_name), + middle_name: titleCase(body.middle_name) || null, + last_name_p: titleCase(body.last_name_p), + last_name_m: titleCase(body.last_name_m), + curp: normUpper(body.curp), + rfc: normUpper(body.rfc), + nss: formatNss(body.nss ?? ""), + phone: normName(body.phone), + email: (body.email ?? "").toString().trim().toLowerCase(), + address: sentenceCase(body.address), + blood_type: normUpper(body.blood_type) || null, + hire_type: (body.hire_type ?? "").toUpperCase(), + position: titleCase(body.position), + risk_code: canonicalRiskCode(body.risk_code), + work_type: (body.work_type ?? "").toUpperCase(), + daily_wage: Number(body.daily_wage ?? 0), + needs_badge: body.needs_badge === false || body.needs_badge === 0 ? 0 : 1, + status: body.status === "baja" ? "baja" : "activo", + }; +} + +export function fullName(w: { + first_name: string; + middle_name?: string | null; + last_name_p: string; + last_name_m: string; +}): string { + return [w.first_name, w.middle_name, w.last_name_p, w.last_name_m] + .filter(Boolean) + .join(" "); +} + +export function frontName(w: { first_name: string; last_name_p: string }): string { + return `${w.first_name} ${w.last_name_p}`.trim(); +} diff --git a/api/mx_test.ts b/api/mx_test.ts new file mode 100644 index 0000000..9559d09 --- /dev/null +++ b/api/mx_test.ts @@ -0,0 +1,54 @@ +import { assertEquals } from "jsr:@std/assert@1"; +import { + canonicalRiskCode, + formatNss, + normalizeWorker, + pipelineLabelFor, + titleCase, + validateWorkerFields, +} from "./mx.ts"; + +Deno.test("normaliza identidad y conserva el código real de riesgo", () => { + const worker = normalizeWorker({ + first_name: " JUAN CARLOS ", + last_name_p: "DE LA CRUZ", + last_name_m: "PÉREZ", + curp: "abcd010101hqrxxx01", + rfc: "abcd010101ab1", + nss: "12 34 56 78 901", + phone: "998 100 2000", + email: "PERSONA@EJEMPLO.COM", + address: "AVENIDA PRINCIPAL 10", + hire_type: "arctec", + position: "AYUDANTE GENERAL", + risk_code: "negro", + work_type: "n", + daily_wage: 500, + }); + assertEquals(worker.first_name, "Juan Carlos"); + assertEquals(worker.last_name_p, "De la Cruz"); + assertEquals(worker.email, "persona@ejemplo.com"); + assertEquals(worker.risk_code, "negro"); + assertEquals(worker.nss, "12345678901"); +}); + +Deno.test("riesgo semántico solo se convierte cuando llega como nivel", () => { + assertEquals(canonicalRiskCode("alto"), "rojo"); + assertEquals(canonicalRiskCode("medio"), "amarillo"); + assertEquals(canonicalRiskCode("bajo"), "verde"); + assertEquals(canonicalRiskCode("naranja"), "naranja"); +}); + +Deno.test("campos laborales obligatorios permanecen validados", () => { + const errors = validateWorkerFields({}); + assertEquals(errors.first_name, "Nombre obligatorio"); + assertEquals(errors.hire_type, undefined); + assertEquals(errors.position, "Cargo obligatorio"); + assertEquals(errors.daily_wage, "Jornal obligatorio"); +}); + +Deno.test("utilidades de texto mantienen formato mexicano", () => { + assertEquals(titleCase("MARÍA DE LOS ÁNGELES"), "María de los Ángeles"); + assertEquals(formatNss("12-34-56-78901"), "12345678901"); + assertEquals(pipelineLabelFor("listo_gafete"), "Listo gafete"); +}); diff --git a/api/pdf.ts b/api/pdf.ts new file mode 100644 index 0000000..c19123c --- /dev/null +++ b/api/pdf.ts @@ -0,0 +1,350 @@ +import type { Database } from "@db/sqlite"; +import { mkdir } from "node:fs/promises"; +import { join } from "node:path"; +// mkdir used in saveJobPdf +import QRCode from "qrcode"; +import { PDFDocument, StandardFonts, rgb, degrees, type PDFPage, type PDFFont, type PDFImage } from "pdf-lib"; +import { config, PDFS_DIR } from "./config.ts"; +import { decryptBytes } from "./docs_crypto.ts"; +import { fullName, frontName } from "./mx.ts"; +import { workerDir } from "./db.ts"; + +const CM = 28.346456692913385; +const CARD_W = 6.7 * CM; +const CARD_H = 10.5 * CM; +const PAGE_W = 792; +const PAGE_H = 612; + +type WorkerRow = { + id: number; + first_name: string; + middle_name: string | null; + last_name_p: string; + last_name_m: string; + curp: string; + nss: string; + blood_type: string | null; + position: string; + risk_color: string; + risk_text: string; + photo_storage?: string | null; + photo_iv?: string | null; +}; + +export function badgeVcardUrl(curp: string) { + const token = btoa(curp).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", ""); + return `${config.vcardBase}${token}`; +} + +export async function badgeQrPng(curp: string): Promise { + return await QRCode.toBuffer(badgeVcardUrl(curp), { + errorCorrectionLevel: "H", + width: 280, + margin: 1, + }) as Uint8Array; +} + +function hexRgb(hex: string) { + const h = hex.replace("#", ""); + return rgb( + parseInt(h.slice(0, 2), 16) / 255, + parseInt(h.slice(2, 4), 16) / 255, + parseInt(h.slice(4, 6), 16) / 255, + ); +} + +export async function loadCurrentPhoto(db: Database, workerId: number): Promise { + const doc = db.prepare( + `SELECT storage_name, iv FROM documents + WHERE worker_id = ? AND type_code = 'foto' AND is_current = 1 + ORDER BY id DESC LIMIT 1`, + ).get(workerId) as { storage_name: string; iv: string } | undefined; + if (!doc) return null; + try { + const enc = await Deno.readFile(join(workerDir(workerId), doc.storage_name)); + return await decryptBytes(doc.iv, enc); + } catch { + return null; + } +} + +async function embedMaybe( + pdf: PDFDocument, + bytes: Uint8Array | null, +): Promise { + if (!bytes) return null; + try { + return await pdf.embedJpg(bytes); + } catch { + try { + return await pdf.embedPng(bytes); + } catch { + return null; + } + } +} + +function drawCentered( + page: PDFPage, + font: PDFFont, + text: string, + x: number, + y: number, + w: number, + size: number, + color = rgb(0, 0, 0), +) { + const tw = font.widthOfTextAtSize(text, size); + page.drawText(text, { x: x + Math.max(0, (w - tw) / 2), y, size, font, color }); +} + +export async function generateBadgePdf( + db: Database, + projectId: number, + workerIds: number[], +): Promise { + const project = db.prepare( + "SELECT name, code, theme_id, logo_left_path, logo_right_path FROM projects WHERE id = ?", + ).get(projectId) as { + name: string; + code: string; + theme_id: string; + logo_left_path: string | null; + logo_right_path: string | null; + } | undefined; + if (!project) throw new Error("Proyecto no encontrado"); + + const placeholders = workerIds.map(() => "?").join(","); + const workers = db.prepare( + `SELECT w.id, w.first_name, w.middle_name, w.last_name_p, w.last_name_m, + w.curp, w.nss, w.blood_type, w.position, + r.color AS risk_color, r.text_color AS risk_text + FROM workers w + JOIN risk_levels r ON r.code = w.risk_code + WHERE w.id IN (${placeholders})`, + ).all(...workerIds) as WorkerRow[]; + + const byId = new Map(workers.map((w) => [w.id, w])); + const ordered = workerIds.map((id) => byId.get(id)).filter(Boolean) as WorkerRow[]; + + const pdf = await PDFDocument.create(); + const font = await pdf.embedFont(StandardFonts.Helvetica); + const fontBold = await pdf.embedFont(StandardFonts.HelveticaBold); + + let logoL: PDFImage | null = null; + let logoR: PDFImage | null = null; + if (project.logo_left_path) { + try { + logoL = await embedMaybe(pdf, await Deno.readFile(project.logo_left_path)); + } catch { /* text fallback */ } + } + if (project.logo_right_path) { + try { + logoR = await embedMaybe(pdf, await Deno.readFile(project.logo_right_path)); + } catch { /* text fallback */ } + } + + const gap = 4; + const totalW = 4 * CARD_W + 3 * gap; + const originX = (PAGE_W - totalW) / 2; + const topY = PAGE_H - 8 - CARD_H; + const botY = 8; + + const chunk = 4; + for (let i = 0; i < ordered.length; i += chunk) { + const group = ordered.slice(i, i + chunk); + const page = pdf.addPage([PAGE_W, PAGE_H]); + for (let c = 0; c < group.length; c++) { + const w = group[c]; + const x = originX + c * (CARD_W + gap); + const photo = await embedMaybe(pdf, await loadCurrentPhoto(db, w.id)); + const qrPng = await badgeQrPng(w.curp); + const qrImg = await pdf.embedPng(qrPng); + drawFront(page, font, fontBold, x, topY, w, photo, logoL, logoR); + drawBack(page, font, fontBold, x, botY, w, qrImg, project.name, project.code); + } + } + + return await pdf.save(); +} + +function drawFront( + page: PDFPage, + font: PDFFont, + fontBold: PDFFont, + x: number, + y: number, + w: WorkerRow, + photo: PDFImage | null, + logoL: PDFImage | null, + logoR: PDFImage | null, +) { + page.drawRectangle({ + x, + y, + width: CARD_W, + height: CARD_H, + borderColor: rgb(0, 0, 0), + borderWidth: 1.5, + }); + const logoH = CARD_H * 0.16; + if (logoL) { + const scale = Math.min((CARD_W / 2 - 8) / logoL.width, (logoH - 4) / logoL.height); + page.drawImage(logoL, { + x: x + 6, + y: y + CARD_H - logoH - 4, + width: logoL.width * scale, + height: logoL.height * scale, + }); + } else { + page.drawText("Construcciones Arctec", { + x: x + 6, + y: y + CARD_H - 22, + size: 7, + font: fontBold, + }); + } + if (logoR) { + const scale = Math.min((CARD_W / 2 - 8) / logoR.width, (logoH - 4) / logoR.height); + page.drawImage(logoR, { + x: x + CARD_W / 2 + 4, + y: y + CARD_H - logoH - 4, + width: logoR.width * scale, + height: logoR.height * scale, + }); + } else { + page.drawText("Zendala", { + x: x + CARD_W / 2 + 8, + y: y + CARD_H - 22, + size: 9, + font: fontBold, + color: rgb(0.1, 0.25, 0.55), + }); + } + + const photoW = 3.5 * CM; + const photoH = 4.5 * CM; + const px = x + (CARD_W - photoW) / 2; + const py = y + CARD_H - logoH - photoH - 18; + page.drawRectangle({ + x: px, + y: py, + width: photoW, + height: photoH, + borderColor: rgb(0, 0, 0), + borderWidth: 2, + }); + if (photo) { + page.drawImage(photo, { x: px + 1, y: py + 1, width: photoW - 2, height: photoH - 2 }); + } + + const name = frontName(w).toUpperCase(); + drawCentered(page, fontBold, w.first_name.toUpperCase(), x, py - 22, CARD_W, 13); + drawCentered(page, fontBold, w.last_name_p.toUpperCase(), x, py - 38, CARD_W, 13); + + const barH = 28; + const barY = y + 10; + page.drawRectangle({ + x: x + 1.5, + y: barY, + width: CARD_W - 3, + height: barH, + color: hexRgb(w.risk_color), + }); + drawCentered( + page, + fontBold, + w.position.toUpperCase(), + x, + barY + 8, + CARD_W, + 11, + hexRgb(w.risk_text || "#FFFFFF"), + ); +} + +function drawBack( + page: PDFPage, + font: PDFFont, + fontBold: PDFFont, + x: number, + y: number, + w: WorkerRow, + qr: PDFImage, + projectName: string, + projectCode: string, +) { + const cx = x + CARD_W / 2; + const cy = y + CARD_H / 2; + page.drawRectangle({ + x, + y, + width: CARD_W, + height: CARD_H, + borderColor: rgb(0, 0, 0), + borderWidth: 1.5, + rotate: degrees(180), + // pdf-lib rotate is around origin of the op; we translate via origin option + }); + // Draw rotated content using origin at card center + const opts = { rotate: degrees(180) as ReturnType }; + + page.drawRectangle({ + x: x + CARD_W, + y: y + CARD_H, + width: CARD_W, + height: CARD_H, + borderColor: rgb(0, 0, 0), + borderWidth: 1.5, + rotate: degrees(180), + }); + + const qrSize = CARD_W * 0.5; + page.drawImage(qr, { + x: x + CARD_W - (CARD_W - qrSize) / 2 - qrSize, + y: y + CARD_H - 16 - qrSize, + width: qrSize, + height: qrSize, + rotate: degrees(180), + }); + + const lines = [ + `GRUPO SANGUINEO: ${w.blood_type ?? ""}`, + `CURP: ${w.curp}`, + `NSS: ${w.nss}`, + `NOMBRE: ${fullName(w)}`, + ]; + let ly = y + CARD_H - qrSize - 36; + for (const line of lines) { + page.drawText(line, { + x: x + CARD_W - 12, + y: ly, + size: 7, + font, + rotate: degrees(180), + maxWidth: CARD_W - 20, + }); + ly -= 12; + } + page.drawText((projectCode || "").toUpperCase(), { + x: x + CARD_W - 20, + y: y + 36, + size: 10, + font: fontBold, + rotate: degrees(180), + }); + page.drawText(projectName.toUpperCase(), { + x: x + CARD_W - 20, + y: y + 22, + size: 10, + font: fontBold, + rotate: degrees(180), + }); +} + +export async function saveJobPdf(bytes: Uint8Array, jobId: number): Promise { + await mkdir(PDFS_DIR, { recursive: true }); + const path = join(PDFS_DIR, `gafetes-${jobId}.pdf`); + await Deno.writeFile(path, bytes); + return path; +} diff --git a/api/platform_db.ts b/api/platform_db.ts new file mode 100644 index 0000000..33a3b76 --- /dev/null +++ b/api/platform_db.ts @@ -0,0 +1,135 @@ +import { Database } from "@db/sqlite"; +import { mkdir } from "node:fs/promises"; +import { DATA_DIR, PLATFORM_DB_PATH } from "./config.ts"; +import { config } from "./config.ts"; +import { hashPassword } from "./crypto.ts"; +import { runLiquibase } from "./liquibase.ts"; + +export type PlatformDb = Database; + +let platformDb: Database | null = null; + +export type PlatformUser = { + id: number; + username: string; + display_name: string; + status: string; +}; + +export async function getPlatformDb(): Promise { + if (platformDb) return platformDb; + await mkdir(DATA_DIR, { recursive: true }); + await runLiquibase(); + platformDb = new Database(PLATFORM_DB_PATH); + platformDb.exec("PRAGMA foreign_keys = ON;"); + await seedPlatform(platformDb); + return platformDb; +} + +async function seedPlatform(database: Database) { + if (!config.seedPassword) { + console.warn("[platform] SEED_PASSWORD vacío; no se siembra usuario admin"); + } else { + const hash = await hashPassword(config.seedPassword); + const admin = database.prepare("SELECT id FROM platform_users WHERE username = ?").get("admin") as + | { id: number } + | undefined; + const legacy = database.prepare("SELECT id FROM platform_users WHERE username = ?").get("operador") as + | { id: number } + | undefined; + + if (legacy && !admin) { + database.prepare( + `UPDATE platform_users + SET username = ?, password_hash = ?, display_name = ? + WHERE id = ?`, + ).run("admin", hash, "Admin PANELS", legacy.id); + } else if (!admin) { + database.prepare( + `INSERT INTO platform_users (username, password_hash, display_name, status) + VALUES (?, ?, ?, 'activo')`, + ).run("admin", hash, "Admin PANELS"); + } else if (legacy) { + database.prepare("DELETE FROM platform_users WHERE id = ?").run(legacy.id); + } + + const sync = ["1", "true", "yes"].includes( + (Deno.env.get("SEED_SYNC_PASSWORD") ?? "").toLowerCase(), + ); + if (sync) { + const row = database.prepare("SELECT id FROM platform_users WHERE username = ?").get("admin") as + | { id: number } + | undefined; + if (row) { + database.prepare( + `UPDATE platform_users SET password_hash = ?, display_name = ? WHERE id = ?`, + ).run(hash, "Admin PANELS", row.id); + console.warn("[platform] SEED_SYNC_PASSWORD: password de admin actualizado"); + } + } + } + const arctec = database.prepare( + "SELECT id FROM tenants WHERE code IN ('ARCT2608', 'ARCTEC')", + ).get(); + if (!arctec) { + database.prepare( + `INSERT INTO tenants (id, code, name, status) VALUES (1, 'ARCT2608', 'ARCTEC', 'activo')`, + ).run(); + } +} + +export function listTenants(database: Database) { + return database.prepare( + `SELECT id, code, name, status, created_at, + COALESCE(plan, 'trial') AS plan, valid_until, COALESCE(notes, '') AS notes, + COALESCE(contact_email, '') AS contact_email, + COALESCE(contact_name, '') AS contact_name, + access_sent_at + FROM tenants ORDER BY id`, + ).all() as { + id: number; + code: string; + name: string; + status: string; + created_at: string; + plan: string; + valid_until: string | null; + notes: string; + contact_email: string; + contact_name: string; + access_sent_at: string | null; + }[]; +} + +export function tenantByCode(database: Database, code: string) { + return database.prepare("SELECT * FROM tenants WHERE code = ?").get(code.trim().toUpperCase()) as + | { + id: number; + code: string; + name: string; + status: string; + plan?: string; + valid_until?: string | null; + notes?: string; + } + | undefined; +} + +export function tenantById(database: Database, id: number) { + return database.prepare("SELECT * FROM tenants WHERE id = ?").get(id) as + | { + id: number; + code: string; + name: string; + status: string; + plan?: string; + valid_until?: string | null; + notes?: string; + created_at?: string; + } + | undefined; +} + +export function lastInsertId(database: Database): number { + return Number((database.prepare("SELECT last_insert_rowid() AS id").get() as { id: number }).id); +} diff --git a/api/saas.ts b/api/saas.ts new file mode 100644 index 0000000..a95b783 --- /dev/null +++ b/api/saas.ts @@ -0,0 +1,584 @@ +import type { Database } from "@db/sqlite"; +import { generateSecurePassword, hashPassword } from "./crypto.ts"; +import { lastInsertId as appLastId } from "./db.ts"; +import { + lastInsertId as platformLastId, + listTenants as listTenantsRaw, + tenantByCode, + tenantById, + type PlatformDb, +} from "./platform_db.ts"; +import { companyByCode, normCompanyCode, normalizeCompanyProfile, validateCompanyProfile, type CompanyProfileInput } from "./companies.ts"; +import { accessEmailBody, sendMail } from "./mail.ts"; +import { config } from "./config.ts"; + +export type CreateTenantInput = CompanyProfileInput & { + admin_username?: string; + admin_display_name?: string; + contact_email?: string; + contact_name?: string; + plan?: string; + valid_until?: string | null; + notes?: string; + send_email?: boolean; +}; + +export type UpdateTenantInput = CompanyProfileInput & { + status?: string; + plan?: string; + valid_until?: string | null; + notes?: string; + contact_email?: string; + contact_name?: string; +}; + +export type TenantAdmin = { + id: number; + username: string; + display_name: string; + email: string; + role: string; + must_change_password: boolean; + created_at: string; +}; + +export type CompanySnapshot = { + id: number; + code: string; + name: string; + rfc: string; + razon_social: string; + nombre_comercial: string; + regimen_fiscal: string; + registro_patronal: string; + clase_riesgo: string; + domicilio_fiscal: string; + codigo_postal: string; + ciudad: string; + estado: string; + telefono: string; + email: string; + representante_legal: string; + giro: string; +}; + +export type TenantDetail = { + id: number; + code: string; + name: string; + status: string; + plan: string; + valid_until: string | null; + notes: string; + contact_email: string; + contact_name: string; + access_sent_at: string | null; + created_at: string; + company: CompanySnapshot | null; + admins: TenantAdmin[]; +}; + +const STATUSES = ["activo", "suspendido", "cancelado"] as const; +const PLANS = ["trial", "basic", "pro"] as const; +const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +/** Usuario admin = el código en minúsculas (corto). */ +export function adminUsernameForCode(code: string): string { + const c = normCompanyCode(code).toLowerCase(); + return c || "emp"; +} + +/** Prefijo corto (máx. 4) solo desde nombre comercial. */ +export function shortCodeFromCommercialName(name: string): string { + const slug = (name ?? "") + .toString() + .normalize("NFD") + .replace(/\p{M}/gu, "") + .toUpperCase() + .replace(/[^A-Z0-9]+/g, "") + .slice(0, 4); + return slug.length >= 2 ? slug : (slug || "EMP").padEnd(2, "X").slice(0, 4); +} + +/** YYMM de la fecha de alta (refuerzo corto del código). */ +export function codeDateStamp(when: Date = new Date()): string { + const yy = String(when.getFullYear()).slice(-2); + const mm = String(when.getMonth() + 1).padStart(2, "0"); + return `${yy}${mm}`; +} + +function codeExists(platformDb: PlatformDb, appDb: Database, code: string): boolean { + return !!tenantByCode(platformDb, code) || !!companyByCode(appDb, code); +} + +/** + * Código único: prefijo del nombre comercial + YYMM de creación. + * Ej. «ARCTEC» en ago-2026 → ARCT2608. Si choca, sufijo numérico. + */ +export function allocateUniqueTenantCode( + platformDb: PlatformDb, + appDb: Database, + nombreComercial: string, + createdAt: Date = new Date(), +): string { + const base = shortCodeFromCommercialName(nombreComercial); + const stamp = codeDateStamp(createdAt); + const primary = `${base}${stamp}`; + if (!codeExists(platformDb, appDb, primary)) return primary; + for (let n = 2; n < 1000; n++) { + const suffix = String(n); + const head = base.slice(0, Math.max(2, 4 - suffix.length)); + const candidate = `${head}${stamp}${suffix}`; + if (candidate.length <= 16 && !codeExists(platformDb, appDb, candidate)) return candidate; + } + return `E${stamp}${Date.now().toString(36).toUpperCase().slice(-4)}`; +} + +function trim(value: unknown): string { + return (value ?? "").toString().trim(); +} + +function isoDateOrNull(value: unknown): string | null { + const raw = trim(value); + if (!raw) return null; + const m = raw.match(/^(\d{4}-\d{2}-\d{2})/); + return m ? m[1] : null; +} + +/** Perfil completo obligatorio en alta/edición SaaS. */ +export function requireSaasCompanyFields( + profile: ReturnType, + extra: { contact_name?: string; contact_email?: string } = {}, +): string | null { + const checks: [string, string][] = [ + ["Nombre comercial", profile.nombre_comercial], + ["Razón social", profile.razon_social], + ["RFC", profile.rfc], + ["Régimen fiscal", profile.regimen_fiscal], + ["Domicilio fiscal", profile.domicilio_fiscal], + ["Código postal", profile.codigo_postal], + ["Ciudad", profile.ciudad], + ["Estado", profile.estado], + ["Teléfono", profile.telefono], + ["Email", profile.email], + ["Representante legal", profile.representante_legal], + ]; + for (const [label, value] of checks) { + if (!value) return `${label} obligatorio`; + } + if (!EMAIL_RE.test(profile.email)) return "Email inválido"; + if (!/^\d{5}$/.test(profile.codigo_postal)) return "Código postal debe ser 5 dígitos"; + + const contactName = trim(extra.contact_name); + if (!contactName) return "Nombre de contacto obligatorio"; + const contactEmail = profile.email; + if (!contactEmail || !EMAIL_RE.test(contactEmail)) { + return "Email obligatorio (ahí se envía el acceso)"; + } + return null; +} + +export function listTenants(platformDb: PlatformDb) { + return listTenantsRaw(platformDb); +} + +export function getTenantDetail( + platformDb: PlatformDb, + appDb: Database, + tenantId: number, +): TenantDetail | null { + const t = platformDb.prepare( + `SELECT id, code, name, status, created_at, + COALESCE(plan, 'trial') AS plan, valid_until, COALESCE(notes, '') AS notes, + COALESCE(contact_email, '') AS contact_email, + COALESCE(contact_name, '') AS contact_name, + access_sent_at + FROM tenants WHERE id = ?`, + ).get(tenantId) as + | { + id: number; + code: string; + name: string; + status: string; + created_at: string; + plan: string; + valid_until: string | null; + notes: string; + contact_email: string; + contact_name: string; + access_sent_at: string | null; + } + | undefined; + if (!t) return null; + + const company = appDb.prepare( + `SELECT id, code, name, + COALESCE(rfc, '') AS rfc, + COALESCE(razon_social, '') AS razon_social, + COALESCE(nombre_comercial, '') AS nombre_comercial, + COALESCE(regimen_fiscal, '') AS regimen_fiscal, + COALESCE(registro_patronal, '') AS registro_patronal, + COALESCE(clase_riesgo, '') AS clase_riesgo, + COALESCE(domicilio_fiscal, '') AS domicilio_fiscal, + COALESCE(codigo_postal, '') AS codigo_postal, + COALESCE(ciudad, '') AS ciudad, + COALESCE(estado, '') AS estado, + COALESCE(telefono, '') AS telefono, + COALESCE(email, '') AS email, + COALESCE(representante_legal, '') AS representante_legal, + COALESCE(giro, '') AS giro + FROM companies WHERE tenant_id = ? AND kind = 'principal' ORDER BY id LIMIT 1`, + ).get(tenantId) as CompanySnapshot | undefined; + + const admins = (appDb.prepare( + `SELECT id, username, display_name, COALESCE(email, '') AS email, role, + COALESCE(must_change_password, 0) AS must_change_password, created_at + FROM users WHERE tenant_id = ? AND role = 'tenant_admin' + ORDER BY id`, + ).all(tenantId) as Array>).map((a) => ({ + id: Number(a.id), + username: String(a.username), + display_name: String(a.display_name), + email: String(a.email || ""), + role: String(a.role), + must_change_password: Boolean(a.must_change_password), + created_at: String(a.created_at), + })); + + return { + ...t, + company: company ?? null, + admins, + }; +} + +export async function createTenant( + platformDb: PlatformDb, + appDb: Database, + input: CreateTenantInput, +): Promise<{ + tenant?: { id: number; code: string; name: string; status: string }; + admin?: { username: string; temporary_password: string; email: string }; + email?: { sent: boolean; error?: string }; + error?: string; +}> { + const profileErr = validateCompanyProfile(input, { requireLegal: true }); + if (profileErr) return { error: profileErr }; + + const profile = normalizeCompanyProfile(input); + const completeErr = requireSaasCompanyFields(profile, { + contact_name: input.contact_name, + }); + if (completeErr) return { error: completeErr }; + + const code = allocateUniqueTenantCode(platformDb, appDb, profile.nombre_comercial); + + const displayName = profile.nombre_comercial; + const adminUser = adminUsernameForCode(code); + const contactName = trim(input.contact_name); + const contactEmail = profile.email; + const adminName = trim(input.admin_display_name) || contactName; + + const userClash = appDb.prepare("SELECT id FROM users WHERE username = ?").get(adminUser); + if (userClash) return { error: `Ya existe el usuario ${adminUser}` }; + + const plan = trim(input.plan) || "trial"; + if (!(PLANS as readonly string[]).includes(plan)) return { error: "Plan inválido" }; + const validUntil = isoDateOrNull(input.valid_until); + const notes = trim(input.notes); + const temporaryPassword = generateSecurePassword(); + + platformDb.prepare( + `INSERT INTO tenants (code, name, status, plan, valid_until, notes, contact_email, contact_name) + VALUES (?, ?, 'activo', ?, ?, ?, ?, ?)`, + ).run(code, displayName, plan, validUntil, notes, contactEmail, contactName); + const tenantId = platformLastId(platformDb); + + try { + appDb.prepare( + `INSERT INTO companies ( + code, name, parent_id, kind, status, tenant_id, + razon_social, nombre_comercial, rfc, regimen_fiscal, registro_patronal, clase_riesgo, + domicilio_fiscal, codigo_postal, ciudad, estado, telefono, email, representante_legal, giro + ) VALUES (?, ?, NULL, 'principal', 'activo', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ).run( + code, + displayName, + tenantId, + profile.razon_social, + profile.nombre_comercial, + profile.rfc, + profile.regimen_fiscal, + profile.registro_patronal, + profile.clase_riesgo, + profile.domicilio_fiscal, + profile.codigo_postal, + profile.ciudad, + profile.estado, + profile.telefono, + contactEmail, + profile.representante_legal, + profile.giro, + ); + const companyId = appLastId(appDb); + + const hash = await hashPassword(temporaryPassword); + appDb.prepare( + `INSERT INTO users ( + username, password_hash, display_name, company_id, tenant_id, role, + must_change_password, email + ) VALUES (?, ?, ?, ?, ?, 'tenant_admin', 1, ?)`, + ).run(adminUser, hash, adminName, companyId, tenantId, contactEmail); + } catch (e) { + platformDb.prepare("DELETE FROM tenants WHERE id = ?").run(tenantId); + return { error: e instanceof Error ? e.message : "Error al crear empresa" }; + } + + const tenant = platformDb.prepare( + "SELECT id, code, name, status FROM tenants WHERE id = ?", + ).get(tenantId) as { id: number; code: string; name: string; status: string }; + + let emailResult: { sent: boolean; error?: string } = { sent: false }; + if (input.send_email && contactEmail) { + emailResult = await sendAccessEmail(platformDb, { + tenantId, + companyName: displayName, + username: adminUser, + password: temporaryPassword, + contactEmail, + contactName, + }); + } else if (input.send_email && !contactEmail) { + emailResult = { sent: false, error: "Indique correo de contacto para enviar el acceso" }; + } + + return { + tenant, + admin: { username: adminUser, temporary_password: temporaryPassword, email: contactEmail }, + email: emailResult, + }; +} + +export function updateTenant( + platformDb: PlatformDb, + appDb: Database, + tenantId: number, + input: UpdateTenantInput, +): { error?: string } { + const current = tenantById(platformDb, tenantId); + if (!current) return { error: "Empresa no encontrada" }; + + const company = appDb.prepare( + `SELECT * FROM companies WHERE tenant_id = ? AND kind = 'principal' ORDER BY id LIMIT 1`, + ).get(tenantId) as Record | undefined; + + const profileErr = validateCompanyProfile({ + ...company, + ...input, + name: input.nombre_comercial ?? input.name ?? (company?.name as string), + }, { requireLegal: true }); + if (profileErr) return { error: profileErr }; + + const merged: CompanyProfileInput = { + name: input.name ?? input.nombre_comercial ?? (company?.name as string) ?? current.name, + nombre_comercial: input.nombre_comercial ?? (company?.nombre_comercial as string) ?? "", + razon_social: input.razon_social ?? (company?.razon_social as string) ?? "", + rfc: input.rfc ?? (company?.rfc as string) ?? "", + regimen_fiscal: input.regimen_fiscal ?? (company?.regimen_fiscal as string) ?? "", + registro_patronal: input.registro_patronal ?? (company?.registro_patronal as string) ?? "", + clase_riesgo: input.clase_riesgo ?? (company?.clase_riesgo as string) ?? "", + domicilio_fiscal: input.domicilio_fiscal ?? (company?.domicilio_fiscal as string) ?? "", + codigo_postal: input.codigo_postal ?? (company?.codigo_postal as string) ?? "", + ciudad: input.ciudad ?? (company?.ciudad as string) ?? "", + estado: input.estado ?? (company?.estado as string) ?? "", + telefono: input.telefono ?? (company?.telefono as string) ?? "", + email: input.email ?? (company?.email as string) ?? "", + representante_legal: input.representante_legal ?? (company?.representante_legal as string) ?? "", + giro: input.giro ?? (company?.giro as string) ?? "", + }; + const profile = normalizeCompanyProfile(merged, current.name); + + const row = platformDb.prepare( + `SELECT COALESCE(plan, 'trial') AS plan, valid_until, COALESCE(notes, '') AS notes, + COALESCE(contact_email, '') AS contact_email, COALESCE(contact_name, '') AS contact_name + FROM tenants WHERE id = ?`, + ).get(tenantId) as { + plan: string; + valid_until: string | null; + notes: string; + contact_email: string; + contact_name: string; + }; + + const contactName = input.contact_name !== undefined ? trim(input.contact_name) : row.contact_name; + const contactEmail = profile.email; + + const completeErr = requireSaasCompanyFields(profile, { + contact_name: contactName, + }); + if (completeErr) return { error: completeErr }; + + let status = current.status; + if (input.status !== undefined) { + if (!(STATUSES as readonly string[]).includes(input.status)) { + return { error: "Estatus inválido (activo, suspendido, cancelado)" }; + } + status = input.status; + } + if (!status) return { error: "Estatus obligatorio" }; + + let plan = row.plan; + if (input.plan !== undefined) { + const p = trim(input.plan); + if (!(PLANS as readonly string[]).includes(p)) return { error: "Plan inválido" }; + plan = p; + } + if (!plan) return { error: "Plan obligatorio" }; + + let validUntil = row.valid_until; + if (input.valid_until !== undefined) validUntil = isoDateOrNull(input.valid_until); + if (!validUntil) return { error: "Vigencia obligatoria" }; + + let notes = row.notes; + if (input.notes !== undefined) notes = trim(input.notes); + if (!notes) return { error: "Notas internas obligatorias" }; + + if (!contactEmail || !EMAIL_RE.test(contactEmail)) { + return { error: "Email inválido" }; + } + if (!contactName) return { error: "Nombre de contacto obligatorio" }; + + const displayName = profile.nombre_comercial || profile.razon_social || current.name; + + platformDb.prepare( + `UPDATE tenants SET name = ?, status = ?, plan = ?, valid_until = ?, notes = ?, + contact_email = ?, contact_name = ? WHERE id = ?`, + ).run(displayName, status, plan, validUntil, notes, contactEmail, contactName, tenantId); + + if (company?.id) { + appDb.prepare( + `UPDATE companies SET + name = ?, razon_social = ?, nombre_comercial = ?, rfc = ?, regimen_fiscal = ?, + registro_patronal = ?, clase_riesgo = ?, domicilio_fiscal = ?, codigo_postal = ?, + ciudad = ?, estado = ?, telefono = ?, email = ?, representante_legal = ?, giro = ? + WHERE id = ?`, + ).run( + displayName, + profile.razon_social, + profile.nombre_comercial, + profile.rfc, + profile.regimen_fiscal, + profile.registro_patronal, + profile.clase_riesgo, + profile.domicilio_fiscal, + profile.codigo_postal, + profile.ciudad, + profile.estado, + profile.telefono, + profile.email || contactEmail, + profile.representante_legal, + profile.giro, + company.id, + ); + } + + return {}; +} + +/** Regenera contraseña del admin, marca must_change_password y opcionalmente envía correo. */ +export async function issueTenantAdminAccess( + platformDb: PlatformDb, + appDb: Database, + tenantId: number, + opts: { userId?: number; send_email?: boolean } = {}, +): Promise<{ + admin?: { username: string; temporary_password: string; email: string }; + email?: { sent: boolean; error?: string }; + error?: string; +}> { + const detail = getTenantDetail(platformDb, appDb, tenantId); + if (!detail) return { error: "Empresa no encontrada" }; + const admin = opts.userId + ? detail.admins.find((a) => a.id === opts.userId) + : detail.admins[0]; + if (!admin) return { error: "No hay usuario administrador" }; + + const temporaryPassword = generateSecurePassword(); + const hash = await hashPassword(temporaryPassword); + appDb.prepare( + `UPDATE users SET password_hash = ?, must_change_password = 1 WHERE id = ? AND tenant_id = ?`, + ).run(hash, admin.id, tenantId); + + const contactEmail = detail.contact_email || admin.email; + let emailResult: { sent: boolean; error?: string } = { sent: false }; + if (opts.send_email) { + if (!contactEmail) { + emailResult = { sent: false, error: "Falta correo de contacto en la empresa" }; + } else { + emailResult = await sendAccessEmail(platformDb, { + tenantId, + companyName: detail.name, + username: admin.username, + password: temporaryPassword, + contactEmail, + contactName: detail.contact_name || admin.display_name, + }); + } + } + + return { + admin: { + username: admin.username, + temporary_password: temporaryPassword, + email: contactEmail, + }, + email: emailResult, + }; +} + +async function sendAccessEmail( + platformDb: PlatformDb, + opts: { + tenantId: number; + companyName: string; + username: string; + password: string; + contactEmail: string; + contactName: string; + }, +): Promise<{ sent: boolean; error?: string }> { + const result = await sendMail(platformDb, { + to: opts.contactEmail, + subject: `Acceso a PANELS — ${opts.companyName}`, + text: accessEmailBody({ + companyName: opts.companyName, + username: opts.username, + password: opts.password, + contactName: opts.contactName, + }), + }); + if (result.sent) { + platformDb.prepare( + `UPDATE tenants SET access_sent_at = datetime('now') WHERE id = ?`, + ).run(opts.tenantId); + } + return result; +} + +export function tenantAccessBlocked(platformDb: PlatformDb, tenantId: number | null): string | null { + if (tenantId == null) return "Cuenta sin empresa asignada"; + const t = platformDb.prepare( + `SELECT status, valid_until FROM tenants WHERE id = ?`, + ).get(tenantId) as { status: string; valid_until: string | null } | undefined; + if (!t) return "Empresa no encontrada"; + if (t.status === "suspendido") return "La suscripción de esta empresa está suspendida"; + if (t.status === "cancelado") return "La cuenta de esta empresa fue cancelada"; + if (t.status !== "activo") return "La empresa no está activa"; + if (t.valid_until) { + const today = new Date().toISOString().slice(0, 10); + if (t.valid_until < today) return "La licencia de esta empresa ha vencido"; + } + return null; +} + +export { config }; diff --git a/api/schema.sql b/api/schema.sql new file mode 100644 index 0000000..b7ab904 --- /dev/null +++ b/api/schema.sql @@ -0,0 +1,252 @@ +PRAGMA foreign_keys = ON; +PRAGMA journal_mode = WAL; + +CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + display_name TEXT NOT NULL, + company_id INTEGER REFERENCES companies(id), + tenant_id INTEGER, + role TEXT NOT NULL DEFAULT 'user', + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS companies ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + code TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + parent_id INTEGER REFERENCES companies(id), + kind TEXT NOT NULL DEFAULT 'sub' CHECK (kind IN ('principal', 'sub')), + status TEXT NOT NULL DEFAULT 'activo' CHECK (status IN ('activo', 'inactivo')), + tenant_id INTEGER, + registro_patronal TEXT NOT NULL DEFAULT '', + razon_social TEXT NOT NULL DEFAULT '', + nombre_comercial TEXT NOT NULL DEFAULT '', + rfc TEXT NOT NULL DEFAULT '', + regimen_fiscal TEXT NOT NULL DEFAULT '', + clase_riesgo TEXT NOT NULL DEFAULT '', + domicilio_fiscal TEXT NOT NULL DEFAULT '', + codigo_postal TEXT NOT NULL DEFAULT '', + ciudad TEXT NOT NULL DEFAULT '', + estado TEXT NOT NULL DEFAULT '', + telefono TEXT NOT NULL DEFAULT '', + email TEXT NOT NULL DEFAULT '', + representante_legal TEXT NOT NULL DEFAULT '', + giro TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS risk_levels ( + code TEXT PRIMARY KEY, + label TEXT NOT NULL, + color TEXT NOT NULL, + text_color TEXT NOT NULL DEFAULT '#FFFFFF' +); + +CREATE TABLE IF NOT EXISTS badge_themes ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + layout TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS projects ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + code TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + address TEXT NOT NULL DEFAULT '', + stage TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'activo' CHECK (status IN ('activo', 'pausado', 'concluido', 'cancelado')), + theme_id TEXT NOT NULL REFERENCES badge_themes(id), + logo_left_path TEXT, + logo_right_path TEXT, + company_id INTEGER REFERENCES companies(id), + tenant_id INTEGER, + contract_amount REAL, + start_date TEXT, + end_date TEXT, + resident_name TEXT NOT NULL DEFAULT '', + siroc TEXT NOT NULL DEFAULT '', + payroll_tax_pct REAL NOT NULL DEFAULT 4, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS workers ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + first_name TEXT NOT NULL, + middle_name TEXT, + last_name_p TEXT NOT NULL, + last_name_m TEXT NOT NULL, + curp TEXT NOT NULL COLLATE NOCASE, + rfc TEXT NOT NULL COLLATE NOCASE, + nss TEXT NOT NULL, + phone TEXT NOT NULL, + email TEXT NOT NULL, + address TEXT NOT NULL, + blood_type TEXT, + hire_type TEXT NOT NULL, + company_id INTEGER REFERENCES companies(id), + tenant_id INTEGER, + position TEXT NOT NULL, + risk_code TEXT NOT NULL REFERENCES risk_levels(code), + work_type TEXT NOT NULL CHECK (work_type IN ('N', 'D')), + daily_wage REAL NOT NULL DEFAULT 0, + needs_badge INTEGER NOT NULL DEFAULT 1, + status TEXT NOT NULL DEFAULT 'activo' CHECK (status IN ('activo', 'baja')), + pipeline_status TEXT NOT NULL DEFAULT 'incompleto', + imss_status TEXT NOT NULL DEFAULT 'sin_alta' CHECK (imss_status IN ('sin_alta', 'alta', 'baja_imss')), + imss_company_id INTEGER REFERENCES companies(id), + imss_alta_at TEXT, + imss_baja_at TEXT, + last_rehire_at TEXT, + vcard_password_enc TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_workers_curp ON workers(curp); +CREATE UNIQUE INDEX IF NOT EXISTS idx_workers_rfc ON workers(rfc); +CREATE UNIQUE INDEX IF NOT EXISTS idx_workers_nss ON workers(nss); + +CREATE TABLE IF NOT EXISTS assignments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + worker_id INTEGER NOT NULL REFERENCES workers(id) ON DELETE CASCADE, + project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + active INTEGER NOT NULL DEFAULT 1, + start_date TEXT NOT NULL, + end_date TEXT, + UNIQUE (worker_id, project_id) +); + +CREATE TABLE IF NOT EXISTS document_types ( + code TEXT PRIMARY KEY, + label TEXT NOT NULL, + required INTEGER NOT NULL DEFAULT 1, + validity_mode TEXT NOT NULL DEFAULT 'none' CHECK (validity_mode IN ('none', 'freshness', 'expiry')), + freshness_days INTEGER, + requires_issued_at INTEGER NOT NULL DEFAULT 0, + requires_expires_at INTEGER NOT NULL DEFAULT 0 +); + +CREATE TABLE IF NOT EXISTS documents ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + worker_id INTEGER NOT NULL REFERENCES workers(id) ON DELETE CASCADE, + type_code TEXT NOT NULL REFERENCES 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', + issued_at TEXT, + expires_at TEXT, + imss_company_id INTEGER REFERENCES companies(id), + imss_alta_at TEXT, + uploaded_by INTEGER REFERENCES users(id), + uploaded_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS project_document_types ( + code TEXT PRIMARY KEY, + label TEXT NOT NULL, + required INTEGER NOT NULL DEFAULT 1 +); + +CREATE TABLE IF NOT EXISTS project_documents ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + type_code TEXT NOT NULL REFERENCES project_document_types(code), + original_name TEXT NOT NULL, + mime TEXT NOT NULL, + size_bytes INTEGER NOT NULL, + sha256 TEXT NOT NULL, + iv TEXT NOT NULL, + storage_name TEXT NOT NULL, + is_current INTEGER NOT NULL DEFAULT 1, + parse_status TEXT NOT NULL DEFAULT 'manual', + uploaded_by INTEGER REFERENCES users(id), + uploaded_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS badge_jobs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + project_id INTEGER NOT NULL REFERENCES projects(id), + status TEXT NOT NULL DEFAULT 'done', + pdf_path TEXT, + created_by INTEGER REFERENCES users(id), + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS badge_job_people ( + job_id INTEGER NOT NULL REFERENCES badge_jobs(id) ON DELETE CASCADE, + worker_id INTEGER NOT NULL REFERENCES workers(id), + delivered INTEGER NOT NULL DEFAULT 0, + delivered_at TEXT, + PRIMARY KEY (job_id, worker_id) +); + +CREATE TABLE IF NOT EXISTS loans ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + worker_id INTEGER NOT NULL REFERENCES workers(id) ON DELETE CASCADE, + amount REAL NOT NULL, + balance REAL NOT NULL, + weekly_payment REAL NOT NULL, + note TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS attendance ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + worker_id INTEGER NOT NULL REFERENCES workers(id) ON DELETE CASCADE, + project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + work_date TEXT NOT NULL, + present INTEGER NOT NULL DEFAULT 1, + UNIQUE (worker_id, project_id, work_date) +); + +CREATE TABLE IF NOT EXISTS payroll_periods ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + project_id INTEGER NOT NULL REFERENCES projects(id), + week_start TEXT NOT NULL, + week_end TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'draft', + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS payroll_lines ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + period_id INTEGER NOT NULL REFERENCES payroll_periods(id) ON DELETE CASCADE, + worker_id INTEGER NOT NULL REFERENCES workers(id), + days REAL NOT NULL DEFAULT 0, + daily_wage REAL NOT NULL, + gross REAL NOT NULL, + discounts REAL NOT NULL DEFAULT 0, + loan_payment REAL NOT NULL DEFAULT 0, + net REAL NOT NULL +); + +CREATE TABLE IF NOT EXISTS budget_chapters ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + parent_id INTEGER REFERENCES budget_chapters(id) ON DELETE SET NULL, + code TEXT NOT NULL DEFAULT '', + name TEXT NOT NULL, + wbs TEXT NOT NULL DEFAULT '', + sort_order INTEGER NOT NULL DEFAULT 0 +); + +CREATE TABLE IF NOT EXISTS budget_items ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + chapter_id INTEGER REFERENCES budget_chapters(id) ON DELETE SET NULL, + code TEXT NOT NULL DEFAULT '', + description TEXT NOT NULL, + unit TEXT NOT NULL DEFAULT '', + quantity REAL NOT NULL DEFAULT 0, + unit_price REAL NOT NULL DEFAULT 0, + amount REAL NOT NULL DEFAULT 0, + wbs TEXT NOT NULL DEFAULT '', + sort_order INTEGER NOT NULL DEFAULT 0 +); diff --git a/api/seed_lista.ts b/api/seed_lista.ts new file mode 100644 index 0000000..1c602c1 --- /dev/null +++ b/api/seed_lista.ts @@ -0,0 +1,148 @@ +/** + * Carga LISTA COLABORADORES GAFETES.xlsx al padrón para pruebas. + * Completa CURP/RFC/NSS/contacto faltantes con valores válidos únicos. + */ +import * as XLSX from "xlsx"; +import { getDb } from "./db.ts"; +import { upsertWorker, storeDocument } from "./excel.ts"; +import { resolveCompany } from "./companies.ts"; +import { + normalizeWorker, + validateWorkerFields, + validateCurp, + validateRfc, + validateNss, + canonicalRiskCode, + type WorkerInput, +} from "./mx.ts"; + +const FILE = "/mnt/c/Users/betom/Downloads/LISTA COLABORADORES GAFETES.xlsx"; +const CONS = "BCDFGHJKLMNPQRSTVWXYZ"; + +function cell(r: Record, k: string) { + return String(r[k] ?? "").trim(); +} + +function makeCurp(i: number) { + const v = "AEIOU"[i % 5]; + const yy = String(70 + (i % 30)).padStart(2, "0"); + const mm = String(1 + (i % 12)).padStart(2, "0"); + const dd = String(1 + (i % 28)).padStart(2, "0"); + const c1 = CONS[i % CONS.length]; + const c2 = CONS[(i * 3) % CONS.length]; + const c3 = CONS[(i * 7) % CONS.length]; + const hom = "ABCDEFGHJK"[i % 10]; + return `X${v}AA${yy}${mm}${dd}HDF${c1}${c2}${c3}${hom}${i % 10}`; +} + +function makeRfc(curp: string, i: number) { + return `${curp.slice(0, 10)}${CONS[i % CONS.length]}${i % 10}${CONS[(i * 5) % CONS.length]}`; +} + +function makeNss(seq: number) { + const base = String(8100000000 + seq).padStart(10, "0").slice(-10); + const digits = base.split("").map(Number); + let sum = 0; + for (let i = 0; i < 10; i++) { + let n = digits[i] * (i % 2 === 1 ? 2 : 1); + if (n > 9) n -= 9; + sum += n; + } + return base + String((10 - (sum % 10)) % 10); +} + +function slug(s: string) { + return s.normalize("NFD").replace(/\p{M}/gu, "").toLowerCase().replace(/[^a-z0-9]+/g, ".").replace(/^\.|\.$/g, "") || "colaborador"; +} + +async function fetchPhoto(url: string): Promise { + if (!url.startsWith("http")) return null; + try { + const res = await fetch(url, { signal: AbortSignal.timeout(8000) }); + if (!res.ok) return null; + const buf = new Uint8Array(await res.arrayBuffer()); + if (buf.byteLength < 80) return null; + return buf; + } catch { + return null; + } +} + +const wb = XLSX.read(await Deno.readFile(FILE), { type: "array" }); +const db = await getDb(); +const project = db.prepare("SELECT id FROM projects ORDER BY id LIMIT 1").get() as { id: number } | undefined; +const risks = new Set((db.prepare("SELECT code FROM risk_levels").all() as { code: string }[]).map((x) => x.code)); + +type Job = { sheet: string; status: "activo" | "baja" }; +const jobs: Job[] = [ + { sheet: "ACTUALES", status: "activo" }, + { sheet: "HISTORICO", status: "baja" }, +]; + +let inserted = 0, existed = 0, skipped = 0, photos = 0, i = 0; + +for (const job of jobs) { + const rows = XLSX.utils.sheet_to_json>(wb.Sheets[job.sheet], { defval: "" }); + for (const raw of rows) { + const first = cell(raw, "NOMBRE"); + const lastp = cell(raw, "APELLIDO PATERNO"); + if (!first || !lastp) { + skipped++; + continue; + } + i++; + let curp = cell(raw, "CURP"); + let rfc = cell(raw, "RFC"); + let nss = cell(raw, "NSS"); + if (validateCurp(curp)) curp = makeCurp(i); + if (validateRfc(rfc)) rfc = makeRfc(curp || makeCurp(i), i); + if (validateNss(nss)) nss = makeNss(1000 + i); + const ids = { curp, rfc, nss }; + const risk = canonicalRiskCode(cell(raw, "RIESGO")); + const hire = cell(raw, "ALTA").toUpperCase(); + const input: WorkerInput = { + first_name: first, + middle_name: cell(raw, "2 NOMBRE") || null, + last_name_p: lastp, + last_name_m: cell(raw, "APELLIDO MATERNO") || "X", + curp: ids.curp, + rfc: ids.rfc, + nss: ids.nss, + phone: cell(raw, "TELEFONO") || String(9981000000 + i), + email: cell(raw, "CORREO") || `${slug(first)}.${slug(lastp)}.${i}@pruebas.arctec.local`, + address: cell(raw, "DIRECCION") || "Sin domicilio en lista original", + blood_type: cell(raw, "TIPO SANGRE") || null, + hire_type: hire || "ARCT2608", + position: cell(raw, "CARGO") || "AYUDANTE", + risk_code: risks.has(risk) ? risk : "rojo", + work_type: cell(raw, "TIPO TRABAJO").toUpperCase() === "D" ? "D" : "N", + daily_wage: 450, + needs_badge: ["si", "sí", "yes"].includes(cell(raw, "GAFETE").toLowerCase()), + status: job.status, + }; + const errors = validateWorkerFields(input); + const company = resolveCompany(db, input) ?? resolveCompany(db, { hire_type: "ARCT2608" }); + if (!company) errors.hire_type = "Empresa no encontrada"; + if (Object.keys(errors).length || !company) { + console.log("SKIP", first, lastp, errors); + skipped++; + continue; + } + const n = { ...normalizeWorker(input), hire_type: company.code, company_id: company.id }; + const res = upsertWorker(db, n, job.status === "activo" ? project?.id ?? null : null); + if (res.action === "inserted") inserted++; + else existed++; + const url = cell(raw, "URL FOTO"); + if (url) { + const photo = await fetchPhoto(url); + if (photo) { + const mime = photo[0] === 0xff ? "image/jpeg" : "image/png"; + await storeDocument(db, res.id, "foto", "foto-lista.jpg", mime, photo, 1); + photos++; + } + } + } +} + +const total = db.prepare("SELECT COUNT(*) AS n FROM workers").get() as { n: number }; +console.log(JSON.stringify({ inserted, existed, skipped, photos, workers_in_db: total.n }, null, 2)); diff --git a/api/smtp.ts b/api/smtp.ts new file mode 100644 index 0000000..51060fd --- /dev/null +++ b/api/smtp.ts @@ -0,0 +1,149 @@ +import type { PlatformDb } from "./platform_db.ts"; +import { config } from "./config.ts"; + +export type SmtpSettings = { + host: string; + port: number; + username: string; + password: string; + from_address: string; + enabled: boolean; + updated_at?: string; +}; + +export type SmtpPublic = { + host: string; + port: number; + username: string; + from_address: string; + enabled: boolean; + configured: boolean; + has_password: boolean; + updated_at: string | null; + source: "db" | "env" | "none"; +}; + +type SmtpRow = { + host: string; + port: number; + username: string; + password: string; + from_address: string; + enabled: number; + updated_at: string; +}; + +function trim(v: unknown): string { + return (v ?? "").toString().trim(); +} + +function fromEnv(): SmtpSettings { + return { + host: config.smtpHost, + port: config.smtpPort || 587, + username: config.smtpUser, + password: config.smtpPass, + from_address: config.smtpFrom, + enabled: Boolean(config.smtpHost && config.smtpFrom), + }; +} + +function readRow(platformDb: PlatformDb): SmtpRow | undefined { + try { + return platformDb.prepare( + `SELECT host, port, username, password, from_address, enabled, updated_at + FROM smtp_settings WHERE id = 1`, + ).get() as SmtpRow | undefined; + } catch { + return undefined; + } +} + +/** Config efectiva: fila en platform.db si hay host; si no, variables de entorno. */ +export function resolveSmtp(platformDb: PlatformDb): SmtpSettings & { source: "db" | "env" | "none" } { + const row = readRow(platformDb); + if (row && trim(row.host)) { + return { + host: trim(row.host), + port: Number(row.port) || 587, + username: trim(row.username), + password: row.password ?? "", + from_address: trim(row.from_address) || config.smtpFrom, + enabled: Number(row.enabled) === 1, + updated_at: row.updated_at, + source: "db", + }; + } + const env = fromEnv(); + if (env.host) return { ...env, source: "env" }; + return { ...env, enabled: false, source: "none" }; +} + +export function smtpConfigured(platformDb: PlatformDb): boolean { + const s = resolveSmtp(platformDb); + return s.enabled && Boolean(s.host && s.from_address); +} + +export function smtpPublicView(platformDb: PlatformDb): SmtpPublic { + const s = resolveSmtp(platformDb); + return { + host: s.host, + port: s.port, + username: s.username, + from_address: s.from_address, + enabled: s.enabled, + configured: smtpConfigured(platformDb), + has_password: Boolean(s.password), + updated_at: s.updated_at ?? null, + source: s.source, + }; +} + +export type SaveSmtpInput = { + host?: string; + port?: number | string; + username?: string; + password?: string; + from_address?: string; + enabled?: boolean; + /** Si true y password vacío, conserva la contraseña actual. */ + keep_password?: boolean; +}; + +export function saveSmtpSettings( + platformDb: PlatformDb, + input: SaveSmtpInput, +): { error?: string; settings?: SmtpPublic } { + const host = trim(input.host); + const fromAddress = trim(input.from_address); + const username = trim(input.username); + const port = Number(input.port ?? 587); + if (!Number.isFinite(port) || port < 1 || port > 65535) { + return { error: "Puerto SMTP inválido" }; + } + + const enabled = input.enabled !== false; + if (enabled && !host) return { error: "Host SMTP obligatorio si está habilitado" }; + if (enabled && !fromAddress) return { error: "Remitente (From) obligatorio si está habilitado" }; + + const current = readRow(platformDb); + let password = input.password ?? ""; + if ((input.keep_password || password === "") && current?.password) { + password = current.password; + } + + platformDb.prepare( + `INSERT INTO smtp_settings (id, host, port, username, password, from_address, enabled, updated_at) + VALUES (1, ?, ?, ?, ?, ?, ?, datetime('now')) + ON CONFLICT(id) DO UPDATE SET + host = excluded.host, + port = excluded.port, + username = excluded.username, + password = excluded.password, + from_address = excluded.from_address, + enabled = excluded.enabled, + updated_at = datetime('now')`, + ).run(host, port, username, password, fromAddress, enabled ? 1 : 0); + + return { settings: smtpPublicView(platformDb) }; +} diff --git a/db/README.md b/db/README.md new file mode 100644 index 0000000..483ee1a --- /dev/null +++ b/db/README.md @@ -0,0 +1,28 @@ +# Database migrations (Liquibase) + +Homologa el schema de cada ambiente con la misma cadena de changesets. + +## Bases + +| Archivo | Changelog | +|---------|-----------| +| `data/app.db` | `db/app/` | +| `data/platform.db` | `db/platform/` | + +## Cómo aplicar + +Requisitos: Java 17+. + +```bash +# descarga CLI + JDBC (una vez) y aplica changesets +./db/update.sh all +# o solo una +./db/update.sh app +./db/update.sh platform +``` + +También: `npm run db:migrate` o al arrancar la API (`runLiquibase()` en Deno). + +Nuevos cambios de schema → nuevo changeset en `db/app/changesets/` o `db/platform/changesets/` y referenciarlo en el `changelog-master.xml` correspondiente. No añadir migraciones en `api/db.ts`. + +Las herramientas viven en `db/tools/` (gitignored); `bootstrap-tools.sh` las descarga. diff --git a/db/app/changelog-master.xml b/db/app/changelog-master.xml new file mode 100644 index 0000000..86cfc35 --- /dev/null +++ b/db/app/changelog-master.xml @@ -0,0 +1,11 @@ + + + + + + + diff --git a/db/app/changesets/001-baseline.sql b/db/app/changesets/001-baseline.sql new file mode 100644 index 0000000..b1a4004 --- /dev/null +++ b/db/app/changesets/001-baseline.sql @@ -0,0 +1,254 @@ +--liquibase formatted sql + +--changeset panel:app-001-baseline endDelimiter:; splitStatements:true +--preconditions onFail:MARK_RAN +--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='users' +--comment: Baseline schema (skipped/MARK_RAN when legacy schema.sql already applied) + +PRAGMA foreign_keys = ON; + +CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + display_name TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS companies ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + code TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + parent_id INTEGER REFERENCES companies(id), + kind TEXT NOT NULL DEFAULT 'sub' CHECK (kind IN ('principal', 'sub')), + status TEXT NOT NULL DEFAULT 'activo' CHECK (status IN ('activo', 'inactivo')), + registro_patronal TEXT NOT NULL DEFAULT '', + razon_social TEXT NOT NULL DEFAULT '', + nombre_comercial TEXT NOT NULL DEFAULT '', + rfc TEXT NOT NULL DEFAULT '', + regimen_fiscal TEXT NOT NULL DEFAULT '', + clase_riesgo TEXT NOT NULL DEFAULT '', + domicilio_fiscal TEXT NOT NULL DEFAULT '', + codigo_postal TEXT NOT NULL DEFAULT '', + ciudad TEXT NOT NULL DEFAULT '', + estado TEXT NOT NULL DEFAULT '', + telefono TEXT NOT NULL DEFAULT '', + email TEXT NOT NULL DEFAULT '', + representante_legal TEXT NOT NULL DEFAULT '', + giro TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS risk_levels ( + code TEXT PRIMARY KEY, + label TEXT NOT NULL, + color TEXT NOT NULL, + text_color TEXT NOT NULL DEFAULT '#FFFFFF' +); + +CREATE TABLE IF NOT EXISTS badge_themes ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + layout TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS projects ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + code TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + address TEXT NOT NULL DEFAULT '', + stage TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'activo' CHECK (status IN ('activo', 'pausado', 'concluido', 'cancelado')), + theme_id TEXT NOT NULL REFERENCES badge_themes(id), + logo_left_path TEXT, + logo_right_path TEXT, + company_id INTEGER REFERENCES companies(id), + contract_amount REAL, + start_date TEXT, + end_date TEXT, + resident_name TEXT NOT NULL DEFAULT '', + siroc TEXT NOT NULL DEFAULT '', + payroll_tax_pct REAL NOT NULL DEFAULT 4, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS workers ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + first_name TEXT NOT NULL, + middle_name TEXT, + last_name_p TEXT NOT NULL, + last_name_m TEXT NOT NULL, + curp TEXT NOT NULL COLLATE NOCASE, + rfc TEXT NOT NULL COLLATE NOCASE, + nss TEXT NOT NULL, + phone TEXT NOT NULL, + email TEXT NOT NULL, + address TEXT NOT NULL, + blood_type TEXT, + hire_type TEXT NOT NULL, + company_id INTEGER REFERENCES companies(id), + position TEXT NOT NULL, + risk_code TEXT NOT NULL REFERENCES risk_levels(code), + work_type TEXT NOT NULL CHECK (work_type IN ('N', 'D')), + daily_wage REAL NOT NULL DEFAULT 0, + needs_badge INTEGER NOT NULL DEFAULT 1, + status TEXT NOT NULL DEFAULT 'activo' CHECK (status IN ('activo', 'baja')), + pipeline_status TEXT NOT NULL DEFAULT 'incompleto', + imss_status TEXT NOT NULL DEFAULT 'sin_alta' CHECK (imss_status IN ('sin_alta', 'alta', 'baja_imss')), + imss_company_id INTEGER REFERENCES companies(id), + imss_alta_at TEXT, + imss_baja_at TEXT, + last_rehire_at TEXT, + vcard_password_enc TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_workers_curp ON workers(curp); +CREATE UNIQUE INDEX IF NOT EXISTS idx_workers_rfc ON workers(rfc); +CREATE UNIQUE INDEX IF NOT EXISTS idx_workers_nss ON workers(nss); + +CREATE TABLE IF NOT EXISTS assignments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + worker_id INTEGER NOT NULL REFERENCES workers(id) ON DELETE CASCADE, + project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + active INTEGER NOT NULL DEFAULT 1, + start_date TEXT NOT NULL, + end_date TEXT, + UNIQUE (worker_id, project_id) +); + +CREATE TABLE IF NOT EXISTS document_types ( + code TEXT PRIMARY KEY, + label TEXT NOT NULL, + required INTEGER NOT NULL DEFAULT 1, + validity_mode TEXT NOT NULL DEFAULT 'none' CHECK (validity_mode IN ('none', 'freshness', 'expiry')), + freshness_days INTEGER, + requires_issued_at INTEGER NOT NULL DEFAULT 0, + requires_expires_at INTEGER NOT NULL DEFAULT 0 +); + +CREATE TABLE IF NOT EXISTS documents ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + worker_id INTEGER NOT NULL REFERENCES workers(id) ON DELETE CASCADE, + type_code TEXT NOT NULL REFERENCES 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', + issued_at TEXT, + expires_at TEXT, + imss_company_id INTEGER REFERENCES companies(id), + imss_alta_at TEXT, + uploaded_by INTEGER REFERENCES users(id), + uploaded_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS project_document_types ( + code TEXT PRIMARY KEY, + label TEXT NOT NULL, + required INTEGER NOT NULL DEFAULT 1 +); + +CREATE TABLE IF NOT EXISTS project_documents ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + type_code TEXT NOT NULL REFERENCES project_document_types(code), + original_name TEXT NOT NULL, + mime TEXT NOT NULL, + size_bytes INTEGER NOT NULL, + sha256 TEXT NOT NULL, + iv TEXT NOT NULL, + storage_name TEXT NOT NULL, + is_current INTEGER NOT NULL DEFAULT 1, + parse_status TEXT NOT NULL DEFAULT 'manual', + uploaded_by INTEGER REFERENCES users(id), + uploaded_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS badge_jobs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + project_id INTEGER NOT NULL REFERENCES projects(id), + status TEXT NOT NULL DEFAULT 'done', + pdf_path TEXT, + created_by INTEGER REFERENCES users(id), + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS badge_job_people ( + job_id INTEGER NOT NULL REFERENCES badge_jobs(id) ON DELETE CASCADE, + worker_id INTEGER NOT NULL REFERENCES workers(id), + delivered INTEGER NOT NULL DEFAULT 0, + delivered_at TEXT, + PRIMARY KEY (job_id, worker_id) +); + +CREATE TABLE IF NOT EXISTS loans ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + worker_id INTEGER NOT NULL REFERENCES workers(id) ON DELETE CASCADE, + amount REAL NOT NULL, + balance REAL NOT NULL, + weekly_payment REAL NOT NULL, + note TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS attendance ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + worker_id INTEGER NOT NULL REFERENCES workers(id) ON DELETE CASCADE, + project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + work_date TEXT NOT NULL, + present INTEGER NOT NULL DEFAULT 1, + UNIQUE (worker_id, project_id, work_date) +); + +CREATE TABLE IF NOT EXISTS payroll_periods ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + project_id INTEGER NOT NULL REFERENCES projects(id), + week_start TEXT NOT NULL, + week_end TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'draft', + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS payroll_lines ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + period_id INTEGER NOT NULL REFERENCES payroll_periods(id) ON DELETE CASCADE, + worker_id INTEGER NOT NULL REFERENCES workers(id), + days REAL NOT NULL DEFAULT 0, + daily_wage REAL NOT NULL, + gross REAL NOT NULL, + discounts REAL NOT NULL DEFAULT 0, + loan_payment REAL NOT NULL DEFAULT 0, + net REAL NOT NULL +); + +CREATE TABLE IF NOT EXISTS budget_chapters ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + parent_id INTEGER REFERENCES budget_chapters(id) ON DELETE SET NULL, + code TEXT NOT NULL DEFAULT '', + name TEXT NOT NULL, + wbs TEXT NOT NULL DEFAULT '', + sort_order INTEGER NOT NULL DEFAULT 0 +); + +CREATE TABLE IF NOT EXISTS budget_items ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + chapter_id INTEGER REFERENCES budget_chapters(id) ON DELETE SET NULL, + code TEXT NOT NULL DEFAULT '', + description TEXT NOT NULL, + unit TEXT NOT NULL DEFAULT '', + quantity REAL NOT NULL DEFAULT 0, + unit_price REAL NOT NULL DEFAULT 0, + amount REAL NOT NULL DEFAULT 0, + wbs TEXT NOT NULL DEFAULT '', + sort_order INTEGER NOT NULL DEFAULT 0 +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_projects_code ON projects(code); diff --git a/db/app/changesets/002-tenant-id.sql b/db/app/changesets/002-tenant-id.sql new file mode 100644 index 0000000..9ad4792 --- /dev/null +++ b/db/app/changesets/002-tenant-id.sql @@ -0,0 +1,43 @@ +--liquibase formatted sql + +--changeset panel:app-002a-users-tenant endDelimiter:; +--preconditions onFail:MARK_RAN +--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM pragma_table_info('users') WHERE name='tenant_id' +ALTER TABLE users ADD COLUMN tenant_id INTEGER; + +--changeset panel:app-002b-users-role endDelimiter:; +--preconditions onFail:MARK_RAN +--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM pragma_table_info('users') WHERE name='role' +ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'user'; + +--changeset panel:app-002c-users-company endDelimiter:; +--preconditions onFail:MARK_RAN +--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM pragma_table_info('users') WHERE name='company_id' +ALTER TABLE users ADD COLUMN company_id INTEGER REFERENCES companies(id); + +--changeset panel:app-002d-companies-tenant endDelimiter:; +--preconditions onFail:MARK_RAN +--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM pragma_table_info('companies') WHERE name='tenant_id' +ALTER TABLE companies ADD COLUMN tenant_id INTEGER; + +--changeset panel:app-002e-workers-tenant endDelimiter:; +--preconditions onFail:MARK_RAN +--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM pragma_table_info('workers') WHERE name='tenant_id' +ALTER TABLE workers ADD COLUMN tenant_id INTEGER; + +--changeset panel:app-002f-projects-tenant endDelimiter:; +--preconditions onFail:MARK_RAN +--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM pragma_table_info('projects') WHERE name='tenant_id' +ALTER TABLE projects ADD COLUMN tenant_id INTEGER; + +--changeset panel:app-002g-backfill-tenant endDelimiter:; splitStatements:true +UPDATE companies SET tenant_id = 1 WHERE tenant_id IS NULL; +UPDATE workers SET tenant_id = 1 WHERE tenant_id IS NULL; +UPDATE projects SET tenant_id = 1 WHERE tenant_id IS NULL; +UPDATE users SET tenant_id = 1 WHERE tenant_id IS NULL; +UPDATE users SET role = 'tenant_admin' WHERE COALESCE(role, 'user') = 'user'; + +CREATE INDEX IF NOT EXISTS idx_companies_tenant ON companies(tenant_id); +CREATE INDEX IF NOT EXISTS idx_workers_tenant ON workers(tenant_id); +CREATE INDEX IF NOT EXISTS idx_projects_tenant ON projects(tenant_id); +CREATE INDEX IF NOT EXISTS idx_users_tenant ON users(tenant_id); diff --git a/db/app/changesets/003-must-change-password.sql b/db/app/changesets/003-must-change-password.sql new file mode 100644 index 0000000..3f9c238 --- /dev/null +++ b/db/app/changesets/003-must-change-password.sql @@ -0,0 +1,11 @@ +--liquibase formatted sql + +--changeset panel:app-003a-must-change-password endDelimiter:; +--preconditions onFail:MARK_RAN +--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM pragma_table_info('users') WHERE name='must_change_password' +ALTER TABLE users ADD COLUMN must_change_password INTEGER NOT NULL DEFAULT 0; + +--changeset panel:app-003b-user-email endDelimiter:; +--preconditions onFail:MARK_RAN +--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM pragma_table_info('users') WHERE name='email' +ALTER TABLE users ADD COLUMN email TEXT NOT NULL DEFAULT ''; diff --git a/db/app/liquibase.properties b/db/app/liquibase.properties new file mode 100644 index 0000000..aca1901 --- /dev/null +++ b/db/app/liquibase.properties @@ -0,0 +1,4 @@ +changeLogFile=changelog-master.xml +driver=org.sqlite.JDBC +url=jdbc:sqlite:../../data/app.db +classpath=../tools/sqlite-jdbc.jar diff --git a/db/bootstrap-tools.sh b/db/bootstrap-tools.sh new file mode 100755 index 0000000..cd866d2 --- /dev/null +++ b/db/bootstrap-tools.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +TOOLS="$ROOT/db/tools" +mkdir -p "$TOOLS" +cd "$TOOLS" + +if [[ ! -x "$TOOLS/liquibase/liquibase" ]]; then + echo "Downloading Liquibase 4.29.2..." + curl -fsSL -o liquibase.zip "https://github.com/liquibase/liquibase/releases/download/v4.29.2/liquibase-4.29.2.zip" + rm -rf liquibase + unzip -q -o liquibase.zip -d liquibase + rm -f liquibase.zip +fi + +if [[ ! -f "$TOOLS/sqlite-jdbc.jar" ]]; then + echo "Downloading sqlite-jdbc..." + curl -fsSL -o sqlite-jdbc.jar \ + "https://repo1.maven.org/maven2/org/xerial/sqlite-jdbc/3.46.1.3/sqlite-jdbc-3.46.1.3.jar" +fi + +echo "Liquibase tools ready." diff --git a/db/platform/changelog-master.xml b/db/platform/changelog-master.xml new file mode 100644 index 0000000..050c58d --- /dev/null +++ b/db/platform/changelog-master.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + diff --git a/db/platform/changesets/001-init.sql b/db/platform/changesets/001-init.sql new file mode 100644 index 0000000..a4621db --- /dev/null +++ b/db/platform/changesets/001-init.sql @@ -0,0 +1,20 @@ +--liquibase formatted sql + +--changeset panel:platform-001-init endDelimiter:; splitStatements:true + +CREATE TABLE IF NOT EXISTS tenants ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + code TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'activo' CHECK (status IN ('activo', 'inactivo')), + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS platform_users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + display_name TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'activo' CHECK (status IN ('activo', 'inactivo')), + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); diff --git a/db/platform/changesets/002-seed-arctec-tenant.sql b/db/platform/changesets/002-seed-arctec-tenant.sql new file mode 100644 index 0000000..1b661fd --- /dev/null +++ b/db/platform/changesets/002-seed-arctec-tenant.sql @@ -0,0 +1,7 @@ +--liquibase formatted sql + +--changeset panel:platform-002-seed-arctec endDelimiter:; splitStatements:true +--preconditions onFail:MARK_RAN +--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM tenants WHERE code = 'ARCTEC' + +INSERT INTO tenants (id, code, name, status) VALUES (1, 'ARCTEC', 'ARCTEC', 'activo'); diff --git a/db/platform/changesets/003-tenant-license.sql b/db/platform/changesets/003-tenant-license.sql new file mode 100644 index 0000000..56c093d --- /dev/null +++ b/db/platform/changesets/003-tenant-license.sql @@ -0,0 +1,16 @@ +--liquibase formatted sql + +--changeset panel:platform-003a-plan endDelimiter:; +--preconditions onFail:MARK_RAN +--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM pragma_table_info('tenants') WHERE name='plan' +ALTER TABLE tenants ADD COLUMN plan TEXT NOT NULL DEFAULT 'trial'; + +--changeset panel:platform-003b-valid-until endDelimiter:; +--preconditions onFail:MARK_RAN +--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM pragma_table_info('tenants') WHERE name='valid_until' +ALTER TABLE tenants ADD COLUMN valid_until TEXT; + +--changeset panel:platform-003c-notes endDelimiter:; +--preconditions onFail:MARK_RAN +--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM pragma_table_info('tenants') WHERE name='notes' +ALTER TABLE tenants ADD COLUMN notes TEXT NOT NULL DEFAULT ''; diff --git a/db/platform/changesets/004-tenant-status-expand.sql b/db/platform/changesets/004-tenant-status-expand.sql new file mode 100644 index 0000000..d37e667 --- /dev/null +++ b/db/platform/changesets/004-tenant-status-expand.sql @@ -0,0 +1,30 @@ +--liquibase formatted sql + +--changeset panel:platform-004-status-expand endDelimiter:; splitStatements:true +--comment: Expand tenant status to activo|suspendido|cancelado (SQLite rebuild) + +CREATE TABLE tenants_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + code TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'activo' CHECK (status IN ('activo', 'suspendido', 'cancelado')), + created_at TEXT NOT NULL DEFAULT (datetime('now')), + plan TEXT NOT NULL DEFAULT 'trial', + valid_until TEXT, + notes TEXT NOT NULL DEFAULT '' +); + +INSERT INTO tenants_new (id, code, name, status, created_at, plan, valid_until, notes) +SELECT + id, + code, + name, + CASE WHEN status = 'inactivo' THEN 'suspendido' ELSE status END, + created_at, + COALESCE(plan, 'trial'), + valid_until, + COALESCE(notes, '') +FROM tenants; + +DROP TABLE tenants; +ALTER TABLE tenants_new RENAME TO tenants; diff --git a/db/platform/changesets/005-contact-access.sql b/db/platform/changesets/005-contact-access.sql new file mode 100644 index 0000000..fdd9f14 --- /dev/null +++ b/db/platform/changesets/005-contact-access.sql @@ -0,0 +1,16 @@ +--liquibase formatted sql + +--changeset panel:platform-005a-contact-email endDelimiter:; +--preconditions onFail:MARK_RAN +--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM pragma_table_info('tenants') WHERE name='contact_email' +ALTER TABLE tenants ADD COLUMN contact_email TEXT NOT NULL DEFAULT ''; + +--changeset panel:platform-005b-contact-name endDelimiter:; +--preconditions onFail:MARK_RAN +--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM pragma_table_info('tenants') WHERE name='contact_name' +ALTER TABLE tenants ADD COLUMN contact_name TEXT NOT NULL DEFAULT ''; + +--changeset panel:platform-005c-access-sent-at endDelimiter:; +--preconditions onFail:MARK_RAN +--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM pragma_table_info('tenants') WHERE name='access_sent_at' +ALTER TABLE tenants ADD COLUMN access_sent_at TEXT; diff --git a/db/platform/changesets/006-tenant-code-date.sql b/db/platform/changesets/006-tenant-code-date.sql new file mode 100644 index 0000000..3d97866 --- /dev/null +++ b/db/platform/changesets/006-tenant-code-date.sql @@ -0,0 +1,7 @@ +--liquibase formatted sql + +--changeset panel:platform-006-tenant-code-date endDelimiter:; splitStatements:true +--preconditions onFail:MARK_RAN +--precondition-sql-check expectedResult:1 SELECT COUNT(*) FROM tenants WHERE code = 'ARCTEC' + +UPDATE tenants SET code = 'ARCT2608' WHERE code = 'ARCTEC'; diff --git a/db/platform/changesets/007-smtp-settings.sql b/db/platform/changesets/007-smtp-settings.sql new file mode 100644 index 0000000..9d88021 --- /dev/null +++ b/db/platform/changesets/007-smtp-settings.sql @@ -0,0 +1,18 @@ +--liquibase formatted sql + +--changeset panel:platform-007-smtp-settings endDelimiter:; splitStatements:true + +CREATE TABLE IF NOT EXISTS smtp_settings ( + id INTEGER PRIMARY KEY CHECK (id = 1), + host TEXT NOT NULL DEFAULT '', + port INTEGER NOT NULL DEFAULT 587, + username TEXT NOT NULL DEFAULT '', + password TEXT NOT NULL DEFAULT '', + from_address TEXT NOT NULL DEFAULT '', + enabled INTEGER NOT NULL DEFAULT 0, + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +INSERT INTO smtp_settings (id, host, port, username, password, from_address, enabled) +SELECT 1, '', 587, '', '', '', 0 +WHERE NOT EXISTS (SELECT 1 FROM smtp_settings WHERE id = 1); diff --git a/db/platform/liquibase.properties b/db/platform/liquibase.properties new file mode 100644 index 0000000..908e48b --- /dev/null +++ b/db/platform/liquibase.properties @@ -0,0 +1,4 @@ +changeLogFile=changelog-master.xml +driver=org.sqlite.JDBC +url=jdbc:sqlite:../../data/platform.db +classpath=../tools/sqlite-jdbc.jar diff --git a/db/update.sh b/db/update.sh new file mode 100755 index 0000000..d8c22d7 --- /dev/null +++ b/db/update.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +"$ROOT/db/bootstrap-tools.sh" +LIQUIBASE="$ROOT/db/tools/liquibase/liquibase" +SQLITE_JDBC="$ROOT/db/tools/sqlite-jdbc.jar" + +mkdir -p "$ROOT/data" + +TARGET="${1:-all}" + +run_one() { + local name="$1" + local dir="$ROOT/db/$name" + local db="$ROOT/data/${name}.db" + echo "==> Liquibase update: $name ($db)" + ( + cd "$dir" + "$LIQUIBASE" \ + --defaultsFile=liquibase.properties \ + --classpath="$SQLITE_JDBC" \ + --url="jdbc:sqlite:$db" \ + update + ) +} + +case "$TARGET" in + app) run_one app ;; + platform) run_one platform ;; + all) + run_one app + run_one platform + ;; + *) + echo "Usage: $0 [all|app|platform]" >&2 + exit 1 + ;; +esac + +echo "Liquibase OK" diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..c995e29 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,66 @@ +# Coolify / local stack — 3 services, 0 managed DBs (2 SQLite en volumen) +# +# Secretos OBLIGATORIOS (sin default en compose): defínelos en Coolify o en un +# archivo .env local (gitignored). Si faltan, `docker compose` falla al arrancar. +services: + api: + build: + context: . + dockerfile: Dockerfile.api + restart: unless-stopped + environment: + PORT: ${PORT:-8000} + SESSION_SECRET: ${SESSION_SECRET:?SESSION_SECRET is required} + DOCS_KEY: ${DOCS_KEY:?DOCS_KEY is required (64 hex chars)} + SEED_PASSWORD: ${SEED_PASSWORD:?SEED_PASSWORD is required} + API_KEY: ${API_KEY:-} + PANEL_LOGIN_URL: ${PANEL_LOGIN_URL:?PANEL_LOGIN_URL is required} + COOKIE_SECURE: ${COOKIE_SECURE:-true} + CORS_ORIGINS: ${CORS_ORIGINS:-} + VCARD_BASE: ${VCARD_BASE:-} + SMTP_HOST: ${SMTP_HOST:-} + SMTP_PORT: ${SMTP_PORT:-587} + SMTP_USER: ${SMTP_USER:-} + SMTP_PASS: ${SMTP_PASS:-} + SMTP_FROM: ${SMTP_FROM:-} + volumes: + - panel-data:/app/data + expose: + - "8000" + healthcheck: + test: + [ + "CMD", + "deno", + "eval", + "const r=await fetch('http://127.0.0.1:'+(Deno.env.get('PORT')||'8000')+'/v1/health'); if(!r.ok) Deno.exit(1)", + ] + interval: 30s + timeout: 5s + retries: 5 + start_period: 45s + + web-panel: + build: + context: . + dockerfile: Dockerfile.web-panel + restart: unless-stopped + depends_on: + api: + condition: service_healthy + expose: + - "80" + + web-saas: + build: + context: . + dockerfile: Dockerfile.web-saas + restart: unless-stopped + depends_on: + api: + condition: service_healthy + expose: + - "80" + +volumes: + panel-data: diff --git a/docs/branding/panels-brand-board-pronunciation.png b/docs/branding/panels-brand-board-pronunciation.png new file mode 100644 index 0000000..62b3d69 Binary files /dev/null and b/docs/branding/panels-brand-board-pronunciation.png differ diff --git a/docs/branding/panels-brand-board.png b/docs/branding/panels-brand-board.png new file mode 100644 index 0000000..21b083d Binary files /dev/null and b/docs/branding/panels-brand-board.png differ diff --git a/docs/coolify.md b/docs/coolify.md new file mode 100644 index 0000000..7e4e212 --- /dev/null +++ b/docs/coolify.md @@ -0,0 +1,98 @@ +# Deploy PANELS en Coolify (Docker Compose) + +## Resumen + +| Qué | Cuánto | +|-----|--------| +| Bases gestionadas Coolify (Postgres/MySQL) | **0** | +| Archivos SQLite en volumen | **2** (`app.db`, `platform.db`) | +| Contenedores | **3** (`api`, `web-panel`, `web-saas`) | +| Volumen persistente | **1** → `/app/data` en `api` | + +Los fronts (Alpine + nginx) hacen proxy de `/v1` al servicio `api`, así las cookies de sesión van same-origin. + +## Datos sensibles (qué NO va al git) + +| Ítem | Estado | +|------|--------| +| `.env` | gitignored — no commitear | +| `data/` (`*.db`, expedientes, pdfs) | gitignored | +| SMTP password en SaaS | vive en `platform.db` (volumen) — proteger backups | +| Defaults de desarrollo en código | solo fallbacks locales; Coolify **exige** secrets | + +## Variables de entorno (servicio `api`) + +### Obligatorias + +| Variable | Formato | Uso | +|----------|---------|-----| +| `SESSION_SECRET` | string largo aleatorio | Firma cookie de sesión | +| `DOCS_KEY` | **64** caracteres hex (32 bytes) | Cifrado de documentos | +| `SEED_PASSWORD` | string | Password inicial de `admin` SaaS (solo al crear / migrar seed) | +| `PANEL_LOGIN_URL` | URL absoluta | Link en correos de acceso | + +Generar: + +```bash +openssl rand -hex 32 # SESSION_SECRET o DOCS_KEY +``` + +### Recomendadas (HTTPS / Coolify) + +| Variable | Default compose | Uso | +|----------|-----------------|-----| +| `COOKIE_SECURE` | `true` | Cookie solo por HTTPS | +| `PORT` | `8000` | Puerto interno API | +| `CORS_ORIGINS` | vacío | Lista `https://a,https://b` si la API se llama cross-origin; con proxy `/v1` en nginx **no hace falta** | + +### Opcionales + +| Variable | Uso | +|----------|-----| +| `API_KEY` | Auth alternativa por header `X-API-Key` (vacío = desactivado) | +| `VCARD_BASE` | Prefijo QR/vCard gafetes | +| `SMTP_HOST` / `SMTP_PORT` / `SMTP_USER` / `SMTP_PASS` / `SMTP_FROM` | Correo por env (alternativa al panel `/smtp`) | + +`web-panel` y `web-saas` **no** necesitan variables de entorno en runtime (estáticos + proxy nginx). + +## Imágenes + +- **api:** Deno 2.9 + OpenJDK 21 JRE + Liquibase (Debian; no Alpine por FFI SQLite) +- **web-panel / web-saas:** build Node Alpine → **nginx Alpine** + +Archivos: `Dockerfile.api`, `Dockerfile.web-panel`, `Dockerfile.web-saas`, `docker-compose.yml`, `web-panel/nginx.conf`, `web-saas/nginx.conf`. + +En Coolify el servicio de PANELS se llama **`web-panel`** (FQDN ej. `panels.mrdev.mx`). La carpeta `web/` queda libre para una website futura. + +## Paso a paso en Coolify + +1. **Push** este repo (sin `.env` ni `data/`). +2. Coolify → **New Resource → Docker Compose** → `docker-compose.yml`. +3. **Persistent storage:** volumen `panel-data` → `/app/data` en `api`. +4. **Dominios:** `web-panel` → panels; `web-saas` → saas; `api` sin FQDN (proxy `/v1`). +5. Cargar en Coolify las **obligatorias** + `COOKIE_SECURE=true`. +6. Deploy (Liquibase crea/migra las 2 SQLite en el volumen). +7. Verificar `https://app…/v1/health`, login SaaS `admin` / tu `SEED_PASSWORD`. +8. SMTP en `/smtp` o por `SMTP_*`. + +## Local + +```bash +cp .env.example .env # rellenar obligatorias +docker compose up --build +``` + +Sin `SESSION_SECRET` / `DOCS_KEY` / `SEED_PASSWORD` / `PANEL_LOGIN_URL`, compose **no arranca**. + +## Checklist ops + +- [ ] `.env` y `data/` fuera del git +- [ ] Secrets distintos a los de desarrollo +- [ ] Backup del volumen `panel-data` (incluye DBs + SMTP guardado) +- [ ] `COOKIE_SECURE=true` en HTTPS +- [ ] Healthcheck API OK + +## Qué no hace falta + +- Postgres/MySQL/MariaDB en Coolify +- Contenedor monolítico Node+Deno+Java diff --git a/docs/mockups/panel-login-mockup-v2.png b/docs/mockups/panel-login-mockup-v2.png new file mode 100644 index 0000000..1f42a9a Binary files /dev/null and b/docs/mockups/panel-login-mockup-v2.png differ diff --git a/docs/mockups/panel-login-mockup.png b/docs/mockups/panel-login-mockup.png new file mode 100644 index 0000000..62d7f4a Binary files /dev/null and b/docs/mockups/panel-login-mockup.png differ diff --git a/docs/padron-v2.md b/docs/padron-v2.md new file mode 100644 index 0000000..4519b7e --- /dev/null +++ b/docs/padron-v2.md @@ -0,0 +1,50 @@ +# Padrón v2 + +## Alcance + +Padrón conserva el modelo actual: la persona es el registro maestro, los proyectos son asignaciones y el estado de expediente/gafete se deriva de documentos, impresiones y asignaciones. Esta fase no cambia la nómina ni el generador fijo de gafetes. + +## Arquitectura + +- `web-panel/pages/padron/index.vue`: orquesta carga, selección, árbol y comandos de cinta. +- `web-panel/components/padron/PadronTable.vue`: tabla, filtros y acciones por persona. +- `web-panel/components/padron/WorkerFormDialog.vue`: alta y edición de datos maestros. +- `web-panel/components/padron/WorkerImportDialog.vue`: importación de Excel y reporte. +- `web-panel/components/WorkerFicha.vue`: ficha lateral y navegación por secciones. +- `web-panel/components/padron/WorkerDocuments.vue`: expediente obligatorio/opcional. +- `web-panel/components/padron/WorkerAssignments.vue`: altas, reactivaciones y bajas de asignación. +- `web-panel/types/padron.ts`: contratos del frontend para persona, proyecto, riesgo e importación. +- `api/main.ts`: reglas HTTP; `api/db.ts` conserva el cálculo de pipeline. + +## Invariantes + +1. Una persona no se elimina: `status=baja` conserva su historial. +2. Solo se asigna o importa personal a proyectos activos. +3. Una asignación repetida se reactiva; al finalizarla se conserva su fecha de fin. +4. `pipeline_status` no es un dato editable. Solo la baja es manual; el resto se recalcula. +5. CURP, RFC y NSS son únicos. +6. Foto, INE, CURP, NSS/IMSS y RFC forman el expediente obligatorio. +7. El código exacto de riesgo se conserva porque define el color del gafete; Alto/Medio/Bajo es una lectura semántica para la interfaz. + +## Contratos usados por Padrón + +- `GET /v1/workers`: lista maestra con proyectos activos agregados, faltantes y saldo. +- `GET /v1/workers/:id`: persona, documentos, checklist, asignaciones y préstamos. +- `POST /v1/workers/validate`: validación de formato y unicidad. +- `POST /v1/workers` / `PATCH /v1/workers/:id`: alta y edición. +- `POST /v1/workers/:id/assign`: asignar, reactivar o finalizar. +- `POST /v1/workers/:id/documents`: guardar una nueva versión cifrada. +- `PATCH /v1/workers/:id/pipeline`: baja manual o recálculo/reactivación. + +## Verificación + +```bash +cd api +deno task check +deno task test + +cd ../web-panel +npm run build +``` + +La siguiente fase puede reutilizar estos contratos para el editor de plantillas; no debe acoplar el layout del gafete a la ficha de Padrón. diff --git a/package.json b/package.json new file mode 100644 index 0000000..b5334d8 --- /dev/null +++ b/package.json @@ -0,0 +1,12 @@ +{ + "name": "panel-obra", + "private": true, + "scripts": { + "dev:api": "cd api && deno task dev", + "dev:web": "cd web-panel && npm run dev", + "dev:saas": "cd web-saas && npm run dev", + "db:migrate": "./db/update.sh all", + "docker:build": "docker compose build", + "docker:up": "docker compose up -d" + } +} diff --git a/scripts/migrate-tenant-codes.ts b/scripts/migrate-tenant-codes.ts new file mode 100644 index 0000000..c289be8 --- /dev/null +++ b/scripts/migrate-tenant-codes.ts @@ -0,0 +1,129 @@ +/** + * One-off: tenant/company code = nombre(4) + YYMM; username = code lowercase. + * Usage: deno run -A scripts/migrate-tenant-codes.ts + */ +import { Database } from "jsr:@db/sqlite@0.12"; + +const PLATFORM = new URL("../data/platform.db", import.meta.url).pathname; +const APP = new URL("../data/app.db", import.meta.url).pathname; + +function shortFromName(name: string): string { + const slug = (name ?? "") + .toString() + .normalize("NFD") + .replace(/\p{M}/gu, "") + .toUpperCase() + .replace(/[^A-Z0-9]+/g, "") + .slice(0, 4); + return slug.length >= 2 ? slug : (slug || "EMP").padEnd(2, "X").slice(0, 4); +} + +function stampFromCreated(createdAt: string): string { + const m = createdAt.match(/^(\d{4})-(\d{2})/); + if (!m) { + const d = new Date(); + return `${String(d.getFullYear()).slice(-2)}${String(d.getMonth() + 1).padStart(2, "0")}`; + } + return `${m[1].slice(-2)}${m[2]}`; +} + +const platform = new Database(PLATFORM); +const app = new Database(APP); +platform.exec("PRAGMA foreign_keys = ON;"); +app.exec("PRAGMA foreign_keys = ON;"); + +const tenants = platform.prepare( + "SELECT id, code, name, created_at FROM tenants ORDER BY id", +).all() as { id: number; code: string; name: string; created_at: string }[]; + +const used = new Set([ + ...(app.prepare("SELECT code FROM companies").all() as { code: string }[]).map((r) => r.code), + ...tenants.map((t) => t.code), +]); + +function allocate(name: string, createdAt: string, oldCode: string): string { + const base = shortFromName(name); + const stamp = stampFromCreated(createdAt); + let candidate = `${base}${stamp}`; + if (candidate === oldCode || !used.has(candidate)) return candidate; + for (let n = 2; n < 1000; n++) { + const suffix = String(n); + const head = base.slice(0, Math.max(2, 4 - suffix.length)); + candidate = `${head}${stamp}${suffix}`; + if (candidate === oldCode || !used.has(candidate)) return candidate; + } + throw new Error(`No unique code for ${name}`); +} + +type MapRow = { + tenantId: number; + oldCode: string; + newCode: string; + oldUser: string; + newUser: string; +}; + +const mappings: MapRow[] = []; +for (const t of tenants) { + const newCode = allocate(t.name, t.created_at, t.code); + used.add(newCode); + mappings.push({ + tenantId: t.id, + oldCode: t.code, + newCode, + oldUser: `adm.${t.code.toLowerCase()}`, + newUser: newCode.toLowerCase(), + }); +} + +console.log("Migrating:", mappings); + +for (const m of mappings) { + if (m.oldCode !== m.newCode) { + platform.prepare("UPDATE tenants SET code = ? WHERE id = ?").run(m.newCode, m.tenantId); + + const principal = app.prepare( + "SELECT id, code FROM companies WHERE tenant_id = ? AND kind = 'principal' ORDER BY id LIMIT 1", + ).get(m.tenantId) as { id: number; code: string } | undefined; + + if (principal && principal.code === m.oldCode) { + app.prepare("UPDATE companies SET code = ? WHERE id = ?").run(m.newCode, principal.id); + app.prepare("UPDATE workers SET hire_type = ? WHERE company_id = ? AND hire_type = ?") + .run(m.newCode, principal.id, m.oldCode); + } + } + + const clash = app.prepare("SELECT id FROM users WHERE username = ?").get(m.newUser); + if (!clash) { + app.prepare( + "UPDATE users SET username = ? WHERE tenant_id = ? AND username = ?", + ).run(m.newUser, m.tenantId, m.oldUser); + // also catch if username already equals old code lowercase without adm. + app.prepare( + "UPDATE users SET username = ? WHERE tenant_id = ? AND username = ? AND username != ?", + ).run(m.newUser, m.tenantId, m.oldCode.toLowerCase(), m.newUser); + } else { + // ensure old adm.* row is renamed only if target not taken by another user + const old = app.prepare( + "SELECT id FROM users WHERE tenant_id = ? AND username = ?", + ).get(m.tenantId, m.oldUser) as { id: number } | undefined; + if (old && (clash as { id: number }).id === old.id) { + // same row already correct + } else if (old) { + console.warn(`Skip user rename ${m.oldUser} → ${m.newUser}: target exists`); + } + } +} + +console.log("TENANTS", platform.prepare("SELECT id, code, name FROM tenants").all()); +console.log( + "PRINCIPALS", + app.prepare("SELECT id, code, tenant_id, kind FROM companies WHERE kind = 'principal'").all(), +); +console.log( + "USERS", + app.prepare("SELECT id, username, tenant_id, role FROM users").all(), +); + +platform.close(); +app.close(); diff --git a/web-panel/.gitignore b/web-panel/.gitignore new file mode 100644 index 0000000..1c4c47b --- /dev/null +++ b/web-panel/.gitignore @@ -0,0 +1,26 @@ +# Nuxt dev/build outputs +.output +.data +.nuxt +.nitro +.cache +dist + +# Node dependencies +node_modules + +# Logs +logs +*.log + +# Misc +.DS_Store +*:Zone.Identifier +.fleet +.idea +.vscode + +# Local env files +.env +.env.* +!.env.example diff --git a/web-panel/README.md b/web-panel/README.md new file mode 100644 index 0000000..25b5821 --- /dev/null +++ b/web-panel/README.md @@ -0,0 +1,75 @@ +# Nuxt Minimal Starter + +Look at the [Nuxt documentation](https://nuxt.com/docs/getting-started/introduction) to learn more. + +## Setup + +Make sure to install dependencies: + +```bash +# npm +npm install + +# pnpm +pnpm install + +# yarn +yarn install + +# bun +bun install +``` + +## Development Server + +Start the development server on `http://localhost:3000`: + +```bash +# npm +npm run dev + +# pnpm +pnpm dev + +# yarn +yarn dev + +# bun +bun run dev +``` + +## Production + +Build the application for production: + +```bash +# npm +npm run build + +# pnpm +pnpm build + +# yarn +yarn build + +# bun +bun run build +``` + +Locally preview production build: + +```bash +# npm +npm run preview + +# pnpm +pnpm preview + +# yarn +yarn preview + +# bun +bun run preview +``` + +Check out the [deployment documentation](https://nuxt.com/docs/getting-started/deployment) for more information. diff --git a/web-panel/app.vue b/web-panel/app.vue new file mode 100644 index 0000000..e4ce5c4 --- /dev/null +++ b/web-panel/app.vue @@ -0,0 +1,18 @@ + diff --git a/web-panel/assets/css/app.css b/web-panel/assets/css/app.css new file mode 100644 index 0000000..a634f20 --- /dev/null +++ b/web-panel/assets/css/app.css @@ -0,0 +1,319 @@ +html, body, #__nuxt { + margin: 0; + height: 100%; + font-family: "Segoe UI", Inter, system-ui, sans-serif; + background: #eef1f4; + color: #1c2430; +} +.shell { + display: flex; + flex-direction: column; + min-height: 100vh; + height: 100%; +} +.brand { + font-size: 14px; + font-weight: 800; + letter-spacing: 0.16em; + text-transform: uppercase; + color: #0f2744; + padding: 0 12px 0 4px; +} +.menubar { + border-radius: 0 !important; + border: none !important; + border-bottom: 1px solid #d5dde6 !important; + background: #fff !important; + min-height: 44px; + padding: 0 8px !important; + flex-shrink: 0; +} +.menubar-link { + display: flex; + align-items: center; + gap: 8px; + padding: 0.5rem 0.75rem; + color: inherit; + text-decoration: none; + cursor: pointer; +} +.menubar-link i { + font-size: 14px; +} +.menubar .p-menubar-end { + display: flex; + align-items: center; + gap: 0.5rem; +} +.menubar-link.is-active { + color: #1c4a7a; + box-shadow: inset 0 -2px 0 #1c4a7a; +} +.ribbon.p-toolbar { + border-radius: 0 !important; + border: none !important; + border-bottom: 1px solid #d5dde6 !important; + background: #fff !important; + padding: 4px 10px 6px !important; + flex-shrink: 0; +} +.ribbon-cluster { + display: flex; + align-items: stretch; + gap: 8px; +} +.ribbon-group { + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; +} +.ribbon-group-items { + display: flex; + gap: 2px; +} +.ribbon-group-label { + font-size: 10px; + letter-spacing: 0.08em; + text-transform: uppercase; + color: #6b7785; +} +.ribbon .p-button { + min-width: 58px; +} +.ribbon .p-button-label { + font-size: 11px; +} +.statusbar.p-toolbar { + border-radius: 0 !important; + border: none !important; + border-top: 1px solid #d5dde6 !important; + background: #f7f9fb !important; + padding: 2px 10px !important; + min-height: 32px; + flex-shrink: 0; +} +.desk-body { + flex: 1; + min-height: 0; +} +.desk-split { + height: 100%; + border: none !important; + border-radius: 0 !important; + background: transparent !important; +} +.desk-pane { + overflow: hidden; +} +.desk-main { + height: 100%; + display: flex; + flex-direction: column; + background: #fff; + min-width: 0; +} +.desk-tabstrip { + display: flex; + gap: 4px; + padding: 8px 12px 0; + border-bottom: 1px solid #d5dde6; + background: #f7f9fb; + flex-shrink: 0; +} +.desk-tab { + display: inline-block; + padding: 8px 14px; + font-size: 13px; + color: #6b7785; +} +.desk-tab.is-active { + background: #fff; + color: #0f2744; + font-weight: 600; + border: 1px solid #d5dde6; + border-bottom-color: #fff; + border-radius: 10px 10px 0 0; + margin-bottom: -1px; +} +.desk-page:has(.kanban-page) { + display: flex; + flex-direction: column; + overflow: hidden; + padding: 8px 12px 12px; +} +.desk-side { + height: 100%; + display: flex; + flex-direction: column; + background: #fff; + min-width: 0; +} +.desk-side-title { + padding: 10px 12px; + font-size: 13px; + font-weight: 700; + color: #0f2744; + border-bottom: 1px solid #d5dde6; + flex-shrink: 0; +} +.desk-tree { + flex: 1; + min-height: 0; + overflow: auto; + border: none; + padding: 8px; +} +.tree-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + width: 100%; +} +.desk-detail-scroll { + flex: 1; + min-height: 0; + overflow: auto; + padding: 16px; + box-sizing: border-box; +} +.ficha { + min-width: 0; + box-sizing: border-box; +} +.ficha .p-datatable, +.ficha .p-button.w-full { + max-width: 100%; +} +.ficha .p-datatable-table-container { + overflow-x: auto; +} +.ficha-head { + display: flex; + align-items: center; + gap: 10px; +} +.ficha-tags { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 10px; +} +.workspace { + overflow: auto; + padding: 12px 16px 20px; + min-height: 0; + display: flex; + flex-direction: column; +} +.toolbar-title { + margin: 0; + font-size: 16px; + font-weight: 700; + color: #0f2744; +} +.p-toolbar-start, +.p-toolbar-center, +.p-toolbar-end { + gap: 0.5rem; +} +.kpi-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 12px; +} +.kpi-value { + font-size: 28px; + font-weight: 700; + color: #0f2744; + line-height: 1.1; +} +.home-split .list-page { min-height: 0; } +.form-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 14px 12px; +} +.form-grid .span-2 { grid-column: 1 / -1; } +.muted { color: #6b7785; font-size: 12px; } +.w-full { width: 100%; } +.mt-2 { margin-top: 8px; } +.mt-3 { margin-top: 12px; } +.ml-2 { margin-left: 8px; } +.gap-2 { gap: 8px; } +.flex { display: flex; } +.align-items-center { align-items: center; } +.mb-3 { margin-bottom: 12px; } +.p-error { color: #b00020; font-size: 11px; } +.person-cell { + display: flex; + align-items: center; + gap: 10px; +} +.person-cell .muted { margin-top: 2px; } +.person-cell, +.toolbar-title, +.data-text, +.data-text .p-inputtext { + text-transform: uppercase; +} +.workspace .p-datatable-tbody > tr > td, +.desk-page .p-datatable-tbody > tr > td { + text-transform: uppercase; +} +.workspace .p-datatable-tbody .p-tag, +.workspace .p-datatable-tbody .p-chip, +.workspace .p-datatable-tbody .p-badge, +.workspace .p-datatable-tbody .p-button, +.workspace .p-datatable-tbody .p-button-label, +.workspace .p-datatable-tbody .p-toggleswitch, +.desk-page .p-datatable-tbody .p-tag, +.desk-page .p-datatable-tbody .p-chip, +.desk-page .p-datatable-tbody .p-badge, +.desk-page .p-datatable-tbody .p-button, +.desk-page .p-datatable-tbody .p-button-label, +.desk-page .p-datatable-tbody .p-toggleswitch { + text-transform: none; +} +.workspace .p-inputtext:not(.no-caps), +.desk-page .p-inputtext:not(.no-caps) { + text-transform: uppercase; +} +.badge-pair { + display: flex; + gap: 24px; + flex-wrap: wrap; + align-items: flex-start; +} +.letter-wrap { + overflow: auto; + padding-bottom: 8px; +} +.letter-caption { margin-bottom: 8px; } +.letter-sheet { + width: 900px; + aspect-ratio: 11 / 8.5; + background: #fff; + border: 1px solid #cfd6de; + box-shadow: 0 8px 24px rgba(15, 39, 68, 0.12); + display: flex; + flex-direction: column; + justify-content: space-between; + padding: 10px 14px; + box-sizing: border-box; +} +.letter-row { + display: flex; + justify-content: space-between; + gap: 6px; +} +.letter-row > * { + flex: 1; + min-width: 0; +} +@media (max-width: 1100px) { + .kpi-grid, .home-split, .form-grid { + grid-template-columns: 1fr; + } +} diff --git a/web-panel/assets/css/desktop-v2.css b/web-panel/assets/css/desktop-v2.css new file mode 100644 index 0000000..738eb15 --- /dev/null +++ b/web-panel/assets/css/desktop-v2.css @@ -0,0 +1,2226 @@ +/* Escritorio ARCTEC v2: densidad operativa y jerarquía inspirada en software de obra. */ +html, body, #__nuxt { + background: #f4f6f8; + color: #172033; + font-size: 13px; +} +.shell { height: 100dvh; min-height: 640px; } +.brand { + color: #172033; + font-size: 15px; + letter-spacing: .08em; +} +.menubar { + min-height: 50px; + padding: 0 14px !important; +} +.menubar-link { + min-height: 50px; + box-sizing: border-box; + padding: 0 14px; + font-size: 12px; +} +.menubar-link.is-active { + color: #1f5fd1; + box-shadow: inset 0 -2px 0 #2f6fe4; +} +.ribbon.p-toolbar { + min-height: 82px; + padding: 6px 12px 5px !important; +} +.ribbon-group-items { gap: 4px; } +.ribbon-group-label { + letter-spacing: .02em; + text-transform: none; + font-size: 10px; + color: #748095; +} +.ribbon .p-button { + min-width: 60px; + padding: 6px 9px; + color: #334155; +} +.ribbon .p-button-icon { font-size: 17px; color: #345b91; } +.ribbon .p-button-label { font-size: 11px; font-weight: 500; } +.ribbon .p-divider-vertical { margin: 2px 6px; } +.statusbar.p-toolbar { + min-height: 30px; + background: #fff !important; + padding: 0 12px !important; +} +.desk-tabstrip { + min-height: 37px; + padding: 5px 10px 0; + background: #f7f8fa; +} +.desk-tab { + padding: 7px 14px; + font-size: 12px; +} +.desk-tab.is-active { + border-radius: 6px 6px 0 0; + color: #1f5fd1; +} +.desk-page { + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; + overflow: auto; + padding: 10px 12px 12px; + box-sizing: border-box; +} +.desk-side-title { + min-height: 39px; + box-sizing: border-box; + padding: 11px 12px; + font-size: 12px; +} +.desk-tree { padding: 6px; font-size: 12px; } +.desk-tree .p-tree-node-content { padding: 6px 8px; } + +.section-title { + color: #172033; + font-size: 13px; + font-weight: 700; +} +.muted { color: #748095; font-size: 11px; } +.identifier, +.identifier-input { + font-family: "Cascadia Mono", "Segoe UI Mono", monospace; + letter-spacing: .02em; + text-transform: uppercase !important; +} +.person-cell, +.toolbar-title, +.data-text, +.data-text .p-inputtext, +.workspace .p-datatable-tbody > tr > td, +.desk-page .p-datatable-tbody > tr > td, +.workspace .p-inputtext:not(.identifier-input), +.desk-page .p-inputtext:not(.identifier-input) { + text-transform: none !important; +} + +.p-button { font-weight: 500; } +.p-button-sm { font-size: 11px; } +.p-inputtext, +.p-select, +.p-inputnumber-input { + min-height: 34px; + font-size: 12px; +} +.p-tag { + font-size: 10px; + font-weight: 600; + padding: 2px 7px; +} +.p-chip { + height: 28px; + font-size: 11px; +} +.p-toolbar { + border-color: #dfe4ea; + border-radius: 6px; + padding: 7px 9px; +} +.p-datatable { + overflow: hidden; + border: 1px solid #e1e6ed; + border-radius: 10px; + background: #fff; + box-shadow: 0 1px 3px rgba(23, 32, 51, .035); + font-size: 12px; + width: 100%; +} +.p-datatable .p-datatable-table { + width: 100%; +} +/* Native scrollbar always hidden — overlay rail is drawn on top and never pushes layout. */ +.p-datatable.p-datatable-scrollable { + overflow: hidden; +} +.p-datatable .p-datatable-table-container, +.desk-tree, +.desk-detail-scroll { + scrollbar-width: none; + -ms-overflow-style: none; +} +.p-datatable .p-datatable-table-container::-webkit-scrollbar, +.desk-tree::-webkit-scrollbar, +.desk-detail-scroll::-webkit-scrollbar { + width: 0; + height: 0; + display: none; +} +.overlay-scroll-host { + position: relative; + display: flex; + flex: 1 1 auto; + flex-direction: column; + min-height: 0; + min-width: 0; + height: 100%; + overflow: hidden; +} +.overlay-scroll-host > .desk-detail-scroll, +.overlay-scroll-host > .desk-tree, +.overlay-scroll-host > .p-datatable-table-container { + flex: 1 1 auto; + min-height: 0; + height: 100%; + width: 100%; +} +.overlay-scroll-rail { + position: absolute; + z-index: 6; + pointer-events: none; + opacity: 0; + transition: opacity .65s ease; +} +.overlay-scroll-rail-y { + top: 8px; + right: 6px; + bottom: 8px; + width: 3px; +} +.overlay-scroll-rail-x { + left: 8px; + right: 8px; + bottom: 6px; + height: 3px; +} +.overlay-scroll-host.has-overlay-scroll-both > .overlay-scroll-rail-y { + bottom: 14px; +} +.overlay-scroll-host.has-overlay-scroll-both > .overlay-scroll-rail-x { + right: 14px; +} +.overlay-scroll-thumb { + position: absolute; + border-radius: 999px; + background: rgba(164, 174, 190, .55); + pointer-events: auto; + cursor: default; + transition: background-color .2s ease; +} +.overlay-scroll-rail-y > .overlay-scroll-thumb { + top: 0; + left: 0; + right: 0; + width: auto; + min-width: 0; + min-height: 24px; +} +.overlay-scroll-rail-x > .overlay-scroll-thumb { + top: 0; + left: 0; + bottom: 0; + height: auto; + min-height: 0; + min-width: 24px; +} +.overlay-scroll-thumb:hover, +.is-overlay-scroll-dragging .overlay-scroll-thumb { + background: rgba(132, 144, 164, .8); +} +.overlay-scroll-host.is-overlay-scroll-visible > .overlay-scroll-rail { + opacity: 1; + pointer-events: auto; + transition: opacity .65s ease; +} +/* Keep last cells clear of the rounded card edge */ +.overlay-scroll-host > .p-datatable-table-container { + scroll-padding-right: 12px; + scroll-padding-bottom: 12px; +} +.overlay-scroll-host > .p-datatable-table-container .p-datatable-thead > tr > th:last-child, +.overlay-scroll-host > .p-datatable-table-container .p-datatable-tbody > tr > td:last-child { + padding-right: 14px; +} +.p-datatable-flex-scrollable > .overlay-scroll-host { + display: flex; + flex-direction: column; + flex: 1 1 auto; + min-height: 0; + height: 100%; +} +.p-datatable-flex-scrollable > .overlay-scroll-host > .p-datatable-table-container { + display: flex; + flex-direction: column; + flex: 1 1 auto; + min-height: 0; + height: 100%; +} +.padron-list > .overlay-scroll-host, +.list-page > .overlay-scroll-host, +.presupuesto-page > .overlay-scroll-host { + flex: 1 1 auto; + min-height: 0; +} +.desk-detail > .overlay-scroll-host { + flex: 1 1 auto; + min-height: 0; +} +.p-datatable .p-paginator { + border: none; + border-top: 1px solid #edf0f3; + background: #fff; +} +.p-datatable .p-datatable-thead > tr > th { + background: #f7f8fa; + color: #526074; + font-size: 10px; + font-weight: 700; + letter-spacing: .02em; + padding: 8px 9px; + white-space: nowrap; +} +.p-datatable .p-datatable-thead > tr:nth-child(2) > th { + background: #fbfcfd; + padding: 5px 6px; +} +.p-datatable .p-datatable-thead > tr:nth-child(2) .p-inputtext, +.p-datatable .p-datatable-thead > tr:nth-child(2) .p-select { + min-height: 30px; + width: 100%; + font-size: 11px; +} +.p-datatable .p-datatable-tbody > tr > td { + padding: 7px 9px; + border-color: #edf0f3; +} +.p-datatable .p-datatable-tbody > tr { + transition: background-color .12s ease; +} +.p-datatable .p-datatable-tbody > tr:hover { background: #f6f9ff; } +.p-datatable .p-datatable-tbody > tr.p-datatable-row-selected { + color: #172033; + background: #eaf2ff; +} +.p-paginator { min-height: 40px; padding: 4px 8px; font-size: 11px; } + +.padron-page, +.list-page, +.presupuesto-page { + flex: 1; + min-height: 0; + height: 100%; + display: flex; + flex-direction: column; + gap: 10px; + overflow: hidden; +} +.padron-overview .p-chip.chip-filter-active { + background: #e8f0fe; + color: #1f5fd1; + box-shadow: inset 0 0 0 1px #9db7ef; + cursor: pointer; +} +.padron-overview .p-chip { + cursor: pointer; +} +.desk-page:has(> .padron-page), +.desk-page:has(> .list-page), +.desk-page:has(> .presupuesto-page) { + overflow: hidden; +} +.padron-overview { + flex: none; + min-height: 42px; + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; + padding: 0; + box-sizing: border-box; + border: 0; + border-radius: 0; + background: transparent; +} +.padron-overview-chips { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 7px; + min-width: 0; + flex: 1; +} +.padron-overview .p-chip { background: #f1f4f8; color: #405069; } +.padron-overview .table-search { + flex: none; + width: min(280px, 100%); + margin-left: auto; +} + +/* Shared search: bordered shell + icon in-flow (avoids PrimeVue absolute clipping). */ +.desk-search.p-iconfield, +.module-tree-panel .p-tree-filter.p-iconfield { + position: relative; + display: flex !important; + align-items: center; + gap: 8px; + box-sizing: border-box; + min-height: 36px; + padding: 0 12px; + border: 1px solid #dfe5ed; + border-radius: 8px; + background: #fff; +} +.desk-search.p-iconfield:hover, +.module-tree-panel .p-tree-filter.p-iconfield:hover { + border-color: #c8d2df; +} +.desk-search.p-iconfield:focus-within, +.module-tree-panel .p-tree-filter.p-iconfield:focus-within { + border-color: #7aa5f4; + box-shadow: 0 0 0 3px rgba(47, 111, 228, .09); +} +.desk-search .p-inputtext, +.module-tree-panel .p-tree-filter-input.p-inputtext, +.module-tree-panel .p-tree-filter-input { + flex: 1 1 auto; + width: 100% !important; + min-width: 0; + min-height: 34px !important; + box-sizing: border-box; + margin: 0 !important; + padding: 0 !important; + border: 0 !important; + border-radius: 0 !important; + background: transparent !important; + color: #273449; + font-size: 12px; + box-shadow: none !important; + outline: none !important; +} +.desk-search .p-inputtext::placeholder, +.module-tree-panel .p-tree-filter-input::placeholder { + color: #8a96a8; +} +.desk-search .p-inputicon, +.module-tree-panel .p-tree-filter .p-inputicon { + position: static !important; + inset: unset !important; + top: auto !important; + left: auto !important; + right: auto !important; + bottom: auto !important; + margin: 0 !important; + transform: none !important; + order: -1; + flex: none; + display: inline-flex !important; + align-items: center; + justify-content: center; + width: 14px; + height: 14px; + color: #77869a !important; + font-size: 13px !important; + line-height: 1 !important; + pointer-events: none; +} +.desk-search .p-inputicon::before, +.module-tree-panel .p-tree-filter .p-inputicon::before { + display: block; + line-height: 1; +} +.desk-search .p-inputicon svg, +.module-tree-panel .p-tree-filter .p-inputicon svg { + display: block; + width: 14px; + height: 14px; +} +.padron-list { + min-height: 0; + display: flex; + flex: 1; + flex-direction: column; + width: 100%; + overflow: hidden; +} +.padron-datatable, +.list-page > .p-datatable, +.presupuesto-page > .p-datatable { + flex: 1 1 auto; + min-height: 0; + width: 100%; + height: 100%; +} +.padron-page > .padron-list, +.list-page > .p-datatable, +.presupuesto-page > .p-datatable { + min-width: 0; +} +.p-datatable.p-datatable-flex-scrollable, +.p-datatable.p-datatable-scrollable { + display: flex; + flex-direction: column; +} +.p-datatable.p-datatable-flex-scrollable .p-datatable-table-container, +.p-datatable.p-datatable-scrollable .p-datatable-table-container { + flex: 1 1 auto; + min-height: 0; +} +.person-cell-copy { min-width: 0; } +.person-name { + color: #172033; + font-size: 12px; + font-weight: 600; + white-space: nowrap; +} + +.dialog-intro { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + padding: 0 2px 12px; +} +.worker-form-dialog .p-dialog-content { padding-top: 0; } +.worker-form-dialog .p-tabpanels { padding: 14px 2px 2px; } +.form-grid-compact { gap: 13px 14px; } +.form-field { + min-width: 0; + display: flex; + flex-direction: column; + gap: 5px; +} +.form-field-label { + color: #526074; + font-size: 11px; + font-weight: 600; +} +.required-mark { color: #c2414b; } +.form-field .p-component, +.form-field .p-inputtext, +.form-field .p-inputnumber { width: 100%; } +.toggle-field, +.risk-option { + min-height: 34px; + display: flex; + align-items: center; + gap: 9px; +} +.risk-swatch { + width: 16px; + height: 16px; + border: 1px solid rgba(0,0,0,.15); + border-radius: 3px; +} +.import-layout, +.import-actions { + display: flex; + flex-direction: column; + gap: 13px; +} +.import-summary { + display: grid; + grid-template-columns: repeat(4, 1fr); + border: 1px solid #dfe4ea; + border-radius: 6px; +} +.import-summary > div { + display: flex; + flex-direction: column; + padding: 12px; + border-right: 1px solid #dfe4ea; +} +.import-summary > div:last-child { border-right: 0; } +.import-summary strong { color: #172033; font-size: 21px; } +.import-summary span { color: #748095; font-size: 10px; } +.validation-list { margin: 0; padding-left: 16px; } + +.worker-detail { min-height: 100%; } +.detail-loading { + min-height: 240px; + display: grid; + place-items: center; +} +.worker-detail-head { + display: grid; + grid-template-columns: 54px minmax(0, 1fr) auto; + align-items: center; + gap: 10px; +} +.worker-photo { + width: 54px; + height: 62px; + display: grid; + place-items: center; + overflow: hidden; + background: #eef2f6; + border: 1px solid #dfe4ea; + border-radius: 6px; +} +.worker-photo img { width: 100%; height: 100%; object-fit: cover; } +.worker-detail-copy { min-width: 0; } +.detail-eyebrow { + color: #748095; + font-size: 9px; + letter-spacing: .08em; + text-transform: uppercase; +} +.worker-detail-name { + margin-top: 2px; + color: #172033; + font-size: 14px; + font-weight: 700; + line-height: 1.2; +} +.worker-mini-stats { + display: grid; + grid-template-columns: repeat(3, 1fr); + margin-top: 12px; + border: 1px solid #dfe4ea; + border-radius: 6px; +} +.worker-mini-stats > div { + min-width: 0; + display: flex; + flex-direction: column; + padding: 9px 7px; + border-right: 1px solid #dfe4ea; +} +.worker-mini-stats > div:last-child { border-right: 0; } +.worker-mini-stats strong { color: #172033; font-size: 12px; } +.worker-mini-stats span { color: #748095; font-size: 9px; } +.detail-tabs .p-tablist { overflow-x: auto; } +.detail-tabs .p-tab { padding: 8px 9px; font-size: 10px; } +.detail-tabs .p-tabpanels { padding: 12px 0 0; } +.profile-list { display: flex; flex-direction: column; } +.profile-list > div { + display: flex; + flex-direction: column; + gap: 2px; + padding: 8px 0; + border-bottom: 1px solid #edf0f3; +} +.profile-list span { color: #748095; font-size: 9px; } +.profile-list strong { + color: #273449; + font-size: 11px; + font-weight: 500; + overflow-wrap: anywhere; +} +.document-progress { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} +.document-checklist { + display: flex; + flex-direction: column; + gap: 4px; + margin-top: 10px; +} +.document-check { + display: grid; + grid-template-columns: 17px minmax(0, 1fr) auto; + align-items: center; + gap: 5px; + min-height: 30px; + padding: 4px 7px; + color: #59667a; + background: #f7f8fa; + border-radius: 4px; + font-size: 10px; +} +.document-check.complete { color: #1d6b42; background: #eef8f2; } +.document-check small { color: #8a94a4; font-size: 8px; } +.assignment-create { display: flex; gap: 6px; } +.assignment-create .p-select { min-width: 0; } +.detail-section .p-fieldset { padding: 9px; } +.detail-section .p-fieldset-legend { font-size: 10px; } + +@media (max-width: 1100px) { + .menubar-link { padding: 0 8px; } + .ribbon .p-button { min-width: 52px; } + .padron-overview .table-search { width: 100%; margin-left: 0; } +} + +/* Desktop skeleton: independent pane cards with clear gaps (no parent splitter chrome). */ +.shell { + --shell-gap: 8px; + --shell-border: #e1e6ed; + --shell-shadow: 0 2px 9px rgba(23, 32, 51, .055); + height: 100dvh; + min-height: 640px; + background: #f5f7fa; +} +.menubar { + z-index: 2; + min-height: 54px; + padding: 0 18px !important; + box-shadow: 0 1px 0 rgba(23, 32, 51, .025); +} +.menubar-link { min-height: 54px; } +.ribbon.p-toolbar { + width: calc(100% - 16px); + min-height: 88px; + margin: 8px 8px 10px; + box-sizing: border-box; + padding: 7px 14px 6px !important; + overflow: hidden; + border: 1px solid var(--shell-border) !important; + border-radius: 8px !important; + background: #fff !important; + box-shadow: var(--shell-shadow); +} +.desk-body { + padding: 0 6px 8px; + box-sizing: border-box; + background: #f5f7fa; +} +/* Kill PrimeVue Splitter frame: its top border was drawing through the gaps. */ +.desk-split.p-splitter, +.desk-split { + overflow: visible; + gap: 0; + border: none !important; + border-radius: 0 !important; + background: transparent !important; + color: inherit; +} +.desk-split > .p-splitter-gutter { + flex-basis: var(--shell-gap) !important; + width: var(--shell-gap) !important; + min-width: var(--shell-gap) !important; + margin: 0 !important; + padding: 0 !important; + background: transparent !important; + border: none !important; +} +.desk-split > .p-splitter-gutter .p-splitter-gutter-handle { + width: 3px; + height: 48px; + border-radius: 1px; + background: #cbd3de; + opacity: 0; + transition: opacity .15s ease, background-color .15s ease; +} +.desk-split > .p-splitter-gutter:hover .p-splitter-gutter-handle, +.desk-split > .p-splitter-gutter:focus-visible .p-splitter-gutter-handle { + background: #2f6fe4; + opacity: 1; +} +.desk-pane { + overflow: hidden; + box-sizing: border-box; + border: 1px solid var(--shell-border); + border-radius: 8px; + background: #fff; + box-shadow: var(--shell-shadow); +} +.desk-center-pane { + box-sizing: border-box; +} +.desk-main, +.desk-side { + overflow: hidden; + border-radius: inherit; +} +.desk-tabstrip { + min-height: 41px; + padding: 6px 11px 0; +} +.desk-page { padding: 10px 12px 12px; } +.desk-side-title { + min-height: 41px; + padding: 12px 14px; +} +.desk-tree { padding: 10px; } +.desk-tree .p-tree-filter-container { margin-bottom: 8px; } +.desk-tree .p-tree-node-content { + padding: 6px 8px; + border-radius: 5px; +} +.desk-detail-scroll { padding: 16px; } + +.padron-page, +.list-page, +.presupuesto-page { gap: 12px; } +.padron-datatable, +.list-page > .p-datatable, +.presupuesto-page > .p-datatable { + flex: 1 1 auto; + min-height: 0; +} + +@media (max-width: 1100px) { + .ribbon.p-toolbar { margin-bottom: 8px; } + .desk-body { padding: 0 4px 6px; } + .desk-split > .p-splitter-gutter { flex-basis: 6px !important; width: 6px !important; min-width: 6px !important; } + .desk-page { padding: 8px; } +} + +.desk-pane { box-sizing: border-box; } +.desk-page { + flex: 1; + min-height: 0; + height: 100%; +} + +/* Top navigation: larger rhythm and constrained content on ultrawide screens. */ +.menubar { + min-height: 64px; + padding-inline: 22px !important; +} +.brand { + min-width: 104px; + padding: 0 30px 0 2px; + font-size: 17px; + letter-spacing: .1em; +} +.menubar .p-menubar-root-list { gap: 4px; } +.menubar-link { + min-height: 64px; + padding-inline: 16px; + font-size: 13px; +} +.menubar-link > i { display: none; } +.menubar-link.is-active { + box-shadow: inset 0 -3px 0 #2f6fe4; +} +.menubar .p-menubar-end { + gap: 14px; + margin-left: auto; +} +.menubar .desk-search--topbar { + width: 260px; + margin-right: 8px; +} +.menubar .p-avatar { + width: 36px; + height: 36px; + margin-left: 8px; + flex: 0 0 36px; + font-size: 12px; + background: #eaf1ff; + color: #245fc7; +} + +@media (min-width: 2200px) { + .menubar { + padding-inline: calc((100vw - 1920px) / 2) !important; + } +} + +@media (max-width: 1280px) { + .brand { padding-right: 16px; } + .menubar-link { padding-inline: 10px; } + .menubar .desk-search--topbar { width: 220px; } +} + +/* Ribbon actions: same content grid as the top navigation, with stronger icons. */ +.ribbon.p-toolbar { + min-height: 104px; + padding: 9px 14px 7px !important; +} +.ribbon-group { gap: 4px; } +.ribbon-group-items { gap: 6px; } +.ribbon .ribbon-action { + min-width: 68px; + min-height: 70px; + padding: 9px 11px 7px; + border-radius: 6px; +} +.ribbon .ribbon-action .p-button-icon { + width: 24px; + height: 24px; + margin-bottom: 5px; + font-size: 22px !important; + line-height: 24px; + color: #4b6380; +} +.ribbon .ribbon-action .p-button-label { + font-size: 12px; + line-height: 1.2; +} +.ribbon .ribbon-group-label { + font-size: 10px; + line-height: 1; +} +.ribbon .ribbon-action--alta .p-button-icon, +.ribbon .ribbon-action--print .p-button-icon, +.ribbon .ribbon-action--padron .p-button-icon { + color: #2f6fe4; +} +.ribbon .ribbon-action--edit .p-button-icon, +.ribbon .ribbon-action--refresh .p-button-icon, +.ribbon .ribbon-action--back .p-button-icon { + color: #52729b; +} +.ribbon .ribbon-action--baja .p-button-icon { + color: #c25564; +} +.ribbon .ribbon-action--import .p-button-icon { + color: #27a85d; +} +.ribbon .ribbon-action--docs .p-button-icon, +.ribbon .ribbon-action--gen .p-button-icon { + color: #7659b5; +} +.ribbon .ribbon-action:not(:disabled):hover { + background: #f4f7fb; +} +.ribbon .p-divider-vertical { + min-height: 76px; + margin-inline: 8px; +} + +@media (min-width: 2200px) { + .ribbon.p-toolbar { + padding-inline: calc((100vw - 1920px) / 2 - 8px) !important; + } +} + +@media (max-width: 1280px) { + .ribbon .ribbon-action { + min-width: 60px; + padding-inline: 8px; + } + .ribbon .ribbon-action .p-button-icon { + font-size: 20px !important; + } +} + +/* Module tree: compact catalog-style navigation matching the desktop mockup. */ +.module-tree-panel { background: #fff; } +.module-tree-title { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding-inline: 16px; + font-size: 13px; +} +.module-tree-title-icon { + color: #53647a; + font-size: 14px; +} +.module-tree-panel .desk-tree { + padding: 10px 14px 6px; + border: 0; + border-radius: 0; + background: #fff; +} +.module-tree-panel .p-tree-filter { + margin: 0 0 10px; + border-bottom: 0; +} +.module-tree-panel .p-tree-root-children { + display: flex; + flex-direction: column; + gap: 1px; +} +.module-tree-panel .p-tree-node-children { + padding-left: 19px; +} +.module-tree-panel .p-tree-node-content { + min-height: 34px; + padding: 5px 6px; + gap: 2px; + border-radius: 5px; + color: #273449; + font-size: 12px; +} +.module-tree-panel .p-tree-node-content:hover { + background: #f5f7fa; +} +.module-tree-panel .p-tree-node-content.p-tree-node-selected { + background: #eaf2ff; + color: #174fba; +} +.module-tree-panel .p-tree-node-toggle-button { + width: 22px; + height: 22px; + color: #65758b; +} +.module-tree-panel .p-tree-node-toggle-icon { + width: 11px; + height: 11px; + font-size: 11px; +} +.module-tree-panel .p-tree-node-icon { + margin-right: 5px; + color: #52657c; + font-size: 13px; +} +.module-tree-panel .p-tree-node-content.p-tree-node-selected .p-tree-node-icon { + color: #2f6fe4; +} +.module-tree-panel .p-tree-node-label { + min-width: 0; + flex: 1; +} +.module-tree-panel .tree-row { + min-width: 0; + min-height: 22px; + font-weight: 500; +} +.module-tree-panel .tree-row > span:first-child { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.module-tree-panel .tree-row .p-badge { + min-width: 24px; + height: 20px; + padding: 0 6px; + border: 1px solid #dfe5ed; + border-radius: 4px; + background: #f8fafc; + color: #53647a; + font-size: 10px; + font-weight: 500; + line-height: 18px; +} +.module-tree-panel .p-tree-node-content.p-tree-node-selected .p-badge { + border-color: #d4e2fb; + background: #f8fbff; + color: #245fc7; +} +.desk-tree-footer { + flex: 0 0 48px; + display: grid; + grid-template-columns: repeat(3, 1fr); + align-items: center; + border-top: 1px solid #e5e9ef; + background: #fff; +} +.desk-tree-footer .p-button { + width: 100%; + height: 47px; + border-radius: 0; + color: #53647a; +} +.desk-tree-footer .p-button:not(:disabled):hover { + color: #2f6fe4; + background: #f6f8fb; +} +.desk-tree-footer .p-button-icon { + font-size: 14px; +} + +/* Shared panel headers and record-detail presentation. */ +.desk-side-title, +.desk-tabstrip { + background: #f7f8fa; +} +.desk-detail-title { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding-inline: 16px; +} +.desk-detail-title-actions { + display: inline-flex; + align-items: center; + gap: 13px; + color: #607187; + font-size: 12px; +} +.detail-empty { + min-height: 280px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 7px; + padding: 24px; + box-sizing: border-box; + color: #65758b; + text-align: center; +} +.detail-empty-icon { + width: 42px; + height: 42px; + display: grid; + place-items: center; + margin-bottom: 3px; + border: 1px solid #e0e6ee; + border-radius: 8px; + background: #f7f9fc; + color: #8290a2; + font-size: 18px; +} +.detail-empty strong { + color: #354258; + font-size: 12px; +} +.detail-empty span { + font-size: 10px; +} + +.worker-record-code { + color: #1760d6; + font-size: 14px; + font-weight: 700; + letter-spacing: .01em; +} +.worker-record-photo { + width: 116px; + height: 132px; + margin: 18px auto 0; + border-radius: 5px; + background: #f0f2f5; +} +.worker-record-lead { + margin-top: 14px; + text-align: center; +} +.worker-record-lead .worker-detail-name { + margin: 0 0 3px; + font-size: 14px; + line-height: 1.35; +} +.worker-record-fields { + margin-top: 12px; +} +.worker-record-fields > div { + gap: 3px; + padding: 8px 0; + border-bottom: 0; +} +.worker-record-fields span, +.worker-record-status > span { + color: #6c7a8d; + font-size: 10px; + font-weight: 500; +} +.worker-record-fields strong { + color: #202b3c; + font-size: 11px; + font-weight: 500; + line-height: 1.45; +} +.worker-record-status { + margin-top: 7px; +} +.worker-record-status .ficha-tags { + margin-top: 6px; +} +.worker-record-edit { + min-height: 36px; + margin-top: 16px; +} +.worker-imss-block { + margin-top: 14px; + padding-top: 12px; + border-top: 1px solid #edf0f4; +} +.worker-imss-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 8px; + margin-bottom: 6px; +} +.worker-imss-title { + margin-bottom: 0; + color: #405069; + font-size: 11px; + font-weight: 700; + letter-spacing: .04em; + text-transform: uppercase; + padding-top: 6px; +} +.worker-imss-actions { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 6px; +} +.worker-record .worker-mini-stats { + margin-top: 18px; +} +.worker-record .detail-tabs { + padding-top: 2px; + border-top: 1px solid #edf0f4; +} + +/* Status bar: full desktop footer with contextual blocks and utilities. */ +.statusbar.p-toolbar { + min-height: 48px; + padding-inline: 22px !important; + border-top: 1px solid #e1e6ed !important; + background: #fff !important; + box-shadow: 0 -1px 5px rgba(23, 32, 51, .035); + overflow: visible; + z-index: 20; +} +.statusbar .p-toolbar-start, +.statusbar .p-toolbar-end { + height: 100%; + display: flex; + align-items: center; +} +.statusbar .p-toolbar-start { + min-width: 0; + gap: 0; +} +.statusbar .p-toolbar-end { + gap: 10px; + margin-left: auto; + position: relative; + overflow: visible; +} +.statusbar-item { + height: 32px; + display: inline-flex; + align-items: center; + gap: 8px; + padding-inline: 13px; + color: #59687c; + font-size: 11px; + white-space: nowrap; +} +.statusbar-item:first-child { + padding-left: 0; +} +.statusbar-item > i { + color: #53667d; + font-size: 13px; +} +.statusbar-item strong { + color: #344156; + font-weight: 500; +} +.statusbar-online { + width: 8px; + height: 8px; + flex: 0 0 8px; + border-radius: 50%; + background: #37bd68; + box-shadow: 0 0 0 2px rgba(55, 189, 104, .1); +} +.statusbar .p-divider-vertical { + min-height: 22px; + margin: 0 3px; +} + +@media (min-width: 2200px) { + .statusbar.p-toolbar { + padding-inline: calc((100vw - 1920px) / 2) !important; + } +} + +@media (max-width: 1100px) { + .statusbar.p-toolbar { padding-inline: 12px !important; } + .statusbar-item { padding-inline: 8px; } +} + +/* Top bar refinement: brand lockup, navigation rhythm and desktop utilities. */ +.brand-lockup { + min-width: 164px; + height: 64px; + display: inline-flex; + align-items: center; + gap: 11px; + padding: 0 35px 0 0; + border: 0; + background: transparent; + color: #162033; + cursor: pointer; +} +.brand-mark { + width: 25px; + height: 25px; + flex: 0 0 25px; + display: grid; + grid-template-columns: repeat(2, 7px); + grid-template-rows: repeat(2, 7px); + place-content: center; + gap: 3px; + border-radius: 7px; + background: #2f6fe4; + box-shadow: 0 2px 5px rgba(47, 111, 228, .2); +} +.brand-mark span { + width: 7px; + height: 7px; + border-radius: 2px; + background: #fff; +} +.brand-word { + font-size: 18px; + font-weight: 800; + letter-spacing: .045em; + line-height: 1; +} +.menubar .p-menubar-root-list { + gap: 5px; + margin-left: 0; +} +.menubar-link { + padding-inline: 16px; + font-size: 13px; +} +.topbar-tools { + display: flex; + align-items: center; + gap: 8px; + margin-left: auto; +} +.topbar-tools > .p-button { + width: 36px; + height: 36px; + color: #596b82; +} +.topbar-tools > .p-button .p-button-icon { + font-size: 15px; +} +.topbar-tools > .p-button:hover { + color: #2f6fe4; + background: #f1f5fb; +} +.topbar-user-trigger { + min-width: 57px; + height: 42px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 7px; + margin-left: 3px; + padding: 3px 4px 3px 7px; + border: 0; + border-radius: 22px; + background: transparent; + color: #5d6e84; + cursor: pointer; +} +.topbar-user-trigger:hover { + background: #f1f5fa; +} +.menubar .topbar-user-trigger .p-avatar { + width: 36px; + height: 36px; + margin-left: 0; +} +.topbar-user-trigger > i { + margin-right: 2px; + font-size: 10px; +} + +@media (max-width: 1280px) { + .brand-lockup { + min-width: 132px; + padding-right: 18px; + } + .menubar-link { + padding-inline: 10px; + } + .topbar-tools { + gap: 4px; + } + .topbar-tools .desk-search--topbar { + width: 220px; + margin-right: 4px; + } +} + +@media (max-width: 1050px) { + .topbar-tools .desk-search--topbar { + width: 185px; + } +} + +/* ── Dark mode (alineado al login PANELS) ─────────────────────────────── */ +html.dark, +html.dark body, +html.dark #__nuxt { + background: #0b1220; + color: #e2e8f0; + color-scheme: dark; +} + +html.dark .shell { + --shell-border: #243044; + --shell-shadow: 0 2px 10px rgba(0, 0, 0, .35); + background: #0b1220; +} + +html.dark .brand, +html.dark .brand-word, +html.dark .section-title, +html.dark .toolbar-title, +html.dark .menubar-link { + color: #f1f5f9; +} + +html.dark .menubar .p-menubar-item, +html.dark .menubar .p-menubar-item-content, +html.dark .menubar .p-menubar-item-link, +html.dark .menubar .menubar-link, +html.dark .menubar .menubar-link span { + color: #f1f5f9 !important; + opacity: 1 !important; +} + +html.dark .menubar.p-menubar, +html.dark .menubar { + background: #111b2e !important; + border-bottom-color: #243044 !important; + box-shadow: 0 1px 0 rgba(0, 0, 0, .25); +} + +html.dark .menubar-link.is-active { + color: #93c5fd !important; + background: transparent !important; + box-shadow: inset 0 -3px 0 #4f86f7 !important; + opacity: 1 !important; +} + +html.dark .menubar .p-menubar-item, +html.dark .menubar .p-menubar-item-content, +html.dark .menubar .p-menubar-item-link, +html.dark .menubar .p-menubar-item.p-focus > .p-menubar-item-content, +html.dark .menubar .p-menubar-item.p-menubar-item-active > .p-menubar-item-content, +html.dark .menubar .p-menubar-item:not(.p-disabled) > .p-menubar-item-content:hover { + background: transparent !important; + color: #f1f5f9 !important; + box-shadow: none !important; + opacity: 1 !important; +} + +html.dark .menubar .p-menubar-item:not(.p-disabled) > .p-menubar-item-content:hover .menubar-link, +html.dark .menubar .p-menubar-item:not(.p-disabled) > .p-menubar-item-content:hover .menubar-link span { + color: #bfdbfe !important; + background: rgba(47, 111, 228, 0.14) !important; + border-radius: 8px 8px 0 0; + opacity: 1 !important; +} + +html.dark .menubar .menubar-link { + background: transparent !important; + font-weight: 600; + opacity: 1 !important; +} + +html.dark .menubar .menubar-link:hover, +html.dark .menubar .menubar-link:hover span { + color: #bfdbfe !important; +} + +html.dark .topbar-user-trigger { + color: #e2e8f0; +} + +html.dark .ribbon.p-toolbar { + background: #111b2e !important; + border-color: #243044 !important; +} + +html.dark .ribbon-group-label { + color: #94a3b8; +} + +html.dark .ribbon .p-button { + color: #cbd5e1; +} + +html.dark .ribbon .p-button-icon { + color: #8eb4ff; +} + +html.dark .ribbon .p-button:not(:disabled):hover { + background: rgba(47, 111, 228, 0.14) !important; +} + +html.dark .statusbar.p-toolbar { + background: #111b2e !important; + border-top-color: #243044 !important; + color: #e2e8f0 !important; +} + +html.dark .statusbar-item, +html.dark .statusbar-item span { + color: #cbd5e1 !important; + opacity: 1 !important; +} + +html.dark .statusbar-item > i { + color: #93c5fd !important; +} + +html.dark .statusbar-item strong { + color: #f8fafc !important; + font-weight: 600; +} + +html.dark .statusbar .p-divider-vertical { + border-color: #475569 !important; + opacity: 1 !important; +} + +html.dark .desk-status-messages-trigger, +html.dark .desk-status-messages-trigger span, +html.dark .desk-status-messages-trigger strong { + color: #f1f5f9 !important; + opacity: 1 !important; +} + +html.dark .desk-status-messages-trigger > i, +html.dark .desk-status-messages-caret { + color: #fbbf24 !important; +} + +html.dark .desk-status-messages-trigger:hover, +html.dark .desk-status-messages-trigger:hover span { + color: #ffffff !important; +} + +html.dark .desk-body { + background: #0b1220; +} + +html.dark .desk-pane { + background: #111b2e; + border-color: #243044; +} + +html.dark .desk-tabstrip { + background: #0f172a; + border-bottom-color: #243044; +} + +html.dark .desk-tab { + color: #94a3b8; +} + +html.dark .desk-tab.is-active { + color: #8eb4ff; + background: #111b2e; + border-color: #243044; + border-bottom-color: #111b2e; +} + +html.dark .desk-main, +html.dark .desk-side { + background: #111b2e; +} + +html.dark .desk-side-title, +html.dark .module-tree-title { + color: #e2e8f0; + border-bottom-color: #243044; + background: #111b2e; +} + +html.dark .module-tree-panel { + background: #111b2e !important; +} + +html.dark .module-tree-title, +html.dark .module-tree-title-icon { + color: #f1f5f9 !important; +} + +html.dark .module-tree-panel .desk-tree, +html.dark .module-tree-panel .p-tree, +html.dark .module-tree-panel .p-tree-root, +html.dark .module-tree-panel .p-tree-root-children { + background: #111b2e !important; + color: #e2e8f0 !important; + border-color: transparent !important; +} + +html.dark .module-tree-panel .p-tree-node-content { + color: #e2e8f0 !important; +} + +html.dark .module-tree-panel .p-tree-node-content:hover { + background: #1a2744 !important; + color: #f8fafc !important; +} + +html.dark .module-tree-panel .p-tree-node-content.p-tree-node-selected, +html.dark .module-tree-panel .p-tree-node-content.p-tree-node-selected .p-tree-node-label, +html.dark .module-tree-panel .p-tree-node-content.p-tree-node-selected .tree-row, +html.dark .module-tree-panel .p-tree-node-content.p-tree-node-selected .tree-row > span { + background: rgba(47, 111, 228, 0.28) !important; + color: #f8fafc !important; +} + +html.dark .module-tree-panel .p-tree-node-icon, +html.dark .module-tree-panel .p-tree-node-toggle-button, +html.dark .module-tree-panel .p-tree-node-toggle-icon { + color: #94a3b8 !important; +} + +html.dark .module-tree-panel .p-tree-node-content.p-tree-node-selected .p-tree-node-icon, +html.dark .module-tree-panel .p-tree-node-content.p-tree-node-selected .p-tree-node-toggle-button { + color: #93c5fd !important; +} + +html.dark .module-tree-panel .tree-row .p-badge { + border-color: #334155 !important; + background: #0f172a !important; + color: #cbd5e1 !important; +} + +html.dark .module-tree-panel .p-tree-node-content.p-tree-node-selected .p-badge { + border-color: rgba(147, 197, 253, 0.45) !important; + background: rgba(15, 23, 42, 0.65) !important; + color: #bfdbfe !important; +} + +html.dark .desk-tree-footer { + border-top-color: #243044 !important; + background: #0f172a !important; +} + +html.dark .desk-tree-footer .p-button { + color: #94a3b8 !important; + background: transparent !important; +} + +html.dark .desk-tree-footer .p-button:not(:disabled):hover { + color: #93c5fd !important; + background: #1a2744 !important; +} + +html.dark .module-tree-panel .p-tree-filter.p-iconfield, +html.dark .module-tree-panel .p-tree-filter-input, +html.dark .module-tree-panel .p-tree-filter-input.p-inputtext { + background: #0f172a !important; + border-color: #243044 !important; + color: #e2e8f0 !important; +} + +html.dark .module-tree-panel .p-tree-filter-input::placeholder { + color: #64748b !important; +} + +html.dark .module-tree-panel .p-tree-filter .p-inputicon { + color: #94a3b8 !important; +} + +html.dark .desk-split > .p-splitter-gutter .p-splitter-gutter-handle { + background: #475569; +} + +html.dark .desk-split > .p-splitter-gutter:hover .p-splitter-gutter-handle, +html.dark .desk-split > .p-splitter-gutter:focus-visible .p-splitter-gutter-handle { + background: #4f86f7; +} + +html.dark .muted { + color: #94a3b8; +} + +html.dark .detail-empty { + color: #94a3b8; +} + +html.dark .detail-empty-icon { + color: #475569; +} + +html.dark .p-datatable { + border-color: #243044; +} + +html.dark .p-datatable .p-datatable-thead > tr > th { + background: #152238; + color: #cbd5e1; + border-color: #243044; +} + +html.dark .p-datatable .p-datatable-tbody > tr { + background: #111b2e; + color: #e2e8f0; +} + +html.dark .p-datatable .p-datatable-tbody > tr > td { + border-color: #243044; +} + +html.dark .p-datatable .p-datatable-tbody > tr:hover { + background: #1a2744; +} + +html.dark .p-datatable .p-paginator { + background: #111b2e !important; + border-top-color: #243044 !important; +} + +html.dark .p-datatable .p-datatable-tbody > tr.p-datatable-row-selected { + color: #f8fafc !important; + background: rgba(47, 111, 228, 0.28) !important; +} + +html.dark .p-datatable .p-datatable-tbody > tr.p-datatable-row-selected > td, +html.dark .p-datatable .p-datatable-tbody > tr.p-datatable-row-selected .person-cell, +html.dark .p-datatable .p-datatable-tbody > tr.p-datatable-row-selected .person-cell-copy, +html.dark .p-datatable .p-datatable-tbody > tr.p-datatable-row-selected .person-name, +html.dark .p-datatable .p-datatable-tbody > tr.p-datatable-row-selected .data-text, +html.dark .p-datatable .p-datatable-tbody > tr.p-datatable-row-selected .muted, +html.dark .p-datatable .p-datatable-tbody > tr.p-datatable-row-selected span, +html.dark .p-datatable .p-datatable-tbody > tr.p-datatable-row-selected div { + color: #f8fafc !important; +} + +html.dark .p-datatable .p-datatable-tbody > tr.p-datatable-row-selected .muted { + color: #cbd5e1 !important; +} + +html.dark .person-name { + color: #e2e8f0; +} + +html.dark .p-datatable .p-datatable-tbody > tr > td { + color: #e2e8f0; +} + +html.dark .desk-search .p-inputtext, +html.dark .module-tree-panel .p-tree-filter.p-iconfield { + background: #0f172a; + border-color: #243044; + color: #e2e8f0; +} + +html.dark .menubar .desk-search--topbar { + background: #0f172a; + border-color: #243044; +} + +html.dark .padron-overview .p-chip, +html.dark .p-chip { + background: #1a2744; + color: #cbd5e1; +} + +html.dark .brand-lockup { + color: #e2e8f0; +} + +html.dark .brand-mark { + background: #2f6fe4; + box-shadow: none; +} + +html.dark .brand-mark span { + background: #fff; +} + +html.dark .topbar-tools > .p-button { + color: #94a3b8; +} + +html.dark .ficha, +html.dark .workspace, +html.dark .list-page, +html.dark .padron-page, +html.dark .presupuesto-page { + color: #e2e8f0; +} + +/* Panel de detalle / ficha de trabajador */ +html.dark .desk-detail, +html.dark .desk-detail-scroll, +html.dark .worker-detail, +html.dark .ficha-body, +html.dark .worker-record { + color: #e2e8f0 !important; + background: #111b2e !important; +} + +html.dark .desk-detail-title, +html.dark .desk-detail-title-actions { + color: #f1f5f9 !important; + background: #111b2e !important; + border-bottom-color: #243044 !important; +} + +html.dark .worker-record-code { + color: #60a5fa !important; +} + +html.dark .worker-detail-name, +html.dark .worker-record-lead .worker-detail-name { + color: #f8fafc !important; +} + +html.dark .worker-record-lead .muted, +html.dark .worker-detail .muted { + color: #94a3b8 !important; +} + +html.dark .worker-photo, +html.dark .worker-record-photo { + background: #1a2744 !important; + border-color: #243044 !important; +} + +html.dark .profile-list > div { + border-bottom-color: #243044 !important; +} + +html.dark .profile-list span, +html.dark .worker-record-fields span, +html.dark .worker-record-status > span { + color: #94a3b8 !important; +} + +html.dark .profile-list strong, +html.dark .worker-record-fields strong { + color: #f1f5f9 !important; +} + +html.dark .worker-imss-block { + border-top-color: #243044 !important; +} + +html.dark .worker-imss-title { + color: #e2e8f0 !important; +} + +html.dark .detail-empty { + color: #94a3b8 !important; +} + +html.dark .detail-empty strong { + color: #f1f5f9 !important; +} + +html.dark .detail-empty-icon { + background: #1a2744 !important; + border-color: #243044 !important; + color: #94a3b8 !important; +} + +html.dark .detail-eyebrow { + color: #94a3b8 !important; +} + +html.dark .worker-mini-stats { + border-color: #243044 !important; + background: #0f172a !important; +} + +html.dark .worker-mini-stats > div { + border-right-color: #243044 !important; +} + +html.dark .worker-mini-stats strong { + color: #f1f5f9 !important; +} + +html.dark .worker-mini-stats span { + color: #94a3b8 !important; +} + +html.dark .document-check { + color: #cbd5e1 !important; + background: #1a2744 !important; +} + +html.dark .document-check.complete { + color: #4ade80 !important; + background: rgba(34, 197, 94, 0.12) !important; +} + +html.dark .document-check small { + color: #94a3b8 !important; +} + +html.dark .ficha-head, +html.dark .ficha-head h3, +html.dark .ficha-description { + color: #f1f5f9 !important; +} + +html.dark .p-card, +html.dark .p-dialog, +html.dark .p-menu, +html.dark .p-tieredmenu, +html.dark .p-popover, +html.dark .p-datepicker, +html.dark .p-select-overlay, +html.dark .p-multiselect-overlay { + --p-content-background: #111b2e; +} + +html.dark .import-summary, +html.dark .worker-mini-stats, +html.dark .document-check { + border-color: #243044; + background: #152238; +} + +html.dark .import-summary strong, +html.dark .worker-mini-stats strong { + color: #f1f5f9; +} + +html.dark .document-check.complete { + color: #4ade80; + background: rgba(34, 197, 94, 0.12); +} + +html.dark .kpi-value { + color: #f1f5f9; +} + +/* Avisos de inicio (statusbar popup) */ +html.dark .desk-status-messages-inner { + border-color: #243044 !important; + background: #111b2e !important; + box-shadow: 0 12px 28px rgba(0, 0, 0, 0.5) !important; +} + +html.dark .desk-status-messages-head { + border-bottom-color: #243044 !important; + color: #e2e8f0 !important; +} + +html.dark .desk-status-messages-head .muted { + color: #94a3b8 !important; +} + +html.dark .desk-status-messages-trigger:hover { + color: #e2e8f0 !important; +} + +html.dark .desk-status-messages-caret { + color: #94a3b8 !important; +} + +html.dark .desk-status-message { + color: #cbd5e1 !important; +} + +html.dark .desk-status-message:hover { + background: #1a2744 !important; +} + +html.dark .desk-status-message strong { + color: #f1f5f9 !important; +} + +html.dark .desk-status-message small { + color: #94a3b8 !important; +} + +html.dark .desk-status-message.is-info > i { + color: #60a5fa !important; +} + +html.dark .desk-status-message.is-warn > i { + color: #fbbf24 !important; +} + +html.dark .desk-status-message.is-danger > i { + color: #f87171 !important; +} + +html.dark .desk-status-messages-list { + scrollbar-color: #475569 #0f172a; +} + +/* Home / cards / panels / tables still light in dark mode */ +html.dark .p-card, +html.dark .p-card.p-component, +html.dark .p-card .p-card-body, +html.dark .p-card .p-card-content, +html.dark .p-card .p-card-title, +html.dark .p-card .p-card-subtitle { + background: #111b2e !important; + color: #e2e8f0 !important; + border-color: #243044 !important; +} + +html.dark .p-card { + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.28); +} + +html.dark .p-panel, +html.dark .p-panel .p-panel-header, +html.dark .p-panel .p-panel-content, +html.dark .p-panel .p-panel-footer { + background: #111b2e !important; + color: #e2e8f0 !important; + border-color: #243044 !important; +} + +html.dark .p-panel .p-panel-header { + border-bottom-color: #243044 !important; +} + +html.dark .p-panel .p-panel-title, +html.dark .p-panel .p-panel-header .p-panel-toggle-button { + color: #e2e8f0 !important; +} + +html.dark .p-metergroup .p-metergroup-label, +html.dark .p-metergroup .p-metergroup-label-list, +html.dark .p-metergroup .p-metergroup-label-text { + color: #cbd5e1 !important; +} + +html.dark .p-metergroup .p-metergroup-meters { + background: #1a2744 !important; +} + +html.dark .p-datatable, +html.dark .p-datatable .p-datatable-table-container, +html.dark .p-datatable .p-datatable-header, +html.dark .p-datatable .p-paginator, +html.dark .p-datatable-mask { + background: #111b2e !important; + color: #e2e8f0 !important; + border-color: #243044 !important; +} + +html.dark .p-paginator, +html.dark .p-paginator .p-paginator-page, +html.dark .p-paginator .p-paginator-prev, +html.dark .p-paginator .p-paginator-next, +html.dark .p-paginator .p-paginator-first, +html.dark .p-paginator .p-paginator-last { + background: transparent !important; + color: #cbd5e1 !important; + border-color: #243044 !important; +} + +html.dark .p-paginator .p-paginator-page.p-paginator-page-selected { + background: rgba(47, 111, 228, 0.22) !important; + color: #f8fafc !important; +} + +html.dark .p-datatable .p-datatable-emptymessage td { + background: #111b2e !important; + color: #94a3b8 !important; +} + +html.dark .padron-overview { + background: transparent; +} + +html.dark .padron-overview .table-search, +html.dark .padron-overview .table-search .p-inputtext, +html.dark .desk-search.table-search, +html.dark .desk-search.table-search .p-inputtext { + background: #0f172a !important; + border-color: #243044 !important; + color: #e2e8f0 !important; +} + +html.dark .p-message, +html.dark .p-message.p-message-info { + background: rgba(47, 111, 228, 0.12) !important; + border-color: rgba(47, 111, 228, 0.28) !important; + color: #bfdbfe !important; +} + +html.dark .p-button.p-button-outlined { + background: transparent !important; + border-color: #3b82f6 !important; + color: #93c5fd !important; +} + +html.dark .p-button.p-button-outlined:not(:disabled):hover { + background: rgba(59, 130, 246, 0.12) !important; +} + +html.dark .home-split .list-page { + background: transparent; +} + +html.dark .p-inputtext, +html.dark .p-select, +html.dark .p-textarea, +html.dark .p-inputnumber-input, +html.dark .p-password-input { + background: #0f172a !important; + border-color: #243044 !important; + color: #e2e8f0 !important; +} + +html.dark .p-inputtext::placeholder { + color: #64748b !important; +} + +html.dark .p-datatable-header-cell, +html.dark .p-datatable .p-datatable-thead > tr > th { + background: #152238 !important; +} + +/* Expediente / documentos / formularios en ficha */ +html.dark .detail-section, +html.dark .detail-tabs .p-tabpanels, +html.dark .detail-tabs .p-tabpanel, +html.dark .document-list-tabs .p-tabpanels, +html.dark .document-list-tabs .p-tabpanel { + background: transparent !important; + color: #e2e8f0 !important; +} + +html.dark .detail-tabs .p-tablist, +html.dark .document-list-tabs .p-tablist { + background: transparent !important; + border-color: #243044 !important; +} + +html.dark .detail-tabs .p-tab, +html.dark .document-list-tabs .p-tab { + color: #94a3b8 !important; +} + +html.dark .detail-tabs .p-tab.p-tab-active, +html.dark .document-list-tabs .p-tab.p-tab-active { + color: #93c5fd !important; + border-color: #4f86f7 !important; +} + +html.dark .section-title { + color: #f1f5f9 !important; +} + +html.dark .document-progress .muted, +html.dark .detail-section .muted { + color: #94a3b8 !important; +} + +html.dark .p-message, +html.dark .p-message .p-message-text, +html.dark .p-message .p-message-content { + color: #e2e8f0 !important; +} + +html.dark .p-message.p-message-warn, +html.dark .p-message.p-message-warn .p-message-text, +html.dark .p-message.p-message-warn .p-message-content { + background: rgba(245, 158, 11, 0.16) !important; + border-color: rgba(251, 191, 36, 0.35) !important; + color: #fde68a !important; +} + +html.dark .p-message.p-message-error, +html.dark .p-message.p-message-error .p-message-text, +html.dark .p-message.p-message-error .p-message-content { + background: rgba(239, 68, 68, 0.16) !important; + border-color: rgba(248, 113, 113, 0.35) !important; + color: #fecaca !important; +} + +html.dark .p-message.p-message-info, +html.dark .p-message.p-message-info .p-message-text, +html.dark .p-message.p-message-info .p-message-content { + background: rgba(47, 111, 228, 0.18) !important; + border-color: rgba(96, 165, 250, 0.35) !important; + color: #bfdbfe !important; +} + +html.dark .p-message.p-message-success { + background: rgba(34, 197, 94, 0.14) !important; + border-color: rgba(74, 222, 128, 0.3) !important; + color: #bbf7d0 !important; +} + +html.dark .p-fieldset, +html.dark .p-fieldset .p-fieldset-legend, +html.dark .p-fieldset .p-fieldset-content { + background: #0f172a !important; + border-color: #243044 !important; + color: #e2e8f0 !important; +} + +html.dark .p-fieldset .p-fieldset-legend { + color: #f1f5f9 !important; +} + +html.dark .form-field-label { + color: #cbd5e1 !important; +} + +html.dark .p-select, +html.dark .p-select .p-select-label, +html.dark .p-select .p-select-dropdown, +html.dark .p-datepicker, +html.dark .p-datepicker .p-inputtext, +html.dark .p-datepicker-input, +html.dark .p-multiselect, +html.dark .p-multiselect .p-multiselect-label { + background: #0f172a !important; + border-color: #334155 !important; + color: #f1f5f9 !important; +} + +html.dark .p-select .p-select-label.p-placeholder, +html.dark .p-select-label.p-placeholder { + color: #64748b !important; +} + +html.dark .p-select-overlay, +html.dark .p-datepicker-panel, +html.dark .p-multiselect-overlay { + background: #111b2e !important; + border-color: #243044 !important; + color: #e2e8f0 !important; +} + +html.dark .p-select-option, +html.dark .p-multiselect-option { + color: #e2e8f0 !important; +} + +html.dark .p-select-option:not(.p-disabled).p-focus, +html.dark .p-select-option:not(.p-disabled).p-select-option-selected, +html.dark .p-multiselect-option:not(.p-disabled).p-focus { + background: rgba(47, 111, 228, 0.22) !important; + color: #f8fafc !important; +} + +html.dark .p-progressbar { + background: #1a2744 !important; +} + +html.dark .p-progressbar .p-progressbar-value { + background: #4f86f7 !important; +} + +html.dark .worker-form-dialog .p-dialog-content, +html.dark .worker-form-dialog .p-tabpanels, +html.dark .worker-form-dialog .p-tabpanel { + background: #111b2e !important; + color: #e2e8f0 !important; +} + +html.dark .p-dialog, +html.dark .p-dialog .p-dialog-header, +html.dark .p-dialog .p-dialog-content, +html.dark .p-dialog .p-dialog-footer { + background: #111b2e !important; + border-color: #243044 !important; + color: #e2e8f0 !important; +} + +html.dark .p-dialog .p-dialog-title { + color: #f8fafc !important; +} + +html.dark .p-dialog .p-dialog-header-icon { + color: #94a3b8 !important; +} + +.nomina-jornal-msg { + margin-bottom: 0; +} + +.nomina-loan-fieldset { + margin-top: 28px; +} + +.nomina-loan-fieldset.p-fieldset, +.nomina-loan-fieldset .p-fieldset-legend { + margin-top: 0; +} + +.detail-tabs .nomina-loan-fieldset { + margin-top: 28px; +} diff --git a/web-panel/assets/css/desktop-v2.css.orig b/web-panel/assets/css/desktop-v2.css.orig new file mode 100644 index 0000000..d0a047c --- /dev/null +++ b/web-panel/assets/css/desktop-v2.css.orig @@ -0,0 +1,1095 @@ +/* Escritorio ARCTEC v2: densidad operativa y jerarquía inspirada en software de obra. */ +html, body, #__nuxt { + background: #f4f6f8; + color: #172033; + font-size: 13px; +} +.shell { height: 100dvh; min-height: 640px; } +.brand { + color: #172033; + font-size: 15px; + letter-spacing: .08em; +} +.menubar { + min-height: 50px; + padding: 0 14px !important; +} +.menubar-link { + min-height: 50px; + box-sizing: border-box; + padding: 0 14px; + font-size: 12px; +} +.menubar-link.is-active { + color: #1f5fd1; + box-shadow: inset 0 -2px 0 #2f6fe4; +} +.ribbon.p-toolbar { + min-height: 82px; + padding: 6px 12px 5px !important; +} +.ribbon-group-items { gap: 4px; } +.ribbon-group-label { + letter-spacing: .02em; + text-transform: none; + font-size: 10px; + color: #748095; +} +.ribbon .p-button { + min-width: 60px; + padding: 6px 9px; + color: #334155; +} +.ribbon .p-button-icon { font-size: 17px; color: #345b91; } +.ribbon .p-button-label { font-size: 11px; font-weight: 500; } +.ribbon .p-divider-vertical { margin: 2px 6px; } +.statusbar.p-toolbar { + min-height: 30px; + background: #fff !important; + padding: 0 12px !important; +} +.desk-tabstrip { + min-height: 37px; + padding: 5px 10px 0; + background: #f7f8fa; +} +.desk-tab { + padding: 7px 14px; + font-size: 12px; +} +.desk-tab.is-active { + border-radius: 6px 6px 0 0; + color: #1f5fd1; +} +.desk-page { + height: 100%; + min-height: 0; + overflow: auto; + padding: 10px 12px 12px; + box-sizing: border-box; +} +.desk-side-title { + min-height: 39px; + box-sizing: border-box; + padding: 11px 12px; + font-size: 12px; +} +.desk-tree { padding: 6px; font-size: 12px; } +.desk-tree .p-tree-node-content { padding: 6px 8px; } +.desk-detail-scroll .p-scrollpanel-content { padding: 12px; } + +.section-title { + color: #172033; + font-size: 13px; + font-weight: 700; +} +.muted { color: #748095; font-size: 11px; } +.identifier, +.identifier-input { + font-family: "Cascadia Mono", "Segoe UI Mono", monospace; + letter-spacing: .02em; + text-transform: uppercase !important; +} +.person-cell, +.toolbar-title, +.data-text, +.data-text .p-inputtext, +.workspace .p-datatable-tbody > tr > td, +.desk-page .p-datatable-tbody > tr > td, +.workspace .p-inputtext:not(.identifier-input), +.desk-page .p-inputtext:not(.identifier-input) { + text-transform: none !important; +} + +.p-button { font-weight: 500; } +.p-button-sm { font-size: 11px; } +.p-inputtext, +.p-select, +.p-inputnumber-input { + min-height: 34px; + font-size: 12px; +} +.p-tag { + font-size: 10px; + font-weight: 600; + padding: 2px 7px; +} +.p-chip { + height: 28px; + font-size: 11px; +} +.p-toolbar { + border-color: #dfe4ea; + border-radius: 6px; + padding: 7px 9px; +} +.p-datatable { + border-radius: 6px; + font-size: 12px; +} +.p-datatable .p-datatable-thead > tr > th { + background: #f7f8fa; + color: #526074; + font-size: 10px; + font-weight: 700; + letter-spacing: .02em; + padding: 8px 9px; + white-space: nowrap; +} +.p-datatable .p-datatable-thead > tr:nth-child(2) > th { + background: #fbfcfd; + padding: 5px 6px; +} +.p-datatable .p-datatable-thead > tr:nth-child(2) .p-inputtext, +.p-datatable .p-datatable-thead > tr:nth-child(2) .p-select { + min-height: 30px; + width: 100%; + font-size: 11px; +} +.p-datatable .p-datatable-tbody > tr > td { + padding: 7px 9px; + border-color: #edf0f3; +} +.p-datatable .p-datatable-tbody > tr { + transition: background-color .12s ease; +} +.p-datatable .p-datatable-tbody > tr:hover { background: #f6f9ff; } +.p-datatable .p-datatable-tbody > tr.p-datatable-row-selected { + color: #172033; + background: #eaf2ff; +} +.p-paginator { min-height: 40px; padding: 4px 8px; font-size: 11px; } + +.padron-page { + min-height: 100%; + display: flex; + flex-direction: column; + gap: 8px; +} +.padron-overview { + min-height: 30px; + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 7px; +} +.padron-overview .p-chip { background: #f1f4f8; color: #405069; } +.padron-list { + min-height: 0; + display: flex; + flex: 1; + flex-direction: column; + gap: 8px; +} +.padron-listbar { + flex: none; + background: #fff; +} +.padron-listbar .p-toolbar-end { + flex: 1; + justify-content: flex-end; +} +.padron-listbar .p-iconfield { width: min(380px, 48vw); } +.padron-listbar .p-inputtext { width: 100%; } +.padron-datatable { flex: 1; } +.person-cell-copy { min-width: 0; } +.person-name { + color: #172033; + font-size: 12px; + font-weight: 600; + white-space: nowrap; +} + +.dialog-intro { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + padding: 0 2px 12px; +} +.worker-form-dialog .p-dialog-content { padding-top: 0; } +.worker-form-dialog .p-tabpanels { padding: 14px 2px 2px; } +.form-grid-compact { gap: 13px 14px; } +.form-field { + min-width: 0; + display: flex; + flex-direction: column; + gap: 5px; +} +.form-field-label { + color: #526074; + font-size: 11px; + font-weight: 600; +} +.required-mark { color: #c2414b; } +.form-field .p-component, +.form-field .p-inputtext, +.form-field .p-inputnumber { width: 100%; } +.toggle-field, +.risk-option { + min-height: 34px; + display: flex; + align-items: center; + gap: 9px; +} +.risk-swatch { + width: 16px; + height: 16px; + border: 1px solid rgba(0,0,0,.15); + border-radius: 3px; +} +.import-layout, +.import-actions { + display: flex; + flex-direction: column; + gap: 13px; +} +.import-summary { + display: grid; + grid-template-columns: repeat(4, 1fr); + border: 1px solid #dfe4ea; + border-radius: 6px; +} +.import-summary > div { + display: flex; + flex-direction: column; + padding: 12px; + border-right: 1px solid #dfe4ea; +} +.import-summary > div:last-child { border-right: 0; } +.import-summary strong { color: #172033; font-size: 21px; } +.import-summary span { color: #748095; font-size: 10px; } +.validation-list { margin: 0; padding-left: 16px; } + +.worker-detail { min-height: 100%; } +.detail-loading { + min-height: 240px; + display: grid; + place-items: center; +} +.worker-detail-head { + display: grid; + grid-template-columns: 54px minmax(0, 1fr) auto; + align-items: center; + gap: 10px; +} +.worker-photo { + width: 54px; + height: 62px; + display: grid; + place-items: center; + overflow: hidden; + background: #eef2f6; + border: 1px solid #dfe4ea; + border-radius: 6px; +} +.worker-photo img { width: 100%; height: 100%; object-fit: cover; } +.worker-detail-copy { min-width: 0; } +.detail-eyebrow { + color: #748095; + font-size: 9px; + letter-spacing: .08em; + text-transform: uppercase; +} +.worker-detail-name { + margin-top: 2px; + color: #172033; + font-size: 14px; + font-weight: 700; + line-height: 1.2; +} +.worker-mini-stats { + display: grid; + grid-template-columns: repeat(3, 1fr); + margin-top: 12px; + border: 1px solid #dfe4ea; + border-radius: 6px; +} +.worker-mini-stats > div { + min-width: 0; + display: flex; + flex-direction: column; + padding: 9px 7px; + border-right: 1px solid #dfe4ea; +} +.worker-mini-stats > div:last-child { border-right: 0; } +.worker-mini-stats strong { color: #172033; font-size: 12px; } +.worker-mini-stats span { color: #748095; font-size: 9px; } +.detail-tabs .p-tablist { overflow-x: auto; } +.detail-tabs .p-tab { padding: 8px 9px; font-size: 10px; } +.detail-tabs .p-tabpanels { padding: 12px 0 0; } +.profile-list { display: flex; flex-direction: column; } +.profile-list > div { + display: flex; + flex-direction: column; + gap: 2px; + padding: 8px 0; + border-bottom: 1px solid #edf0f3; +} +.profile-list span { color: #748095; font-size: 9px; } +.profile-list strong { + color: #273449; + font-size: 11px; + font-weight: 500; + overflow-wrap: anywhere; +} +.document-progress { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} +.document-checklist { + display: flex; + flex-direction: column; + gap: 4px; + margin-top: 10px; +} +.document-check { + display: grid; + grid-template-columns: 17px minmax(0, 1fr) auto; + align-items: center; + gap: 5px; + min-height: 30px; + padding: 4px 7px; + color: #59667a; + background: #f7f8fa; + border-radius: 4px; + font-size: 10px; +} +.document-check.complete { color: #1d6b42; background: #eef8f2; } +.document-check small { color: #8a94a4; font-size: 8px; } +.assignment-create { display: flex; gap: 6px; } +.assignment-create .p-select { min-width: 0; } +.detail-section .p-fieldset { padding: 9px; } +.detail-section .p-fieldset-legend { font-size: 10px; } + +@media (max-width: 1100px) { + .menubar-link { padding: 0 8px; } + .ribbon .p-button { min-width: 52px; } + .padron-listbar .p-toolbar-end { width: 100%; justify-content: stretch; } + .padron-listbar .p-iconfield { width: 100%; } +} + +/* Desktop skeleton: independent surfaces and spacing matching the approved mockup. */ +.shell { + --shell-gap: 10px; + --shell-border: #e1e6ed; + --shell-shadow: 0 2px 9px rgba(23, 32, 51, .055); + height: 100dvh; + min-height: 640px; + background: #f5f7fa; +} +.menubar { + z-index: 2; + min-height: 54px; + padding: 0 18px !important; + box-shadow: 0 1px 0 rgba(23, 32, 51, .025); +} +.menubar-link { min-height: 54px; } +.ribbon.p-toolbar { + width: calc(100% - 16px); + min-height: 88px; + margin: 8px 8px 12px; + box-sizing: border-box; + padding: 7px 14px 6px !important; + overflow: hidden; + border: 1px solid var(--shell-border) !important; + border-radius: 8px !important; + background: #fff !important; + box-shadow: var(--shell-shadow); +} + +/* Avoid a detached horizontal rule between the ribbon and workspace panels. */ +.ribbon.p-toolbar { + border-bottom-color: transparent !important; + box-shadow: 0 1px 3px rgba(23, 32, 51, .025); +} +.desk-body { + padding: 0 8px 10px; + box-sizing: border-box; + background: #f5f7fa; +} +.desk-split { + overflow: visible; + background: transparent; +} +.desk-split > .p-splitter-gutter { + flex-basis: var(--shell-gap) !important; + background: transparent; +} +.desk-split > .p-splitter-gutter .p-splitter-gutter-handle { + width: 1px; + height: 48px; + border-radius: 1px; + background: #cbd3de; + opacity: 0; + transition: opacity .15s ease, background-color .15s ease; +} +.desk-split > .p-splitter-gutter:hover .p-splitter-gutter-handle, +.desk-split > .p-splitter-gutter:focus-visible .p-splitter-gutter-handle { + background: #2f6fe4; + opacity: 1; +} +.desk-pane { + overflow: hidden; + border: 1px solid var(--shell-border); + border-radius: 7px; + background: #fff; + box-shadow: var(--shell-shadow); +} +.desk-center-pane { + border-inline: 0; + border-radius: 0; + box-shadow: none; +} +.desk-main, +.desk-side { + overflow: hidden; + border-radius: inherit; +} +.desk-tabstrip { + min-height: 41px; + padding: 6px 11px 0; +} +.desk-page { padding: 14px 16px 16px; } +.desk-side-title { + min-height: 41px; + padding: 12px 14px; +} +.desk-tree { padding: 10px; } +.desk-tree .p-tree-filter-container { margin-bottom: 8px; } +.desk-tree .p-tree-node-content { + padding: 6px 8px; + border-radius: 5px; +} +.desk-detail-scroll .p-scrollpanel-content { padding: 16px; } + +.padron-page { gap: 12px; } +.padron-overview { + min-height: 42px; + gap: 8px; + padding: 6px 9px; + box-sizing: border-box; + border: 1px solid #e5e9ef; + border-radius: 6px; + background: #fbfcfd; +} +.padron-list { gap: 10px; } +.padron-listbar { + background: #fff; + box-shadow: 0 1px 3px rgba(23, 32, 51, .035); +} +.padron-datatable { + flex: 1; + overflow: hidden; + border: 1px solid #e1e6ed; + background: #fff; + box-shadow: 0 1px 3px rgba(23, 32, 51, .035); +} + +@media (max-width: 1100px) { + .ribbon.p-toolbar { margin-bottom: 8px; } + .desk-body { padding: 0 6px 6px; } + .desk-split > .p-splitter-gutter { flex-basis: 6px !important; } + .desk-page { padding: 10px; } +} + +.desk-pane { box-sizing: border-box; } +.desk-page { + flex: 1; + height: auto; +} + +/* Top navigation: larger rhythm and constrained content on ultrawide screens. */ +.menubar { + min-height: 64px; + padding-inline: 22px !important; +} +.brand { + min-width: 104px; + padding: 0 30px 0 2px; + font-size: 17px; + letter-spacing: .1em; +} +.menubar .p-menubar-root-list { gap: 4px; } +.menubar-link { + min-height: 64px; + padding-inline: 16px; + font-size: 13px; +} +.menubar-link > i { display: none; } +.menubar-link.is-active { + box-shadow: inset 0 -3px 0 #2f6fe4; +} +.menubar .p-menubar-end { + gap: 14px; + margin-left: auto; +} +.menubar .p-iconfield { width: 260px; } +.menubar .p-iconfield .p-inputtext { + width: 100%; + min-height: 38px; + padding-left: 36px; + border-color: #dfe5ed; + border-radius: 8px !important; + background: #fbfcfe; + box-shadow: none; +} +.menubar .p-iconfield .p-inputtext:hover { + border-color: #c8d2df; +} +.menubar .p-iconfield .p-inputtext:focus { + border-color: #7aa5f4; + box-shadow: 0 0 0 3px rgba(47, 111, 228, .1); +} +.menubar .p-inputicon { + color: #7b8799; + font-size: 13px; +} +.menubar .p-avatar { + width: 36px; + height: 36px; + margin-left: 8px; + flex: 0 0 36px; + font-size: 12px; + background: #eaf1ff; + color: #245fc7; +} + +@media (min-width: 2200px) { + .menubar { + padding-inline: calc((100vw - 1920px) / 2) !important; + } +} + +@media (max-width: 1280px) { + .brand { padding-right: 16px; } + .menubar-link { padding-inline: 10px; } + .menubar .p-iconfield { width: 220px; } +} + +/* Ribbon actions: same content grid as the top navigation, with stronger icons. */ +.ribbon.p-toolbar { + min-height: 104px; + padding: 9px 14px 7px !important; +} +.ribbon-group { gap: 4px; } +.ribbon-group-items { gap: 6px; } +.ribbon .ribbon-action { + min-width: 68px; + min-height: 70px; + padding: 9px 11px 7px; + border-radius: 6px; +} +.ribbon .ribbon-action .p-button-icon { + width: 24px; + height: 24px; + margin-bottom: 5px; + font-size: 22px !important; + line-height: 24px; + color: #4b6380; +} +.ribbon .ribbon-action .p-button-label { + font-size: 12px; + line-height: 1.2; +} +.ribbon .ribbon-group-label { + font-size: 10px; + line-height: 1; +} +.ribbon .ribbon-action--alta .p-button-icon, +.ribbon .ribbon-action--print .p-button-icon, +.ribbon .ribbon-action--padron .p-button-icon { + color: #2f6fe4; +} +.ribbon .ribbon-action--edit .p-button-icon, +.ribbon .ribbon-action--refresh .p-button-icon, +.ribbon .ribbon-action--back .p-button-icon { + color: #52729b; +} +.ribbon .ribbon-action--baja .p-button-icon { + color: #c25564; +} +.ribbon .ribbon-action--import .p-button-icon { + color: #27a85d; +} +.ribbon .ribbon-action--docs .p-button-icon, +.ribbon .ribbon-action--gen .p-button-icon { + color: #7659b5; +} +.ribbon .ribbon-action:not(:disabled):hover { + background: #f4f7fb; +} +.ribbon .p-divider-vertical { + min-height: 76px; + margin-inline: 8px; +} + +@media (min-width: 2200px) { + .ribbon.p-toolbar { + padding-inline: calc((100vw - 1920px) / 2 - 8px) !important; + } +} + +@media (max-width: 1280px) { + .ribbon .ribbon-action { + min-width: 60px; + padding-inline: 8px; + } + .ribbon .ribbon-action .p-button-icon { + font-size: 20px !important; + } +} + +/* Module tree: compact catalog-style navigation matching the desktop mockup. */ +.module-tree-panel { background: #fff; } +.module-tree-title { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding-inline: 16px; + font-size: 13px; +} +.module-tree-title-icon { + color: #53647a; + font-size: 14px; +} +.module-tree-panel .desk-tree { + padding: 10px 12px 6px; + border: 0; + border-radius: 0; + background: #fff; +} +.module-tree-panel .p-tree-filter { + margin: 0 -12px 8px; + padding: 0 14px 10px; + border-bottom: 1px solid #edf0f4; +} +.module-tree-panel .p-tree-filter-input { + width: 100%; + min-height: 36px; + padding-left: 34px; + border-color: #dfe5ed; + border-radius: 6px; + background: #fff; + font-size: 11px; + box-shadow: none; +} +.module-tree-panel .p-tree-filter-input:focus { + border-color: #7aa5f4; + box-shadow: 0 0 0 3px rgba(47, 111, 228, .09); +} +.module-tree-panel .p-tree-filter-icon { + left: 12px; + color: #77869a; + font-size: 12px; +} +.module-tree-panel .p-tree-root-children { + display: flex; + flex-direction: column; + gap: 1px; +} +.module-tree-panel .p-tree-node-children { + padding-left: 19px; +} +.module-tree-panel .p-tree-node-content { + min-height: 34px; + padding: 5px 6px; + gap: 2px; + border-radius: 5px; + color: #273449; + font-size: 12px; +} +.module-tree-panel .p-tree-node-content:hover { + background: #f5f7fa; +} +.module-tree-panel .p-tree-node-content.p-tree-node-selected { + background: #eaf2ff; + color: #174fba; +} +.module-tree-panel .p-tree-node-toggle-button { + width: 22px; + height: 22px; + color: #65758b; +} +.module-tree-panel .p-tree-node-toggle-icon { + width: 11px; + height: 11px; + font-size: 11px; +} +.module-tree-panel .p-tree-node-icon { + margin-right: 5px; + color: #52657c; + font-size: 13px; +} +.module-tree-panel .p-tree-node-content.p-tree-node-selected .p-tree-node-icon { + color: #2f6fe4; +} +.module-tree-panel .p-tree-node-label { + min-width: 0; + flex: 1; +} +.module-tree-panel .tree-row { + min-width: 0; + min-height: 22px; + font-weight: 500; +} +.module-tree-panel .tree-row > span:first-child { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.module-tree-panel .tree-row .p-badge { + min-width: 24px; + height: 20px; + padding: 0 6px; + border: 1px solid #dfe5ed; + border-radius: 4px; + background: #f8fafc; + color: #53647a; + font-size: 10px; + font-weight: 500; + line-height: 18px; +} +.module-tree-panel .p-tree-node-content.p-tree-node-selected .p-badge { + border-color: #d4e2fb; + background: #f8fbff; + color: #245fc7; +} +.desk-tree-footer { + flex: 0 0 48px; + display: grid; + grid-template-columns: repeat(3, 1fr); + align-items: center; + border-top: 1px solid #e5e9ef; + background: #fff; +} +.desk-tree-footer .p-button { + width: 100%; + height: 47px; + border-radius: 0; + color: #53647a; +} +.desk-tree-footer .p-button:not(:disabled):hover { + color: #2f6fe4; + background: #f6f8fb; +} +.desk-tree-footer .p-button-icon { + font-size: 14px; +} + +/* Shared panel headers and record-detail presentation. */ +.desk-side-title, +.desk-tabstrip { + background: #f7f8fa; +} +.desk-detail-title { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding-inline: 16px; +} +.desk-detail-title-actions { + display: inline-flex; + align-items: center; + gap: 13px; + color: #607187; + font-size: 12px; +} +.detail-empty { + min-height: 280px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 7px; + padding: 24px; + box-sizing: border-box; + color: #65758b; + text-align: center; +} +.detail-empty-icon { + width: 42px; + height: 42px; + display: grid; + place-items: center; + margin-bottom: 3px; + border: 1px solid #e0e6ee; + border-radius: 8px; + background: #f7f9fc; + color: #8290a2; + font-size: 18px; +} +.detail-empty strong { + color: #354258; + font-size: 12px; +} +.detail-empty span { + font-size: 10px; +} + +.worker-record-code { + color: #1760d6; + font-size: 14px; + font-weight: 700; + letter-spacing: .01em; +} +.worker-record-photo { + width: 116px; + height: 132px; + margin-top: 18px; + border-radius: 5px; + background: #f0f2f5; +} +.worker-record-lead { + margin-top: 14px; +} +.worker-record-lead .worker-detail-name { + margin: 0 0 3px; + font-size: 14px; + line-height: 1.35; +} +.worker-record-fields { + margin-top: 12px; +} +.worker-record-fields > div { + gap: 3px; + padding: 8px 0; + border-bottom: 0; +} +.worker-record-fields span, +.worker-record-status > span { + color: #6c7a8d; + font-size: 10px; + font-weight: 500; +} +.worker-record-fields strong { + color: #202b3c; + font-size: 11px; + font-weight: 500; + line-height: 1.45; +} +.worker-record-status { + margin-top: 7px; +} +.worker-record-status .ficha-tags { + margin-top: 6px; +} +.worker-record-edit { + min-height: 36px; + margin-top: 16px; +} +.worker-record .worker-mini-stats { + margin-top: 18px; +} +.worker-record .detail-tabs { + padding-top: 2px; + border-top: 1px solid #edf0f4; +} + +/* Status bar: full desktop footer with contextual blocks and utilities. */ +.statusbar.p-toolbar { + min-height: 48px; + padding-inline: 22px !important; + border-top: 1px solid #e1e6ed !important; + background: #fff !important; + box-shadow: 0 -1px 5px rgba(23, 32, 51, .035); +} +.statusbar .p-toolbar-start, +.statusbar .p-toolbar-end { + height: 100%; + display: flex; + align-items: center; +} +.statusbar .p-toolbar-start { + min-width: 0; + gap: 0; +} +.statusbar .p-toolbar-end { + gap: 16px; + margin-left: auto; +} +.statusbar-item { + height: 32px; + display: inline-flex; + align-items: center; + gap: 8px; + padding-inline: 13px; + color: #59687c; + font-size: 11px; + white-space: nowrap; +} +.statusbar-item:first-child { + padding-left: 0; +} +.statusbar-item > i { + color: #53667d; + font-size: 13px; +} +.statusbar-item strong { + color: #344156; + font-weight: 500; +} +.statusbar-online { + width: 8px; + height: 8px; + flex: 0 0 8px; + border-radius: 50%; + background: #37bd68; + box-shadow: 0 0 0 2px rgba(55, 189, 104, .1); +} +.statusbar .p-divider-vertical { + min-height: 22px; + margin: 0 3px; +} +.statusbar-utilities { + display: inline-flex; + align-items: center; + gap: 24px; + color: #52647a; +} +.statusbar-utilities i { + font-size: 15px; +} +.statusbar .p-button { + width: 34px; + height: 34px; + color: #52647a; +} +.statusbar .p-button:hover { + color: #2f6fe4; + background: #f2f6fc; +} + +@media (min-width: 2200px) { + .statusbar.p-toolbar { + padding-inline: calc((100vw - 1920px) / 2) !important; + } +} + +@media (max-width: 1100px) { + .statusbar.p-toolbar { padding-inline: 12px !important; } + .statusbar-item { padding-inline: 8px; } + .statusbar-utilities { gap: 14px; } +} + +/* Top bar refinement: brand lockup, navigation rhythm and desktop utilities. */ +.brand-lockup { + min-width: 164px; + height: 64px; + display: inline-flex; + align-items: center; + gap: 11px; + padding: 0 35px 0 0; + border: 0; + background: transparent; + color: #162033; + cursor: pointer; +} +.brand-mark { + width: 25px; + height: 25px; + flex: 0 0 25px; + display: grid; + grid-template-columns: repeat(2, 7px); + grid-template-rows: repeat(2, 7px); + place-content: center; + gap: 3px; + border-radius: 7px; + background: #2f6fe4; + box-shadow: 0 2px 5px rgba(47, 111, 228, .2); +} +.brand-mark span { + width: 7px; + height: 7px; + border-radius: 2px; + background: #fff; +} +.brand-word { + font-size: 18px; + font-weight: 800; + letter-spacing: .045em; + line-height: 1; +} +.menubar .p-menubar-root-list { + gap: 5px; + margin-left: 0; +} +.menubar-link { + padding-inline: 16px; + font-size: 13px; +} +.topbar-tools { + display: flex; + align-items: center; + gap: 8px; + margin-left: auto; +} +.topbar-tools .p-iconfield { + margin-right: 8px; +} +.topbar-tools > .p-button { + width: 36px; + height: 36px; + color: #596b82; +} +.topbar-tools > .p-button .p-button-icon { + font-size: 15px; +} +.topbar-tools > .p-button:hover { + color: #2f6fe4; + background: #f1f5fb; +} +.topbar-user-trigger { + min-width: 57px; + height: 42px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 7px; + margin-left: 3px; + padding: 3px 4px 3px 7px; + border: 0; + border-radius: 22px; + background: transparent; + color: #5d6e84; + cursor: pointer; +} +.topbar-user-trigger:hover { + background: #f1f5fa; +} +.menubar .topbar-user-trigger .p-avatar { + width: 36px; + height: 36px; + margin-left: 0; +} +.topbar-user-trigger > i { + margin-right: 2px; + font-size: 10px; +} + +@media (max-width: 1280px) { + .brand-lockup { + min-width: 132px; + padding-right: 18px; + } + .menubar-link { + padding-inline: 10px; + } + .topbar-tools { + gap: 4px; + } + .topbar-tools .p-iconfield { + width: 220px; + margin-right: 4px; + } +} + +@media (max-width: 1050px) { + .topbar-tools .p-iconfield { + width: 185px; + } +} diff --git a/web-panel/components/AppModuleTree.vue b/web-panel/components/AppModuleTree.vue new file mode 100644 index 0000000..6dfd426 --- /dev/null +++ b/web-panel/components/AppModuleTree.vue @@ -0,0 +1,105 @@ + + + diff --git a/web-panel/components/AppRibbon.vue b/web-panel/components/AppRibbon.vue new file mode 100644 index 0000000..7d71ec5 --- /dev/null +++ b/web-panel/components/AppRibbon.vue @@ -0,0 +1,30 @@ + + + diff --git a/web-panel/components/AppStatusbar.vue b/web-panel/components/AppStatusbar.vue new file mode 100644 index 0000000..1a8aca8 --- /dev/null +++ b/web-panel/components/AppStatusbar.vue @@ -0,0 +1,50 @@ + + + diff --git a/web-panel/components/BadgeCard.vue b/web-panel/components/BadgeCard.vue new file mode 100644 index 0000000..c9b6659 --- /dev/null +++ b/web-panel/components/BadgeCard.vue @@ -0,0 +1,181 @@ + + + + + diff --git a/web-panel/components/BudgetFicha.vue b/web-panel/components/BudgetFicha.vue new file mode 100644 index 0000000..d565d2d --- /dev/null +++ b/web-panel/components/BudgetFicha.vue @@ -0,0 +1,60 @@ + + + + + diff --git a/web-panel/components/BudgetImportDialog.vue b/web-panel/components/BudgetImportDialog.vue new file mode 100644 index 0000000..146d63d --- /dev/null +++ b/web-panel/components/BudgetImportDialog.vue @@ -0,0 +1,296 @@ + + + + + diff --git a/web-panel/components/DeskStatusMessages.vue b/web-panel/components/DeskStatusMessages.vue new file mode 100644 index 0000000..e07376f --- /dev/null +++ b/web-panel/components/DeskStatusMessages.vue @@ -0,0 +1,320 @@ + + + + + diff --git a/web-panel/components/DeskTableBar.vue b/web-panel/components/DeskTableBar.vue new file mode 100644 index 0000000..f74fd21 --- /dev/null +++ b/web-panel/components/DeskTableBar.vue @@ -0,0 +1,26 @@ + + + diff --git a/web-panel/components/DocumentPreviewDialog.vue b/web-panel/components/DocumentPreviewDialog.vue new file mode 100644 index 0000000..a36d09a --- /dev/null +++ b/web-panel/components/DocumentPreviewDialog.vue @@ -0,0 +1,146 @@ +