first commit

This commit is contained in:
Alberto Martinez 2026-08-21 16:55:01 -05:00
commit 70d89609f4
142 changed files with 42547 additions and 0 deletions

19
.dockerignore Normal file
View file

@ -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/

33
.env.example Normal file
View file

@ -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 <noreply@tudominio>

64
.gitignore vendored Normal file
View file

@ -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/

34
Dockerfile.api Normal file
View file

@ -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"]

14
Dockerfile.web-panel Normal file
View file

@ -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

14
Dockerfile.web-saas Normal file
View file

@ -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

216
api/auth.ts Normal file
View file

@ -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<string, unknown>): 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<AuthUser | null> {
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<SessionPayload>(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<string, unknown> | 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<AuthUser | null> {
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<string, unknown> & { 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 {};
}

963
api/budget.ts Normal file
View file

@ -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<string, string> = {
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<string, number>;
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<string, { name: string; code: string }>();
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<string, { name: string; code: string }>();
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<string, ParsedGroup>();
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<number, ExportItem[]>();
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<number | null, ChapterRow[]>();
const byId = new Map<number, ChapterRow>();
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<number>();
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<number, { amount: number; item_count: number }>();
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<string, number>();
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<string>();
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<ParsedBudget["format"], string> = {
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<string, ParsedGroup[]>();
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,
};
}

98
api/budget_test.ts Normal file
View file

@ -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);
});

334
api/companies.ts Normal file
View file

@ -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) };
}

10
api/companies_test.ts Normal file
View file

@ -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");
});

33
api/config.ts Normal file
View file

@ -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 <noreply@panel.local>",
/** En HTTPS (Coolify) poner COOKIE_SECURE=true */
cookieSecure: ["1", "true", "yes"].includes(
(Deno.env.get("COOKIE_SECURE") ?? "").toLowerCase(),
),
};

109
api/crypto.ts Normal file
View file

@ -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<string> {
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<boolean> {
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<string> {
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<boolean> {
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<T>(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<string> {
return toHex(await crypto.subtle.digest("SHA-256", data));
}
export { toHex, fromHex, encoder, decoder };

345
api/db.ts Normal file
View file

@ -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<Database> {
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),
}));
}

22
api/deno.json Normal file
View file

@ -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"]
}
}

358
api/deno.lock Normal file
View file

@ -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"
]
}
}

27
api/docs_crypto.ts Normal file
View file

@ -0,0 +1,27 @@
import { fromHex, toHex } from "./crypto.ts";
import { config } from "./config.ts";
async function docsKey(): Promise<CryptoKey> {
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<Uint8Array> {
const key = await docsKey();
const iv = fromHex(ivHex);
return new Uint8Array(
await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, cipher),
);
}

170
api/document_validity.ts Normal file
View file

@ -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 },
];

View file

@ -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");
});

546
api/excel.ts Normal file
View file

@ -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<string, string> = {
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<string, string> = {
"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<string, unknown>): Record<string, string> {
const out: Record<string, string> = {};
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<string, string> }[] {
const sheet = wb.Sheets[name];
if (!sheet) return [];
const rows = XLSX.utils.sheet_to_json<Record<string, unknown>>(sheet, { defval: "" });
return rows.map((r, i) => ({ row: i + 2, data: normalizeRow(r) }));
}
function parseWage(r: Record<string, string>): 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<string, string>, 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<string, string>) {
return IMPORT_COLUMNS.every((c) => !r[c]);
}
function errorMessages(errors: Record<string, string>): 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<Uint8Array | null> {
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<typeof normalizeWorker> & { 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<ImportReport> {
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<string, number>();
const seenRfc = new Map<string, number>();
const seenNss = new Map<string, number>();
const jobs: { sheet: string; fallback: "activo" | "baja"; rows: { row: number; data: Record<string, string> }[] }[] = [];
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 };

67
api/liquibase.ts Normal file
View file

@ -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<boolean> {
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<void> {
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<void> {
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;
}

100
api/mail.ts Normal file
View file

@ -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
`;
}

1260
api/main.ts Normal file

File diff suppressed because it is too large Load diff

195
api/mx.ts Normal file
View file

@ -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<string, string> {
const errors: Record<string, string> = {};
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<string, string> = {
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();
}

54
api/mx_test.ts Normal file
View file

@ -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");
});

350
api/pdf.ts Normal file
View file

@ -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<Uint8Array> {
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<Uint8Array | null> {
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<PDFImage | null> {
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<Uint8Array> {
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<typeof degrees> };
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<string> {
await mkdir(PDFS_DIR, { recursive: true });
const path = join(PDFS_DIR, `gafetes-${jobId}.pdf`);
await Deno.writeFile(path, bytes);
return path;
}

135
api/platform_db.ts Normal file
View file

@ -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<Database> {
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);
}

584
api/saas.ts Normal file
View file

@ -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<typeof normalizeCompanyProfile>,
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<Record<string, unknown>>).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<string, unknown> | 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 };

252
api/schema.sql Normal file
View file

@ -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
);

148
api/seed_lista.ts Normal file
View file

@ -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<string, unknown>, 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<Uint8Array | null> {
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<Record<string, unknown>>(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));

149
api/smtp.ts Normal file
View file

@ -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) };
}

28
db/README.md Normal file
View file

@ -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.

View file

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
https://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-latest.xsd">
<include file="changesets/001-baseline.sql" relativeToChangelogFile="true"/>
<include file="changesets/002-tenant-id.sql" relativeToChangelogFile="true"/>
<include file="changesets/003-must-change-password.sql" relativeToChangelogFile="true"/>
</databaseChangeLog>

View file

@ -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);

View file

@ -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);

View file

@ -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 '';

View file

@ -0,0 +1,4 @@
changeLogFile=changelog-master.xml
driver=org.sqlite.JDBC
url=jdbc:sqlite:../../data/app.db
classpath=../tools/sqlite-jdbc.jar

22
db/bootstrap-tools.sh Executable file
View file

@ -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."

View file

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
https://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-latest.xsd">
<include file="changesets/001-init.sql" relativeToChangelogFile="true"/>
<include file="changesets/002-seed-arctec-tenant.sql" relativeToChangelogFile="true"/>
<include file="changesets/003-tenant-license.sql" relativeToChangelogFile="true"/>
<include file="changesets/004-tenant-status-expand.sql" relativeToChangelogFile="true"/>
<include file="changesets/005-contact-access.sql" relativeToChangelogFile="true"/>
<include file="changesets/006-tenant-code-date.sql" relativeToChangelogFile="true"/>
<include file="changesets/007-smtp-settings.sql" relativeToChangelogFile="true"/>
</databaseChangeLog>

View file

@ -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'))
);

View file

@ -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');

View file

@ -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 '';

View file

@ -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;

View file

@ -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;

View file

@ -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';

View file

@ -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);

View file

@ -0,0 +1,4 @@
changeLogFile=changelog-master.xml
driver=org.sqlite.JDBC
url=jdbc:sqlite:../../data/platform.db
classpath=../tools/sqlite-jdbc.jar

40
db/update.sh Executable file
View file

@ -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"

66
docker-compose.yml Normal file
View file

@ -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:

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

98
docs/coolify.md Normal file
View file

@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 966 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 877 KiB

50
docs/padron-v2.md Normal file
View file

@ -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.

12
package.json Normal file
View file

@ -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"
}
}

View file

@ -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<string>([
...(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();

26
web-panel/.gitignore vendored Normal file
View file

@ -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

75
web-panel/README.md Normal file
View file

@ -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.

18
web-panel/app.vue Normal file
View file

@ -0,0 +1,18 @@
<template>
<NuxtErrorBoundary>
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
<template #error="{ error, clearError }">
<div style="padding: 32px; max-width: 640px">
<Message severity="error">No se pudo cargar el panel</Message>
<pre class="muted" style="white-space: pre-wrap">{{ error }}</pre>
<Button label="Reintentar" icon="pi pi-refresh" class="mt-3" @click="clearError" />
</div>
</template>
</NuxtErrorBoundary>
<ClientOnly>
<Toast />
<ConfirmDialog />
</ClientOnly>
</template>

View file

@ -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;
}
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,105 @@
<template>
<div class="desk-side module-tree-panel">
<div class="desk-side-title module-tree-title">
<span>{{ treeTitle }}</span>
<i class="pi pi-list module-tree-title-icon" aria-hidden="true" />
</div>
<Tree
:value="tree"
filter
:filter-placeholder="filterPlaceholder"
selection-mode="single"
:selection-keys="keys"
:expanded-keys="expanded"
class="desk-tree"
@update:selection-keys="onKeys"
@update:expanded-keys="onExpand"
@node-select="onSelect"
>
<template #default="{ node }">
<span class="tree-row">
<span>{{ node.label }}</span>
<Badge v-if="node.data?.count != null" :value="node.data.count" />
</span>
</template>
</Tree>
<div class="desk-tree-footer">
<Button
icon="pi pi-star"
text
rounded
aria-label="Todos"
:disabled="!hasAll"
@click="selectTree('all')"
/>
<Button
icon="pi pi-clock"
text
rounded
aria-label="Historial"
:disabled="!hasHistory"
@click="selectTree('bajas')"
/>
<Button
icon="pi pi-cog"
text
rounded
aria-label="Configuracion"
@click="navigateTo('/configuracion')"
/>
</div>
</div>
</template>
<script setup lang="ts">
import type { TreeNode } from "primevue/treenode";
import type { TreeSelectionKeys } from "primevue/tree";
const { tree, treeKey, treeTitle, selectTree } = useDesktop();
const keys = computed<TreeSelectionKeys>(() =>
treeKey.value ? { [treeKey.value]: true } : {},
);
const expanded = ref<Record<string, boolean>>({ projects: true, statuses: true });
const filterPlaceholder = computed(() => `Buscar en ${treeTitle.value.toLocaleLowerCase("es-MX")}...`);
function findNode(nodes: TreeNode[] | undefined, key: string): TreeNode | undefined {
for (const node of nodes || []) {
if (node.key === key) return node;
const hit = findNode(node.children, key);
if (hit) return hit;
}
}
const hasAll = computed(() => Boolean(findNode(tree.value, "all")));
const hasHistory = computed(() => Boolean(findNode(tree.value, "bajas")));
watch(tree, (nodes) => {
const next = { ...expanded.value, projects: true, statuses: true, companies: true };
function walk(list: TreeNode[] | undefined, depth: number) {
for (const node of list || []) {
if (node.key && depth < 4) next[String(node.key)] = true;
if (node.children?.length) walk(node.children, depth + 1);
}
}
walk(nodes, 0);
expanded.value = next;
}, { deep: true });
function onKeys(value: TreeSelectionKeys) {
const next = Object.entries(value || {}).find(([, on]) => on)?.[0];
if (!next) return;
const node = findNode(tree.value, next);
if (node?.selectable === false) return;
selectTree(next);
}
function onExpand(value: Record<string, boolean>) {
expanded.value = value;
}
function onSelect(node: TreeNode) {
if (node.selectable === false) return;
if (node.key) selectTree(node.key);
}
</script>

View file

@ -0,0 +1,30 @@
<template>
<Toolbar class="ribbon">
<template #start>
<div v-for="(group, i) in ribbon" :key="group.key" class="ribbon-cluster">
<Divider v-if="i > 0" layout="vertical" />
<div class="ribbon-group">
<div class="ribbon-group-items">
<Button
v-for="item in group.items"
:key="item.key"
:icon="item.icon"
:label="item.label"
:class="['ribbon-action', `ribbon-action--${item.key}`]"
icon-pos="top"
text
size="small"
:disabled="item.disabled"
@click="item.command?.()"
/>
</div>
<div class="ribbon-group-label">{{ group.label }}</div>
</div>
</div>
</template>
</Toolbar>
</template>
<script setup lang="ts">
const { ribbon } = useDesktop();
</script>

View file

@ -0,0 +1,50 @@
<template>
<Toolbar class="statusbar">
<template #start>
<span class="statusbar-item statusbar-user">
<span class="statusbar-online" />
<span>Usuario: <strong>{{ user?.display_name || user?.username || "—" }}</strong></span>
</span>
<Divider layout="vertical" />
<span class="statusbar-item">
<i class="pi pi-sitemap" />
<span>Proyecto: <strong>{{ projectLabel }}</strong></span>
</span>
<Divider layout="vertical" />
<span class="statusbar-item">
<i class="pi pi-briefcase" />
<span>Empresa: <strong>{{ user?.company_name || user?.company_code || "—" }}</strong></span>
</span>
<Divider layout="vertical" />
<span class="statusbar-item">
<i class="pi pi-box" />
<span>Versión: <strong>1.0.0</strong></span>
</span>
</template>
<template #end>
<DeskStatusMessages />
</template>
</Toolbar>
</template>
<script setup lang="ts">
import type { TreeNode } from "primevue/treenode";
import DeskStatusMessages from "~/components/DeskStatusMessages.vue";
const { user } = useAuth();
const { tree, treeKey } = useDesktop();
function findNode(nodes: TreeNode[] | undefined, key: string): TreeNode | undefined {
for (const node of nodes || []) {
if (node.key === key) return node;
const hit = findNode(node.children, key);
if (hit) return hit;
}
}
const projectLabel = computed(() => {
if (!treeKey.value.startsWith("p:")) return "Todos los proyectos";
const node = findNode(tree.value, treeKey.value);
return String(node?.label || "Proyecto seleccionado");
});
</script>

View file

@ -0,0 +1,181 @@
<template>
<article class="badge" :class="{ 'badge-back-rot': side === 'back' && rotated, fluid }">
<template v-if="side === 'front'">
<div class="badge-logos">
<div class="logo-l">Construcciones<br />Arctec</div>
<div class="logo-r">{{ projectName }}</div>
</div>
<div class="badge-photo">
<img v-if="photoSrc" :src="photoSrc" alt="" @error="onPhotoError" />
<i v-else class="pi pi-user" />
</div>
<div class="badge-names">
<div>{{ firstName }}</div>
<div>{{ lastName }}</div>
</div>
<div class="badge-bar" :style="{ background: riskColor, color: riskText }">
{{ position }}
</div>
</template>
<template v-else>
<img v-if="qrSrc && !qrBroken" class="badge-qr" :src="qrSrc" alt="QR" @error="qrBroken = true" />
<div v-else class="badge-qr badge-qr-ph"><i class="pi pi-qrcode" /></div>
<div class="badge-meta">
<div>GRUPO SANGUINEO: {{ blood }}</div>
<div>CURP: {{ curp }}</div>
<div>NSS: {{ nss }}</div>
<div>NOMBRE: {{ full }}</div>
</div>
<div class="badge-project">
<div>{{ projectCode }}</div>
<div>{{ projectName }}</div>
</div>
</template>
</article>
</template>
<script setup lang="ts">
const props = defineProps<{
side: "front" | "back";
rotated?: boolean;
firstName: string;
lastName: string;
full: string;
position: string;
curp: string;
nss: string;
blood: string;
riskColor: string;
riskText: string;
projectName: string;
projectCode: string;
photoSrc?: string;
qrSrc?: string;
fluid?: boolean;
}>();
const photoBroken = ref(false);
const qrBroken = ref(false);
const photoSrc = computed(() => (photoBroken.value ? "" : props.photoSrc || ""));
function onPhotoError() {
photoBroken.value = true;
}
watch(() => props.photoSrc, () => {
photoBroken.value = false;
});
watch(() => props.qrSrc, () => {
qrBroken.value = false;
});
</script>
<style scoped>
.badge {
width: 201px;
height: 315px;
background: #fff;
border: 2px solid #111;
box-sizing: border-box;
display: flex;
flex-direction: column;
font-family: Arial, Helvetica, sans-serif;
color: #111;
overflow: hidden;
flex: none;
}
.badge-back-rot {
transform: rotate(180deg);
}
.badge-logos {
height: 16%;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 4px;
padding: 6px 8px 2px;
align-items: center;
}
.logo-l {
font-size: 8px;
font-weight: 700;
line-height: 1.15;
text-transform: uppercase;
}
.logo-r {
font-size: 11px;
font-weight: 800;
color: #1a3f8c;
text-align: right;
text-transform: uppercase;
line-height: 1.1;
}
.badge-photo {
width: 52%;
aspect-ratio: 3.5 / 4.5;
border: 2.5px solid #111;
margin: 6px auto 0;
background: #e8edf3;
display: grid;
place-items: center;
overflow: hidden;
}
.badge-photo img {
width: 100%;
height: 100%;
object-fit: cover;
}
.badge-photo i { font-size: 42px; color: #8a96a6; }
.badge-names {
text-align: center;
font-weight: 800;
text-transform: uppercase;
font-size: 13px;
letter-spacing: 0.02em;
margin-top: 10px;
line-height: 1.2;
padding: 0 6px;
}
.badge-bar {
margin: auto 4px 8px;
min-height: 28px;
display: flex;
align-items: center;
justify-content: center;
font-weight: 800;
text-transform: uppercase;
font-size: 11px;
letter-spacing: 0.04em;
padding: 4px 6px;
text-align: center;
}
.badge-qr {
width: 50%;
aspect-ratio: 1;
margin: 14px auto 8px;
display: block;
}
.badge-qr-ph {
background: #f0f2f5;
display: grid;
place-items: center;
font-size: 36px;
color: #888;
}
.badge-meta {
font-size: 8px;
line-height: 1.45;
padding: 0 10px;
text-transform: uppercase;
}
.badge-project {
margin-top: auto;
padding: 0 10px 12px;
font-weight: 800;
font-size: 11px;
text-transform: uppercase;
line-height: 1.2;
}
.fluid {
width: 100%;
height: auto;
aspect-ratio: 6.7 / 10.5;
}
</style>

View file

@ -0,0 +1,60 @@
<template>
<div class="ficha">
<Message v-if="!item" severity="secondary" :closable="false">Seleccione una partida</Message>
<div v-else class="ficha-body">
<div class="ficha-head">
<Avatar icon="pi pi-list" shape="circle" />
<div>
<div class="toolbar-title">{{ item.code || "Sin clave" }}</div>
<div class="muted">{{ item.parent_path || item.chapter_name || "Sin capítulo" }}</div>
</div>
</div>
<div class="ficha-tags">
<Tag v-if="item.unit" :value="String(item.unit)" />
<Tag :value="money(item.amount)" severity="info" />
</div>
<p class="mt-3 ficha-description">{{ item.description }}</p>
<DataTable :value="rows" size="small" class="mt-3">
<Column field="label" header="Campo" />
<Column field="value" header="Dato" />
</DataTable>
<Button label="Editar partida" icon="pi pi-pencil" outlined class="w-full mt-3" @click="emit('edit')" />
</div>
</div>
</template>
<script setup lang="ts">
const props = defineProps<{ item?: Record<string, unknown> | null }>();
const emit = defineEmits<{ edit: [] }>();
const rows = computed(() => {
const item = props.item;
if (!item) return [];
return [
{ label: "Clave", value: item.code || "—" },
{ label: "Ruta", value: item.parent_path || item.chapter_name || "Sin capítulo" },
{ label: "Unidad", value: item.unit || "—" },
{ label: "Cantidad", value: qty(item.quantity) },
{ label: "Precio unitario", value: money(item.unit_price) },
{ label: "Importe", value: money(item.amount) },
];
});
function money(value: unknown) {
const amount = Number(value ?? 0);
if (Number.isNaN(amount)) return "—";
return new Intl.NumberFormat("es-MX", { style: "currency", currency: "MXN" }).format(amount);
}
function qty(value: unknown) {
const amount = Number(value ?? 0);
if (Number.isNaN(amount)) return "—";
return new Intl.NumberFormat("es-MX", { maximumFractionDigits: 4 }).format(amount);
}
</script>
<style scoped>
.ficha-description {
white-space: pre-wrap;
line-height: 1.45;
font-size: 12px;
}
</style>

View file

@ -0,0 +1,296 @@
<template>
<Dialog
v-model:visible="visibleModel"
header="Leer presupuesto desde Excel"
modal
maximizable
:style="{ width: 'min(96vw, 92rem)' }"
:breakpoints="{ '1200px': '96vw' }"
:content-style="{ overflow: 'auto', maxHeight: 'calc(100vh - 9rem)' }"
>
<Message severity="info" :closable="false">
Suba el Excel de Opus/Neodata, uno plano o la plantilla del panel. Primero se muestra lo encontrado;
nada se guarda hasta que confirme. Reemplazará el presupuesto de <strong>{{ projectName }}</strong>.
</Message>
<div class="flex flex-wrap align-items-center gap-2 mt-3">
<Button label="Descargar plantilla" icon="pi pi-download" outlined :loading="templateLoading" @click="downloadTemplate" />
<input ref="fileInput" type="file" accept=".xlsx,.xls,application/vnd.ms-excel,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" class="hidden-file" @change="onNativeFile" />
<Button label="Seleccionar Excel" icon="pi pi-plus" :disabled="busy" @click="pickFile" />
<span v-if="pending" class="muted">{{ pending.name }}</span>
</div>
<ProgressBar v-if="busy" mode="indeterminate" class="mt-3" style="height: 4px" />
<template v-if="preview">
<div class="import-summary mt-3">
<div><strong>{{ preview.format_label }}</strong><span>Formato</span></div>
<div><strong>{{ preview.totals.item_count }}</strong><span>Partidas</span></div>
<div><strong>{{ preview.tree.length }}</strong><span>Lotes / capítulos</span></div>
<div><strong>{{ money(preview.totals.subtotal) }}</strong><span>Subtotal</span></div>
<div><strong>{{ money(preview.totals.iva) }}</strong><span>IVA 16%</span></div>
<div><strong>{{ money(preview.totals.total) }}</strong><span>Total</span></div>
</div>
<p v-if="preview.skipped" class="muted mt-2">Se ignoraron {{ preview.skipped }} filas (totales, IVA o texto de cierre).</p>
<div class="preview-split mt-3">
<div>
<div class="section-title mb-2">Árbol encontrado</div>
<Tree :value="tree" class="desk-tree preview-tree">
<template #default="{ node }">
<span class="tree-row">
<span>{{ node.label }}</span>
<Badge v-if="node.data?.count != null" :value="node.data.count" />
</span>
</template>
</Tree>
</div>
<div>
<DeskTableBar v-model="tableQ" placeholder="Buscar clave o descripción">
<Chip :label="`${listedItems.length} partidas`" icon="pi pi-list" />
</DeskTableBar>
<DataTable :value="listedItems" size="small" class="mt-2" scrollable scroll-height="52vh">
<Column field="code" header="Clave" style="width: 8rem">
<template #body="{ data }"><span class="identifier">{{ data.code || "—" }}</span></template>
</Column>
<Column field="chapter" header="Capítulo" style="width: 10rem" />
<Column field="description" header="Descripción">
<template #body="{ data }">{{ shortDesc(data.description) }}</template>
</Column>
<Column field="unit" header="Un." style="width: 4rem" />
<Column field="amount" header="Importe" style="width: 8rem">
<template #body="{ data }">{{ money(data.amount) }}</template>
</Column>
</DataTable>
</div>
</div>
</template>
<Message v-if="error" severity="error" class="mt-3" :closable="false">{{ error }}</Message>
<template #footer>
<Button label="Cerrar" severity="secondary" text @click="visibleModel = false" />
<Button
label="Guardar en esta obra"
icon="pi pi-check"
:disabled="!canSave"
:loading="saving"
@click="confirmSave"
/>
</template>
</Dialog>
</template>
<script setup lang="ts">
import type { TreeNode } from "primevue/treenode";
type PreviewNode = {
wbs: string;
code: string;
name: string;
amount: number;
item_count: number;
children: PreviewNode[];
};
type Preview = {
format_label: string;
sheet: string;
tree: PreviewNode[];
items: {
code: string;
description: string;
unit: string;
amount: number;
chapter: string;
}[];
totals: { subtotal: number; iva: number; total: number; item_count: number };
skipped: number;
errors: { messages: string[] }[];
};
const props = defineProps<{ visible: boolean; projectId: number; projectName: string }>();
const emit = defineEmits<{ "update:visible": [value: boolean]; imported: [] }>();
const visibleModel = computed({
get: () => props.visible,
set: (value) => emit("update:visible", value),
});
const { api, download } = useApi();
const toast = useToast();
const templateLoading = ref(false);
const reading = ref(false);
const saving = ref(false);
const preview = ref<Preview | null>(null);
const pending = ref<File | null>(null);
const error = ref("");
const fileInput = ref<HTMLInputElement | null>(null);
const { tableQ, matches } = useTableSearch();
const busy = computed(() => reading.value || saving.value);
const canSave = computed(() => Boolean(pending.value && preview.value?.totals.item_count && !busy.value));
const tree = computed<TreeNode[]>(() => mapTree(preview.value?.tree || []));
const listedItems = computed(() =>
(preview.value?.items || []).filter((item) => matches(item.code, item.description, item.unit, item.chapter)),
);
function mapTree(nodes: PreviewNode[]): TreeNode[] {
return nodes.map((node) => ({
key: node.wbs,
label: [node.code, node.name].filter(Boolean).join(" "),
icon: node.children?.length ? "pi pi-folder" : "pi pi-bookmark",
data: { count: node.item_count },
children: mapTree(node.children || []),
}));
}
function money(value: number) {
return new Intl.NumberFormat("es-MX", { style: "currency", currency: "MXN" }).format(Number(value || 0));
}
function shortDesc(value: string) {
const text = String(value || "").replace(/\s+/g, " ").trim();
return text.length > 160 ? `${text.slice(0, 157)}…` : text;
}
async function downloadTemplate() {
if (!props.projectId) return;
templateLoading.value = true;
try {
await download(`/v1/projects/${props.projectId}/budget/template`, "plantilla-presupuesto.xlsx");
} catch (err) {
toast.add({ severity: "error", summary: err instanceof Error ? err.message : "No se pudo descargar", life: 3000 });
} finally {
templateLoading.value = false;
}
}
function pickFile() {
fileInput.value?.click();
}
function onNativeFile(event: Event) {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
input.value = "";
if (file) readFile(file);
}
async function readFile(file: File) {
if (!file) return;
if (!props.projectId) {
error.value = "Seleccione una obra en el árbol antes de importar.";
return;
}
reading.value = true;
error.value = "";
preview.value = null;
pending.value = file;
try {
const body = new FormData();
body.append("file", file);
preview.value = await api<Preview>(`/v1/projects/${props.projectId}/budget/preview`, { method: "POST", body });
if (preview.value.errors?.length && !preview.value.totals.item_count) {
error.value = preview.value.errors[0]?.messages?.[0] || "No se pudo leer el Excel";
}
} catch (err) {
pending.value = null;
const detail = err as { data?: { errors?: { messages: string[] }[] } };
error.value = detail.data?.errors?.[0]?.messages?.[0] || (err instanceof Error ? err.message : "No se pudo leer el Excel");
toast.add({ severity: "error", summary: error.value, life: 3500 });
} finally {
reading.value = false;
}
}
async function confirmSave() {
if (!pending.value || !props.projectId) return;
saving.value = true;
error.value = "";
try {
const body = new FormData();
body.append("file", pending.value);
const report = await api<{ inserted: number; chapters: number; contract_amount?: number }>(
`/v1/projects/${props.projectId}/budget/import`,
{ method: "POST", body },
);
const amount = report.contract_amount != null
? ` · contrato ${Number(report.contract_amount).toLocaleString("es-MX", { style: "currency", currency: "MXN" })}`
: "";
toast.add({ severity: "success", summary: `${report.inserted} partidas guardadas${amount}`, life: 3200 });
emit("imported");
visibleModel.value = false;
} catch (err) {
error.value = err instanceof Error ? err.message : "No se pudo guardar";
toast.add({ severity: "error", summary: error.value, life: 3500 });
} finally {
saving.value = false;
}
}
watch(
() => props.visible,
(open) => {
if (!open) return;
preview.value = null;
pending.value = null;
error.value = "";
},
);
</script>
<style scoped>
.hidden-file {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
border: 0;
}
.preview-split {
display: grid;
grid-template-columns: minmax(18rem, 26rem) 1fr;
gap: 16px;
min-height: 0;
}
.preview-tree {
max-height: 52vh;
overflow: auto;
border: 1px solid #e1e6ed;
border-radius: 6px;
}
.preview-tree :deep(.tree-row) {
align-items: flex-start;
white-space: normal;
}
.preview-tree :deep(.tree-row span:first-child) {
line-height: 1.35;
}
.import-summary {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 8px;
}
.import-summary > div {
display: flex;
flex-direction: column;
gap: 2px;
padding: 8px 10px;
border: 1px solid #e5e9ef;
border-radius: 6px;
background: #fbfcfd;
}
.import-summary strong {
font-size: 13px;
color: #172033;
}
.import-summary span {
font-size: 10px;
color: #748095;
}
@media (max-width: 900px) {
.preview-split,
.import-summary {
grid-template-columns: 1fr;
}
}
</style>

View file

@ -0,0 +1,320 @@
<template>
<div
v-if="messages.length"
ref="rootEl"
class="desk-status-messages"
:class="{ 'is-open': open }"
>
<div class="desk-status-messages-panel" role="dialog" :aria-hidden="!open">
<div class="desk-status-messages-inner">
<div class="desk-status-messages-head">
<strong>Avisos de {{ screenLabel }}</strong>
<span class="muted">{{ messages.length }}</span>
</div>
<ul class="desk-status-messages-list">
<li v-for="item in messages" :key="item.key">
<button type="button" class="desk-status-message" :class="`is-${item.severity}`" @click="activate(item)">
<i :class="iconFor(item.severity)" />
<span>
<strong>{{ item.title }}</strong>
<small v-if="item.detail">{{ item.detail }}</small>
</span>
</button>
</li>
</ul>
</div>
</div>
<button
type="button"
class="statusbar-item desk-status-messages-trigger"
:aria-expanded="open"
:title="summaryTitle"
@click="toggle"
>
<i :class="summaryIcon" />
<span>{{ summaryLabel }}</span>
<i class="pi pi-angle-up desk-status-messages-caret" />
</button>
</div>
</template>
<script setup lang="ts">
import type { DeskStatusMessage, PadronImssFilter } from "~/composables/useDesktop";
const { statusMessages, tabLabel, padronImssFilter, treeKey } = useDesktop();
const rootEl = ref<HTMLElement | null>(null);
const open = ref(false);
const pinned = ref(false);
let autoTimer: ReturnType<typeof setTimeout> | null = null;
const messages = computed(() => statusMessages.value);
const screenLabel = computed(() => tabLabel.value || "esta pantalla");
const severityRank: Record<DeskStatusMessage["severity"], number> = {
danger: 3,
warn: 2,
info: 1,
};
const topSeverity = computed(() => {
let best: DeskStatusMessage["severity"] = "info";
for (const item of messages.value) {
if (severityRank[item.severity] > severityRank[best]) best = item.severity;
}
return best;
});
const summaryIcon = computed(() => iconFor(topSeverity.value));
const summaryLabel = computed(() => {
if (messages.value.length === 1) return messages.value[0].title;
return `${messages.value.length} avisos`;
});
const summaryTitle = computed(() => messages.value.map((item) => item.title).join(" · "));
const signature = computed(() =>
messages.value.map((item) => `${item.key}:${item.title}:${item.severity}`).join("|"),
);
function iconFor(severity: DeskStatusMessage["severity"]) {
if (severity === "warn") return "pi pi-exclamation-triangle";
if (severity === "info") return "pi pi-info-circle";
return "pi pi-exclamation-circle";
}
function clearAuto() {
if (autoTimer) {
clearTimeout(autoTimer);
autoTimer = null;
}
}
function collapse() {
open.value = false;
pinned.value = false;
clearAuto();
}
function expandTemporary() {
pinned.value = false;
open.value = true;
clearAuto();
autoTimer = setTimeout(() => {
if (!pinned.value) open.value = false;
autoTimer = null;
}, 4500);
}
function toggle() {
if (open.value && pinned.value) {
collapse();
return;
}
if (open.value && !pinned.value) {
pinned.value = true;
clearAuto();
return;
}
open.value = true;
pinned.value = true;
clearAuto();
}
async function activate(item: DeskStatusMessage) {
if (item.treeKey) {
treeKey.value = item.treeKey;
} else if (item.filter && item.route === "/padron") {
treeKey.value = "all";
}
if (item.filter) {
padronImssFilter.value = item.filter as PadronImssFilter;
}
if (item.route && useRoute().path !== item.route) {
await navigateTo(item.route);
}
collapse();
}
function onDocPointer(event: PointerEvent) {
if (!open.value || !rootEl.value) return;
if (rootEl.value.contains(event.target as Node)) return;
collapse();
}
watch(signature, (value, previous) => {
if (!value) {
collapse();
return;
}
if (value !== previous) expandTemporary();
}, { immediate: true });
onMounted(() => {
document.addEventListener("pointerdown", onDocPointer);
});
onBeforeUnmount(() => {
document.removeEventListener("pointerdown", onDocPointer);
clearAuto();
});
</script>
<style scoped>
.desk-status-messages {
position: relative;
display: flex;
flex-direction: column;
align-items: flex-end;
overflow: visible;
}
.desk-status-messages-trigger {
border: 0;
background: transparent;
cursor: pointer;
font: inherit;
max-width: min(360px, 42vw);
padding-inline: 10px;
}
.desk-status-messages-trigger:hover {
color: #344156;
}
.desk-status-messages-caret {
font-size: 11px !important;
color: #7a8798 !important;
transition: transform .35s ease;
}
.desk-status-messages.is-open .desk-status-messages-caret {
transform: rotate(180deg);
}
.desk-status-messages-panel {
position: absolute;
right: 0;
bottom: calc(100% + 8px);
width: min(360px, 72vw);
display: grid;
grid-template-rows: 0fr;
opacity: 0;
transform: translateY(12px);
pointer-events: none;
transition:
grid-template-rows .45s cubic-bezier(.22, 1, .36, 1),
opacity .28s ease,
transform .45s cubic-bezier(.22, 1, .36, 1);
z-index: 40;
}
.desk-status-messages.is-open .desk-status-messages-panel {
grid-template-rows: 1fr;
opacity: 1;
transform: translateY(0);
pointer-events: auto;
}
.desk-status-messages-inner {
min-height: 0;
overflow: hidden;
border: 1px solid #e1e6ed;
border-radius: 12px;
background: #fff;
box-shadow: 0 12px 28px rgba(23, 32, 51, .14);
}
.desk-status-messages-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 10px 12px 8px;
border-bottom: 1px solid #edf0f4;
color: #344156;
font-size: 12px;
}
.desk-status-messages-list {
list-style: none;
margin: 0;
padding: 6px;
max-height: 240px;
overflow: auto;
}
.desk-status-message {
width: 100%;
display: flex;
align-items: flex-start;
gap: 10px;
padding: 10px;
border: 0;
border-radius: 8px;
background: transparent;
text-align: left;
cursor: pointer;
color: #405069;
}
.desk-status-message:hover {
background: #f5f7fa;
}
.desk-status-message > i {
margin-top: 2px;
font-size: 14px;
}
.desk-status-message span {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.desk-status-message strong {
font-size: 12px;
font-weight: 600;
color: #2d3a4d;
}
.desk-status-message small {
font-size: 11px;
line-height: 1.35;
color: #6b7785;
}
.desk-status-message.is-danger > i { color: #c2414b; }
.desk-status-message.is-warn > i { color: #b7791f; }
.desk-status-message.is-info > i { color: #2f6fe4; }
.muted {
color: #6b7785;
font-size: 11px;
}
:global(html.dark) .desk-status-messages-trigger:hover {
color: #e2e8f0;
}
:global(html.dark) .desk-status-messages-caret {
color: #94a3b8 !important;
}
:global(html.dark) .desk-status-messages-inner {
border-color: #243044;
background: #111b2e;
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.45);
}
:global(html.dark) .desk-status-messages-head {
border-bottom-color: #243044;
color: #e2e8f0;
}
:global(html.dark) .desk-status-message {
color: #cbd5e1;
}
:global(html.dark) .desk-status-message:hover {
background: #1a2744;
}
:global(html.dark) .desk-status-message strong {
color: #f1f5f9;
}
:global(html.dark) .desk-status-message small,
:global(html.dark) .muted {
color: #94a3b8;
}
:global(html.dark) .desk-status-message.is-info > i {
color: #60a5fa;
}
</style>

View file

@ -0,0 +1,26 @@
<template>
<div class="padron-overview">
<div class="padron-overview-chips">
<slot />
</div>
<IconField class="desk-search table-search">
<InputIcon class="pi pi-search" />
<InputText
:model-value="modelValue"
:placeholder="placeholder"
@update:model-value="onInput"
/>
</IconField>
</div>
</template>
<script setup lang="ts">
withDefaults(defineProps<{ modelValue: string; placeholder?: string }>(), {
placeholder: "Buscar…",
});
const emit = defineEmits<{ "update:modelValue": [value: string] }>();
function onInput(value: string | undefined) {
emit("update:modelValue", String(value ?? ""));
}
</script>

View file

@ -0,0 +1,146 @@
<template>
<Dialog
v-model:visible="visible"
modal
:header="title"
class="document-preview-dialog"
:style="{ width: 'min(920px, 96vw)' }"
:content-style="{ padding: '0' }"
@hide="onHide"
>
<div class="document-preview-body">
<div v-if="loading" class="document-preview-state">
<ProgressSpinner stroke-width="4" />
<span class="muted">Cargando vista previa…</span>
</div>
<div v-else-if="error" class="document-preview-state">
<i class="pi pi-exclamation-triangle" />
<span>{{ error }}</span>
</div>
<img v-else-if="kind === 'image' && url" :src="url" :alt="title" class="document-preview-image" />
<iframe v-else-if="kind === 'pdf' && url" :src="url" class="document-preview-frame" title="Vista previa PDF" />
<div v-else class="document-preview-state">
<i class="pi pi-file" />
<span>Este archivo no tiene vista previa en el panel.</span>
<Button label="Descargar" icon="pi pi-download" outlined size="small" @click="downloadFile" />
</div>
</div>
<template #footer>
<Button label="Descargar" icon="pi pi-download" text :disabled="!url" @click="downloadFile" />
<Button label="Cerrar" severity="secondary" @click="visible = false" />
</template>
</Dialog>
</template>
<script setup lang="ts">
const props = defineProps<{
modelValue: boolean;
path: string;
name: string;
mime?: string | null;
}>();
const emit = defineEmits<{ "update:modelValue": [value: boolean] }>();
const { api } = useApi();
const visible = computed({
get: () => props.modelValue,
set: (value) => emit("update:modelValue", value),
});
const title = computed(() => props.name || "Documento");
const loading = ref(false);
const error = ref("");
const url = ref("");
const blobRef = ref<Blob | null>(null);
const kind = computed<"image" | "pdf" | "other">(() => {
const mime = (props.mime || blobRef.value?.type || "").toLowerCase();
const name = props.name.toLowerCase();
if (mime.startsWith("image/") || /\.(jpe?g|png|gif|webp|bmp)$/i.test(name)) return "image";
if (mime.includes("pdf") || name.endsWith(".pdf")) return "pdf";
return "other";
});
async function loadPreview() {
revoke();
error.value = "";
blobRef.value = null;
if (!props.path) return;
loading.value = true;
try {
const blob = await api<Blob>(props.path);
blobRef.value = blob;
url.value = URL.createObjectURL(blob);
} catch (err: unknown) {
error.value = err instanceof Error ? err.message : "No se pudo abrir el documento";
} finally {
loading.value = false;
}
}
function downloadFile() {
if (!url.value) return;
const a = document.createElement("a");
a.href = url.value;
a.download = props.name || "documento";
a.rel = "noopener";
document.body.appendChild(a);
a.click();
a.remove();
}
function revoke() {
if (url.value) URL.revokeObjectURL(url.value);
url.value = "";
}
function onHide() {
revoke();
blobRef.value = null;
error.value = "";
}
watch(
() => [props.modelValue, props.path] as const,
([open]) => {
if (open) loadPreview();
else onHide();
},
);
</script>
<style scoped>
.document-preview-body {
min-height: 360px;
max-height: min(78vh, 760px);
display: flex;
align-items: center;
justify-content: center;
background: #f4f6f8;
}
.document-preview-state {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
padding: 28px 20px;
color: #5d6e84;
text-align: center;
}
.document-preview-state .pi {
font-size: 28px;
color: #8a96a8;
}
.document-preview-image {
display: block;
max-width: 100%;
max-height: min(78vh, 760px);
object-fit: contain;
background: #fff;
}
.document-preview-frame {
width: 100%;
height: min(78vh, 760px);
border: 0;
background: #fff;
}
</style>

View file

@ -0,0 +1,166 @@
<template>
<div class="detail-section">
<div class="document-progress">
<div>
<div class="section-title">Expediente de obra</div>
<div class="muted">{{ requiredDone }} de {{ requiredItems.length }} documentos obligatorios</div>
</div>
<Tag :value="`${requiredProgress}%`" :severity="requiredProgress === 100 ? 'success' : 'warn'" />
</div>
<ProgressBar :value="requiredProgress" :show-value="false" class="mt-2" />
<div class="document-checklist">
<div v-for="item in requiredItems" :key="item.code" class="document-check" :class="{ complete: item.present }">
<i :class="item.present ? 'pi pi-check-circle' : 'pi pi-circle'" />
<span>{{ item.label }}</span>
<small>Obligatorio</small>
</div>
</div>
<Panel v-if="optionalItems.length" header="Documentos opcionales" toggleable collapsed class="mt-3">
<div class="document-checklist">
<div v-for="item in optionalItems" :key="item.code" class="document-check" :class="{ complete: item.present }">
<i :class="item.present ? 'pi pi-check-circle' : 'pi pi-circle'" /><span>{{ item.label }}</span>
</div>
</div>
</Panel>
<Fieldset legend="Subir o reemplazar documento" class="mt-3">
<FormField label="Tipo de documento">
<Select v-model="documentType" :options="documentTypes" option-label="label" option-value="code" class="w-full" />
</FormField>
<FileUpload
mode="basic"
:auto="true"
choose-label="Seleccionar archivo"
accept=".pdf,.xlsx,.xls,.doc,.docx,.jpg,.jpeg,.png"
custom-upload
class="mt-2"
:disabled="uploading"
@uploader="uploadDocument"
/>
<div class="muted mt-2">Al elegir el archivo se sube solo. PDF, Excel o imagen.</div>
</Fieldset>
<Tabs v-model:value="docsTab" class="mt-3 document-list-tabs">
<TabList>
<Tab value="current">Vigentes ({{ currentDocuments.length }})</Tab>
<Tab value="history">Histórico ({{ historyDocuments.length }})</Tab>
</TabList>
<TabPanels>
<TabPanel value="current">
<DataTable :value="currentDocuments" size="small" empty-message="No hay documentos vigentes">
<Column header="Tipo">
<template #body="{ data }">{{ typeLabel(data.type_code) }}</template>
</Column>
<Column field="original_name" header="Archivo" />
<Column header="">
<template #body="{ data }">
<Button icon="pi pi-eye" text rounded size="small" aria-label="Ver documento" @click="openDocument(data)" />
</template>
</Column>
</DataTable>
</TabPanel>
<TabPanel value="history">
<DataTable :value="historyDocuments" size="small" empty-message="Sin versiones anteriores">
<Column header="Tipo">
<template #body="{ data }">{{ typeLabel(data.type_code) }}</template>
</Column>
<Column field="original_name" header="Archivo" />
<Column header="Fecha">
<template #body="{ data }">{{ formatDate(data.uploaded_at) }}</template>
</Column>
<Column header="">
<template #body="{ data }">
<Button icon="pi pi-eye" text rounded size="small" aria-label="Ver documento" @click="openDocument(data)" />
</template>
</Column>
</DataTable>
</TabPanel>
</TabPanels>
</Tabs>
<DocumentPreviewDialog
v-model="previewOpen"
:path="previewPath"
:name="previewName"
:mime="previewMime"
/>
</div>
</template>
<script setup lang="ts">
import FormField from "~/components/padron/FormField.vue";
import DocumentPreviewDialog from "~/components/DocumentPreviewDialog.vue";
type ChecklistItem = { code: string; label: string; required: boolean; present: boolean };
type DocumentRow = {
id: number;
type_code: string;
original_name: string;
uploaded_at: string;
is_current: number;
mime?: string;
};
const props = defineProps<{ projectId: number; documents: DocumentRow[]; checklist: ChecklistItem[]; initialType?: string }>();
const emit = defineEmits<{ changed: [] }>();
const { api } = useApi();
const toast = useToast();
const documentType = ref(props.initialType || "contrato");
const uploading = ref(false);
const docsTab = ref("current");
const previewOpen = ref(false);
const previewPath = ref("");
const previewName = ref("");
const previewMime = ref<string | null>(null);
const documentTypes = computed(() => props.checklist.map(({ code, label }) => ({ code, label })));
const requiredItems = computed(() => props.checklist.filter((item) => item.required));
const optionalItems = computed(() => props.checklist.filter((item) => !item.required));
const requiredDone = computed(() => requiredItems.value.filter((item) => item.present).length);
const requiredProgress = computed(() => requiredItems.value.length ? Math.round(requiredDone.value / requiredItems.value.length * 100) : 0);
const currentDocuments = computed(() => props.documents.filter((doc) => doc.is_current));
const historyDocuments = computed(() => props.documents.filter((doc) => !doc.is_current));
function typeLabel(code: string) {
return props.checklist.find((item) => item.code === code)?.label || code;
}
function formatDate(value: string) {
if (!value) return "—";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleDateString("es-MX", { day: "2-digit", month: "short", year: "numeric" });
}
async function uploadDocument(event: { files: File[] }) {
const file = event.files?.[0];
if (!file || uploading.value) return;
uploading.value = true;
try {
const body = new FormData();
body.append("type", documentType.value);
body.append("file", file);
await api(`/v1/projects/${props.projectId}/documents`, { method: "POST", body });
toast.add({ severity: "success", summary: "Documento cifrado y guardado", life: 2200 });
docsTab.value = "current";
emit("changed");
} catch (error: unknown) {
toast.add({
severity: "error",
summary: error instanceof Error ? error.message : "No se pudo subir el archivo",
life: 3500,
});
} finally {
uploading.value = false;
}
}
function openDocument(row: DocumentRow) {
previewPath.value = `/v1/projects/${props.projectId}/documents/${row.id}`;
previewName.value = row.original_name;
previewMime.value = row.mime || null;
previewOpen.value = true;
}
watch(() => props.initialType, (value) => { if (value) documentType.value = value; });
watch(() => props.checklist, (items) => {
if (!items.some((item) => item.code === documentType.value) && items[0]) documentType.value = items[0].code;
}, { immediate: true });
</script>

View file

@ -0,0 +1,128 @@
<template>
<div class="ficha">
<Message v-if="!projectId" severity="secondary" :closable="false">Seleccione un proyecto</Message>
<div v-else-if="loading && !detail" class="detail-loading"><ProgressSpinner /></div>
<div v-else-if="view" class="ficha-body">
<div class="ficha-head">
<Avatar icon="pi pi-building" shape="circle" />
<div>
<div class="toolbar-title">{{ view.name }}</div>
<div class="muted">{{ view.code }}</div>
</div>
</div>
<div class="ficha-tags">
<Tag :value="projectStatusLabel(String(view.status))" :severity="projectStatusSeverity(String(view.status))" />
<Tag v-if="view.company_name || view.company_code" :value="String(view.company_name || view.company_code)" />
<Tag :value="docsReady ? 'Expediente completo' : 'Falta expediente'" :severity="docsReady ? 'success' : 'warn'" />
</div>
<Button label="Editar registro" icon="pi pi-pencil" outlined class="w-full mt-3" @click="emit('edit')" />
<Button label="Ver presupuesto" icon="pi pi-calculator" outlined class="w-full mt-2" @click="navigateTo(`/presupuesto?project=${projectId}`)" />
<Tabs v-model:value="tab" class="mt-3 detail-tabs">
<TabList>
<Tab value="datos">Datos</Tab>
<Tab value="docs">Expediente</Tab>
</TabList>
<TabPanels>
<TabPanel value="datos">
<DataTable :value="rows" size="small">
<Column field="label" header="Campo" />
<Column field="value" header="Dato" />
</DataTable>
</TabPanel>
<TabPanel value="docs">
<ProjectDocuments
:project-id="projectId"
:documents="detail?.documents || []"
:checklist="detail?.checklist || []"
:initial-type="docType"
@changed="reload"
/>
</TabPanel>
</TabPanels>
</Tabs>
</div>
</div>
</template>
<script setup lang="ts">
import ProjectDocuments from "~/components/ProjectDocuments.vue";
type ChecklistItem = { code: string; label: string; required: boolean; present: boolean };
type DocumentRow = { id: number; type_code: string; original_name: string; uploaded_at: string; is_current: number };
type ProjectRow = Record<string, unknown> & {
id: number;
code: string;
name: string;
status: string;
};
type DetailData = {
project: ProjectRow;
documents: DocumentRow[];
checklist: ChecklistItem[];
};
const props = defineProps<{ project?: Record<string, unknown> | null; doc?: string }>();
const emit = defineEmits<{ edit: [] }>();
const { api } = useApi();
const { projectStatusLabel, projectStatusSeverity } = useUi();
const desk = useDesktop();
const tab = ref("datos");
const loading = ref(false);
const detail = ref<DetailData | null>(null);
const projectId = computed(() => Number(props.project?.id || 0) || 0);
const view = computed(() => detail.value?.project || (props.project as ProjectRow | undefined));
const docsReady = computed(() => {
const required = detail.value?.checklist.filter((item) => item.required) || [];
return required.length > 0 && required.every((item) => item.present);
});
const docType = computed(() => props.doc || "contrato");
const rows = computed(() => {
const p = view.value;
if (!p) return [];
return [
{ label: "Código", value: p.code },
{ label: "Empresa", value: p.company_name || p.company_code || "—" },
{ label: "Nombre", value: p.name },
{ label: "Domicilio", value: p.address || "—" },
{ label: "Monto contrato", value: money(p.contract_amount) },
{ label: "Inicio", value: p.start_date || "—" },
{ label: "Término", value: p.end_date || "—" },
{ label: "Residente de obra", value: p.resident_name || "—" },
{ label: "SIROC", value: p.siroc || "—" },
{ label: "Sobre nómina", value: p.payroll_tax_pct == null || p.payroll_tax_pct === "" ? "—" : `${p.payroll_tax_pct}%` },
{ label: "Tema gafete", value: p.theme_id },
{ label: "Activos", value: p.active_count ?? 0 },
];
});
function money(value: unknown) {
if (value == null || value === "") return "—";
const amount = Number(value);
if (Number.isNaN(amount)) return "—";
return new Intl.NumberFormat("es-MX", { style: "currency", currency: "MXN" }).format(amount);
}
async function load() {
if (!projectId.value) {
detail.value = null;
return;
}
loading.value = true;
try {
detail.value = await api<DetailData>(`/v1/projects/${projectId.value}`);
} finally {
loading.value = false;
}
}
function reload() {
load();
desk.bumpDetail();
}
watch(() => [projectId.value, props.doc, desk.detailRev.value] as const, ([, doc]) => {
if (doc) tab.value = "docs";
load();
}, { immediate: true });
</script>

View file

@ -0,0 +1,306 @@
<template>
<div class="ficha worker-detail">
<div v-if="!workerId" class="detail-empty">
<i class="pi pi-file detail-empty-icon" />
<strong>Seleccione un registro</strong>
<span>La ficha aparecerá en este panel.</span>
</div>
<div v-else-if="loading" class="detail-loading"><ProgressSpinner /></div>
<div v-else-if="data" class="ficha-body worker-record">
<div class="worker-record-code">{{ recordCode }}</div>
<div class="worker-photo worker-record-photo">
<img v-if="photoOk" :src="photoSrc" :alt="title" @error="photoOk = false" />
<Avatar v-else :label="initials(data.worker.first_name, data.worker.last_name_p)" shape="circle" size="xlarge" />
</div>
<div class="worker-record-lead">
<div class="worker-detail-name">{{ title }}</div>
<div class="muted">{{ data.worker.position }}</div>
</div>
<div class="profile-list worker-record-fields">
<div v-for="row in recordRows" :key="row.label">
<span>{{ row.label }}</span>
<strong :class="{ identifier: row.identifier }">{{ row.value }}</strong>
</div>
</div>
<div class="worker-record-status">
<span>Estatus</span>
<div class="ficha-tags">
<Tag :value="pipelineLabel(data.worker.pipeline_status)" :severity="pipelineSeverity(data.worker.pipeline_status)" />
<Tag :value="riskLevelLabel(data.worker.risk_code)" :severity="riskLevelSeverity(data.worker.risk_code)" />
<Tag v-if="empresaTag" :value="empresaTag" :severity="hireSeverity(empresaTag)" />
<Tag v-else value="Sin patrón" severity="secondary" />
<Tag :value="imssStatusLabel" :severity="imssStatusSeverity" />
</div>
</div>
<div class="worker-imss-block">
<div class="worker-imss-head">
<div class="worker-imss-title">IMSS</div>
<div class="worker-imss-actions">
<Button
v-if="data.worker.imss_status !== 'alta'"
label="Registrar alta"
icon="pi pi-file-pdf"
size="small"
@click="openImss('alta')"
/>
<template v-else>
<Button
label="Reemplazar alta"
icon="pi pi-upload"
size="small"
outlined
@click="openImss('alta')"
/>
<Button
label="Registrar baja"
icon="pi pi-times-circle"
size="small"
severity="warn"
outlined
@click="openImss('baja')"
/>
</template>
<Button
label="Historial"
icon="pi pi-history"
size="small"
text
@click="openImss('historial')"
/>
</div>
</div>
<div class="profile-list worker-record-fields">
<div><span>Estado</span><strong>{{ imssStatusLabel }}</strong></div>
<div v-if="data.worker.imss_status === 'alta'">
<span>Alta desde</span>
<strong>{{ formatDay(data.worker.imss_alta_at) }}</strong>
</div>
<div v-if="data.worker.imss_status === 'baja_imss' && data.worker.imss_baja_at">
<span>Baja desde</span>
<strong>{{ formatDay(data.worker.imss_baja_at) }}</strong>
</div>
<div v-if="data.worker.imss_company_name || data.worker.imss_company_code">
<span>Patrón</span>
<strong>{{ data.worker.imss_company_name || data.worker.imss_company_code }}</strong>
</div>
<div v-if="data.worker.imss_registro_patronal">
<span>Registro patronal</span>
<strong class="identifier">{{ data.worker.imss_registro_patronal }}</strong>
</div>
</div>
</div>
<Button label="Editar registro" icon="pi pi-pencil" outlined class="w-full worker-record-edit" @click="emit('edit')" />
<div class="worker-mini-stats">
<div><strong>{{ requiredDone }}/{{ requiredItems.length }}</strong><span>Documentos</span></div>
<div><strong>{{ activeAssignments }}</strong><span>Proyectos</span></div>
<div><strong>{{ money(loanBalance) }}</strong><span>Préstamos</span></div>
</div>
<Tabs v-model:value="tab" class="mt-3 detail-tabs">
<TabList>
<Tab value="docs">Expediente</Tab>
<Tab value="proyectos">Proyectos</Tab>
<Tab value="nomina">Nómina</Tab>
</TabList>
<TabPanels>
<TabPanel value="docs">
<WorkerDocuments
:worker-id="workerId"
:documents="data.documents"
:checklist="data.checklist"
:companies="companies"
:initial-type="doc"
:freshness-required="data.freshness_required || data.worker.freshness_required"
:imss-ready="data.worker.imss_ready"
:in-project-without-imss="data.worker.in_project_without_imss"
@changed="refreshRelated"
@register-alta="openImss('alta')"
/>
</TabPanel>
<TabPanel value="proyectos">
<WorkerAssignments
:worker-id="workerId"
:assignments="data.assignments"
:projects="projects"
@changed="refreshRelated"
/>
</TabPanel>
<TabPanel value="nomina">
<Message :closable="false" class="nomina-jornal-msg">Jornal {{ money(Number(data.worker.daily_wage)) }} · {{ data.worker.work_type === "D" ? "Destajo" : "Normal" }}</Message>
<Fieldset legend="Registrar préstamo" class="nomina-loan-fieldset">
<FormField label="Monto"><InputNumber v-model="loanAmount" mode="currency" currency="MXN" locale="es-MX" class="w-full" /></FormField>
<FormField label="Abono semanal" class="mt-2"><InputNumber v-model="weeklyPayment" mode="currency" currency="MXN" locale="es-MX" class="w-full" /></FormField>
<Button label="Registrar préstamo" icon="pi pi-plus" class="w-full mt-2" :disabled="loanAmount <= 0" @click="addLoan" />
</Fieldset>
<DataTable :value="data.loans" size="small" class="mt-3" empty-message="Sin préstamos">
<Column header="Monto"><template #body="{ data: row }">{{ money(Number(row.amount)) }}</template></Column>
<Column header="Saldo"><template #body="{ data: row }">{{ money(Number(row.balance)) }}</template></Column>
<Column header="Abono"><template #body="{ data: row }">{{ money(Number(row.weekly_payment)) }}</template></Column>
</DataTable>
</TabPanel>
</TabPanels>
</Tabs>
<AltaImssDialog
v-if="workerId"
v-model="showAltaImss"
:worker-id="workerId"
:companies="companies"
:documents="data.documents"
:default-company-id="data.worker.company_id"
:imss-status="data.worker.imss_status"
:initial-tab="imssDialogTab"
@saved="onImssSaved"
/>
</div>
</div>
</template>
<script setup lang="ts">
import WorkerDocuments from "~/components/padron/WorkerDocuments.vue";
import WorkerAssignments from "~/components/padron/WorkerAssignments.vue";
import AltaImssDialog from "~/components/padron/AltaImssDialog.vue";
import FormField from "~/components/padron/FormField.vue";
import type { ChecklistItem, CompanyOption, ProjectOption, Worker } from "~/types/padron";
type DetailData = {
worker: Worker & { full_name: string };
documents: {
id: number;
type_code: string;
original_name: string;
uploaded_at: string;
is_current: number;
mime?: string;
issued_at?: string | null;
expires_at?: string | null;
imss_company_id?: number | null;
imss_alta_at?: string | null;
}[];
assignments: { id: number; project_id: number; project_name: string; project_code: string; active: number; start_date: string; end_date?: string | null }[];
loans: { id: number; amount: number; balance: number; weekly_payment: number }[];
checklist: ChecklistItem[];
freshness_required?: boolean;
};
const props = defineProps<{ workerId?: number; doc?: string }>();
const emit = defineEmits<{ edit: [] }>();
const { pipelineLabel, pipelineSeverity, initials, riskLevelLabel, riskLevelSeverity, hireSeverity } = useUi();
const { api } = useApi();
const toast = useToast();
const desk = useDesktop();
const data = ref<DetailData | null>(null);
const projects = ref<ProjectOption[]>([]);
const companies = ref<CompanyOption[]>([]);
const loading = ref(false);
const tab = ref("docs");
const photoOk = ref(true);
const showAltaImss = ref(false);
const imssDialogTab = ref<"alta" | "baja" | "historial">("alta");
const loanAmount = ref(0);
const weeklyPayment = ref(0);
const title = computed(() => data.value ? [data.value.worker.first_name, data.value.worker.middle_name, data.value.worker.last_name_p, data.value.worker.last_name_m].filter(Boolean).join(" ") : "Expediente");
const recordCode = computed(() => data.value ? `PER-${String(data.value.worker.id).padStart(4, "0")}` : "PER-0000");
const photoSrc = computed(() => `/v1/workers/${props.workerId}/photo?v=${desk.detailRev.value}`);
const requiredItems = computed(() => data.value?.checklist.filter((item) => item.required) || []);
const requiredDone = computed(() => requiredItems.value.filter((item) => item.present && item.valid !== false).length);
const activeAssignments = computed(() => data.value?.assignments.filter((assignment) => assignment.active).length || 0);
const activeProjectNames = computed(() => data.value?.assignments.filter((assignment) => assignment.active).map((assignment) => assignment.project_name).join(", ") || "Sin asignar");
const loanBalance = computed(() => data.value?.loans.reduce((sum, loan) => sum + Math.max(0, Number(loan.balance)), 0) || 0);
const imssStatusLabel = computed(() => {
const status = data.value?.worker.imss_status;
if (status === "alta") return "Alta IMSS";
if (status === "baja_imss") return "Baja IMSS";
return "Sin alta IMSS";
});
const imssStatusSeverity = computed(() => {
const status = data.value?.worker.imss_status;
if (status === "alta") return "success";
if (data.value?.worker.in_project_without_imss) return "danger";
return "warn";
});
const empresaTag = computed(() => {
const worker = data.value?.worker;
if (!worker || worker.imss_status !== "alta") return "";
return worker.imss_company_code || worker.imss_company_name || worker.company_name || worker.hire_type || "";
});
const recordRows = computed(() => {
const worker = data.value?.worker;
if (!worker) return [];
return [
{ label: "Empresa", value: empresaTag.value || "Sin patrón" },
{ label: "Proyecto", value: activeProjectNames.value },
{ label: "CURP", value: worker.curp, identifier: true },
{ label: "RFC", value: worker.rfc, identifier: true },
{ label: "NSS", value: worker.nss, identifier: true },
{ label: "Teléfono", value: worker.phone },
{ label: "Correo", value: worker.email },
{ label: "Dirección", value: worker.address },
{ label: "Tipo de sangre", value: worker.blood_type || "—" },
{ label: "Jornal", value: money(Number(worker.daily_wage)) },
];
});
function money(value: number) {
return value.toLocaleString("es-MX", { style: "currency", currency: "MXN", maximumFractionDigits: 0 });
}
function formatDay(value?: string | null) {
if (!value) return "—";
const date = new Date(`${String(value).slice(0, 10)}T12:00:00`);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleDateString("es-MX", { day: "2-digit", month: "short", year: "numeric" });
}
async function load() {
if (!props.workerId) { data.value = null; return; }
loading.value = true;
try {
const [detail, projectResponse, companyResponse] = await Promise.all([
api<DetailData>(`/v1/workers/${props.workerId}`),
api<{ projects: ProjectOption[] }>("/v1/projects?status=activo"),
api<{ companies: CompanyOption[] }>("/v1/companies"),
]);
data.value = detail;
projects.value = projectResponse.projects;
companies.value = companyResponse.companies.filter((c) => c.status === "activo");
photoOk.value = true;
} finally {
loading.value = false;
}
}
function refreshRelated() {
desk.bumpDetail();
}
function openImss(tab: "alta" | "baja" | "historial") {
imssDialogTab.value = tab;
showAltaImss.value = true;
}
function onImssSaved(kind: "alta" | "baja") {
toast.add({
severity: "success",
summary: kind === "baja" ? "Baja IMSS registrada" : "Alta IMSS registrada",
life: 2200,
});
refreshRelated();
}
async function addLoan() {
if (!props.workerId || loanAmount.value <= 0) return;
await api("/v1/loans", { method: "POST", json: { worker_id: props.workerId, amount: loanAmount.value, weekly_payment: weeklyPayment.value } });
loanAmount.value = 0;
weeklyPayment.value = 0;
toast.add({ severity: "success", summary: "Préstamo registrado", life: 1800 });
refreshRelated();
}
watch(() => [props.workerId, props.doc, desk.detailRev.value] as const, ([, doc]) => {
if (doc) tab.value = "docs";
load();
}, { immediate: true });
</script>

View file

@ -0,0 +1,382 @@
<template>
<Dialog
v-model:visible="visible"
modal
:header="dialogTitle"
class="imss-mov-dialog"
:style="{ width: 'min(480px, 96vw)' }"
@hide="reset"
>
<Tabs v-model:value="tab" class="imss-mov-tabs">
<TabList>
<Tab value="alta">Alta</Tab>
<Tab value="baja" :disabled="!canRegisterBaja">Baja</Tab>
<Tab value="historial">Historial</Tab>
</TabList>
<TabPanels>
<TabPanel value="alta">
<p class="muted imss-mov-intro">
Adjunta el PDF (o imagen) del movimiento de alta, la empresa patrón y la fecha.
</p>
<div class="imss-mov-form">
<FormField label="Empresa patrón">
<Select
v-model="imssCompanyId"
:options="companies"
:option-label="companyOptionLabel"
option-value="id"
placeholder="Seleccione empresa patrón"
class="w-full"
/>
</FormField>
<FormField label="Fecha de alta">
<DatePicker v-model="imssAltaAt" date-format="dd/mm/yy" show-icon class="w-full" />
</FormField>
<FormField label="Archivo (PDF o imagen)">
<FileUpload
mode="basic"
:auto="false"
choose-label="Seleccionar PDF"
accept=".pdf,.jpg,.jpeg,.png,.webp"
custom-upload
:disabled="uploading"
class="imss-file-upload"
@select="onPick"
/>
<div class="imss-file-name muted">{{ file ? file.name : "Ningún archivo seleccionado" }}</div>
</FormField>
<Message v-if="error && tab === 'alta'" severity="error" :closable="false">{{ error }}</Message>
</div>
</TabPanel>
<TabPanel value="baja">
<p class="muted imss-mov-intro">
Registra la baja IMSS con la fecha del movimiento y el PDF o imagen de respaldo.
</p>
<Message v-if="!canRegisterBaja" severity="warn" :closable="false" class="mb-3">
Solo disponible cuando el trabajador tiene alta IMSS vigente.
</Message>
<div v-else class="imss-mov-form">
<FormField label="Fecha de baja">
<DatePicker v-model="imssBajaAt" date-format="dd/mm/yy" show-icon class="w-full" />
</FormField>
<FormField label="Archivo (PDF o imagen)">
<FileUpload
mode="basic"
:auto="false"
choose-label="Seleccionar PDF"
accept=".pdf,.jpg,.jpeg,.png,.webp"
custom-upload
:disabled="uploading"
class="imss-file-upload"
@select="onPick"
/>
<div class="imss-file-name muted">{{ file ? file.name : "Ningún archivo seleccionado" }}</div>
</FormField>
<Message v-if="error && tab === 'baja'" severity="error" :closable="false">{{ error }}</Message>
</div>
</TabPanel>
<TabPanel value="historial">
<p class="muted imss-mov-intro">Movimientos de alta y baja registrados en el expediente.</p>
<div v-if="!history.length" class="imss-history-empty muted">Sin movimientos IMSS aún.</div>
<ul v-else class="imss-history-list">
<li v-for="row in history" :key="row.id" class="imss-history-item">
<div class="imss-history-main">
<Tag
:value="row.type_code === 'baja_imss' ? 'Baja' : 'Alta'"
:severity="row.type_code === 'baja_imss' ? 'warn' : 'success'"
/>
<div>
<strong>{{ formatDay(row.imss_alta_at || row.issued_at || row.uploaded_at) }}</strong>
<div class="muted">{{ row.original_name }}</div>
<div v-if="companyName(row.imss_company_id)" class="muted">
Patrón: {{ companyName(row.imss_company_id) }}
</div>
</div>
</div>
<Button
icon="pi pi-eye"
text
rounded
severity="secondary"
aria-label="Ver documento"
@click="preview(row)"
/>
</li>
</ul>
</TabPanel>
</TabPanels>
</Tabs>
<template #footer>
<Button label="Cerrar" severity="secondary" text :disabled="uploading" @click="visible = false" />
<Button
v-if="tab === 'alta'"
label="Guardar alta"
icon="pi pi-check"
:loading="uploading"
:disabled="!canSaveAlta"
@click="submitAlta"
/>
<Button
v-else-if="tab === 'baja' && canRegisterBaja"
label="Guardar baja"
icon="pi pi-check"
severity="warn"
:loading="uploading"
:disabled="!canSaveBaja"
@click="submitBaja"
/>
</template>
</Dialog>
<DocumentPreviewDialog
v-model="previewOpen"
:path="previewPath"
:name="previewName"
:mime="previewMime"
/>
</template>
<script setup lang="ts">
import FormField from "~/components/padron/FormField.vue";
import DocumentPreviewDialog from "~/components/DocumentPreviewDialog.vue";
import type { CompanyOption } from "~/types/padron";
import { companyLabel } from "~/types/padron";
type ImssDoc = {
id: number;
type_code: string;
original_name: string;
uploaded_at: string;
mime?: string;
issued_at?: string | null;
imss_company_id?: number | null;
imss_alta_at?: string | null;
};
const props = defineProps<{
modelValue: boolean;
workerId: number;
companies: CompanyOption[];
documents?: ImssDoc[];
defaultCompanyId?: number | null;
imssStatus?: string | null;
initialTab?: "alta" | "baja" | "historial";
}>();
const emit = defineEmits<{ "update:modelValue": [value: boolean]; saved: [kind: "alta" | "baja"] }>();
const { api } = useApi();
const visible = computed({
get: () => props.modelValue,
set: (value) => emit("update:modelValue", value),
});
const tab = ref<"alta" | "baja" | "historial">("alta");
const imssCompanyId = ref<number | null>(null);
const imssAltaAt = ref<Date | null>(new Date());
const imssBajaAt = ref<Date | null>(new Date());
const file = ref<File | null>(null);
const uploading = ref(false);
const error = ref("");
const previewOpen = ref(false);
const previewPath = ref("");
const previewName = ref("");
const previewMime = ref<string | null>(null);
const canRegisterBaja = computed(() => props.imssStatus === "alta");
const dialogTitle = computed(() => {
if (tab.value === "baja") return "Registrar baja IMSS";
if (tab.value === "historial") return "Historial IMSS";
return "Registrar alta IMSS";
});
const history = computed(() =>
(props.documents || [])
.filter((doc) => doc.type_code === "alta_imss" || doc.type_code === "baja_imss")
.slice()
.sort((a, b) => String(b.uploaded_at).localeCompare(String(a.uploaded_at))),
);
const canSaveAlta = computed(() =>
Boolean(imssCompanyId.value && imssAltaAt.value && file.value && !uploading.value),
);
const canSaveBaja = computed(() =>
Boolean(canRegisterBaja.value && imssBajaAt.value && file.value && !uploading.value),
);
function companyOptionLabel(company: CompanyOption) {
const base = companyLabel(company);
return company.registro_patronal ? `${base} · RP ${company.registro_patronal}` : base;
}
function companyName(id?: number | null) {
if (!id) return "";
const company = props.companies.find((item) => item.id === id);
return company ? companyLabel(company) : "";
}
function toIsoDate(value: Date) {
const y = value.getFullYear();
const m = String(value.getMonth() + 1).padStart(2, "0");
const d = String(value.getDate()).padStart(2, "0");
return `${y}-${m}-${d}`;
}
function formatDay(value?: string | null) {
if (!value) return "—";
const date = new Date(`${String(value).slice(0, 10)}T12:00:00`);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleDateString("es-MX", { day: "2-digit", month: "short", year: "numeric" });
}
function onPick(event: { files: File[] }) {
file.value = event.files?.[0] || null;
error.value = "";
}
function reset() {
file.value = null;
error.value = "";
uploading.value = false;
imssAltaAt.value = new Date();
imssBajaAt.value = new Date();
imssCompanyId.value = props.defaultCompanyId || props.companies[0]?.id || null;
tab.value = props.initialTab || "alta";
}
function preview(row: ImssDoc) {
previewPath.value = `/v1/workers/${props.workerId}/documents/${row.id}`;
previewName.value = row.original_name;
previewMime.value = row.mime || null;
previewOpen.value = true;
}
async function submitAlta() {
if (!canSaveAlta.value || !file.value || !imssCompanyId.value || !imssAltaAt.value) return;
uploading.value = true;
error.value = "";
try {
const body = new FormData();
body.append("type", "alta_imss");
body.append("file", file.value);
body.append("imss_company_id", String(imssCompanyId.value));
body.append("imss_alta_at", toIsoDate(imssAltaAt.value));
await api(`/v1/workers/${props.workerId}/documents`, { method: "POST", body });
visible.value = false;
emit("saved", "alta");
} catch (err: unknown) {
error.value = err instanceof Error ? err.message : "No se pudo guardar el alta";
} finally {
uploading.value = false;
}
}
async function submitBaja() {
if (!canSaveBaja.value || !file.value || !imssBajaAt.value) return;
uploading.value = true;
error.value = "";
try {
const body = new FormData();
body.append("type", "baja_imss");
body.append("file", file.value);
body.append("imss_baja_at", toIsoDate(imssBajaAt.value));
await api(`/v1/workers/${props.workerId}/documents`, { method: "POST", body });
visible.value = false;
emit("saved", "baja");
} catch (err: unknown) {
error.value = err instanceof Error ? err.message : "No se pudo guardar la baja";
} finally {
uploading.value = false;
}
}
watch(
() => props.modelValue,
(open) => {
if (open) {
reset();
if (props.initialTab) tab.value = props.initialTab;
}
},
);
watch(tab, () => {
file.value = null;
error.value = "";
});
</script>
<style scoped>
.imss-mov-intro {
margin: 0 0 16px;
font-size: 12px;
line-height: 1.45;
}
.imss-mov-form {
display: flex;
flex-direction: column;
gap: 16px;
}
.imss-mov-form :deep(.p-select),
.imss-mov-form :deep(.p-datepicker),
.imss-mov-form :deep(.p-inputtext) {
width: 100%;
}
.imss-mov-form :deep(.p-datepicker) {
display: flex;
}
.imss-file-upload :deep(.p-fileupload-basic),
.imss-file-upload :deep(.p-fileupload) {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 6px;
width: 100%;
}
.imss-file-upload :deep(.p-button) {
width: 100%;
justify-content: center;
}
.imss-file-name {
margin-top: 6px;
font-size: 12px;
word-break: break-all;
}
.imss-history-empty {
padding: 18px 4px;
text-align: center;
}
.imss-history-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 10px;
max-height: min(360px, 50vh);
overflow: auto;
}
.imss-history-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 10px 12px;
border: 1px solid #e4e9f0;
border-radius: 10px;
background: #fafbfd;
}
.imss-history-main {
display: flex;
align-items: flex-start;
gap: 10px;
min-width: 0;
}
.imss-history-main strong {
font-size: 13px;
}
.imss-mov-tabs :deep(.p-tabpanels) {
padding: 14px 0 0;
}
</style>

View file

@ -0,0 +1,11 @@
<template>
<label class="form-field">
<span class="form-field-label">{{ label }}<span v-if="required" class="required-mark"> *</span></span>
<slot />
<small v-if="error" class="p-error">{{ error }}</small>
</label>
</template>
<script setup lang="ts">
defineProps<{ label: string; required?: boolean; error?: string }>();
</script>

View file

@ -0,0 +1,303 @@
<template>
<section class="padron-list">
<DataTable
v-model:selection="selection"
v-model:filters="filters"
:value="workers"
data-key="id"
class="padron-datatable"
paginator
:rows="25"
:rows-per-page-options="[25, 50, 100]"
removable-sort
scrollable
scroll-height="flex"
selection-mode="single"
:meta-key-selection="false"
:global-filter-fields="['full_name', 'position', 'curp', 'rfc', 'nss', 'proyectos', 'proyecto_pairs']"
context-menu
:context-menu-selection="selection"
empty-message="No hay personal que coincida con esta vista."
table-style="min-width: 52rem"
@row-click="onRow"
@row-dblclick="emit('edit', $event.data)"
@row-contextmenu="onContext"
>
<Column selection-mode="single" header-style="width: 2.75rem" />
<Column field="full_name" header="Persona" sortable>
<template #body="{ data }">
<div class="person-cell">
<Avatar :label="initials(data.first_name, data.last_name_p)" shape="circle" size="small" />
<div class="person-cell-copy"><div class="person-name">{{ data.full_name }}</div><div class="muted">{{ data.position }}</div></div>
</div>
</template>
</Column>
<Column field="risk_level" header="Riesgo" sortable header-style="width: 4.5rem" body-style="text-align: center">
<template #body="{ data }">
<Tag
:icon="riskIcon(data.risk_code)"
:severity="riskLevelSeverity(data.risk_code)"
rounded
class="status-icon-tag"
v-tooltip.top="riskLevelLabel(data.risk_code)"
/>
</template>
</Column>
<Column field="hire_type" header="Empresa" sortable>
<template #body="{ data }">
<Tag
v-if="empresaLabel(data)"
:value="empresaLabel(data)"
:severity="hireSeverity(empresaLabel(data))"
/>
<span v-else class="muted">Sin patrón</span>
</template>
</Column>
<Column field="imss_status" header="IMSS" sortable header-style="width: 5.5rem" body-style="text-align: center">
<template #body="{ data }">
<Tag
:icon="imssIcon(data)"
:severity="imssSeverity(data)"
rounded
class="status-icon-tag"
v-tooltip.top="imssTooltip(data)"
/>
</template>
</Column>
<Column field="curp" header="CURP" sortable>
<template #body="{ data }"><span class="identifier">{{ data.curp }}</span></template>
</Column>
<Column field="nss" header="NSS" sortable>
<template #body="{ data }"><span class="identifier">{{ data.nss }}</span></template>
</Column>
<Column field="proyectos" header="Proyecto" sortable style="width: 8.5rem; min-width: 8.5rem">
<template #body="{ data }">
<div v-if="projectBadges(data).length" class="project-badges">
<Tag
v-for="item in projectBadges(data)"
:key="item.code"
:value="item.code"
severity="info"
class="project-code-tag"
v-tooltip.top="item.name"
/>
</div>
<span v-else class="muted">Sin asignar</span>
</template>
</Column>
<Column field="pipeline_status" header="Expediente" sortable header-style="width: 5.5rem" body-style="text-align: center">
<template #body="{ data }">
<Tag
:icon="pipelineIcon(data.pipeline_status)"
:severity="pipelineSeverity(data.pipeline_status)"
rounded
class="status-icon-tag"
v-tooltip.top="pipelineLabel(data.pipeline_status)"
/>
</template>
</Column>
<Column field="gafete_key" header="Gafete" sortable header-style="width: 5rem" body-style="text-align: center">
<template #body="{ data }">
<Tag
v-if="gafeteIcon(data.pipeline_status)"
:icon="gafeteIcon(data.pipeline_status)"
:severity="gafeteSeverity(data.pipeline_status)"
rounded
class="status-icon-tag"
v-tooltip.top="gafeteLabel(data.pipeline_status)"
/>
<span v-else class="muted">—</span>
</template>
</Column>
<Column field="status" header="Estatus" sortable header-style="width: 5rem" body-style="text-align: center">
<template #body="{ data }">
<Tag
:icon="data.status === 'activo' ? 'pi pi-check' : 'pi pi-times'"
:severity="data.status === 'activo' ? 'success' : 'danger'"
rounded
class="status-icon-tag"
v-tooltip.top="data.status === 'activo' ? 'Activo' : 'Baja'"
/>
</template>
</Column>
</DataTable>
<ContextMenu ref="contextMenu" :model="contextItems" />
</section>
</template>
<script setup lang="ts">
import { FilterMatchMode } from "@primevue/core/api";
import type { ProjectOption, Worker } from "~/types/padron";
import { projectIdsOf } from "~/types/padron";
const props = defineProps<{
workers: Worker[];
modelValue: Worker | null;
query?: string;
projects?: ProjectOption[];
}>();
const emit = defineEmits<{
"update:modelValue": [worker: Worker | null];
open: [worker: Worker];
edit: [worker: Worker];
baja: [worker: Worker];
reactivate: [worker: Worker];
}>();
const { initials, riskLevel, riskLevelLabel, riskLevelSeverity, hireSeverity, pipelineLabel, pipelineSeverity, gafeteLabel, gafeteSeverity } = useUi();
const selection = computed({ get: () => props.modelValue, set: (value) => emit("update:modelValue", value) });
const filters = ref({
global: { value: null as string | null, matchMode: FilterMatchMode.CONTAINS },
});
const contextMenu = ref();
const menuWorker = ref<Worker | null>(null);
const projectById = computed(() => {
const map = new Map<number, ProjectOption>();
for (const project of props.projects || []) map.set(project.id, project);
return map;
});
const contextItems = computed(() => [
{ label: "Abrir ficha", icon: "pi pi-folder-open", command: () => menuWorker.value && emit("open", menuWorker.value) },
{ label: "Editar datos", icon: "pi pi-pencil", command: () => menuWorker.value && emit("edit", menuWorker.value) },
{ separator: true },
{
label: "Dar de baja",
icon: "pi pi-user-minus",
disabled: menuWorker.value?.status === "baja",
command: () => menuWorker.value && emit("baja", menuWorker.value),
},
{
label: "Reactivar",
icon: "pi pi-user-plus",
disabled: menuWorker.value?.status !== "baja",
command: () => menuWorker.value && emit("reactivate", menuWorker.value),
},
]);
function imssIcon(worker: Worker) {
if (worker.imss_status === "alta") return "pi pi-verified";
if (worker.in_project_without_imss) return "pi pi-exclamation-triangle";
if (worker.imss_ready) return "pi pi-send";
return "pi pi-times-circle";
}
function imssSeverity(worker: Worker) {
if (worker.imss_status === "alta") return "success";
if (worker.in_project_without_imss) return "danger";
if (worker.imss_ready) return "info";
return "warn";
}
function imssTooltip(worker: Worker) {
if (worker.imss_status === "alta") {
const since = worker.imss_alta_at ? ` desde ${worker.imss_alta_at}` : "";
const patron = worker.imss_company_code || worker.imss_company_name || "";
return `Alta IMSS${since}${patron ? ` · ${patron}` : ""}`;
}
if (worker.in_project_without_imss) return "En obra sin alta IMSS";
if (worker.imss_ready) return "Expediente listo para alta IMSS";
return "Sin alta IMSS";
}
function empresaLabel(worker: Worker) {
if (worker.imss_status !== "alta") return "";
return worker.imss_company_code || worker.hire_type || worker.imss_company_name || worker.company_name || "";
}
function riskIcon(code: string) {
switch (riskLevel(code)) {
case "alto":
return "pi pi-exclamation-triangle";
case "medio":
return "pi pi-minus-circle";
default:
return "pi pi-check-circle";
}
}
function pipelineIcon(status: string) {
switch (status) {
case "incompleto":
return "pi pi-folder-open";
case "listo_gafete":
return "pi pi-check-circle";
case "impreso":
return "pi pi-id-card";
case "activo":
return "pi pi-briefcase";
case "baja":
return "pi pi-ban";
default:
return "pi pi-folder";
}
}
function gafeteIcon(status: string) {
switch (status) {
case "incompleto":
return "pi pi-times";
case "listo_gafete":
return "pi pi-print";
case "impreso":
case "activo":
return "pi pi-id-card";
default:
return "";
}
}
function projectBadges(worker: Worker) {
const ids = projectIdsOf(worker);
if (ids.length) {
return ids.map((id) => {
const project = projectById.value.get(id);
return {
code: project?.code || `PRY-${String(id).padStart(4, "0")}`,
name: project?.name || worker.proyectos || `Proyecto ${id}`,
};
});
}
const raw = String(worker.proyecto_pairs || "").trim();
if (!raw) return [];
return raw.split(";;").map((part) => {
const sep = part.indexOf("|");
if (sep < 0) return { code: part, name: part };
return { code: part.slice(0, sep), name: part.slice(sep + 1) };
}).filter((item) => item.code && item.code !== item.name);
}
function onRow(event: { data: Worker }) { selection.value = event.data; emit("open", event.data); }
function onContext(event: { originalEvent: Event; data: Worker }) {
menuWorker.value = event.data; selection.value = event.data; emit("open", event.data); contextMenu.value.show(event.originalEvent);
}
watch(() => props.query, (value) => { filters.value.global.value = value || null; }, { immediate: true });
</script>
<style scoped>
.status-icon-tag {
width: 1.75rem;
height: 1.75rem;
padding: 0;
justify-content: center;
}
.status-icon-tag :deep(.p-tag-icon) {
margin: 0;
font-size: 0.8rem;
}
.status-icon-tag :deep(.p-tag-label) {
display: none;
}
.project-badges {
display: flex;
flex-wrap: wrap;
gap: 4px;
align-items: center;
}
.project-code-tag {
font-family: "Cascadia Mono", "Segoe UI Mono", monospace;
letter-spacing: .02em;
white-space: nowrap;
max-width: none;
}
.project-code-tag :deep(.p-tag-label) {
white-space: nowrap;
}
</style>

View file

@ -0,0 +1,60 @@
<template>
<div class="detail-section">
<div class="section-title">Asignaciones a proyectos</div>
<div class="muted">Una persona puede trabajar en varios proyectos activos al mismo tiempo.</div>
<div class="assignment-create mt-3">
<Select v-model="projectId" :options="availableProjects" option-label="name" option-value="id" placeholder="Proyecto activo" class="w-full" />
<Button label="Asignar" icon="pi pi-plus" :disabled="!projectId" @click="assignProject" />
</div>
<DataTable :value="assignments" size="small" class="mt-3" empty-message="Sin historial de asignaciones">
<Column header="Proyecto">
<template #body="{ data }">
<div><span class="identifier">{{ data.project_code }}</span><div>{{ data.project_name }}</div></div>
</template>
</Column>
<Column field="start_date" header="Inicio" />
<Column header="Estado">
<template #body="{ data }"><Tag :value="data.active ? 'Activa' : 'Finalizada'" :severity="data.active ? 'success' : 'secondary'" /></template>
</Column>
<Column header="">
<template #body="{ data }">
<Button v-if="data.active" icon="pi pi-times" text rounded severity="danger" size="small" aria-label="Finalizar asignación" @click="deactivate(data)" />
<Button v-else icon="pi pi-refresh" text rounded size="small" aria-label="Reactivar asignación" @click="reactivate(data)" />
</template>
</Column>
</DataTable>
</div>
</template>
<script setup lang="ts">
import type { ProjectOption } from "~/types/padron";
type Assignment = { id: number; project_id: number; project_name: string; project_code: string; active: number; start_date: string; end_date?: string | null };
const props = defineProps<{ workerId: number; assignments: Assignment[]; projects: ProjectOption[] }>();
const emit = defineEmits<{ changed: [] }>();
const { api } = useApi();
const confirm = useConfirm();
const toast = useToast();
const projectId = ref<number | null>(null);
const availableProjects = computed(() => props.projects.filter((project) => !props.assignments.some((assignment) => assignment.project_id === project.id && assignment.active)));
async function setAssignment(id: number, active: boolean) {
await api(`/v1/workers/${props.workerId}/assign`, { method: "POST", json: { project_id: id, active } });
toast.add({ severity: "success", summary: active ? "Asignación activa" : "Asignación finalizada", life: 1800 });
projectId.value = null;
emit("changed");
}
function assignProject() { if (projectId.value) setAssignment(projectId.value, true); }
function reactivate(assignment: Assignment) { setAssignment(assignment.project_id, true); }
function deactivate(assignment: Assignment) {
confirm.require({
header: "Finalizar asignación",
message: `¿Quitar a esta persona de ${assignment.project_name}? Se conservará la fecha en el historial.`,
icon: "pi pi-exclamation-triangle",
acceptLabel: "Finalizar",
rejectLabel: "Cancelar",
accept: () => setAssignment(assignment.project_id, false),
});
}
</script>

View file

@ -0,0 +1,312 @@
<template>
<div class="detail-section">
<Message v-if="freshnessRequired" severity="warn" :closable="false" class="mb-3">
Reingreso o pendiente de alta IMSS: documentos CURP, NSS, RFC y domicilio deben tener emisión de máximo 3 meses.
</Message>
<Message v-else-if="imssReady" severity="info" :closable="false" class="mb-3">
Expediente listo para alta IMSS.
<Button label="Registrar alta" link class="p-0 ml-1" @click="emit('registerAlta')" />
</Message>
<Message v-else-if="inProjectWithoutImss" severity="error" :closable="false" class="mb-3">
Está en obra sin alta IMSS.
<Button label="Registrar alta" link class="p-0 ml-1" @click="emit('registerAlta')" />
</Message>
<div class="document-progress">
<div>
<div class="section-title">Expediente obligatorio</div>
<div class="muted">{{ requiredDone }} de {{ requiredItems.length }} documentos válidos</div>
</div>
<Tag :value="`${requiredProgress}%`" :severity="requiredProgress === 100 ? 'success' : 'warn'" />
</div>
<ProgressBar :value="requiredProgress" :show-value="false" class="mt-2" />
<div class="document-checklist">
<div
v-for="item in requiredItems"
:key="item.code"
class="document-check"
:class="{ complete: item.present && item.valid }"
>
<i :class="item.present && item.valid ? 'pi pi-check-circle' : 'pi pi-circle'" />
<span>{{ item.label }}</span>
<small>{{ validityLabel(item.validity_status) }}</small>
</div>
</div>
<Panel header="Documentos opcionales" toggleable collapsed class="mt-3">
<div class="document-checklist">
<div
v-for="item in optionalItems"
:key="item.code"
class="document-check"
:class="{ complete: item.present && item.valid }"
>
<i :class="item.present && item.valid ? 'pi pi-check-circle' : 'pi pi-circle'" />
<span>{{ item.label }}</span>
</div>
</div>
</Panel>
<Fieldset legend="Subir o reemplazar documento" class="mt-3">
<FormField label="Tipo de documento">
<Select
v-model="documentType"
:options="documentTypes"
option-label="label"
option-value="code"
class="w-full"
/>
</FormField>
<FormField v-if="selectedPolicy?.requires_issued_at" label="Fecha de emisión" class="mt-2">
<DatePicker v-model="issuedAt" date-format="yy-mm-dd" show-icon class="w-full" />
</FormField>
<FormField v-if="selectedPolicy?.requires_expires_at" label="Vigencia / vencimiento" class="mt-2">
<DatePicker v-model="expiresAt" date-format="yy-mm-dd" show-icon class="w-full" />
</FormField>
<FileUpload
mode="basic"
:auto="true"
choose-label="Seleccionar archivo"
accept=".jpg,.jpeg,.png,.pdf,.webp"
custom-upload
class="mt-2"
:disabled="uploading || !canUpload"
@uploader="uploadDocument"
/>
<div class="muted mt-2">{{ uploadHint }}</div>
</Fieldset>
<Tabs v-model:value="docsTab" class="mt-3 document-list-tabs">
<TabList>
<Tab value="current">Vigentes ({{ currentDocuments.length }})</Tab>
<Tab value="history">Histórico ({{ historyDocuments.length }})</Tab>
</TabList>
<TabPanels>
<TabPanel value="current">
<DataTable :value="currentDocuments" size="small" empty-message="No hay documentos vigentes">
<Column header="Tipo">
<template #body="{ data }">{{ typeLabel(data.type_code) }}</template>
</Column>
<Column field="original_name" header="Archivo" />
<Column header="Vigencia">
<template #body="{ data }">
<Tag
:value="validityLabel(statusForDoc(data))"
:severity="validitySeverity(statusForDoc(data))"
/>
</template>
</Column>
<Column header="">
<template #body="{ data }">
<Button icon="pi pi-eye" text rounded size="small" aria-label="Ver documento" @click="openDocument(data)" />
</template>
</Column>
</DataTable>
</TabPanel>
<TabPanel value="history">
<DataTable :value="historyDocuments" size="small" empty-message="Sin versiones anteriores">
<Column header="Tipo">
<template #body="{ data }">{{ typeLabel(data.type_code) }}</template>
</Column>
<Column field="original_name" header="Archivo" />
<Column header="Fecha">
<template #body="{ data }">{{ formatDate(data.uploaded_at) }}</template>
</Column>
<Column header="">
<template #body="{ data }">
<Button icon="pi pi-eye" text rounded size="small" aria-label="Ver documento" @click="openDocument(data)" />
</template>
</Column>
</DataTable>
</TabPanel>
</TabPanels>
</Tabs>
<DocumentPreviewDialog
v-model="previewOpen"
:path="previewPath"
:name="previewName"
:mime="previewMime"
/>
</div>
</template>
<script setup lang="ts">
import FormField from "./FormField.vue";
import DocumentPreviewDialog from "~/components/DocumentPreviewDialog.vue";
import type { ChecklistItem, CompanyOption, ValidityStatus } from "~/types/padron";
type DocumentRow = {
id: number;
type_code: string;
original_name: string;
uploaded_at: string;
is_current: number;
mime?: string;
issued_at?: string | null;
expires_at?: string | null;
};
const props = defineProps<{
workerId: number;
documents: DocumentRow[];
checklist: ChecklistItem[];
companies?: CompanyOption[];
initialType?: string;
freshnessRequired?: boolean;
imssReady?: boolean;
inProjectWithoutImss?: boolean;
}>();
const emit = defineEmits<{ changed: []; registerAlta: [] }>();
const { api } = useApi();
const toast = useToast();
const documentType = ref(
props.initialType && props.initialType !== "alta_imss" && props.initialType !== "baja_imss"
? props.initialType
: "ine",
);
const uploading = ref(false);
const docsTab = ref("current");
const issuedAt = ref<Date | null>(null);
const expiresAt = ref<Date | null>(null);
const previewOpen = ref(false);
const previewPath = ref("");
const previewName = ref("");
const previewMime = ref<string | null>(null);
const documentTypes = computed(() =>
props.checklist
.filter((item) => item.code !== "alta_imss" && item.code !== "baja_imss")
.map((item) => ({
code: item.code,
label: item.label,
requires_issued_at: !!item.requires_issued_at,
requires_expires_at: !!item.requires_expires_at,
validity_mode: item.validity_mode,
})),
);
const selectedPolicy = computed(() => documentTypes.value.find((item) => item.code === documentType.value));
const requiredItems = computed(() => props.checklist.filter((item) => item.required));
const optionalItems = computed(() =>
props.checklist.filter((item) => !item.required && item.code !== "alta_imss" && item.code !== "baja_imss"),
);
const requiredDone = computed(() => requiredItems.value.filter((item) => item.present && item.valid !== false).length);
const requiredProgress = computed(() =>
requiredItems.value.length ? Math.round((requiredDone.value / requiredItems.value.length) * 100) : 0,
);
const currentDocuments = computed(() =>
props.documents.filter((doc) => doc.is_current && doc.type_code !== "alta_imss" && doc.type_code !== "baja_imss"),
);
const historyDocuments = computed(() =>
props.documents.filter((doc) => !doc.is_current && doc.type_code !== "alta_imss" && doc.type_code !== "baja_imss"),
);
const freshnessRequired = computed(() => !!props.freshnessRequired);
const imssReady = computed(() => !!props.imssReady);
const inProjectWithoutImss = computed(() => !!props.inProjectWithoutImss);
const canUpload = computed(() => {
if (selectedPolicy.value?.requires_issued_at && !issuedAt.value) return false;
if (selectedPolicy.value?.requires_expires_at && !expiresAt.value) return false;
return true;
});
const uploadHint = computed(() => {
if (selectedPolicy.value?.requires_issued_at) return "Indica la fecha de emisión del documento (máx. 3 meses si aplica alta/reingreso).";
if (selectedPolicy.value?.requires_expires_at) return "Indica la vigencia impresa en la INE.";
return "Al elegir el archivo se sube solo. El alta IMSS se registra con el botón del bloque IMSS.";
});
function typeLabel(code: string) {
return props.checklist.find((item) => item.code === code)?.label || code;
}
function statusForDoc(row: DocumentRow): ValidityStatus {
const item = props.checklist.find((entry) => entry.code === row.type_code);
return (item?.validity_status as ValidityStatus) || (row.is_current ? "ok" : "ok");
}
function validityLabel(status?: ValidityStatus | string) {
switch (status) {
case "ok":
return "Vigente";
case "missing":
return "Falta";
case "expired":
return "Caducado";
case "stale_for_alta":
return "Desactualizado para alta";
case "missing_dates":
return "Sin fecha";
default:
return status || "—";
}
}
function validitySeverity(status?: ValidityStatus | string) {
switch (status) {
case "ok":
return "success";
case "expired":
case "stale_for_alta":
return "danger";
case "missing":
case "missing_dates":
return "warn";
default:
return "secondary";
}
}
function formatDate(value: string) {
if (!value) return "—";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleDateString("es-MX", { day: "2-digit", month: "short", year: "numeric" });
}
function toIsoDate(value: Date | null) {
if (!value) return "";
const y = value.getFullYear();
const m = String(value.getMonth() + 1).padStart(2, "0");
const d = String(value.getDate()).padStart(2, "0");
return `${y}-${m}-${d}`;
}
async function uploadDocument(event: { files: File[] }) {
const file = event.files?.[0];
if (!file || uploading.value || !canUpload.value) return;
uploading.value = true;
try {
const body = new FormData();
body.append("type", documentType.value);
body.append("file", file);
if (issuedAt.value) body.append("issued_at", toIsoDate(issuedAt.value));
if (expiresAt.value) body.append("expires_at", toIsoDate(expiresAt.value));
await api(`/v1/workers/${props.workerId}/documents`, { method: "POST", body });
toast.add({ severity: "success", summary: "Documento cifrado y guardado", life: 2200 });
docsTab.value = "current";
issuedAt.value = null;
expiresAt.value = null;
emit("changed");
} catch (error: unknown) {
toast.add({
severity: "error",
summary: error instanceof Error ? error.message : "No se pudo subir el archivo",
life: 3500,
});
} finally {
uploading.value = false;
}
}
function openDocument(row: DocumentRow) {
previewPath.value = `/v1/workers/${props.workerId}/documents/${row.id}`;
previewName.value = row.original_name;
previewMime.value = row.mime || null;
previewOpen.value = true;
}
watch(() => props.initialType, (value) => {
if (value && value !== "alta_imss" && value !== "baja_imss") documentType.value = value;
});
watch(documentType, () => {
issuedAt.value = null;
expiresAt.value = null;
});
</script>

View file

@ -0,0 +1,132 @@
<template>
<Dialog v-model:visible="visibleModel" :header="form.id ? 'Editar persona' : 'Alta de persona'" modal :style="{ width: '860px' }" class="worker-form-dialog">
<div class="dialog-intro">
<div><div class="section-title">{{ form.id ? displayName : "Nuevo registro de padrón" }}</div><div class="muted">La persona vive en el padrón; el proyecto es una asignación.</div></div>
<Tag v-if="form.id" value="Datos maestros" severity="secondary" />
</div>
<Tabs v-model:value="tab">
<TabList>
<Tab value="identidad"><i class="pi pi-user" /> Identidad</Tab>
<Tab value="laboral"><i class="pi pi-briefcase" /> Alta laboral</Tab>
<Tab v-if="!form.id" value="asignacion"><i class="pi pi-building" /> Asignación inicial</Tab>
</TabList>
<TabPanels>
<TabPanel value="identidad">
<Fluid>
<div class="form-grid form-grid-compact">
<FormField label="Nombre" required :error="fieldErr.first_name"><InputText v-model="form.first_name" :invalid="!!fieldErr.first_name" /></FormField>
<FormField label="Segundo nombre"><InputText v-model="form.middle_name" /></FormField>
<FormField label="Apellido paterno" required :error="fieldErr.last_name_p"><InputText v-model="form.last_name_p" :invalid="!!fieldErr.last_name_p" /></FormField>
<FormField label="Apellido materno" required :error="fieldErr.last_name_m"><InputText v-model="form.last_name_m" :invalid="!!fieldErr.last_name_m" /></FormField>
<FormField label="CURP" required :error="fieldErr.curp"><InputText v-model="form.curp" maxlength="18" class="identifier-input" :invalid="!!fieldErr.curp" @blur="validateLive" /></FormField>
<FormField label="RFC" required :error="fieldErr.rfc"><InputText v-model="form.rfc" class="identifier-input" :invalid="!!fieldErr.rfc" @blur="validateLive" /></FormField>
<FormField label="NSS" required :error="fieldErr.nss"><InputMask v-model="form.nss" mask="99999999999" :invalid="!!fieldErr.nss" @blur="validateLive" /></FormField>
<FormField label="Tipo de sangre"><Select v-model="form.blood_type" :options="bloodTypes" show-clear /></FormField>
<FormField label="Teléfono" required :error="fieldErr.phone"><InputText v-model="form.phone" :invalid="!!fieldErr.phone" /></FormField>
<FormField label="Correo" required :error="fieldErr.email"><InputText v-model="form.email" class="no-caps" :invalid="!!fieldErr.email" /></FormField>
<FormField label="Dirección" required :error="fieldErr.address" class="span-2"><InputText v-model="form.address" :invalid="!!fieldErr.address" /></FormField>
</div>
</Fluid>
</TabPanel>
<TabPanel value="laboral">
<Fluid>
<Message severity="info" :closable="false" class="mb-3">
La empresa patrón se asigna al registrar el alta IMSS. Sin alta, la persona no pertenece a un patrón.
</Message>
<div class="form-grid form-grid-compact">
<FormField label="Cargo" required :error="fieldErr.position"><InputText v-model="form.position" :invalid="!!fieldErr.position" /></FormField>
<FormField label="Riesgo / barra de gafete" required :error="fieldErr.risk_code">
<Select v-model="form.risk_code" :options="riskChoices" option-label="display" option-value="code">
<template #option="{ option }"><div class="risk-option"><span class="risk-swatch" :style="{ backgroundColor: option.color }" /><span>{{ option.display }}</span></div></template>
</Select>
</FormField>
<FormField label="Tipo de trabajo"><SelectButton v-model="form.work_type" :options="workTypes" option-label="label" option-value="value" :allow-empty="false" /></FormField>
<FormField label="Jornal diario" required :error="fieldErr.daily_wage"><InputNumber v-model="form.daily_wage" mode="currency" currency="MXN" locale="es-MX" :min="0" /></FormField>
<FormField label="Gafete"><div class="toggle-field"><ToggleSwitch v-model="form.needs_badge" /><span>{{ form.needs_badge ? "Sí lleva gafete" : "No lleva gafete" }}</span></div></FormField>
</div>
</Fluid>
</TabPanel>
<TabPanel v-if="!form.id" value="asignacion">
<Message severity="info" :closable="false">La asignación es opcional. Solo aparecen proyectos activos; también puede dejar a la persona en “Sin asignar”.</Message>
<FormField label="Proyecto activo" class="mt-3"><Select v-model="form.project_id" :options="projects" option-label="name" option-value="id" placeholder="Sin asignar" show-clear class="w-full" /></FormField>
</TabPanel>
</TabPanels>
</Tabs>
<Message v-if="formError" severity="error" class="mt-3" :closable="false">{{ formError }}</Message>
<template #footer>
<Button label="Cancelar" severity="secondary" text @click="visibleModel = false" />
<Button label="Guardar" icon="pi pi-check" :loading="saving" @click="submit" />
</template>
</Dialog>
</template>
<script setup lang="ts">
import type { CompanyOption, ProjectOption, RiskOption, Worker, WorkerForm } from "~/types/padron";
import { emptyWorkerForm } from "~/types/padron";
import FormField from "./FormField.vue";
const props = defineProps<{
visible: boolean;
worker: Worker | null;
projects: ProjectOption[];
risks: RiskOption[];
companies: CompanyOption[];
saving?: boolean;
error?: string;
}>();
const emit = defineEmits<{ "update:visible": [value: boolean]; save: [form: WorkerForm] }>();
const { api } = useApi();
const { riskLevelLabel } = useUi();
const visibleModel = computed({ get: () => props.visible, set: (value) => emit("update:visible", value) });
const form = reactive<WorkerForm>(emptyWorkerForm());
const fieldErr = ref<Record<string, string>>({});
const liveError = ref("");
const tab = ref("identidad");
const formError = computed(() => props.error || liveError.value);
const displayName = computed(() => [form.first_name, form.middle_name, form.last_name_p, form.last_name_m].filter(Boolean).join(" "));
const workTypes = [{ label: "Normal", value: "N" }, { label: "Destajo", value: "D" }];
const bloodTypes = ["A+", "A-", "B+", "B-", "AB+", "AB-", "O+", "O-"];
const riskChoices = computed(() => props.risks.map((risk) => ({ ...risk, display: `${riskLevelLabel(risk.code)} · ${risk.label}` })));
async function validateLive() {
try {
const result = await api<{ errors: Record<string, string> }>("/v1/workers/validate", { method: "POST", json: { ...form, id: form.id || undefined } });
fieldErr.value = result.errors || {};
liveError.value = "";
} catch (error) {
liveError.value = error instanceof Error ? error.message : "No se pudo validar";
}
}
async function submit() {
await validateLive();
if (Object.keys(fieldErr.value).length) {
tab.value = ["first_name", "last_name_p", "last_name_m", "curp", "rfc", "nss", "phone", "email", "address"].some((key) => fieldErr.value[key]) ? "identidad" : "laboral";
liveError.value = "Revise los campos marcados.";
return;
}
emit("save", { ...form });
}
function hydrate() {
Object.assign(form, emptyWorkerForm());
if (props.worker) {
Object.assign(form, props.worker, {
middle_name: props.worker.middle_name || "",
blood_type: props.worker.blood_type || "",
needs_badge: Boolean(props.worker.needs_badge),
project_id: null,
});
} else {
form.hire_type = "";
}
if (!props.risks.some((risk) => risk.code === form.risk_code) && props.risks[0]) form.risk_code = props.risks[0].code;
fieldErr.value = {};
liveError.value = "";
tab.value = "identidad";
}
watch(() => [props.visible, props.worker?.id, props.risks.length, props.companies.length], () => { if (props.visible) hydrate(); });
</script>

View file

@ -0,0 +1,110 @@
<template>
<Dialog v-model:visible="visibleModel" header="Importar padrón desde Excel" modal :style="{ width: '820px' }">
<div class="import-layout">
<Message severity="info" :closable="false">Descargue la plantilla, complete la hoja PERSONAL y súbala. CURP, RFC y NSS se validan antes de guardar.</Message>
<div class="import-actions">
<Button label="Descargar plantilla" icon="pi pi-download" outlined :loading="templateLoading" @click="downloadTemplate" />
<FormField label="Proyecto activo opcional">
<Select v-model="projectId" :options="projects" option-label="name" option-value="id" placeholder="Sin asignación inicial" show-clear class="w-full" />
</FormField>
<FileUpload mode="basic" accept=".xlsx,.xls" :auto="true" choose-label="Seleccionar Excel" custom-upload :disabled="importing" @uploader="importFile" />
<div class="muted">Al elegir el Excel se importa solo.</div>
</div>
<ProgressBar v-if="importing" mode="indeterminate" style="height: 4px" />
<template v-if="report">
<div class="import-summary">
<div><strong>{{ report.inserted }}</strong><span>Insertados</span></div>
<div><strong>{{ report.existed }}</strong><span>Actualizados</span></div>
<div><strong>{{ report.errors }}</strong><span>Con error</span></div>
<div><strong>{{ report.photos }}</strong><span>Fotos</span></div>
</div>
<Panel v-if="report.existed_rows.length" header="Registros que ya existían" toggleable collapsed>
<DeskTableBar v-model="existedQ" placeholder="Buscar nombre o CURP">
<Chip :label="`${listedExisted.length} coincidencias`" icon="pi pi-users" />
</DeskTableBar>
<DataTable :value="listedExisted" size="small" class="mt-2">
<Column field="row" header="Fila" /><Column field="nombre" header="Nombre" /><Column field="curp" header="CURP" /><Column field="matched" header="Coincidencia" />
</DataTable>
</Panel>
<Panel v-if="report.error_rows.length" header="Errores de validación" toggleable>
<DeskTableBar v-model="errorQ" placeholder="Buscar nombre o CURP">
<Chip :label="`${listedErrors.length} errores`" icon="pi pi-exclamation-triangle" />
</DeskTableBar>
<DataTable :value="listedErrors" size="small" class="mt-2">
<Column field="row" header="Fila" /><Column field="nombre" header="Nombre" /><Column field="curp" header="CURP" />
<Column header="Detalle"><template #body="{ data }"><ul class="validation-list"><li v-for="message in data.messages" :key="message">{{ message }}</li></ul></template></Column>
</DataTable>
</Panel>
</template>
</div>
<template #footer><Button label="Cerrar" severity="secondary" text @click="visibleModel = false" /></template>
</Dialog>
</template>
<script setup lang="ts">
import type { ImportReport, ProjectOption } from "~/types/padron";
import FormField from "./FormField.vue";
const props = defineProps<{ visible: boolean; projects: ProjectOption[] }>();
const emit = defineEmits<{ "update:visible": [value: boolean]; imported: [] }>();
const visibleModel = computed({ get: () => props.visible, set: (value) => emit("update:visible", value) });
const { api, download } = useApi();
const toast = useToast();
const projectId = ref<number | null>(null);
const report = ref<ImportReport | null>(null);
const templateLoading = ref(false);
const importing = ref(false);
const existedQ = ref("");
const errorQ = ref("");
const listedExisted = computed(() => {
const rows = report.value?.existed_rows || [];
const q = existedQ.value.trim().toLowerCase();
if (!q) return rows;
return rows.filter((row) => [row.row, row.nombre, row.curp, row.matched].some((part) => String(part ?? "").toLowerCase().includes(q)));
});
const listedErrors = computed(() => {
const rows = report.value?.error_rows || [];
const q = errorQ.value.trim().toLowerCase();
if (!q) return rows;
return rows.filter((row) =>
[row.row, row.nombre, row.curp, ...(row.messages || [])].some((part) => String(part ?? "").toLowerCase().includes(q)),
);
});
async function downloadTemplate() {
templateLoading.value = true;
try {
await download("/v1/workers/import/template", "plantilla-padron-arctec.xlsx");
} catch (error) {
toast.add({ severity: "error", summary: error instanceof Error ? error.message : "No se pudo descargar", life: 3000 });
} finally {
templateLoading.value = false;
}
}
async function importFile(event: { files: File[] }) {
const file = event.files?.[0];
if (!file) return;
importing.value = true;
report.value = null;
try {
const body = new FormData();
body.append("file", file);
if (projectId.value) body.append("project_id", String(projectId.value));
report.value = await api<ImportReport>("/v1/workers/import", { method: "POST", body });
toast.add({
severity: report.value.errors ? "warn" : "success",
summary: `Insertados ${report.value.inserted}, actualizados ${report.value.existed}, errores ${report.value.errors}`,
life: 4000,
});
emit("imported");
} catch (error) {
toast.add({ severity: "error", summary: error instanceof Error ? error.message : "Falló la importación", life: 3500 });
} finally {
importing.value = false;
}
}
watch(() => props.visible, (visible) => {
if (visible) { report.value = null; projectId.value = null; }
});
</script>

View file

@ -0,0 +1,70 @@
export function useApi() {
async function api<T>(path: string, opts: RequestInit & { json?: unknown } = {}): Promise<T> {
const headers = new Headers(opts.headers);
if (opts.json !== undefined) headers.set("Content-Type", "application/json");
const res = await fetch(path, {
...opts,
headers,
credentials: "include",
body: opts.json !== undefined ? JSON.stringify(opts.json) : opts.body,
});
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }));
throw Object.assign(new Error(err.error || "Error"), { status: res.status, data: err });
}
const ct = res.headers.get("content-type") || "";
if (ct.includes("application/json")) return await res.json() as T;
return (await res.blob()) as T;
}
async function download(path: string, filename: string) {
const blob = await api<Blob>(path);
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
a.rel = "noopener";
document.body.appendChild(a);
a.click();
a.remove();
window.setTimeout(() => URL.revokeObjectURL(url), 2000);
}
return { api, download };
}
export const useAuth = () => {
const user = useState<{
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?: "platform_admin" | "tenant_admin" | "user";
realm?: "app" | "platform";
must_change_password?: boolean;
} | null>("auth-user", () => null);
const { api } = useApi();
async function fetchMe() {
try {
const r = await api<{ user: NonNullable<typeof user.value> }>("/v1/auth/me");
user.value = r.user;
} catch {
user.value = null;
}
}
async function login(username: string, password: string) {
const r = await api<{ user: NonNullable<typeof user.value> }>("/v1/auth/login", {
method: "POST",
json: { username, password },
});
user.value = r.user;
}
async function logout() {
await api("/v1/auth/logout", { method: "POST" });
user.value = null;
await navigateTo("/login");
}
return { user, fetchMe, login, logout };
};

View file

@ -0,0 +1,147 @@
import type { TreeNode } from "primevue/treenode";
export type RibbonItem = {
key: string;
label: string;
icon: string;
disabled?: boolean;
command?: () => void;
};
export type RibbonGroup = {
key: string;
label: string;
items: RibbonItem[];
};
export type DeskDetail = {
kind: "none" | "worker" | "project" | "budget";
id?: number;
doc?: string;
project?: Record<string, unknown>;
budgetItem?: Record<string, unknown>;
};
export type DeskStatusMessage = {
key: string;
severity: "danger" | "warn" | "info";
title: string;
detail?: string;
route?: string;
filter?: string;
treeKey?: string;
};
export type PadronViewFilter =
| "all"
| "with_imss"
| "without_imss"
| "in_project_without_imss"
| "imss_ready"
| "incomplete"
| "ready_gafete"
| "unassigned"
| "freshness";
/** @deprecated alias */
export type PadronImssFilter = PadronViewFilter;
export function useDesktop() {
const tabLabel = useState("desk-tab", () => "Inicio");
const ribbon = useState<RibbonGroup[]>("desk-ribbon", () => []);
const tree = useState<TreeNode[]>("desk-tree", () => []);
const treeKey = useState("desk-tree-key", () => "");
const treeTitle = useState("desk-tree-title", () => "Módulo");
const showTree = useState("desk-show-tree", () => true);
const showDetail = useState("desk-show-detail", () => true);
const detail = useState<DeskDetail>("desk-detail", () => ({ kind: "none" }));
const countLabel = useState("desk-count", () => "");
const detailRev = useState("desk-rev", () => 0);
const globalQ = useState("desk-global-q", () => "");
const onTreeSelect = useState<((key: string) => void) | null>("desk-on-tree", () => null);
const statusMessages = useState<DeskStatusMessage[]>("desk-status-messages", () => []);
const padronImssFilter = useState<PadronViewFilter>("padron-imss-filter", () => "all");
function setStatusMessages(messages: DeskStatusMessage[]) {
statusMessages.value = messages;
}
function clearStatusMessages() {
statusMessages.value = [];
}
function configure(opts: {
tab: string;
treeTitle?: string;
ribbon?: RibbonGroup[];
tree?: TreeNode[];
treeKey?: string;
showTree?: boolean;
showDetail?: boolean;
countLabel?: string;
onTreeSelect?: (key: string) => void;
}) {
tabLabel.value = opts.tab;
if (opts.treeTitle != null) treeTitle.value = opts.treeTitle;
if (opts.ribbon) ribbon.value = opts.ribbon;
if (opts.tree) tree.value = opts.tree;
if (opts.treeKey != null) treeKey.value = opts.treeKey;
if (opts.showTree != null) showTree.value = opts.showTree;
if (opts.showDetail != null) showDetail.value = opts.showDetail;
if (opts.countLabel != null) countLabel.value = opts.countLabel;
onTreeSelect.value = opts.onTreeSelect ?? null;
}
function selectTree(key: string) {
treeKey.value = key;
onTreeSelect.value?.(key);
}
function openWorker(id: number, doc?: string) {
detail.value = { kind: "worker", id, doc };
showDetail.value = true;
}
function openProject(row: Record<string, unknown> & { id: number }, doc?: string) {
detail.value = { kind: "project", id: row.id, project: row, doc };
showDetail.value = true;
}
function openBudgetItem(item: Record<string, unknown> & { id: number }) {
detail.value = { kind: "budget", id: item.id, budgetItem: item };
showDetail.value = true;
}
function clearDetail() {
detail.value = { kind: "none" };
}
function bumpDetail() {
detailRev.value += 1;
}
return {
tabLabel,
ribbon,
tree,
treeKey,
treeTitle,
showTree,
showDetail,
detail,
countLabel,
detailRev,
globalQ,
statusMessages,
padronImssFilter,
setStatusMessages,
clearStatusMessages,
configure,
selectTree,
openWorker,
openProject,
openBudgetItem,
clearDetail,
bumpDetail,
};
}

View file

@ -0,0 +1,11 @@
export function useTableSearch() {
const tableQ = ref("");
function matches(...parts: unknown[]) {
const q = tableQ.value.trim().toLowerCase();
if (!q) return true;
return parts.some((part) => String(part ?? "").toLowerCase().includes(q));
}
return { tableQ, matches };
}

View file

@ -0,0 +1,41 @@
const THEME_KEY = "panel-theme";
const LEGACY_KEY = "panel-login-theme";
function readStoredDark(): boolean {
if (!import.meta.client) return false;
const saved = localStorage.getItem(THEME_KEY) ?? localStorage.getItem(LEGACY_KEY);
return saved === "dark";
}
function writeStored(dark: boolean) {
const value = dark ? "dark" : "light";
localStorage.setItem(THEME_KEY, value);
localStorage.setItem(LEGACY_KEY, value);
}
function applyDom(dark: boolean) {
if (!import.meta.client) return;
document.documentElement.classList.toggle("dark", dark);
document.documentElement.style.colorScheme = dark ? "dark" : "light";
}
export function useTheme() {
const isDark = useState("panel-theme-dark", () => false);
function setDark(value: boolean) {
isDark.value = value;
applyDom(value);
if (import.meta.client) writeStored(value);
}
function toggle() {
setDark(!isDark.value);
}
function init() {
if (!import.meta.client) return;
setDark(readStoredDark());
}
return { isDark, setDark, toggle, init };
}

View file

@ -0,0 +1,157 @@
export function pipelineSeverity(status: string) {
switch (status) {
case "activo":
return "success";
case "listo_gafete":
return "info";
case "impreso":
return "warn";
case "baja":
return "danger";
default:
return "secondary";
}
}
export function riskLevel(code: string): "alto" | "medio" | "bajo" {
switch (code) {
case "rojo":
case "negro":
case "naranja":
case "alto":
return "alto";
case "amarillo":
case "medio":
return "medio";
default:
return "bajo";
}
}
export function riskLevelLabel(code: string) {
const labels = { alto: "Alto", medio: "Medio", bajo: "Bajo" };
return labels[riskLevel(code)];
}
export function riskLevelSeverity(code: string) {
switch (riskLevel(code)) {
case "alto":
return "danger";
case "medio":
return "warn";
default:
return "success";
}
}
export function hireSeverity(hire: string) {
const h = (hire || "").toUpperCase();
if (h === "FISICA") return "contrast";
if (h === "ARCOTEC") return "warn";
if (h === "ARCTEC" || h.startsWith("ARCT")) return "info";
return "secondary";
}
export function riskSeverity(code: string) {
switch (code) {
case "rojo":
return "danger";
case "amarillo":
case "naranja":
return "warn";
case "verde":
return "success";
case "azul":
return "info";
default:
return "secondary";
}
}
export function pipelineLabel(status: string) {
const labels: Record<string, string> = {
incompleto: "Falta expediente",
listo_gafete: "Listo gafete",
impreso: "Gafete impreso",
activo: "Activo en proyecto",
baja: "Baja",
};
return labels[status] || status;
}
export function gafeteLabel(status: string) {
switch (status) {
case "incompleto":
return "Sin gafete";
case "listo_gafete":
return "Por imprimir";
case "impreso":
case "activo":
return "Impreso";
default:
return "—";
}
}
export function gafeteSeverity(status: string) {
switch (status) {
case "incompleto":
return "danger";
case "listo_gafete":
return "warn";
case "impreso":
case "activo":
return "success";
default:
return "secondary";
}
}
export function projectStatusSeverity(status: string) {
switch (status) {
case "activo":
return "success";
case "pausado":
return "warn";
case "concluido":
return "info";
case "cancelado":
return "danger";
default:
return "secondary";
}
}
export function projectStatusLabel(status: string) {
const labels: Record<string, string> = {
activo: "Activo",
pausado: "Pausado",
concluido: "Concluido",
cancelado: "Cancelado",
};
return labels[status] || status;
}
export function initials(first?: string, last?: string) {
if (last) return `${first?.[0] ?? ""}${last?.[0] ?? ""}`.toUpperCase() || "?";
const parts = (first || "").trim().split(/\s+/).filter(Boolean);
if (parts.length >= 2) return `${parts[0][0]}${parts[1][0]}`.toUpperCase();
return (first || "?").slice(0, 2).toUpperCase();
}
export function useUi() {
return {
pipelineSeverity,
riskSeverity,
riskLevel,
riskLevelLabel,
riskLevelSeverity,
hireSeverity,
pipelineLabel,
gafeteLabel,
gafeteSeverity,
projectStatusSeverity,
projectStatusLabel,
initials,
};
}

View file

@ -0,0 +1,12 @@
<template>
<div class="auth-root">
<slot />
</div>
</template>
<style scoped>
.auth-root {
min-height: 100%;
height: 100%;
}
</style>

View file

@ -0,0 +1,179 @@
<template>
<div class="shell">
<Menubar class="menubar" :model="menu">
<template #start>
<button type="button" class="brand-lockup" aria-label="Ir al inicio" @click="navigateTo('/')">
<span class="brand-mark" aria-hidden="true">
<span /><span /><span /><span />
</span>
<span class="brand-word">{{ brand }}</span>
</button>
</template>
<template #item="{ item, props }">
<a v-bind="props.action" class="menubar-link" :class="{ 'is-active': isActive(String(item.to || '')) }">
<i v-if="item.icon" :class="item.icon" />
<span>{{ item.label }}</span>
</a>
</template>
<template #end>
<div class="topbar-tools">
<IconField class="desk-search desk-search--topbar">
<InputIcon class="pi pi-search" />
<InputText v-model="globalQ" placeholder="Buscar en el sistema..." @keyup.enter="goSearch" />
</IconField>
<Button icon="pi pi-bell" text rounded aria-label="Notificaciones" @click="showNotifications" />
<Button icon="pi pi-question-circle" text rounded aria-label="Ayuda" @click="showHelp" />
<button type="button" class="topbar-user-trigger" aria-label="Menú de usuario" @click="toggleProfile">
<Avatar :label="initials(user?.display_name)" shape="circle" />
<i class="pi pi-angle-down" aria-hidden="true" />
</button>
</div>
</template>
</Menubar>
<Menu ref="profileMenu" :model="profileItems" popup />
<AppRibbon />
<div class="desk-body">
<Splitter :key="`${showTree}-${showDetail}`" class="desk-split">
<SplitterPanel v-if="showTree" :size="18" :min-size="12" class="desk-pane">
<AppModuleTree />
</SplitterPanel>
<SplitterPanel
:size="showDetail && showTree ? 60 : showDetail || showTree ? 80 : 100"
:min-size="30"
class="desk-pane desk-center-pane"
>
<div class="desk-main">
<div class="desk-tabstrip">
<span class="desk-tab is-active">{{ tabLabel }}</span>
</div>
<div class="desk-page">
<slot />
</div>
</div>
</SplitterPanel>
<SplitterPanel v-if="showDetail" :size="20" :min-size="16" class="desk-pane">
<div class="desk-side desk-detail">
<div class="desk-side-title desk-detail-title">
<span>Detalles del registro</span>
<span class="desk-detail-title-actions" aria-hidden="true">
<i class="pi pi-ellipsis-v" />
<i class="pi pi-angle-down" />
</span>
</div>
<div class="desk-detail-scroll">
<WorkerFicha
v-if="detail.kind === 'worker'"
:worker-id="detail.id"
:doc="detail.doc"
@edit="editFromFicha"
/>
<ProjectFicha
v-else-if="detail.kind === 'project'"
:project="detail.project"
:doc="detail.doc"
@edit="editFromFicha"
/>
<BudgetFicha
v-else-if="detail.kind === 'budget'"
:item="detail.budgetItem"
@edit="editFromFicha"
/>
<div v-else class="detail-empty">
<i class="pi pi-file detail-empty-icon" />
<strong>Seleccione un registro</strong>
<span>La ficha aparecerá en este panel.</span>
</div>
</div>
</div>
</SplitterPanel>
</Splitter>
</div>
<AppStatusbar />
</div>
</template>
<script setup lang="ts">
const { user, logout } = useAuth();
const { initials } = useUi();
const route = useRoute();
const desk = useDesktop();
const toast = useToast();
const { isDark, toggle: toggleThemeMode } = useTheme();
const profileMenu = ref();
const { tabLabel, showTree, showDetail, detail, globalQ } = desk;
const profileItems = computed(() => [
{ label: user.value?.display_name || user.value?.username || "Usuario", disabled: true },
{ label: user.value?.company_name || user.value?.company_code || "Empresa", disabled: true },
{ separator: true },
{
label: isDark.value ? "Tema claro" : "Tema oscuro",
icon: isDark.value ? "pi pi-sun" : "pi pi-moon",
command: toggleTheme,
},
{ label: "Cerrar sesión", icon: "pi pi-sign-out", command: logout },
]);
const brand = computed(() => "PANELS");
const menu = [
{ label: "Inicio", icon: "pi pi-home", to: "/", command: () => navigateTo("/") },
{ label: "Padrón", icon: "pi pi-users", to: "/padron", command: () => navigateTo("/padron") },
{ label: "Proyectos", icon: "pi pi-building", to: "/proyectos", command: () => navigateTo("/proyectos") },
{ label: "Presupuesto", icon: "pi pi-calculator", to: "/presupuesto", command: () => navigateTo("/presupuesto") },
{ label: "Kanban", icon: "pi pi-th-large", to: "/kanban", command: () => navigateTo("/kanban") },
{ label: "Gafetes", icon: "pi pi-id-card", to: "/gafetes", command: () => navigateTo("/gafetes") },
{ label: "Nómina", icon: "pi pi-wallet", to: "/nomina", command: () => navigateTo("/nomina") },
{ label: "Configuración", icon: "pi pi-cog", to: "/configuracion", command: () => navigateTo("/configuracion") },
{ label: "Ayuda", icon: "pi pi-question-circle", command: showHelp },
];
function isActive(to: string) {
if (!to) return false;
if (to === "/") return route.path === "/";
return route.path === to || route.path.startsWith(`${to}/`);
}
function goSearch() {
navigateTo("/padron");
}
function showNotifications() {
toast.add({ severity: "info", summary: "Notificaciones", detail: "No hay notificaciones nuevas.", life: 2200 });
}
function showHelp() {
toast.add({ severity: "info", summary: `Ayuda de ${brand.value}`, detail: "Seleccione un módulo para consultar su flujo de trabajo.", life: 2800 });
}
function toggleTheme() {
toggleThemeMode();
toast.add({
severity: "success",
summary: "Tema",
detail: isDark.value ? "Tema oscuro activado." : "Tema claro activado.",
life: 1800,
});
}
function toggleProfile(event: Event) {
profileMenu.value?.toggle(event);
}
function editFromFicha() {
const edit = desk.ribbon.value.flatMap((g) => g.items).find((i) => i.key === "edit");
edit?.command?.();
}
watch(
() => route.path,
(path) => {
if (!path.startsWith("/padron") && desk.detail.value.kind === "worker") desk.clearDetail();
if (!path.startsWith("/proyectos") && desk.detail.value.kind === "project") desk.clearDetail();
if (!path.startsWith("/presupuesto") && desk.detail.value.kind === "budget") desk.clearDetail();
},
);
</script>

View file

@ -0,0 +1,37 @@
export default defineNuxtRouteMiddleware(async (to) => {
if (to.path === "/login") return;
const { user, fetchMe } = useAuth();
const { api } = useApi();
try {
if (!user.value) {
await Promise.race([
fetchMe(),
new Promise((_, reject) => setTimeout(() => reject(new Error("timeout")), 4000)),
]);
}
} catch {
user.value = null;
}
if (!user.value) return navigateTo("/login");
if (user.value.role === "platform_admin" && user.value.realm === "platform") {
user.value = null;
try {
await api("/v1/auth/logout", { method: "POST" });
} catch {
/* ignore */
}
if (import.meta.client) {
window.location.href = "http://localhost:3001/login";
return abortNavigation();
}
return navigateTo("/login");
}
if (user.value.must_change_password && to.path !== "/cambiar-password") {
return navigateTo("/cambiar-password");
}
if (!user.value.must_change_password && to.path === "/cambiar-password") {
return navigateTo("/");
}
});

29
web-panel/nginx.conf Normal file
View file

@ -0,0 +1,29 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
absolute_redirect off;
port_in_redirect on;
location /v1/ {
proxy_pass http://api:8000/v1/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Cookie $http_cookie;
proxy_pass_header Set-Cookie;
}
location / {
try_files $uri /index.html;
}
location = /favicon.png {
access_log off;
expires 7d;
}
}

34
web-panel/nuxt.config.ts Normal file
View file

@ -0,0 +1,34 @@
export default defineNuxtConfig({
ssr: false,
compatibilityDate: "2024-11-01",
devtools: { enabled: false },
modules: ["@primevue/nuxt-module"],
css: ["primeicons/primeicons.css", "~/assets/css/app.css", "~/assets/css/desktop-v2.css"],
app: {
head: {
title: "PANELS",
link: [
{ rel: "icon", type: "image/png", href: "/favicon.png" },
],
script: [
{
// Aplica el tema guardado antes del paint para evitar flash claro→oscuro.
innerHTML:
"(function(){try{var t=localStorage.getItem('panel-theme')||localStorage.getItem('panel-login-theme');if(t==='dark'){document.documentElement.classList.add('dark');document.documentElement.style.colorScheme='dark';}}catch(e){}})();",
type: "text/javascript",
tagPosition: "head",
},
],
},
},
primevue: {
// El plugin del módulo se genera aparte; el tema lo registra plugins/primevue.ts
// para no meter el preset Aura en runtimeConfig (no es serializable).
usePrimeVue: false,
},
nitro: {
devProxy: {
"/v1": { target: "http://127.0.0.1:8000/v1", changeOrigin: true },
},
},
});

10132
web-panel/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

20
web-panel/package.json Normal file
View file

@ -0,0 +1,20 @@
{
"name": "panel-obra-web-panel",
"type": "module",
"private": true,
"scripts": {
"dev": "nuxt dev --port 3000",
"build": "nuxt build",
"generate": "nuxt generate",
"preview": "nuxt preview"
},
"dependencies": {
"@primeuix/themes": "^1.2.3",
"@primevue/nuxt-module": "^4.3.7",
"nuxt": "^3.16.2",
"primeicons": "^7.0.0",
"primevue": "^4.3.7",
"vue": "^3.5.13",
"vue-router": "^4.5.0"
}
}

View file

@ -0,0 +1,111 @@
<template>
<div class="change-wrap">
<Card class="change-card">
<template #title>Cambiar contraseña</template>
<template #subtitle>Por seguridad debe definir una nueva contraseña antes de continuar.</template>
<template #content>
<form class="change-form" @submit.prevent="onSubmit">
<FloatLabel variant="on">
<Password
id="cur"
v-model="current"
:feedback="false"
toggle-mask
class="w-full"
input-class="w-full"
fluid
/>
<label for="cur">Contraseña temporal</label>
</FloatLabel>
<FloatLabel variant="on">
<Password
id="next"
v-model="next"
toggle-mask
class="w-full"
input-class="w-full"
fluid
/>
<label for="next">Nueva contraseña</label>
</FloatLabel>
<FloatLabel variant="on">
<Password
id="confirm"
v-model="confirm"
:feedback="false"
toggle-mask
class="w-full"
input-class="w-full"
fluid
/>
<label for="confirm">Confirmar</label>
</FloatLabel>
<Message v-if="error" severity="error" :closable="false">{{ error }}</Message>
<Button type="submit" label="Guardar y continuar" icon="pi pi-check" :loading="loading" />
</form>
</template>
</Card>
</div>
</template>
<script setup lang="ts">
definePageMeta({ layout: "auth" });
const { user, fetchMe } = useAuth();
const { api } = useApi();
const current = ref("");
const next = ref("");
const confirm = ref("");
const error = ref("");
const loading = ref(false);
async function onSubmit() {
error.value = "";
if (next.value !== confirm.value) {
error.value = "La confirmación no coincide";
return;
}
loading.value = true;
try {
const r = await api<{ user: NonNullable<typeof user.value> }>("/v1/auth/change-password", {
method: "POST",
json: { current_password: current.value, new_password: next.value },
});
user.value = r.user;
await fetchMe();
await navigateTo("/");
} catch (e: unknown) {
error.value = e instanceof Error ? e.message : "No se pudo cambiar";
} finally {
loading.value = false;
}
}
onMounted(async () => {
if (!user.value) await fetchMe();
if (!user.value) await navigateTo("/login");
else if (!user.value.must_change_password) await navigateTo("/");
});
</script>
<style scoped>
.change-wrap {
min-height: 100vh;
display: grid;
place-items: center;
padding: 1.5rem;
background: linear-gradient(180deg, #f5f7fa 0%, #e8edf3 100%);
}
.change-card {
width: min(420px, 100%);
}
.change-form {
display: flex;
flex-direction: column;
gap: 1.1rem;
}
:global(html.dark) .change-wrap {
background: linear-gradient(180deg, #0f172a 0%, #0b1220 100%);
}
</style>

View file

@ -0,0 +1,409 @@
<template>
<div class="list-page">
<DeskTableBar v-model="tableQ" :placeholder="searchPlaceholder">
<Chip v-if="tab === 'empresas'" :label="`${listedCompanies.length} empresas`" icon="pi pi-briefcase" />
<Chip v-if="tab === 'empresas'" :label="`${subCompanies} subempresas`" icon="pi pi-th-large" />
<Chip v-if="tab === 'riesgos'" :label="`${listedRisks.length} riesgos`" icon="pi pi-exclamation-triangle" />
<Chip v-if="tab === 'temas'" :label="`${listedThemes.length} temas`" icon="pi pi-palette" />
</DeskTableBar>
<Tabs v-model:value="tab">
<TabList>
<Tab value="empresas">Empresas</Tab>
<Tab value="riesgos">Riesgos</Tab>
<Tab value="temas">Temas de gafete</Tab>
</TabList>
<TabPanels>
<TabPanel value="empresas">
<Message severity="info" :closable="false">
La cuenta pertenece a <strong>{{ principal?.name || principal?.code || "la empresa principal" }}</strong>.
Capture razón social, RFC, régimen y registro patronal; se usarán en IMSS y trámites posteriores.
</Message>
<DataTable :value="listedCompanies" class="mt-3" data-key="id" paginator :rows="15" empty-message="No hay empresas registradas.">
<Column field="code" header="Código" />
<Column field="nombre_comercial" header="Nombre comercial">
<template #body="{ data }">{{ data.nombre_comercial || data.name }}</template>
</Column>
<Column field="razon_social" header="Razón social">
<template #body="{ data }">{{ data.razon_social || "—" }}</template>
</Column>
<Column field="rfc" header="RFC">
<template #body="{ data }"><span class="identifier">{{ data.rfc || "—" }}</span></template>
</Column>
<Column field="registro_patronal" header="Reg. patronal">
<template #body="{ data }"><span class="identifier">{{ data.registro_patronal || "—" }}</span></template>
</Column>
<Column field="regimen_fiscal" header="Régimen">
<template #body="{ data }">{{ regimenLabel(data.regimen_fiscal) }}</template>
</Column>
<Column header="Tipo">
<template #body="{ data }">
<Tag :value="data.kind === 'principal' ? 'Principal' : 'Subempresa'" :severity="data.kind === 'principal' ? 'info' : 'secondary'" />
</template>
</Column>
<Column header="Estatus">
<template #body="{ data }">
<Tag :value="data.status === 'activo' ? 'Activa' : 'Inactiva'" :severity="data.status === 'activo' ? 'success' : 'secondary'" />
</template>
</Column>
<Column header="" header-style="width: 8rem">
<template #body="{ data }">
<Button icon="pi pi-pencil" text rounded size="small" aria-label="Editar" @click="openEdit(data)" />
</template>
</Column>
</DataTable>
</TabPanel>
<TabPanel value="riesgos">
<DataTable :value="listedRisks" paginator :rows="15">
<Column field="code" header="Barra gafete" />
<Column field="label" header="Color" />
<Column header="Nivel">
<template #body="{ data }">
<Tag :value="riskLevelLabel(data.code)" :severity="riskLevelSeverity(data.code)" />
</template>
</Column>
<Column header="Muestra">
<template #body="{ data }">
<Tag :value="data.color" :severity="riskSeverity(data.code)" />
</template>
</Column>
</DataTable>
</TabPanel>
<TabPanel value="temas">
<DataTable :value="listedThemes" paginator :rows="15">
<Column field="id" header="ID" />
<Column field="name" header="Nombre" />
<Column header="Layout">
<template #body="{ data }">
<Chip :label="data.layout" icon="pi pi-id-card" />
</template>
</Column>
</DataTable>
</TabPanel>
</TabPanels>
</Tabs>
<Dialog
v-model:visible="showForm"
:header="editing ? 'Editar empresa' : 'Nueva subempresa'"
modal
class="company-form-dialog"
:style="{ width: 'min(760px, 96vw)' }"
>
<Message severity="secondary" :closable="false" class="mb-3">
Datos fiscales y patronales de la empresa. El nombre corto se usa en listados; la razón social y el RFC quedan para IMSS y facturación.
</Message>
<div class="company-form-grid">
<FormField label="Nombre comercial" required>
<InputText v-model="form.nombre_comercial" class="w-full" placeholder="Constructora Maya" />
</FormField>
<FormField label="Razón social" required>
<InputText v-model="form.razon_social" class="w-full" placeholder="Constructora Maya S.A. de C.V." />
</FormField>
<FormField label="Código {{ editing ? '' : '(opcional)' }}">
<InputText v-model="form.code" class="w-full identifier-input" placeholder="Se genera del nombre si lo deja vacío" maxlength="16" />
</FormField>
<FormField label="RFC">
<InputText v-model="form.rfc" class="w-full identifier-input" maxlength="13" placeholder="XAXX010101000" />
</FormField>
<FormField label="Régimen fiscal">
<Select
v-model="form.regimen_fiscal"
:options="regimenOpts"
option-label="label"
option-value="value"
placeholder="Seleccione régimen"
show-clear
class="w-full"
/>
</FormField>
<FormField label="Registro patronal IMSS">
<InputText v-model="form.registro_patronal" class="w-full identifier-input" placeholder="Número patronal" />
</FormField>
<FormField label="Clase de riesgo IMSS">
<Select
v-model="form.clase_riesgo"
:options="claseRiesgoOpts"
option-label="label"
option-value="value"
placeholder="I a V"
show-clear
class="w-full"
/>
</FormField>
<FormField label="Giro / actividad">
<InputText v-model="form.giro" class="w-full" placeholder="Construcción, servicios…" />
</FormField>
<FormField label="Domicilio fiscal" class="span-2">
<InputText v-model="form.domicilio_fiscal" class="w-full" placeholder="Calle, número, colonia" />
</FormField>
<FormField label="C.P.">
<InputText v-model="form.codigo_postal" class="w-full" maxlength="5" placeholder="77500" />
</FormField>
<FormField label="Ciudad">
<InputText v-model="form.ciudad" class="w-full" />
</FormField>
<FormField label="Estado">
<InputText v-model="form.estado" class="w-full" />
</FormField>
<FormField label="Teléfono">
<InputText v-model="form.telefono" class="w-full" />
</FormField>
<FormField label="Correo">
<InputText v-model="form.email" class="w-full no-caps" />
</FormField>
<FormField label="Representante legal" class="span-2">
<InputText v-model="form.representante_legal" class="w-full" />
</FormField>
<FormField v-if="editing && editing.kind !== 'principal'" label="Estatus">
<Select v-model="form.status" :options="statusOpts" option-label="label" option-value="value" class="w-full" />
</FormField>
</div>
<Message v-if="formError" severity="error" class="mt-3" :closable="false">{{ formError }}</Message>
<template #footer>
<Button label="Cancelar" severity="secondary" text @click="showForm = false" />
<Button :label="editing ? 'Guardar' : 'Crear'" icon="pi pi-check" :loading="saving" @click="save" />
</template>
</Dialog>
</div>
</template>
<script setup lang="ts">
import FormField from "~/components/padron/FormField.vue";
import type { CompanyOption } from "~/types/padron";
const emptyForm = () => ({
nombre_comercial: "",
razon_social: "",
code: "",
status: "activo",
registro_patronal: "",
rfc: "",
regimen_fiscal: "",
clase_riesgo: "",
domicilio_fiscal: "",
codigo_postal: "",
ciudad: "",
estado: "",
telefono: "",
email: "",
representante_legal: "",
giro: "",
});
const { riskSeverity, riskLevelLabel, riskLevelSeverity } = useUi();
const desk = useDesktop();
const { api } = useApi();
const toast = useToast();
const tab = ref("empresas");
const risks = ref<Record<string, string>[]>([]);
const themes = ref<Record<string, string>[]>([]);
const companies = ref<CompanyOption[]>([]);
const showForm = ref(false);
const saving = ref(false);
const formError = ref("");
const editing = ref<CompanyOption | null>(null);
const form = reactive(emptyForm());
const statusOpts = [
{ label: "Activa", value: "activo" },
{ label: "Inactiva", value: "inactivo" },
];
const regimenOpts = [
{ value: "601", label: "601 · General de Ley Personas Morales" },
{ value: "603", label: "603 · Personas Morales con Fines no Lucrativos" },
{ value: "605", label: "605 · Sueldos y Salarios" },
{ value: "606", label: "606 · Arrendamiento" },
{ value: "612", label: "612 · Actividades Empresariales y Profesionales" },
{ value: "621", label: "621 · Incorporación Fiscal" },
{ value: "625", label: "625 · Plataformas Tecnológicas" },
{ value: "626", label: "626 · Régimen Simplificado de Confianza" },
];
const claseRiesgoOpts = [
{ value: "I", label: "Clase I" },
{ value: "II", label: "Clase II" },
{ value: "III", label: "Clase III" },
{ value: "IV", label: "Clase IV" },
{ value: "V", label: "Clase V" },
];
const principal = computed(() => companies.value.find((company) => company.kind === "principal"));
const { tableQ, matches } = useTableSearch();
const searchPlaceholder = computed(() =>
tab.value === "riesgos" ? "Buscar riesgo o color" : tab.value === "temas" ? "Buscar tema" : "Buscar empresa, RFC o código",
);
const listedCompanies = computed(() =>
companies.value.filter((company) =>
matches(
company.code,
company.name,
company.nombre_comercial,
company.razon_social,
company.rfc,
company.registro_patronal,
company.regimen_fiscal,
company.kind,
company.parent_name,
company.parent_code,
company.status,
),
),
);
const subCompanies = computed(() => listedCompanies.value.filter((company) => company.kind !== "principal").length);
const listedRisks = computed(() => risks.value.filter((risk) => matches(risk.code, risk.label, risk.color)));
const listedThemes = computed(() => themes.value.filter((theme) => matches(theme.id, theme.name, theme.layout)));
function regimenLabel(code?: string | null) {
if (!code) return "—";
return regimenOpts.find((item) => item.value === code)?.label || code;
}
function resetForm() {
Object.assign(form, emptyForm());
}
async function load() {
const c = await api<{
risks: Record<string, string>[];
themes: Record<string, string>[];
companies: CompanyOption[];
}>("/v1/catalogs");
risks.value = c.risks;
themes.value = c.themes;
companies.value = c.companies || [];
syncChrome();
}
function openNew() {
editing.value = null;
resetForm();
formError.value = "";
showForm.value = true;
}
function openEdit(company: CompanyOption) {
editing.value = company;
resetForm();
form.nombre_comercial = company.nombre_comercial || company.name || "";
form.razon_social = company.razon_social || company.name || "";
form.code = company.code;
form.status = company.status;
form.registro_patronal = company.registro_patronal || "";
form.rfc = company.rfc || "";
form.regimen_fiscal = company.regimen_fiscal || "";
form.clase_riesgo = company.clase_riesgo || "";
form.domicilio_fiscal = company.domicilio_fiscal || "";
form.codigo_postal = company.codigo_postal || "";
form.ciudad = company.ciudad || "";
form.estado = company.estado || "";
form.telefono = company.telefono || "";
form.email = company.email || "";
form.representante_legal = company.representante_legal || "";
form.giro = company.giro || "";
formError.value = "";
showForm.value = true;
}
function payload() {
return {
name: form.nombre_comercial || form.razon_social,
nombre_comercial: form.nombre_comercial,
razon_social: form.razon_social,
code: form.code || undefined,
registro_patronal: form.registro_patronal,
rfc: form.rfc,
regimen_fiscal: form.regimen_fiscal,
clase_riesgo: form.clase_riesgo,
domicilio_fiscal: form.domicilio_fiscal,
codigo_postal: form.codigo_postal,
ciudad: form.ciudad,
estado: form.estado,
telefono: form.telefono,
email: form.email,
representante_legal: form.representante_legal,
giro: form.giro,
};
}
async function save() {
if (!form.nombre_comercial.trim() && !form.razon_social.trim()) {
formError.value = "Indique nombre comercial o razón social";
return;
}
saving.value = true;
formError.value = "";
try {
if (editing.value) {
await api(`/v1/companies/${editing.value.id}`, {
method: "PATCH",
json: {
...payload(),
status: editing.value.kind === "principal" ? "activo" : form.status,
},
});
toast.add({ severity: "success", summary: "Empresa actualizada", life: 2200 });
} else {
await api("/v1/companies", { method: "POST", json: payload() });
toast.add({ severity: "success", summary: "Subempresa creada", life: 2200 });
}
showForm.value = false;
await load();
} catch (error) {
const detail = error as { data?: { error?: string } };
formError.value = detail.data?.error || (error instanceof Error ? error.message : "No se pudo guardar");
} finally {
saving.value = false;
}
}
function syncChrome() {
desk.configure({
tab: "Configuración",
treeTitle: "Catálogos",
showTree: true,
showDetail: false,
treeKey: tab.value,
onTreeSelect: (key) => {
if (key === "empresas" || key === "riesgos" || key === "temas") tab.value = key;
},
tree: [
{ key: "empresas", label: "Empresas", icon: "pi pi-briefcase", data: { count: companies.value.length } },
{ key: "riesgos", label: "Riesgos", icon: "pi pi-exclamation-triangle", data: { count: risks.value.length } },
{ key: "temas", label: "Temas de gafete", icon: "pi pi-palette", data: { count: themes.value.length } },
],
ribbon: [
{
key: "acciones",
label: "Acciones",
items: [
{ key: "alta", label: "Nueva subempresa", icon: "pi pi-plus", disabled: tab.value !== "empresas", command: openNew },
],
},
{
key: "vista",
label: "Vista",
items: [{ key: "refresh", label: "Actualizar", icon: "pi pi-refresh", command: load }],
},
],
});
}
onMounted(load);
watch(tab, (v) => {
desk.treeKey.value = v;
syncChrome();
});
</script>
<style scoped>
.company-form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px 12px;
}
.company-form-grid :deep(.form-field.span-2),
.company-form-grid :deep(.span-2) {
grid-column: 1 / -1;
}
@media (max-width: 700px) {
.company-form-grid {
grid-template-columns: 1fr;
}
}
</style>

343
web-panel/pages/gafetes.vue Normal file
View file

@ -0,0 +1,343 @@
<template>
<div>
<Toolbar>
<template #start>
<Select
v-model="workerId"
:options="workerOptions"
option-label="label"
option-value="id"
placeholder="Persona"
@change="onWorker"
/>
</template>
<template #end>
<SelectButton v-model="view" :options="viewOpts" option-label="label" option-value="value" />
</template>
</Toolbar>
<Tabs value="preview" class="mt-3">
<TabList>
<Tab value="preview">Vista previa</Tab>
<Tab value="jobs">Trabajos PDF</Tab>
</TabList>
<TabPanels>
<TabPanel value="preview">
<Toolbar>
<template #start>
<SelectButton
v-model="riskCode"
:options="risks"
option-label="label"
option-value="code"
:allow-empty="false"
/>
</template>
</Toolbar>
<Message severity="secondary" :closable="false" class="mt-3 mb-3">
El reverso en el PDF va girado 180° para el doblez de la carta. En “Hoja” se ve como sale a impresión.
</Message>
<div v-if="view === 'cards'" class="badge-pair">
<div>
<Tag value="Frente" class="mb-2" />
<BadgeCard v-bind="cardProps" side="front" />
</div>
<div>
<Tag value="Reverso (legible)" severity="info" class="mb-2" />
<BadgeCard v-bind="cardProps" side="back" />
</div>
<div>
<Tag value="Reverso impreso" severity="warn" class="mb-2" />
<BadgeCard v-bind="cardProps" side="back" rotated />
</div>
</div>
<div v-else class="letter-wrap">
<div class="letter-caption muted">Carta 11 × 8.5 in · 4 arriba (frente) · 4 abajo (reverso 180°)</div>
<div class="letter-sheet">
<div class="letter-row">
<BadgeCard v-for="n in 4" :key="'f'+n" v-bind="cardProps" side="front" fluid />
</div>
<div class="letter-row">
<BadgeCard v-for="n in 4" :key="'b'+n" v-bind="cardProps" side="back" rotated fluid />
</div>
</div>
</div>
</TabPanel>
<TabPanel value="jobs">
<div class="list-page">
<DeskTableBar v-model="tableQ" placeholder="Buscar trabajo, proyecto o fecha">
<Chip :label="`${listedJobs.length} trabajos`" icon="pi pi-file-pdf" />
<Chip :label="`${pendingJobs} por entregar`" icon="pi pi-clock" />
</DeskTableBar>
<DataTable :value="listedJobs" context-menu paginator :rows="15" :rows-per-page-options="[15, 25, 50, 100]" empty-message="Aún no hay jobs" @row-contextmenu="onCtx">
<Column field="id" header="#" />
<Column field="project_name" header="Proyecto" />
<Column field="created_at" header="Fecha" />
<Column header="Personas">
<template #body="{ data }"><Badge :value="data.people" /></template>
</Column>
<Column header="Entregados">
<template #body="{ data }">
<Tag :value="String(data.delivered)" :severity="data.delivered ? 'success' : 'secondary'" />
</template>
</Column>
<Column header="">
<template #body="{ data }">
<ButtonGroup>
<Button label="PDF" icon="pi pi-file-pdf" size="small" outlined @click="dl(data.id)" />
<Button label="Entrega" icon="pi pi-check" size="small" @click="openPeople(data.id)" />
</ButtonGroup>
</template>
</Column>
</DataTable>
</div>
</TabPanel>
</TabPanels>
</Tabs>
<ContextMenu ref="cm" :model="[{ label: 'Descargar PDF', icon: 'pi pi-download', command: () => ctx && dl(ctx.id) }]" />
<Dialog v-model:visible="showPeople" header="Control de entrega" modal :style="{ width: '640px' }">
<DeskTableBar v-model="peopleQ" placeholder="Buscar persona">
<Chip :label="`${listedPeople.length} personas`" icon="pi pi-users" />
</DeskTableBar>
<DataTable :value="listedPeople" class="mt-3">
<Column field="last_name_p" header="Apellido" />
<Column field="first_name" header="Nombre" />
<Column header="Entregado">
<template #body="{ data }">
<ToggleSwitch :model-value="!!data.delivered" @update:model-value="(v: boolean) => mark(data, v)" />
</template>
</Column>
</DataTable>
</Dialog>
</div>
</template>
<script setup lang="ts">
type Worker = {
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_code: string;
risk_color: string;
risk_text?: string;
pipeline_status?: string;
};
const SAMPLE: Worker = {
id: 0,
first_name: "Juan",
last_name_p: "Pérez",
last_name_m: "López",
curp: "PELJ850101HDFRRN09",
nss: "12345678901",
blood_type: "O+",
position: "Ayudante general",
risk_code: "rojo",
risk_color: "#A20000",
risk_text: "#FFFFFF",
};
const { api, download } = useApi();
const desk = useDesktop();
const toast = useToast();
const projects = ref<{ id: number; name: string; code?: string }[]>([]);
const projectId = ref<number | null>(null);
const workers = ref<Worker[]>([]);
const workerId = ref(0);
const risks = ref<{ code: string; label: string; color: string; text_color: string }[]>([]);
const riskCode = ref("rojo");
const view = ref<"cards" | "sheet">("cards");
const viewOpts = [
{ label: "Frente y reverso", value: "cards" },
{ label: "Hoja carta", value: "sheet" },
];
const jobs = ref<Record<string, unknown>[]>([]);
const cm = ref();
const ctx = ref<{ id: number } | null>(null);
const showPeople = ref(false);
const people = ref<Record<string, unknown>[]>([]);
const jobId = ref(0);
const { tableQ, matches } = useTableSearch();
const peopleQ = ref("");
const listedJobs = computed(() =>
jobs.value.filter((job) => matches(job.id, job.project_name, job.created_at, job.people, job.delivered)),
);
const pendingJobs = computed(() => listedJobs.value.filter((job) => !job.delivered).length);
const listedPeople = computed(() => {
const q = peopleQ.value.trim().toLowerCase();
if (!q) return people.value;
return people.value.filter((row) =>
[row.last_name_p, row.first_name].some((part) => String(part ?? "").toLowerCase().includes(q)),
);
});
const workerOptions = computed(() => [
{ id: 0, label: "Muestra de diseño" },
...workers.value.map((w) => ({ id: w.id, label: `${w.last_name_p} ${w.first_name}` })),
]);
const project = computed(() => projects.value.find((p) => p.id === projectId.value));
const selected = computed(() => workers.value.find((w) => w.id === workerId.value) || SAMPLE);
const risk = computed(() => risks.value.find((r) => r.code === riskCode.value));
const cardProps = computed(() => {
const w = selected.value;
const r = risk.value;
return {
firstName: w.first_name.toUpperCase(),
lastName: w.last_name_p.toUpperCase(),
full: [w.first_name, w.middle_name, w.last_name_p, w.last_name_m].filter(Boolean).join(" ").toUpperCase(),
position: w.position.toUpperCase(),
curp: w.curp,
nss: w.nss,
blood: (w.blood_type || "").toUpperCase(),
riskColor: r?.color || w.risk_color || "#A20000",
riskText: r?.text_color || w.risk_text || "#FFFFFF",
projectName: (project.value?.name || "Zendala Cancún").toUpperCase(),
projectCode: (project.value?.code || "PRY-0001").toUpperCase(),
photoSrc: w.id ? `/v1/workers/${w.id}/photo` : "",
qrSrc: `/v1/badge-qr?curp=${encodeURIComponent(w.curp)}`,
};
});
function onCtx(e: { originalEvent: Event; data: { id: number } }) {
ctx.value = e.data;
cm.value.show(e.originalEvent);
}
function onWorker() {
const w = workers.value.find((x) => x.id === workerId.value);
if (w?.risk_code) riskCode.value = w.risk_code;
}
async function onProject() {
workerId.value = 0;
await loadWorkers();
}
async function loadWorkers() {
if (!projectId.value) {
workers.value = [];
return;
}
const w = await api<{ workers: Worker[] }>(`/v1/workers?status=activo&project_id=${projectId.value}`);
workers.value = w.workers;
}
async function load() {
const [p, j, c] = await Promise.all([
api<{ projects: { id: number; name: string; code?: string }[] }>("/v1/projects?status=activo"),
api<{ jobs: Record<string, unknown>[] }>("/v1/badge-jobs"),
api<{ risks: { code: string; label: string; color: string; text_color: string }[] }>("/v1/catalogs"),
]);
projects.value = p.projects;
if (!projectId.value && p.projects[0]) projectId.value = p.projects[0].id;
jobs.value = j.jobs;
risks.value = c.risks;
if (c.risks[0] && !risks.value.some((r) => r.code === riskCode.value)) riskCode.value = c.risks[0].code;
await loadWorkers();
syncChrome();
}
function syncChrome() {
const ready = workers.value.filter((w) => w.pipeline_status === "listo_gafete").length;
const messages = [];
if (ready > 0) {
messages.push({
key: "gaf_listos",
severity: "warn" as const,
title: `${ready} personas listas para gafete`,
detail: "Selecciona persona y genera PDF de impresión.",
route: "/gafetes",
});
}
if (pendingJobs.value > 0) {
messages.push({
key: "gaf_pendientes",
severity: "info" as const,
title: `${pendingJobs.value} trabajos PDF por entregar`,
detail: "Marca entrega cuando el gafete llegue a obra.",
route: "/gafetes",
});
}
desk.setStatusMessages(messages);
desk.configure({
tab: "Gafetes",
treeTitle: "Proyectos",
showTree: true,
showDetail: false,
treeKey: projectId.value ? `p:${projectId.value}` : "",
countLabel: project.value ? String(project.value.name) : "",
onTreeSelect: (key) => {
if (!key.startsWith("p:")) return;
projectId.value = Number(key.slice(2));
onProject();
},
tree: projects.value.map((p) => ({
key: `p:${p.id}`,
label: p.name,
icon: "pi pi-folder",
})),
ribbon: [
{
key: "acciones",
label: "Gafete",
items: [{ key: "print", label: "Generar", icon: "pi pi-print", command: generate }],
},
{
key: "vista",
label: "Vista",
items: [{ key: "refresh", label: "Actualizar", icon: "pi pi-refresh", command: load }],
},
],
});
}
async function generate() {
if (!projectId.value) return;
const r = await api<{ id: number; count: number }>(`/v1/projects/${projectId.value}/badge-jobs`, {
method: "POST",
json: {},
});
toast.add({ severity: "success", summary: `PDF ${r.id} (${r.count} gafetes)`, life: 2500 });
await load();
dl(r.id);
}
function dl(id: number) {
download(`/v1/badge-jobs/${id}/pdf`, `gafetes-${id}.pdf`).then(() => {
toast.add({ severity: "success", summary: `Descargado gafetes-${id}.pdf`, detail: "Ábrelo con el visor de PDF del equipo", life: 3500 });
}).catch((e: unknown) => {
toast.add({ severity: "error", summary: e instanceof Error ? e.message : "No se pudo descargar el PDF", life: 3500 });
});
}
async function openPeople(id: number) {
jobId.value = id;
const r = await api<{ people: Record<string, unknown>[] }>(`/v1/badge-jobs/${id}`);
people.value = r.people;
showPeople.value = true;
}
async function mark(row: Record<string, unknown>, delivered: boolean) {
await api(`/v1/badge-jobs/${jobId.value}/people/${row.worker_id}`, {
method: "PATCH",
json: { delivered },
});
await openPeople(jobId.value);
await load();
}
onMounted(load);
onBeforeUnmount(() => desk.clearStatusMessages());
</script>

323
web-panel/pages/index.vue Normal file
View file

@ -0,0 +1,323 @@
<template>
<div>
<div class="kpi-grid">
<Card v-for="s in stats" :key="s.label">
<template #content>
<div class="flex align-items-center gap-2" style="justify-content:space-between">
<Avatar :icon="s.icon" shape="circle" :style="{ background: s.bg, color: '#fff' }" />
<Tag :value="s.hint" :severity="s.severity" />
</div>
<div class="kpi-value mt-3">{{ s.value }}</div>
<div class="muted">{{ s.label }}</div>
</template>
</Card>
</div>
<Panel header="Flujo de expediente" class="mt-3" toggleable>
<MeterGroup :value="meter" label-position="start" />
</Panel>
<div class="home-split">
<div class="list-page">
<DeskTableBar v-model="tableQ" placeholder="Buscar persona o cargo">
<Chip :label="`${listedReady.length} listos para gafete`" icon="pi pi-id-card" />
</DeskTableBar>
<DataTable :value="listedReady" paginator :rows="6" :rows-per-page-options="[6, 12, 25]" empty-message="Nadie en listo gafete">
<Column header="Persona">
<template #body="{ data }">
<div class="person-cell">
<Avatar :label="initials(data.first_name, data.last_name_p)" shape="circle" size="small" />
<span>{{ data.last_name_p }} {{ data.first_name }}</span>
</div>
</template>
</Column>
<Column field="position" header="Cargo" />
<Column header="Riesgo">
<template #body="{ data }">
<Tag :value="riskLevelLabel(data.risk_code)" :severity="riskLevelSeverity(data.risk_code)" />
</template>
</Column>
</DataTable>
</div>
<Panel header="Atajos">
<div style="display:flex;flex-direction:column;gap:10px">
<Button label="Padrón completo" icon="pi pi-users" outlined @click="navigateTo('/padron')" />
<Button label="Personal por proyecto" icon="pi pi-th-large" outlined @click="navigateTo('/kanban')" />
<Button label="Nómina de la semana" icon="pi pi-wallet" outlined @click="navigateTo('/nomina')" />
<Message severity="info" :closable="false">Clic derecho en tablas para el menú de expediente, baja y documentos.</Message>
</div>
</Panel>
</div>
</div>
</template>
<script setup lang="ts">
import type { DeskStatusMessage } from "~/composables/useDesktop";
const { initials, riskLevelLabel, riskLevelSeverity, riskLevel } = useUi();
const desk = useDesktop();
type Worker = {
first_name: string;
last_name_p: string;
status: string;
pipeline_status: string;
position: string;
risk_code: string;
risk_color: string;
imss_status?: string;
imss_ready?: boolean;
in_project_without_imss?: boolean;
freshness_required?: boolean;
loan_balance?: number;
project_ids?: string | null;
missing_docs?: number;
};
type ProjectRow = {
status: string;
missing_docs?: number;
};
type JobRow = {
delivered?: number | boolean;
};
type PeriodRow = {
status?: string;
};
const { api } = useApi();
const workers = ref<Worker[]>([]);
const stats = ref([
{ label: "Activos", value: "—", icon: "pi pi-users", bg: "#0f2744", hint: "Alta", severity: "success" },
{ label: "Proyectos", value: "—", icon: "pi pi-building", bg: "#1c4a7a", hint: "Proyectos", severity: "info" },
{ label: "Listos gafete", value: "—", icon: "pi pi-id-card", bg: "#c9a227", hint: "Imprimir", severity: "warn" },
{ label: "Jobs PDF", value: "—", icon: "pi pi-file-pdf", bg: "#a20000", hint: "Archivo", severity: "danger" },
]);
const meter = ref<{ label: string; color: string; value: number }[]>([]);
const { tableQ, matches } = useTableSearch();
const listedReady = computed(() =>
workers.value
.filter((w) => w.pipeline_status === "listo_gafete")
.filter((w) => matches(w.first_name, w.last_name_p, w.position, w.risk_code)),
);
function hasProject(worker: Worker) {
return String(worker.project_ids || "")
.split(",")
.map((part) => Number(part.trim()))
.some((id) => id > 0);
}
function buildHomeAlerts(input: {
workers: Worker[];
projects: ProjectRow[];
jobs: JobRow[];
periods: PeriodRow[];
imssReady: number;
}): DeskStatusMessage[] {
const active = input.workers.filter((w) => w.status === "activo" && w.pipeline_status !== "baja");
const sinImssObra = active.filter((w) => w.in_project_without_imss).length;
const incompletos = active.filter((w) => w.pipeline_status === "incompleto" || Number(w.missing_docs) > 0).length;
const freshness = active.filter((w) => w.freshness_required).length;
const sinAsignar = active.filter((w) => !hasProject(w)).length;
const listos = active.filter((w) => w.pipeline_status === "listo_gafete").length;
const sinImss = active.filter((w) => w.imss_status !== "alta").length;
const prestamos = active.filter((w) => Number(w.loan_balance) > 0).length;
const riesgoAlto = active.filter((w) => riskLevel(w.risk_code) === "alto").length;
const proyDocs = input.projects.filter((p) => Number(p.missing_docs) > 0).length;
const proyPausados = input.projects.filter((p) => p.status === "pausado").length;
const jobsPendientes = input.jobs.filter((j) => !j.delivered).length;
const periodosAbiertos = input.periods.filter((p) => p.status !== "closed").length;
const messages: DeskStatusMessage[] = [];
if (sinImssObra > 0) {
messages.push({
key: "home_imss_obra",
severity: "danger",
title: `${sinImssObra} en obra sin alta IMSS`,
detail: "Personal en proyecto sin movimiento de alta. Prioridad operativa.",
route: "/padron",
filter: "in_project_without_imss",
});
}
if (incompletos > 0) {
messages.push({
key: "home_expediente",
severity: "warn",
title: `${incompletos} con expediente incompleto`,
detail: "Documentos obligatorios faltantes o inválidos en el padrón.",
route: "/padron",
filter: "incomplete",
});
}
if (freshness > 0) {
messages.push({
key: "home_frescos",
severity: "warn",
title: `${freshness} con documentos por refrescar`,
detail: "Reingreso o alta pendiente: constancias con más de 3 meses.",
route: "/padron",
filter: "freshness",
});
}
if (proyDocs > 0) {
messages.push({
key: "home_proy_docs",
severity: "warn",
title: `${proyDocs} obras con expediente pendiente`,
detail: "Proyectos activos o pausados sin documentos de obra completos.",
route: "/proyectos",
treeKey: "activo",
});
}
if (riesgoAlto > 0) {
messages.push({
key: "home_riesgo",
severity: "warn",
title: `${riesgoAlto} con riesgo alto`,
detail: "Personal con barra de gafete de riesgo alto.",
route: "/kanban",
});
}
if (listos > 0) {
messages.push({
key: "home_gafete",
severity: "info",
title: `${listos} listos para gafete`,
detail: "Pueden pasar a impresión en el módulo de gafetes.",
route: "/gafetes",
});
}
if (input.imssReady > 0) {
messages.push({
key: "home_imss_ready",
severity: "info",
title: `${input.imssReady} listos para alta IMSS`,
detail: "Expediente completo; falta registrar el alta con el patrón.",
route: "/padron",
filter: "imss_ready",
});
}
if (sinAsignar > 0) {
messages.push({
key: "home_sin_asignar",
severity: "info",
title: `${sinAsignar} sin asignar a obra`,
detail: "Activos en padrón sin proyecto. Revisa kanban o padrón.",
route: "/padron",
filter: "unassigned",
treeKey: "unassigned",
});
}
if (sinImss > 0 && sinImssObra === 0) {
messages.push({
key: "home_sin_imss",
severity: "info",
title: `${sinImss} sin alta IMSS`,
detail: "Activos que aún no tienen patrón / alta registrada.",
route: "/padron",
filter: "without_imss",
});
}
if (jobsPendientes > 0) {
messages.push({
key: "home_jobs",
severity: "info",
title: `${jobsPendientes} gafetes PDF por entregar`,
detail: "Trabajos generados pendientes de marcar entrega en obra.",
route: "/gafetes",
});
}
if (proyPausados > 0) {
messages.push({
key: "home_pausados",
severity: "info",
title: `${proyPausados} proyectos pausados`,
detail: "Obras en pausa; confirma si reactivan o concluyen.",
route: "/proyectos",
treeKey: "pausado",
});
}
if (prestamos > 0) {
messages.push({
key: "home_prestamos",
severity: "info",
title: `${prestamos} con préstamo activo`,
detail: "Hay saldos por descontar; revisa ficha o nómina.",
route: "/kanban",
});
}
if (periodosAbiertos > 0) {
messages.push({
key: "home_nomina",
severity: "info",
title: `${periodosAbiertos} periodos de nómina abiertos`,
detail: "Periodos sin cerrar en el módulo de nómina.",
route: "/nomina",
});
}
return messages;
}
onMounted(async () => {
desk.configure({
tab: "Inicio",
treeTitle: "Inicio",
showTree: false,
showDetail: false,
ribbon: [
{
key: "acciones",
label: "Acciones",
items: [
{ key: "alta", label: "Alta", icon: "pi pi-plus", command: () => navigateTo("/padron?nuevo=1") },
{ key: "print", label: "Gafetes", icon: "pi pi-print", command: () => navigateTo("/gafetes") },
],
},
],
});
const [w, p, j, pe] = await Promise.all([
api<{ workers: Worker[]; imss_stats?: { imss_ready_count?: number } }>("/v1/workers"),
api<{ projects: ProjectRow[] }>("/v1/projects?status=activo,pausado"),
api<{ jobs: JobRow[] }>("/v1/badge-jobs"),
api<{ periods: PeriodRow[] }>("/v1/payroll/periods"),
]);
workers.value = w.workers;
const activos = w.workers.filter((x) => x.status === "activo" && x.pipeline_status !== "baja").length;
const listos = w.workers.filter((x) => x.pipeline_status === "listo_gafete").length;
const activosProyectos = p.projects.filter((x) => x.status === "activo").length;
stats.value = [
{ label: "Activos", value: String(activos), icon: "pi pi-users", bg: "#0f2744", hint: "Alta", severity: "success" },
{ label: "Proyectos", value: String(activosProyectos), icon: "pi pi-building", bg: "#1c4a7a", hint: "Proyectos", severity: "info" },
{ label: "Listos gafete", value: String(listos), icon: "pi pi-id-card", bg: "#c9a227", hint: "Imprimir", severity: "warn" },
{ label: "Jobs PDF", value: String(j.jobs.length), icon: "pi pi-file-pdf", bg: "#a20000", hint: "Archivo", severity: "danger" },
];
desk.setStatusMessages(
buildHomeAlerts({
workers: w.workers,
projects: p.projects,
jobs: j.jobs,
periods: pe.periods,
imssReady: w.imss_stats?.imss_ready_count || 0,
}),
);
const counts: Record<string, number> = {};
for (const row of w.workers) counts[row.pipeline_status] = (counts[row.pipeline_status] || 0) + 1;
const total = w.workers.length || 1;
meter.value = [
{ label: "Incompleto", color: "#6b7785", value: Math.round(((counts.incompleto || 0) / total) * 100) },
{ label: "Listo", color: "#1c4a7a", value: Math.round(((counts.listo_gafete || 0) / total) * 100) },
{ label: "Impreso", color: "#c9a227", value: Math.round(((counts.impreso || 0) / total) * 100) },
{ label: "Activo", color: "#008514", value: Math.round(((counts.activo || 0) / total) * 100) },
{ label: "Baja", color: "#a20000", value: Math.round(((counts.baja || 0) / total) * 100) },
];
});
onBeforeUnmount(() => {
desk.clearStatusMessages();
});
</script>

327
web-panel/pages/kanban.vue Normal file
View file

@ -0,0 +1,327 @@
<template>
<div class="kanban-page">
<Toolbar>
<template #start>
<Chip :label="`${activos.length} activos`" icon="pi pi-users" />
<Chip :label="`${unassigned.length} sin asignar`" icon="pi pi-exclamation-triangle" />
<Chip :label="`${faltantes.length} sin expediente`" icon="pi pi-folder" />
<Chip :label="`${conPrestamo.length} con préstamo`" icon="pi pi-wallet" />
<Chip :label="`${altos.length} riesgo alto`" icon="pi pi-exclamation-circle" />
</template>
<template #end>
<Button icon="pi pi-refresh" text v-tooltip.top="'Actualizar'" @click="load" />
</template>
</Toolbar>
<div class="kanban">
<Panel v-for="col in cols" :key="col.key" class="kanban-col">
<template #header>
<div v-if="col.id === 0" class="flex align-items-center gap-2">
<span>Sin asignar</span>
<Badge :value="peopleOf(col).length" severity="warn" />
</div>
<div v-else>
<div class="flex align-items-center gap-2">
<Tag :value="col.code" />
<Badge :value="peopleOf(col).length" />
</div>
<div class="data-text" style="margin-top:4px">{{ col.name }}</div>
</div>
</template>
<Card
v-for="w in peopleOf(col)"
:key="col.key + '-' + w.id"
class="kanban-card"
@click="openWorker(w)"
@contextmenu.prevent="openCtx($event, w)"
>
<template #title>
<div class="person-cell">
<Avatar :label="initials(w.first_name, w.last_name_p)" size="small" shape="circle" />
<div>
<div class="data-text">{{ w.first_name }} {{ w.last_name_p }}</div>
<div class="muted data-text">{{ w.position }}</div>
</div>
</div>
</template>
<template #content>
<div class="kanban-flags">
<Tag :value="riskLevelLabel(String(w.risk_code))" :severity="riskLevelSeverity(String(w.risk_code))" />
<Tag
v-if="Number(w.missing_docs) > 0 || w.pipeline_status === 'incompleto'"
:value="missingLabel(w)"
severity="danger"
/>
<Tag
v-if="w.pipeline_status === 'listo_gafete'"
:value="gafeteLabel(w.pipeline_status)"
:severity="gafeteSeverity(w.pipeline_status)"
/>
<Tag v-if="Number(w.loan_balance) > 0" :value="loanLabel(w)" severity="info" />
<Tag :value="String(w.hire_type)" :severity="hireSeverity(String(w.hire_type))" />
<Tag v-if="w.work_type === 'D'" value="Destajo" severity="warn" />
</div>
</template>
</Card>
<Message v-if="!peopleOf(col).length" severity="secondary" :closable="false" class="kanban-empty">
{{ col.id === 0 ? "Nadie sin proyecto" : "Sin personal" }}
</Message>
</Panel>
</div>
<ContextMenu ref="cm" :model="ctxItems" />
</div>
</template>
<script setup lang="ts">
type W = {
id: number;
first_name: string;
last_name_p: string;
position: string;
risk_code: string;
hire_type: string;
work_type: string;
pipeline_status: string;
project_ids?: string | null;
status: string;
missing_docs?: number;
loan_balance?: number;
};
type P = { id: number; code: string; name: string; address?: string; status: string };
type Col = { key: string; id: number; code?: string; name?: string; address?: string };
const { initials, riskLevel, riskLevelLabel, riskLevelSeverity, hireSeverity, gafeteLabel, gafeteSeverity } = useUi();
const desk = useDesktop();
const { api } = useApi();
const workers = ref<W[]>([]);
const projects = ref<P[]>([]);
const selected = ref<W | null>(null);
const cm = ref();
const ctxItems = computed(() => [
{ label: "Abrir expediente", icon: "pi pi-folder-open", command: () => selected.value && openWorker(selected.value) },
]);
const activos = computed(() =>
workers.value.filter((w) => w.status === "activo" && w.pipeline_status !== "baja"),
);
const unassigned = computed(() => activos.value.filter((w) => !projectIds(w).length));
const faltantes = computed(() =>
activos.value.filter((w) => Number(w.missing_docs) > 0 || w.pipeline_status === "incompleto"),
);
const conPrestamo = computed(() => activos.value.filter((w) => Number(w.loan_balance) > 0));
const altos = computed(() => activos.value.filter((w) => riskLevel(String(w.risk_code)) === "alto"));
const cols = computed<Col[]>(() => {
const all: Col[] = [
{ key: "none", id: 0, name: "Sin asignar" },
...projects.value.map((p) => ({ key: `p${p.id}`, id: p.id, code: p.code, name: p.name, address: p.address })),
];
const key = desk.treeKey.value || "all";
if (key.startsWith("p:")) {
const id = Number(key.slice(2));
return all.filter((c) => c.id === 0 || c.id === id);
}
return all;
});
function projectIds(w: W): number[] {
return String(w.project_ids || "")
.split(",")
.map((x) => Number(x.trim()))
.filter((n) => n > 0);
}
function peopleOf(col: Col) {
if (col.id === 0) return unassigned.value;
return activos.value.filter((w) => projectIds(w).includes(col.id));
}
function missingLabel(w: W) {
const n = Number(w.missing_docs) || 0;
return n > 1 ? `Faltan ${n} docs` : "Falta expediente";
}
function loanLabel(w: W) {
const n = Number(w.loan_balance) || 0;
return `Préstamo ${n.toLocaleString("es-MX", { style: "currency", currency: "MXN", maximumFractionDigits: 0 })}`;
}
function openWorker(w: W) {
desk.openWorker(w.id);
navigateTo("/padron");
}
function openCtx(e: MouseEvent, w: W) {
selected.value = w;
cm.value.show(e);
}
async function load() {
if (!desk.treeKey.value || (desk.treeKey.value !== "all" && !desk.treeKey.value.startsWith("p:"))) {
desk.treeKey.value = "all";
}
const [w, p] = await Promise.all([
api<{ workers: W[] }>("/v1/workers?status=activo"),
api<{ projects: P[] }>("/v1/projects?status=activo,pausado"),
]);
workers.value = w.workers.filter((row) => row.status === "activo" && row.pipeline_status !== "baja");
projects.value = p.projects;
syncChrome();
}
function syncChrome() {
const messages = [];
if (faltantes.value.length > 0) {
messages.push({
key: "kan_faltantes",
severity: "warn" as const,
title: `${faltantes.value.length} con expediente incompleto`,
detail: "Personas activas sin documentos completos en el tablero.",
route: "/kanban",
});
}
if (unassigned.value.length > 0) {
messages.push({
key: "kan_sin_asignar",
severity: "info" as const,
title: `${unassigned.value.length} sin asignar a obra`,
detail: "Arrástralos a un proyecto o asígnalos desde el padrón.",
route: "/kanban",
treeKey: "all",
});
}
if (conPrestamo.value.length > 0) {
messages.push({
key: "kan_prestamo",
severity: "info" as const,
title: `${conPrestamo.value.length} con préstamo activo`,
detail: "Revisa saldos en ficha o nómina antes de baja/cambio.",
route: "/kanban",
});
}
if (altos.value.length > 0) {
messages.push({
key: "kan_riesgo",
severity: "warn" as const,
title: `${altos.value.length} con riesgo alto`,
detail: "Personal con barra de gafete de riesgo alto.",
route: "/kanban",
});
}
desk.setStatusMessages(messages);
desk.configure({
tab: "Kanban",
treeTitle: "Proyectos",
showTree: true,
showDetail: false,
treeKey: desk.treeKey.value || "all",
countLabel: `${activos.value.length} activos`,
tree: [
{ key: "all", label: "Todos", icon: "pi pi-th-large", data: { count: activos.value.length } },
...projects.value.map((p) => ({
key: `p:${p.id}`,
label: p.name,
icon: "pi pi-folder",
data: { count: activos.value.filter((w) => projectIds(w).includes(p.id)).length },
})),
],
ribbon: [
{
key: "vista",
label: "Vista",
items: [
{ key: "refresh", label: "Actualizar", icon: "pi pi-refresh", command: load },
{ key: "padron", label: "Padrón", icon: "pi pi-users", command: () => navigateTo("/padron") },
],
},
],
});
}
onMounted(load);
onBeforeUnmount(() => desk.clearStatusMessages());
watch([workers, projects], syncChrome);
</script>
<style scoped>
.kanban-page {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
gap: 12px;
}
.kanban {
flex: 1;
min-height: 0;
width: 100%;
display: flex;
gap: 10px;
overflow-x: auto;
overflow-y: hidden;
align-items: stretch;
}
.kanban-col {
flex: 1 1 0;
min-width: 340px;
max-height: 100%;
display: flex;
flex-direction: column;
}
.kanban-col :deep(.p-panel-header) {
flex-shrink: 0;
align-items: flex-start;
padding: 8px 10px;
}
.kanban-col :deep(.p-panel-content-container),
.kanban-col :deep(.p-panel-content-wrapper) {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.kanban-col :deep(.p-panel-content) {
flex: 1;
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
display: grid;
grid-template-columns: 1fr 1fr;
align-content: start;
gap: 6px;
padding: 8px;
}
.kanban-empty {
grid-column: 1 / -1;
}
.kanban-card {
cursor: pointer;
min-width: 0;
}
.kanban-card :deep(.p-card-body) {
padding: 6px 8px;
gap: 4px;
}
.kanban-card :deep(.p-card-caption) {
gap: 0;
}
.kanban-card :deep(.p-card-title) {
font-size: 12px;
line-height: 1.2;
}
.kanban-card :deep(.p-card-content) {
padding: 0;
}
.kanban-card .person-cell {
gap: 6px;
min-width: 0;
}
.kanban-card .person-cell > div {
min-width: 0;
overflow: hidden;
}
.kanban-card .muted {
font-size: 10px;
}
.kanban-flags {
display: flex;
flex-wrap: wrap;
gap: 3px;
}
.kanban-flags :deep(.p-tag) {
font-size: 10px;
padding: 1px 5px;
}
</style>

829
web-panel/pages/login.vue Normal file
View file

@ -0,0 +1,829 @@
<template>
<div class="panel-login" :class="{ 'is-dark': isDark, 'is-theme-switching': isThemeSwitching }">
<section class="mosaic">
<div class="mosaic-grid" aria-hidden="true">
<svg
v-for="(tone, i) in mosaicCells"
:key="i"
class="mosaic-motif"
:class="tone ? `is-${tone}` : undefined"
viewBox="0 0 164 164"
:style="{ transitionDelay: `${(i % 5) * 35}ms` }"
>
<!-- Mismas proporciones que el logo: celda 76, hueco 12, rx 18 -->
<rect class="petal" x="0" y="0" width="76" height="76" rx="18" />
<rect class="petal" x="88" y="0" width="76" height="76" rx="18" />
<rect class="petal" x="0" y="88" width="76" height="76" rx="18" />
<rect class="petal" x="88" y="88" width="76" height="76" rx="18" />
</svg>
</div>
<div class="mosaic-brand">
<div class="mosaic-brand-mark">
<img src="/brand/panel-icon-symmetric.svg" alt="" width="40" height="40" draggable="false">
<span>PANELS</span>
</div>
<p class="mosaic-brand-expand">
<span>P</span>lataforma de
<span>A</span>dministración de
<span>N</span>egocios,
<span>E</span>jecución,
<span>L</span>ogística y
<span>S</span>eguimiento
</p>
</div>
<div class="mosaic-copy">
<h2>Control total de<br>tus proyectos.</h2>
<p>Obra, personal, nómina y presupuestos<br>en un solo lugar.</p>
</div>
</section>
<section class="panel">
<div class="theme-toggle" role="group" aria-label="Tema">
<button type="button" :class="{ active: !isDark }" aria-label="Tema claro" @click="setDark(false)">
<i class="pi pi-sun" />
</button>
<button type="button" :class="{ active: isDark }" aria-label="Tema oscuro" @click="setDark(true)">
<i class="pi pi-moon" />
</button>
</div>
<form class="form" @submit.prevent="onSubmit">
<header class="form-head">
<h1>Bienvenido de nuevo</h1>
<p>Ingresa tus datos para continuar</p>
</header>
<label class="field">
<span>Usuario</span>
<span class="control">
<i class="pi pi-user" />
<input
v-model="username"
type="text"
name="username"
autocomplete="username"
spellcheck="false"
placeholder="Ingresa tu usuario"
>
</span>
</label>
<label class="field">
<span>Contraseña</span>
<span class="control">
<i class="pi pi-lock" />
<input
v-model="password"
:type="showPassword ? 'text' : 'password'"
name="password"
autocomplete="current-password"
placeholder="Ingresa tu contraseña"
>
<button
type="button"
class="toggle-pass"
:aria-label="showPassword ? 'Ocultar contraseña' : 'Mostrar contraseña'"
@click="showPassword = !showPassword"
>
<i :class="showPassword ? 'pi pi-eye-slash' : 'pi pi-eye'" />
</button>
</span>
</label>
<div class="form-row">
<label class="remember">
<input v-model="remember" type="checkbox">
<span class="check" />
Recordarme
</label>
<button type="button" class="forgot" @click="onForgot">¿Olvidaste tu contraseña?</button>
</div>
<p v-if="forgotHint" class="hint">{{ forgotHint }}</p>
<p v-if="error" class="error">{{ error }}</p>
<button type="submit" class="submit" :disabled="loading">
<span v-if="loading" class="spinner" />
Iniciar sesión
</button>
<p class="secure">
<i class="pi pi-lock" />
Acceso seguro
</p>
</form>
<p class="copyright">© {{ year }} PANELS</p>
</section>
</div>
</template>
<script setup lang="ts">
definePageMeta({ layout: "auth" });
useHead({
title: "Iniciar sesión · PANELS",
link: [
{
rel: "stylesheet",
href: "https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap",
},
],
});
type MosaicTone = "" | "pale" | "mid" | "strong";
const COLS = 7;
const ROWS = 12;
const CELL_COUNT = COLS * ROWS;
const TONE_STEPS: MosaicTone[] = ["", "pale", "mid", "strong"];
const ACCENT_COUNT = 10;
const INITIAL_ACCENTS: Record<string, MosaicTone> = {
"0-5": "pale",
"1-2": "mid",
"1-6": "strong",
"2-4": "pale",
"3-1": "strong",
"4-5": "pale",
"5-2": "mid",
"5-6": "strong",
"7-3": "pale",
"8-0": "mid",
"8-5": "strong",
"10-2": "pale",
"10-6": "mid",
};
function buildInitialMosaic(): MosaicTone[] {
return Array.from({ length: CELL_COUNT }, (_, i) => {
const row = Math.floor(i / COLS);
const col = i % COLS;
return INITIAL_ACCENTS[`${row}-${col}`] ?? "";
});
}
function toneIndex(tone: MosaicTone): number {
return TONE_STEPS.indexOf(tone);
}
function stepTone(tone: MosaicTone, delta: -1 | 1): MosaicTone {
const next = Math.min(TONE_STEPS.length - 1, Math.max(0, toneIndex(tone) + delta));
return TONE_STEPS[next]!;
}
function shuffleIndices(indices: number[]): number[] {
const next = [...indices];
for (let i = next.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[next[i], next[j]] = [next[j]!, next[i]!];
}
return next;
}
const mosaicCells = ref<MosaicTone[]>(buildInitialMosaic());
let mosaicTimer: ReturnType<typeof setInterval> | null = null;
/** Solo un escalón de tono por celda, pocos cambios por tick → drift suave. */
function driftMosaic() {
const next = [...mosaicCells.value];
const litCount = next.filter(Boolean).length;
const candidates = shuffleIndices(Array.from({ length: CELL_COUNT }, (_, i) => i));
let changed = 0;
const maxChanges = 3;
for (const i of candidates) {
if (changed >= maxChanges) break;
const tone = next[i]!;
const idx = toneIndex(tone);
let delta: -1 | 1 | 0 = 0;
if (litCount > ACCENT_COUNT + 1 && idx > 0) {
delta = -1;
} else if (litCount < ACCENT_COUNT - 1 && idx === 0) {
delta = 1;
} else if (idx === 0) {
if (Math.random() < 0.35) delta = 1;
} else if (idx === TONE_STEPS.length - 1) {
if (Math.random() < 0.55) delta = -1;
} else {
delta = Math.random() < 0.5 ? 1 : -1;
}
if (!delta) continue;
// Evita encender de más si ya hay bastantes acentos.
if (delta === 1 && idx === 0 && litCount >= ACCENT_COUNT + 1) continue;
next[i] = stepTone(tone, delta);
changed++;
}
mosaicCells.value = next;
}
const { login } = useAuth();
const { isDark, setDark: applyTheme } = useTheme();
const username = ref("");
const password = ref("");
const remember = ref(false);
const showPassword = ref(false);
const isThemeSwitching = ref(false);
const error = ref("");
const forgotHint = ref("");
const loading = ref(false);
const year = new Date().getFullYear();
let themeSwitchTimer: ReturnType<typeof setTimeout> | null = null;
onMounted(() => {
const savedUser = localStorage.getItem("panel-login-remember");
if (savedUser) {
username.value = savedUser;
remember.value = true;
}
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (!reduceMotion) {
mosaicTimer = setInterval(driftMosaic, 1400);
}
});
onBeforeUnmount(() => {
if (mosaicTimer) clearInterval(mosaicTimer);
if (themeSwitchTimer) clearTimeout(themeSwitchTimer);
});
function setDark(value: boolean) {
if (value === isDark.value) return;
// Sin transición al cambiar tema: evita el flash de cuadros claros sobre fondo oscuro.
isThemeSwitching.value = true;
applyTheme(value);
if (themeSwitchTimer) clearTimeout(themeSwitchTimer);
themeSwitchTimer = setTimeout(() => {
isThemeSwitching.value = false;
themeSwitchTimer = null;
}, 50);
}
function onForgot() {
forgotHint.value = "Pide al administrador que restablezca tu acceso.";
}
async function onSubmit() {
error.value = "";
forgotHint.value = "";
if (!username.value.trim() || !password.value) {
error.value = "Ingresa tu usuario y contraseña";
return;
}
loading.value = true;
try {
if (remember.value) localStorage.setItem("panel-login-remember", username.value.trim());
else localStorage.removeItem("panel-login-remember");
await login(username.value.trim(), password.value);
const { user } = useAuth();
const { api } = useApi();
if (user.value?.role === "platform_admin" && user.value?.realm === "platform") {
user.value = null;
try {
await api("/v1/auth/logout", { method: "POST" });
} catch {
/* ignore */
}
window.location.href = "http://localhost:3001/login";
return;
}
if (user.value?.must_change_password) {
await navigateTo("/cambiar-password");
return;
}
await navigateTo("/");
} catch (e: unknown) {
error.value = e instanceof Error ? e.message : "No se pudo entrar";
} finally {
loading.value = false;
}
}
</script>
<style scoped>
.panel-login {
--blue: #185ee8;
--text: #0b1127;
--muted: #64748b;
--soft: #94a3b8;
--line: #e1e2e6;
--right-bg: #ffffff;
--left-bg: #ffffff;
--tile: #e8eefc;
--tile-pale: #c8d8fb;
--tile-mid: #8eb4ff;
--tile-strong: #4f86f7;
--input-bg: #ffffff;
--check-bg: #ffffff;
--toggle-bg: #ffffff;
--toggle-line: #e5e7eb;
--toggle-active: #eaf0fe;
--toggle-icon: #94a3b8;
--toggle-icon-active: #334155;
min-height: 100vh;
min-height: 100dvh;
display: grid;
grid-template-columns: minmax(380px, 46fr) minmax(420px, 54fr);
font-family: Inter, ui-sans-serif, system-ui, "Segoe UI", sans-serif;
font-size: 16px;
color: var(--text);
background: var(--right-bg);
overflow: hidden;
box-sizing: border-box;
}
.panel-login *,
.panel-login *::before,
.panel-login *::after {
box-sizing: border-box;
}
.panel-login.is-dark {
--text: #f8fafc;
--muted: #94a3b8;
--soft: #64748b;
--line: #243044;
--right-bg: #0f172a;
--left-bg: #0b1220;
--tile: #1a2744;
--tile-pale: #243b66;
--tile-mid: #2563eb;
--tile-strong: #60a5fa;
--input-bg: #111b2e;
--check-bg: #111b2e;
--toggle-bg: #111b2e;
--toggle-line: #243044;
--toggle-active: #1e3a5f;
--toggle-icon: #64748b;
--toggle-icon-active: #e2e8f0;
}
.mosaic {
position: relative;
background: var(--left-bg);
overflow: hidden;
display: flex;
flex-direction: column;
justify-content: space-between;
padding: 28px 32px 44px;
}
.mosaic-grid {
position: absolute;
inset: -12px;
display: grid;
grid-template-columns: repeat(7, minmax(0, 1fr));
grid-auto-rows: min-content;
align-content: start;
gap: 10px;
padding: 10px;
pointer-events: none;
}
.mosaic-motif {
width: 100%;
aspect-ratio: 1;
display: block;
overflow: visible;
filter: drop-shadow(0 0 0 transparent);
transition:
filter 1.35s cubic-bezier(0.4, 0, 0.2, 1);
will-change: filter;
}
.mosaic-motif .petal {
fill: var(--tile);
transition: fill 1.35s cubic-bezier(0.4, 0, 0.2, 1);
}
.mosaic-motif.is-pale .petal {
fill: var(--tile-pale);
}
.mosaic-motif.is-mid .petal {
fill: var(--tile-mid);
}
.mosaic-motif.is-strong .petal {
fill: var(--tile-strong);
}
.mosaic-motif.is-pale {
filter: drop-shadow(0 0 10px rgba(138, 180, 254, 0.28));
}
.mosaic-motif.is-mid {
filter: drop-shadow(0 0 14px rgba(79, 134, 247, 0.4));
}
.mosaic-motif.is-strong {
filter: drop-shadow(0 0 18px rgba(47, 111, 237, 0.55));
}
.panel-login.is-dark .mosaic-motif.is-pale {
filter: drop-shadow(0 0 12px rgba(59, 130, 246, 0.35));
}
.panel-login.is-dark .mosaic-motif.is-mid {
filter: drop-shadow(0 0 16px rgba(59, 130, 246, 0.5));
}
.panel-login.is-dark .mosaic-motif.is-strong {
filter: drop-shadow(0 0 22px rgba(96, 165, 250, 0.65));
}
@media (prefers-reduced-motion: reduce) {
.mosaic-motif,
.mosaic-motif .petal {
transition: none;
}
}
.panel-login.is-theme-switching .mosaic-motif,
.panel-login.is-theme-switching .mosaic-motif .petal {
transition: none !important;
transition-delay: 0s !important;
}
.mosaic-brand,
.mosaic-copy {
position: relative;
z-index: 1;
}
.mosaic-brand {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 8px;
max-width: 300px;
}
.mosaic-brand-mark {
display: flex;
align-items: center;
gap: 12px;
}
.mosaic-brand-mark img {
width: 40px;
height: 40px;
display: block;
flex-shrink: 0;
}
.mosaic-brand-mark span {
font-size: 20px;
font-weight: 800;
letter-spacing: 0.06em;
color: var(--text);
}
.mosaic-brand-expand {
margin: 0;
padding-left: 52px;
font-size: 11px;
line-height: 1.45;
font-weight: 500;
color: var(--soft);
}
.mosaic-brand-expand span {
color: var(--muted);
font-weight: 700;
}
.mosaic-copy h2 {
margin: 0 0 12px;
font-size: 36px;
font-weight: 800;
line-height: 1.15;
letter-spacing: -0.03em;
color: var(--text);
}
.mosaic-copy p {
margin: 0;
font-size: 15px;
line-height: 1.5;
color: #475569;
}
.panel-login.is-dark .mosaic-copy p {
color: #94a3b8;
}
.panel {
position: relative;
background: var(--right-bg);
display: flex;
align-items: center;
justify-content: center;
}
.theme-toggle {
position: absolute;
top: 24px;
right: 24px;
display: flex;
align-items: center;
gap: 2px;
padding: 4px;
border: 1px solid var(--toggle-line);
border-radius: 999px;
background: var(--toggle-bg);
}
.theme-toggle button {
width: 34px;
height: 30px;
border: 0;
background: transparent;
border-radius: 9px;
color: var(--toggle-icon);
cursor: pointer;
display: grid;
place-items: center;
font-size: 13px;
}
.theme-toggle button.active {
background: var(--toggle-active);
color: var(--toggle-icon-active);
}
.form {
width: 100%;
max-width: 400px;
padding: 0 24px;
}
.form-head {
text-align: center;
margin-bottom: 40px;
}
.form-head h1 {
margin: 0 0 8px;
font-size: 32px;
font-weight: 800;
letter-spacing: -0.03em;
color: var(--text);
}
.form-head p {
margin: 0;
font-size: 15px;
color: var(--muted);
}
.field {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 20px;
}
.field > span:first-child {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--muted);
}
.control {
position: relative;
display: block;
}
.control > i {
position: absolute;
left: 14px;
top: 50%;
transform: translateY(-50%);
color: var(--soft);
font-size: 14px;
pointer-events: none;
}
.control input {
width: 100%;
height: 48px;
box-sizing: border-box;
border: 1px solid var(--line);
border-radius: 12px;
background: var(--input-bg);
color: var(--text);
padding: 0 44px 0 42px;
font: inherit;
font-size: 14px;
outline: none;
}
.control input::placeholder {
color: var(--soft);
}
.control input:focus {
border-color: var(--blue);
box-shadow: 0 0 0 3px rgba(47, 111, 237, 0.12);
}
.toggle-pass {
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
width: 32px;
height: 32px;
border: 0;
background: transparent;
color: var(--soft);
cursor: pointer;
display: grid;
place-items: center;
font-size: 14px;
}
.form-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin: 4px 0 24px;
}
.remember {
position: relative;
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
color: var(--text);
cursor: pointer;
user-select: none;
}
.remember input {
position: absolute;
opacity: 0;
pointer-events: none;
}
.check {
width: 16px;
height: 16px;
border: 1px solid var(--line);
border-radius: 4px;
background: var(--check-bg);
box-sizing: border-box;
display: grid;
place-items: center;
flex-shrink: 0;
}
.remember input:checked + .check {
background: var(--blue);
border-color: var(--blue);
}
.remember input:checked + .check::after {
content: "";
width: 8px;
height: 5px;
border: 2px solid #fff;
border-top: 0;
border-right: 0;
transform: rotate(-45deg) translate(0, -1px);
}
.forgot {
border: 0;
background: transparent;
color: var(--blue);
font: inherit;
font-size: 14px;
font-weight: 500;
cursor: pointer;
padding: 0;
}
.hint,
.error {
margin: -8px 0 16px;
font-size: 13px;
line-height: 1.4;
}
.hint { color: var(--muted); }
.error { color: #dc2626; }
.submit {
width: 100%;
height: 48px;
border: 0;
border-radius: 12px;
background: var(--blue);
color: #fff;
font: inherit;
font-size: 15px;
font-weight: 600;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.panel-login button,
.panel-login input {
appearance: none;
-webkit-appearance: none;
}
.submit:hover:not(:disabled) {
background: #1454d0;
}
.control input:-webkit-autofill,
.control input:-webkit-autofill:hover,
.control input:-webkit-autofill:focus {
-webkit-box-shadow: 0 0 0 30px var(--input-bg) inset;
-webkit-text-fill-color: var(--text);
caret-color: var(--text);
}
.submit:disabled {
opacity: 0.72;
cursor: default;
}
.spinner {
width: 14px;
height: 14px;
border: 2px solid rgba(255, 255, 255, 0.35);
border-top-color: #fff;
border-radius: 50%;
animation: spin 0.7s linear infinite;
}
.secure {
margin: 20px 0 0;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
font-size: 13px;
color: var(--soft);
}
.secure i {
font-size: 12px;
}
.copyright {
position: absolute;
left: 0;
right: 0;
bottom: 28px;
margin: 0;
text-align: center;
font-size: 13px;
color: var(--soft);
}
@keyframes spin {
to { transform: rotate(360deg); }
}
@media (max-width: 960px) {
.panel-login {
grid-template-columns: 1fr;
overflow: auto;
}
.mosaic {
min-height: 240px;
padding: 24px 24px 28px;
}
.mosaic-copy h2 {
font-size: 28px;
}
.theme-toggle {
top: 16px;
right: 16px;
}
.form {
padding: 72px 24px 80px;
}
.copyright {
position: relative;
bottom: auto;
padding-bottom: 24px;
}
}
</style>

Some files were not shown because too many files have changed in this diff Show more