mirror of
https://origin.cursor.com/mrdevmx/panels.git
synced 2026-10-09 10:03:17 +00:00
first commit
This commit is contained in:
commit
70d89609f4
142 changed files with 42547 additions and 0 deletions
19
.dockerignore
Normal file
19
.dockerignore
Normal 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
33
.env.example
Normal 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
64
.gitignore
vendored
Normal 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
34
Dockerfile.api
Normal 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
14
Dockerfile.web-panel
Normal 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
14
Dockerfile.web-saas
Normal 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
216
api/auth.ts
Normal 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
963
api/budget.ts
Normal 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
98
api/budget_test.ts
Normal 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
334
api/companies.ts
Normal 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
10
api/companies_test.ts
Normal 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
33
api/config.ts
Normal 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
109
api/crypto.ts
Normal 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
345
api/db.ts
Normal 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
22
api/deno.json
Normal 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
358
api/deno.lock
Normal 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
27
api/docs_crypto.ts
Normal 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
170
api/document_validity.ts
Normal 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 },
|
||||
];
|
||||
124
api/document_validity_test.ts
Normal file
124
api/document_validity_test.ts
Normal 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
546
api/excel.ts
Normal 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
67
api/liquibase.ts
Normal 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
100
api/mail.ts
Normal 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
1260
api/main.ts
Normal file
File diff suppressed because it is too large
Load diff
195
api/mx.ts
Normal file
195
api/mx.ts
Normal 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
54
api/mx_test.ts
Normal 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
350
api/pdf.ts
Normal 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
135
api/platform_db.ts
Normal 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
584
api/saas.ts
Normal 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
252
api/schema.sql
Normal 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
148
api/seed_lista.ts
Normal 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
149
api/smtp.ts
Normal 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
28
db/README.md
Normal 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.
|
||||
11
db/app/changelog-master.xml
Normal file
11
db/app/changelog-master.xml
Normal 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>
|
||||
254
db/app/changesets/001-baseline.sql
Normal file
254
db/app/changesets/001-baseline.sql
Normal 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);
|
||||
43
db/app/changesets/002-tenant-id.sql
Normal file
43
db/app/changesets/002-tenant-id.sql
Normal 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);
|
||||
11
db/app/changesets/003-must-change-password.sql
Normal file
11
db/app/changesets/003-must-change-password.sql
Normal 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 '';
|
||||
4
db/app/liquibase.properties
Normal file
4
db/app/liquibase.properties
Normal 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
22
db/bootstrap-tools.sh
Executable 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."
|
||||
15
db/platform/changelog-master.xml
Normal file
15
db/platform/changelog-master.xml
Normal 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>
|
||||
20
db/platform/changesets/001-init.sql
Normal file
20
db/platform/changesets/001-init.sql
Normal 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'))
|
||||
);
|
||||
7
db/platform/changesets/002-seed-arctec-tenant.sql
Normal file
7
db/platform/changesets/002-seed-arctec-tenant.sql
Normal 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');
|
||||
16
db/platform/changesets/003-tenant-license.sql
Normal file
16
db/platform/changesets/003-tenant-license.sql
Normal 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 '';
|
||||
30
db/platform/changesets/004-tenant-status-expand.sql
Normal file
30
db/platform/changesets/004-tenant-status-expand.sql
Normal 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;
|
||||
16
db/platform/changesets/005-contact-access.sql
Normal file
16
db/platform/changesets/005-contact-access.sql
Normal 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;
|
||||
7
db/platform/changesets/006-tenant-code-date.sql
Normal file
7
db/platform/changesets/006-tenant-code-date.sql
Normal 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';
|
||||
18
db/platform/changesets/007-smtp-settings.sql
Normal file
18
db/platform/changesets/007-smtp-settings.sql
Normal 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);
|
||||
4
db/platform/liquibase.properties
Normal file
4
db/platform/liquibase.properties
Normal 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
40
db/update.sh
Executable 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
66
docker-compose.yml
Normal 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:
|
||||
BIN
docs/branding/panels-brand-board-pronunciation.png
Normal file
BIN
docs/branding/panels-brand-board-pronunciation.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.5 MiB |
BIN
docs/branding/panels-brand-board.png
Normal file
BIN
docs/branding/panels-brand-board.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 MiB |
98
docs/coolify.md
Normal file
98
docs/coolify.md
Normal 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
|
||||
BIN
docs/mockups/panel-login-mockup-v2.png
Normal file
BIN
docs/mockups/panel-login-mockup-v2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 966 KiB |
BIN
docs/mockups/panel-login-mockup.png
Normal file
BIN
docs/mockups/panel-login-mockup.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 877 KiB |
50
docs/padron-v2.md
Normal file
50
docs/padron-v2.md
Normal 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
12
package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
129
scripts/migrate-tenant-codes.ts
Normal file
129
scripts/migrate-tenant-codes.ts
Normal 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
26
web-panel/.gitignore
vendored
Normal 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
75
web-panel/README.md
Normal 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
18
web-panel/app.vue
Normal 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>
|
||||
319
web-panel/assets/css/app.css
Normal file
319
web-panel/assets/css/app.css
Normal 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;
|
||||
}
|
||||
}
|
||||
2226
web-panel/assets/css/desktop-v2.css
Normal file
2226
web-panel/assets/css/desktop-v2.css
Normal file
File diff suppressed because it is too large
Load diff
1095
web-panel/assets/css/desktop-v2.css.orig
Normal file
1095
web-panel/assets/css/desktop-v2.css.orig
Normal file
File diff suppressed because it is too large
Load diff
105
web-panel/components/AppModuleTree.vue
Normal file
105
web-panel/components/AppModuleTree.vue
Normal 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>
|
||||
30
web-panel/components/AppRibbon.vue
Normal file
30
web-panel/components/AppRibbon.vue
Normal 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>
|
||||
50
web-panel/components/AppStatusbar.vue
Normal file
50
web-panel/components/AppStatusbar.vue
Normal 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>
|
||||
181
web-panel/components/BadgeCard.vue
Normal file
181
web-panel/components/BadgeCard.vue
Normal 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>
|
||||
60
web-panel/components/BudgetFicha.vue
Normal file
60
web-panel/components/BudgetFicha.vue
Normal 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>
|
||||
296
web-panel/components/BudgetImportDialog.vue
Normal file
296
web-panel/components/BudgetImportDialog.vue
Normal 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>
|
||||
320
web-panel/components/DeskStatusMessages.vue
Normal file
320
web-panel/components/DeskStatusMessages.vue
Normal 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>
|
||||
26
web-panel/components/DeskTableBar.vue
Normal file
26
web-panel/components/DeskTableBar.vue
Normal 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>
|
||||
146
web-panel/components/DocumentPreviewDialog.vue
Normal file
146
web-panel/components/DocumentPreviewDialog.vue
Normal 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>
|
||||
166
web-panel/components/ProjectDocuments.vue
Normal file
166
web-panel/components/ProjectDocuments.vue
Normal 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>
|
||||
128
web-panel/components/ProjectFicha.vue
Normal file
128
web-panel/components/ProjectFicha.vue
Normal 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>
|
||||
306
web-panel/components/WorkerFicha.vue
Normal file
306
web-panel/components/WorkerFicha.vue
Normal 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>
|
||||
382
web-panel/components/padron/AltaImssDialog.vue
Normal file
382
web-panel/components/padron/AltaImssDialog.vue
Normal 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>
|
||||
11
web-panel/components/padron/FormField.vue
Normal file
11
web-panel/components/padron/FormField.vue
Normal 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>
|
||||
303
web-panel/components/padron/PadronTable.vue
Normal file
303
web-panel/components/padron/PadronTable.vue
Normal 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>
|
||||
60
web-panel/components/padron/WorkerAssignments.vue
Normal file
60
web-panel/components/padron/WorkerAssignments.vue
Normal 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>
|
||||
312
web-panel/components/padron/WorkerDocuments.vue
Normal file
312
web-panel/components/padron/WorkerDocuments.vue
Normal 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>
|
||||
132
web-panel/components/padron/WorkerFormDialog.vue
Normal file
132
web-panel/components/padron/WorkerFormDialog.vue
Normal 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>
|
||||
110
web-panel/components/padron/WorkerImportDialog.vue
Normal file
110
web-panel/components/padron/WorkerImportDialog.vue
Normal 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>
|
||||
70
web-panel/composables/useApi.ts
Normal file
70
web-panel/composables/useApi.ts
Normal 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 };
|
||||
};
|
||||
147
web-panel/composables/useDesktop.ts
Normal file
147
web-panel/composables/useDesktop.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
11
web-panel/composables/useTableSearch.ts
Normal file
11
web-panel/composables/useTableSearch.ts
Normal 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 };
|
||||
}
|
||||
41
web-panel/composables/useTheme.ts
Normal file
41
web-panel/composables/useTheme.ts
Normal 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 };
|
||||
}
|
||||
157
web-panel/composables/useUi.ts
Normal file
157
web-panel/composables/useUi.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
12
web-panel/layouts/auth.vue
Normal file
12
web-panel/layouts/auth.vue
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<template>
|
||||
<div class="auth-root">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.auth-root {
|
||||
min-height: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
179
web-panel/layouts/default.vue
Normal file
179
web-panel/layouts/default.vue
Normal 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>
|
||||
37
web-panel/middleware/auth.global.ts
Normal file
37
web-panel/middleware/auth.global.ts
Normal 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
29
web-panel/nginx.conf
Normal 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
34
web-panel/nuxt.config.ts
Normal 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
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
20
web-panel/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
111
web-panel/pages/cambiar-password.vue
Normal file
111
web-panel/pages/cambiar-password.vue
Normal 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>
|
||||
409
web-panel/pages/configuracion.vue
Normal file
409
web-panel/pages/configuracion.vue
Normal 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
343
web-panel/pages/gafetes.vue
Normal 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
323
web-panel/pages/index.vue
Normal 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
327
web-panel/pages/kanban.vue
Normal 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
829
web-panel/pages/login.vue
Normal 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
Loading…
Reference in a new issue