mirror of
https://origin.cursor.com/mrdevmx/panels.git
synced 2026-10-09 10:43:18 +00:00
1260 lines
51 KiB
TypeScript
1260 lines
51 KiB
TypeScript
import { Hono } from "hono";
|
|
import { cors } from "hono/cors";
|
|
import { getDb, checklistFor, refreshPipeline, workerDir, lastInsertId, nextProjectCode, isProjectStatus, projectById, projectMustBe, PROJECT_STATUS_CATALOG, projectDir, projectChecklistFor, imssFlagsFor, checklistItemsFor } from "./db.ts";
|
|
import { config } from "./config.ts";
|
|
import { clearSession, createSessionCookie, login, changePassword, requireAuth, requirePlatformAdmin, tenantScope, type AuthUser } from "./auth.ts";
|
|
import { getPlatformDb } from "./platform_db.ts";
|
|
import { createTenant, getTenantDetail, issueTenantAdminAccess, listTenants, updateTenant } from "./saas.ts";
|
|
import { smtpConfigured, testSmtp } from "./mail.ts";
|
|
import { saveSmtpSettings, smtpPublicView } from "./smtp.ts";
|
|
import { decryptBytes } from "./docs_crypto.ts";
|
|
import { importExcel, storeDocument, storeProjectDocument, findExisting, assign, buildImportTemplate } from "./excel.ts";
|
|
import {
|
|
normalizeWorker,
|
|
validateCurp,
|
|
validateNss,
|
|
validateRfc,
|
|
validateWorkerFields,
|
|
fullName,
|
|
formatNss,
|
|
normUpper,
|
|
titleCase,
|
|
sentenceCase,
|
|
type WorkerInput,
|
|
} from "./mx.ts";
|
|
import { generateBadgePdf, saveJobPdf, loadCurrentPhoto, badgeQrPng } from "./pdf.ts";
|
|
import { createSubcompany, listCompanies, resolveCompany, updateCompany } from "./companies.ts";
|
|
import {
|
|
buildBudgetTemplate,
|
|
exportBudgetWorkbook,
|
|
previewBudgetExcel,
|
|
importBudgetExcel,
|
|
lineAmount,
|
|
listBudget,
|
|
} from "./budget.ts";
|
|
import { join } from "node:path";
|
|
|
|
const app = new Hono<{ Variables: { user: AuthUser } }>();
|
|
|
|
function corsOrigins(): string[] {
|
|
const defaults = [
|
|
"http://localhost:3000",
|
|
"http://127.0.0.1:3000",
|
|
"http://localhost:3001",
|
|
"http://127.0.0.1:3001",
|
|
];
|
|
const extra = (Deno.env.get("CORS_ORIGINS") ?? "")
|
|
.split(",")
|
|
.map((s) => s.trim())
|
|
.filter(Boolean);
|
|
return [...new Set([...defaults, ...extra])];
|
|
}
|
|
|
|
app.use(
|
|
"/v1/*",
|
|
cors({
|
|
origin: corsOrigins(),
|
|
credentials: true,
|
|
allowHeaders: ["Content-Type", "X-API-Key"],
|
|
}),
|
|
);
|
|
|
|
app.get("/v1/health", (c) => c.json({ ok: true }));
|
|
|
|
app.post("/v1/auth/login", async (c) => {
|
|
const body = await c.req.json<{ username?: string; password?: string }>();
|
|
try {
|
|
const user = await login(body.username ?? "", body.password ?? "");
|
|
if (!user) return c.json({ error: "Usuario o contraseña incorrectos" }, 401);
|
|
await createSessionCookie(c, user.id, user.realm);
|
|
return c.json({ user });
|
|
} catch (e: unknown) {
|
|
if (e instanceof Error && (e as { code?: string }).code === "TENANT_BLOCKED") {
|
|
return c.json({ error: e.message }, 403);
|
|
}
|
|
throw e;
|
|
}
|
|
});
|
|
|
|
app.post("/v1/auth/logout", (c) => {
|
|
clearSession(c);
|
|
return c.json({ ok: true });
|
|
});
|
|
|
|
app.get("/v1/auth/me", requireAuth, (c) => c.json({ user: c.get("user") }));
|
|
|
|
app.post("/v1/auth/change-password", requireAuth, async (c) => {
|
|
const user = c.get("user");
|
|
if (user.realm !== "app") return c.json({ error: "No aplica" }, 400);
|
|
const body = await c.req.json<{ current_password?: string; new_password?: string }>();
|
|
const result = await changePassword(user.id, body.current_password ?? "", body.new_password ?? "");
|
|
if (result.error) return c.json({ error: result.error }, 400);
|
|
const db = await getDb();
|
|
const row = db.prepare(
|
|
`SELECT u.id, u.username, u.display_name, u.company_id, u.tenant_id, u.role,
|
|
COALESCE(u.must_change_password, 0) AS must_change_password,
|
|
c.code AS company_code, c.name AS company_name, c.kind AS company_kind
|
|
FROM users u LEFT JOIN companies c ON c.id = u.company_id WHERE u.id = ?`,
|
|
).get(user.id) as Record<string, unknown>;
|
|
return c.json({
|
|
ok: true,
|
|
user: {
|
|
...row,
|
|
must_change_password: false,
|
|
role: row.role === "tenant_admin" ? "tenant_admin" : "user",
|
|
realm: "app",
|
|
},
|
|
});
|
|
});
|
|
|
|
app.get("/v1/saas/tenants", requirePlatformAdmin, async (c) => {
|
|
const pdb = await getPlatformDb();
|
|
return c.json({ tenants: listTenants(pdb), smtp_configured: smtpConfigured(pdb) });
|
|
});
|
|
|
|
app.get("/v1/saas/tenants/:id", requirePlatformAdmin, async (c) => {
|
|
const id = Number(c.req.param("id"));
|
|
const pdb = await getPlatformDb();
|
|
const db = await getDb();
|
|
const detail = getTenantDetail(pdb, db, id);
|
|
if (!detail) return c.json({ error: "Empresa no encontrada" }, 404);
|
|
return c.json({ tenant: detail, smtp_configured: smtpConfigured(pdb) });
|
|
});
|
|
|
|
app.get("/v1/saas/smtp", requirePlatformAdmin, async (c) => {
|
|
const pdb = await getPlatformDb();
|
|
return c.json({ smtp: smtpPublicView(pdb) });
|
|
});
|
|
|
|
app.put("/v1/saas/smtp", requirePlatformAdmin, async (c) => {
|
|
const body = await c.req.json();
|
|
const pdb = await getPlatformDb();
|
|
const result = saveSmtpSettings(pdb, {
|
|
...body,
|
|
keep_password: body.password === undefined || body.password === "",
|
|
});
|
|
if (result.error) return c.json({ error: result.error }, 400);
|
|
return c.json({ smtp: result.settings });
|
|
});
|
|
|
|
app.post("/v1/saas/smtp/test", requirePlatformAdmin, async (c) => {
|
|
const body = await c.req.json<{ to?: string }>().catch(() => ({}));
|
|
const pdb = await getPlatformDb();
|
|
if (!smtpConfigured(pdb)) {
|
|
return c.json({ error: "Guarde y habilite SMTP antes de probar" }, 400);
|
|
}
|
|
const result = await testSmtp(pdb, body.to ?? "");
|
|
if (!result.sent) return c.json({ error: result.error || "No se pudo enviar" }, 400);
|
|
return c.json({ ok: true, sent: true, to: body.to, message_id: result.message_id });
|
|
});
|
|
|
|
app.post("/v1/saas/tenants", requirePlatformAdmin, async (c) => {
|
|
const body = await c.req.json();
|
|
const pdb = await getPlatformDb();
|
|
const db = await getDb();
|
|
const result = await createTenant(pdb, db, body);
|
|
if (result.error || !result.tenant) return c.json({ error: result.error }, 400);
|
|
return c.json({
|
|
tenant: result.tenant,
|
|
admin: result.admin,
|
|
email: result.email,
|
|
}, 201);
|
|
});
|
|
|
|
app.patch("/v1/saas/tenants/:id", requirePlatformAdmin, async (c) => {
|
|
const id = Number(c.req.param("id"));
|
|
const body = await c.req.json();
|
|
const pdb = await getPlatformDb();
|
|
const db = await getDb();
|
|
const result = updateTenant(pdb, db, id, body);
|
|
if (result.error) return c.json({ error: result.error }, 400);
|
|
const detail = getTenantDetail(pdb, db, id);
|
|
return c.json({ tenant: detail });
|
|
});
|
|
|
|
/** Regenera contraseña temporal del admin y opcionalmente la envía por correo. */
|
|
app.post("/v1/saas/tenants/:id/send-access", requirePlatformAdmin, async (c) => {
|
|
const id = Number(c.req.param("id"));
|
|
const body = await c.req.json<{ user_id?: number; send_email?: boolean }>().catch(() => ({}));
|
|
const pdb = await getPlatformDb();
|
|
const db = await getDb();
|
|
const result = await issueTenantAdminAccess(pdb, db, id, {
|
|
userId: body.user_id,
|
|
send_email: body.send_email !== false,
|
|
});
|
|
if (result.error) return c.json({ error: result.error }, 400);
|
|
return c.json({
|
|
admin: result.admin,
|
|
email: result.email,
|
|
smtp_configured: smtpConfigured(pdb),
|
|
});
|
|
});
|
|
|
|
app.get("/v1/catalogs", requireAuth, async (c) => {
|
|
const db = await getDb();
|
|
const user = c.get("user");
|
|
const tid = tenantScope(user);
|
|
return c.json({
|
|
risks: db.prepare("SELECT * FROM risk_levels").all(),
|
|
themes: db.prepare("SELECT * FROM badge_themes").all(),
|
|
document_types: db.prepare("SELECT * FROM document_types").all(),
|
|
project_document_types: db.prepare("SELECT * FROM project_document_types").all(),
|
|
project_statuses: PROJECT_STATUS_CATALOG,
|
|
companies: listCompanies(db, tid),
|
|
});
|
|
});
|
|
|
|
app.get("/v1/companies", requireAuth, async (c) => {
|
|
const db = await getDb();
|
|
const tid = tenantScope(c.get("user"));
|
|
return c.json({ companies: listCompanies(db, tid) });
|
|
});
|
|
|
|
app.post("/v1/companies", requireAuth, async (c) => {
|
|
const body = await c.req.json();
|
|
const db = await getDb();
|
|
const tid = tenantScope(c.get("user"));
|
|
const result = createSubcompany(db, body, tid);
|
|
if (result.error || !result.company) return c.json({ error: result.error }, 400);
|
|
return c.json({ company: result.company }, 201);
|
|
});
|
|
|
|
app.patch("/v1/companies/:id", requireAuth, async (c) => {
|
|
const id = Number(c.req.param("id"));
|
|
const body = await c.req.json();
|
|
const db = await getDb();
|
|
const result = updateCompany(db, id, body);
|
|
if (result.error) return c.json({ error: result.error }, (result.status ?? 400) as 400 | 404);
|
|
return c.json({ company: result.company });
|
|
});
|
|
|
|
app.get("/v1/projects", requireAuth, async (c) => {
|
|
const db = await getDb();
|
|
const tid = tenantScope(c.get("user"));
|
|
const status = (c.req.query("status") ?? "").trim();
|
|
const allowed = status ? status.split(",").map((s) => s.trim()).filter(isProjectStatus) : [];
|
|
const clauses: string[] = [];
|
|
const params: (string | number)[] = [];
|
|
if (tid != null) {
|
|
clauses.push("p.tenant_id = ?");
|
|
params.push(tid);
|
|
}
|
|
if (allowed.length) {
|
|
clauses.push(`p.status IN (${allowed.map(() => "?").join(",")})`);
|
|
params.push(...allowed);
|
|
}
|
|
const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
|
|
return c.json({
|
|
projects: db.prepare(
|
|
`SELECT p.*, c.name AS company_name, c.code AS company_code,
|
|
(SELECT COUNT(*) FROM assignments a WHERE a.project_id = p.id AND a.active = 1) AS active_count,
|
|
(SELECT COUNT(*) FROM project_document_types t
|
|
WHERE t.required = 1 AND NOT EXISTS (
|
|
SELECT 1 FROM project_documents d
|
|
WHERE d.project_id = p.id AND d.type_code = t.code AND d.is_current = 1
|
|
)) AS missing_docs,
|
|
(SELECT COUNT(*) FROM budget_items b WHERE b.project_id = p.id) AS budget_count
|
|
FROM projects p
|
|
LEFT JOIN companies c ON c.id = p.company_id
|
|
${where}
|
|
ORDER BY CASE p.status WHEN 'activo' THEN 0 WHEN 'pausado' THEN 1 WHEN 'concluido' THEN 2 ELSE 3 END, p.id`,
|
|
).all(...params),
|
|
});
|
|
});
|
|
|
|
type ProjectInput = {
|
|
name?: string;
|
|
address?: string;
|
|
theme_id?: string;
|
|
status?: string;
|
|
company_id?: number | null;
|
|
contract_amount?: number | null;
|
|
start_date?: string | null;
|
|
end_date?: string | null;
|
|
resident_name?: string | null;
|
|
siroc?: string | null;
|
|
payroll_tax_pct?: number | null;
|
|
};
|
|
|
|
function isoDate(value: unknown): string | null {
|
|
const raw = String(value ?? "").trim();
|
|
if (!raw) return null;
|
|
const match = raw.match(/^(\d{4}-\d{2}-\d{2})/);
|
|
return match ? match[1] : null;
|
|
}
|
|
|
|
function numOrNull(value: unknown): number | null {
|
|
if (value === undefined || value === null || value === "") return null;
|
|
const n = Number(value);
|
|
return Number.isNaN(n) ? null : n;
|
|
}
|
|
|
|
function projectFields(body: ProjectInput, cur?: Record<string, unknown>) {
|
|
const start = isoDate(body.start_date !== undefined ? body.start_date : cur?.start_date);
|
|
const end = isoDate(body.end_date !== undefined ? body.end_date : cur?.end_date);
|
|
const payroll = numOrNull(body.payroll_tax_pct !== undefined ? body.payroll_tax_pct : cur?.payroll_tax_pct);
|
|
const amount = numOrNull(body.contract_amount !== undefined ? body.contract_amount : cur?.contract_amount);
|
|
const companyRaw = body.company_id !== undefined ? body.company_id : cur?.company_id;
|
|
return {
|
|
name: titleCase(String(body.name ?? cur?.name ?? "")),
|
|
address: sentenceCase(String(body.address ?? cur?.address ?? "")),
|
|
theme_id: String(body.theme_id ?? cur?.theme_id ?? "arctec-dos-logos-fold"),
|
|
company_id: companyRaw ? Number(companyRaw) : null,
|
|
contract_amount: amount,
|
|
start_date: start,
|
|
end_date: end,
|
|
resident_name: titleCase(String(body.resident_name ?? cur?.resident_name ?? "")),
|
|
siroc: normUpper(String(body.siroc ?? cur?.siroc ?? "")),
|
|
payroll_tax_pct: payroll ?? 4,
|
|
};
|
|
}
|
|
|
|
app.post("/v1/projects", requireAuth, async (c) => {
|
|
const body = await c.req.json<ProjectInput>();
|
|
if (!body.name?.trim()) return c.json({ error: "Nombre de proyecto obligatorio" }, 400);
|
|
const status = body.status && isProjectStatus(body.status) ? body.status : "activo";
|
|
const db = await getDb();
|
|
const fields = projectFields(body);
|
|
if (!fields.company_id) return c.json({ error: "Seleccione la empresa del proyecto" }, 400);
|
|
if (!resolveCompany(db, { company_id: fields.company_id })) {
|
|
return c.json({ error: "Empresa no encontrada" }, 400);
|
|
}
|
|
if (fields.start_date && fields.end_date && fields.end_date < fields.start_date) {
|
|
return c.json({ error: "La fecha de término no puede ser anterior al inicio" }, 400);
|
|
}
|
|
const code = nextProjectCode(db);
|
|
const tid = tenantScope(c.get("user"));
|
|
db.prepare(
|
|
`INSERT INTO projects (
|
|
code, name, address, theme_id, status, company_id, contract_amount,
|
|
start_date, end_date, resident_name, siroc, payroll_tax_pct, tenant_id
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
).run(
|
|
code,
|
|
fields.name,
|
|
fields.address,
|
|
fields.theme_id,
|
|
status,
|
|
fields.company_id,
|
|
fields.contract_amount,
|
|
fields.start_date,
|
|
fields.end_date,
|
|
fields.resident_name,
|
|
fields.siroc,
|
|
fields.payroll_tax_pct,
|
|
tid,
|
|
);
|
|
const id = lastInsertId(db);
|
|
return c.json({ id, code, status }, 201);
|
|
});
|
|
|
|
app.patch("/v1/projects/:id", requireAuth, async (c) => {
|
|
const id = Number(c.req.param("id"));
|
|
const body = await c.req.json<ProjectInput>();
|
|
const db = await getDb();
|
|
const cur = db.prepare("SELECT * FROM projects WHERE id = ?").get(id) as Record<string, unknown> | undefined;
|
|
if (!cur) return c.json({ error: "Proyecto no encontrado" }, 404);
|
|
const status = body.status && isProjectStatus(body.status) ? body.status : String(cur.status || "activo");
|
|
const fields = projectFields(body, cur);
|
|
if (!fields.company_id) return c.json({ error: "Seleccione la empresa del proyecto" }, 400);
|
|
if (!resolveCompany(db, { company_id: fields.company_id })) {
|
|
return c.json({ error: "Empresa no encontrada" }, 400);
|
|
}
|
|
if (fields.start_date && fields.end_date && fields.end_date < fields.start_date) {
|
|
return c.json({ error: "La fecha de término no puede ser anterior al inicio" }, 400);
|
|
}
|
|
db.prepare(
|
|
`UPDATE projects SET name=?, address=?, theme_id=?, status=?, company_id=?, contract_amount=?,
|
|
start_date=?, end_date=?, resident_name=?, siroc=?, payroll_tax_pct=? WHERE id=?`,
|
|
).run(
|
|
fields.name,
|
|
fields.address,
|
|
fields.theme_id,
|
|
status,
|
|
fields.company_id,
|
|
fields.contract_amount,
|
|
fields.start_date,
|
|
fields.end_date,
|
|
fields.resident_name,
|
|
fields.siroc,
|
|
fields.payroll_tax_pct,
|
|
id,
|
|
);
|
|
return c.json({ ok: true, id, code: cur.code, status });
|
|
});
|
|
|
|
app.get("/v1/projects/:id", requireAuth, async (c) => {
|
|
const id = Number(c.req.param("id"));
|
|
const db = await getDb();
|
|
const project = db.prepare(
|
|
`SELECT p.*, c.name AS company_name, c.code AS company_code,
|
|
(SELECT COUNT(*) FROM assignments a WHERE a.project_id = p.id AND a.active = 1) AS active_count
|
|
FROM projects p
|
|
LEFT JOIN companies c ON c.id = p.company_id
|
|
WHERE p.id = ?`,
|
|
).get(id);
|
|
if (!project) return c.json({ error: "Proyecto no encontrado" }, 404);
|
|
const documents = db.prepare(
|
|
"SELECT id, type_code, original_name, mime, size_bytes, is_current, uploaded_at FROM project_documents WHERE project_id = ? ORDER BY uploaded_at DESC",
|
|
).all(id);
|
|
return c.json({
|
|
project,
|
|
documents,
|
|
checklist: projectChecklistFor(db, id),
|
|
});
|
|
});
|
|
|
|
app.post("/v1/projects/:id/documents", requireAuth, async (c) => {
|
|
const id = Number(c.req.param("id"));
|
|
const form = await c.req.formData();
|
|
const type = String(form.get("type") || "");
|
|
const file = form.get("file");
|
|
if (!(file instanceof File) || !type) return c.json({ error: "type y file requeridos" }, 400);
|
|
const db = await getDb();
|
|
if (!projectById(db, id)) return c.json({ error: "Proyecto no encontrado" }, 404);
|
|
const bytes = new Uint8Array(await file.arrayBuffer());
|
|
try {
|
|
await storeProjectDocument(db, id, type, file.name, file.type || "application/octet-stream", bytes, c.get("user").id);
|
|
} catch (error) {
|
|
return c.json({ error: error instanceof Error ? error.message : "No se pudo guardar el documento" }, 400);
|
|
}
|
|
return c.json({ ok: true, checklist: projectChecklistFor(db, id) });
|
|
});
|
|
|
|
app.get("/v1/projects/:id/documents/:docId", requireAuth, async (c) => {
|
|
const projectId = Number(c.req.param("id"));
|
|
const docId = Number(c.req.param("docId"));
|
|
const db = await getDb();
|
|
const doc = db.prepare(
|
|
"SELECT * FROM project_documents WHERE id=? AND project_id=?",
|
|
).get(docId, projectId) as {
|
|
storage_name: string;
|
|
iv: string;
|
|
mime: string;
|
|
original_name: string;
|
|
} | undefined;
|
|
if (!doc) return c.json({ error: "Documento no encontrado" }, 404);
|
|
const enc = await Deno.readFile(join(projectDir(projectId), doc.storage_name));
|
|
const plain = await decryptBytes(doc.iv, enc);
|
|
c.header("Content-Type", doc.mime);
|
|
c.header("Content-Disposition", `inline; filename="${doc.original_name}"`);
|
|
return c.body(plain.buffer as ArrayBuffer);
|
|
});
|
|
|
|
app.get("/v1/projects/:id/budget", requireAuth, async (c) => {
|
|
const id = Number(c.req.param("id"));
|
|
const db = await getDb();
|
|
if (!projectById(db, id)) return c.json({ error: "Proyecto no encontrado" }, 404);
|
|
return c.json(listBudget(db, id));
|
|
});
|
|
|
|
app.get("/v1/projects/:id/budget/template", requireAuth, (c) => {
|
|
const bytes = buildBudgetTemplate();
|
|
c.header("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
|
|
c.header("Content-Disposition", 'attachment; filename="plantilla-presupuesto.xlsx"');
|
|
return c.body(bytes.slice());
|
|
});
|
|
|
|
app.get("/v1/projects/:id/budget/export", requireAuth, async (c) => {
|
|
const id = Number(c.req.param("id"));
|
|
const db = await getDb();
|
|
const project = projectById(db, id);
|
|
if (!project) return c.json({ error: "Proyecto no encontrado" }, 404);
|
|
const budget = listBudget(db, id);
|
|
const bytes = exportBudgetWorkbook(project.name, budget);
|
|
c.header("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
|
|
c.header("Content-Disposition", `attachment; filename="presupuesto-${project.code}.xlsx"`);
|
|
return c.body(bytes.slice());
|
|
});
|
|
|
|
app.post("/v1/budget/preview", requireAuth, async (c) => {
|
|
const form = await c.req.formData();
|
|
const file = form.get("file");
|
|
if (!(file instanceof File)) return c.json({ error: "Archivo Excel requerido" }, 400);
|
|
const bytes = new Uint8Array(await file.arrayBuffer());
|
|
const preview = previewBudgetExcel(bytes);
|
|
if (preview.errors.length && !preview.items.length) return c.json(preview, 400);
|
|
return c.json(preview);
|
|
});
|
|
|
|
app.post("/v1/projects/:id/budget/preview", requireAuth, async (c) => {
|
|
const id = Number(c.req.param("id"));
|
|
const form = await c.req.formData();
|
|
const file = form.get("file");
|
|
if (!(file instanceof File)) return c.json({ error: "Archivo Excel requerido" }, 400);
|
|
const db = await getDb();
|
|
if (!projectById(db, id)) return c.json({ error: "Proyecto no encontrado" }, 404);
|
|
const bytes = new Uint8Array(await file.arrayBuffer());
|
|
const preview = previewBudgetExcel(bytes);
|
|
if (preview.errors.length && !preview.items.length) return c.json(preview, 400);
|
|
return c.json(preview);
|
|
});
|
|
|
|
app.post("/v1/projects/:id/budget/import", requireAuth, async (c) => {
|
|
const id = Number(c.req.param("id"));
|
|
const form = await c.req.formData();
|
|
const file = form.get("file");
|
|
if (!(file instanceof File)) return c.json({ error: "Archivo Excel requerido" }, 400);
|
|
const db = await getDb();
|
|
if (!projectById(db, id)) return c.json({ error: "Proyecto no encontrado" }, 404);
|
|
const bytes = new Uint8Array(await file.arrayBuffer());
|
|
const report = importBudgetExcel(db, id, bytes);
|
|
if (report.errors.length && !report.inserted) return c.json(report, 400);
|
|
return c.json(report);
|
|
});
|
|
|
|
app.post("/v1/projects/:id/budget/chapters", requireAuth, async (c) => {
|
|
const id = Number(c.req.param("id"));
|
|
const body = await c.req.json<{ name?: string; code?: string; parent_id?: number | null }>();
|
|
if (!body.name?.trim()) return c.json({ error: "Nombre de capítulo obligatorio" }, 400);
|
|
const db = await getDb();
|
|
if (!projectById(db, id)) return c.json({ error: "Proyecto no encontrado" }, 404);
|
|
const sort = (db.prepare("SELECT COALESCE(MAX(sort_order), 0) + 1 AS n FROM budget_chapters WHERE project_id = ?").get(id) as { n: number }).n;
|
|
db.prepare("INSERT INTO budget_chapters (project_id, parent_id, code, name, sort_order) VALUES (?, ?, ?, ?, ?)").run(
|
|
id,
|
|
body.parent_id || null,
|
|
(body.code || "").trim(),
|
|
body.name.trim(),
|
|
sort,
|
|
);
|
|
return c.json({ id: lastInsertId(db) }, 201);
|
|
});
|
|
|
|
app.patch("/v1/projects/:id/budget/chapters/:cid", requireAuth, async (c) => {
|
|
const id = Number(c.req.param("id"));
|
|
const cid = Number(c.req.param("cid"));
|
|
const body = await c.req.json<{ name?: string; code?: string }>();
|
|
const db = await getDb();
|
|
const cur = db.prepare("SELECT * FROM budget_chapters WHERE id = ? AND project_id = ?").get(cid, id) as { name: string; code: string } | undefined;
|
|
if (!cur) return c.json({ error: "Capítulo no encontrado" }, 404);
|
|
db.prepare("UPDATE budget_chapters SET name = ?, code = ? WHERE id = ?").run(
|
|
(body.name ?? cur.name).trim(),
|
|
(body.code ?? cur.code).trim(),
|
|
cid,
|
|
);
|
|
return c.json({ ok: true });
|
|
});
|
|
|
|
app.delete("/v1/projects/:id/budget/chapters/:cid", requireAuth, async (c) => {
|
|
const id = Number(c.req.param("id"));
|
|
const cid = Number(c.req.param("cid"));
|
|
const db = await getDb();
|
|
const found = db.prepare("SELECT id FROM budget_chapters WHERE id = ? AND project_id = ?").get(cid, id);
|
|
if (!found) return c.json({ error: "Capítulo no encontrado" }, 404);
|
|
db.prepare("UPDATE budget_items SET chapter_id = NULL WHERE project_id = ? AND chapter_id = ?").run(id, cid);
|
|
db.prepare("DELETE FROM budget_chapters WHERE id = ? AND project_id = ?").run(cid, id);
|
|
return c.json({ ok: true });
|
|
});
|
|
|
|
type BudgetItemInput = {
|
|
chapter_id?: number | null;
|
|
code?: string;
|
|
description?: string;
|
|
unit?: string;
|
|
quantity?: number;
|
|
unit_price?: number;
|
|
};
|
|
|
|
app.post("/v1/projects/:id/budget/items", requireAuth, async (c) => {
|
|
const id = Number(c.req.param("id"));
|
|
const body = await c.req.json<BudgetItemInput>();
|
|
if (!body.description?.trim()) return c.json({ error: "Descripción de partida obligatoria" }, 400);
|
|
const db = await getDb();
|
|
if (!projectById(db, id)) return c.json({ error: "Proyecto no encontrado" }, 404);
|
|
const qty = Number(body.quantity ?? 0);
|
|
const price = Number(body.unit_price ?? 0);
|
|
const sort = (db.prepare("SELECT COALESCE(MAX(sort_order), 0) + 1 AS n FROM budget_items WHERE project_id = ?").get(id) as { n: number }).n;
|
|
db.prepare(
|
|
`INSERT INTO budget_items (project_id, chapter_id, code, description, unit, quantity, unit_price, amount, sort_order)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
).run(
|
|
id,
|
|
body.chapter_id || null,
|
|
(body.code || "").trim().toUpperCase(),
|
|
body.description.trim(),
|
|
(body.unit || "").trim().toUpperCase(),
|
|
qty,
|
|
price,
|
|
lineAmount(qty, price),
|
|
sort,
|
|
);
|
|
return c.json({ id: lastInsertId(db) }, 201);
|
|
});
|
|
|
|
app.patch("/v1/projects/:id/budget/items/:iid", requireAuth, async (c) => {
|
|
const id = Number(c.req.param("id"));
|
|
const iid = Number(c.req.param("iid"));
|
|
const body = await c.req.json<BudgetItemInput>();
|
|
const db = await getDb();
|
|
const cur = db.prepare("SELECT * FROM budget_items WHERE id = ? AND project_id = ?").get(iid, id) as Record<string, unknown> | undefined;
|
|
if (!cur) return c.json({ error: "Partida no encontrada" }, 404);
|
|
const qty = body.quantity !== undefined ? Number(body.quantity) : Number(cur.quantity);
|
|
const price = body.unit_price !== undefined ? Number(body.unit_price) : Number(cur.unit_price);
|
|
db.prepare(
|
|
`UPDATE budget_items SET chapter_id=?, code=?, description=?, unit=?, quantity=?, unit_price=?, amount=? WHERE id=?`,
|
|
).run(
|
|
body.chapter_id !== undefined ? (body.chapter_id || null) : cur.chapter_id,
|
|
(body.code ?? String(cur.code ?? "")).trim().toUpperCase(),
|
|
(body.description ?? String(cur.description ?? "")).trim(),
|
|
(body.unit ?? String(cur.unit ?? "")).trim().toUpperCase(),
|
|
qty,
|
|
price,
|
|
lineAmount(qty, price),
|
|
iid,
|
|
);
|
|
return c.json({ ok: true });
|
|
});
|
|
|
|
app.delete("/v1/projects/:id/budget/items/:iid", requireAuth, async (c) => {
|
|
const id = Number(c.req.param("id"));
|
|
const iid = Number(c.req.param("iid"));
|
|
const db = await getDb();
|
|
const found = db.prepare("SELECT id FROM budget_items WHERE id = ? AND project_id = ?").get(iid, id);
|
|
if (!found) return c.json({ error: "Partida no encontrada" }, 404);
|
|
db.prepare("DELETE FROM budget_items WHERE id = ? AND project_id = ?").run(iid, id);
|
|
return c.json({ ok: true });
|
|
});
|
|
|
|
app.post("/v1/workers/validate", requireAuth, async (c) => {
|
|
const body = await c.req.json<WorkerInput & { id?: number }>();
|
|
const errors = validateWorkerFields(body);
|
|
const db = await getDb();
|
|
const exclude = body.id ?? 0;
|
|
const checks: { field: string; value: string; err: string | null }[] = [
|
|
{ field: "curp", value: normUpper(body.curp), err: validateCurp(body.curp ?? "") },
|
|
{ field: "rfc", value: normUpper(body.rfc), err: validateRfc(body.rfc ?? "") },
|
|
{ field: "nss", value: formatNss(body.nss ?? ""), err: validateNss(body.nss ?? "") },
|
|
];
|
|
for (const ch of checks) {
|
|
if (ch.err) continue;
|
|
const row = db.prepare(
|
|
`SELECT id, first_name, last_name_p FROM workers WHERE ${ch.field} = ? AND id != ?`,
|
|
).get(ch.value, exclude) as { id: number; first_name: string; last_name_p: string } | undefined;
|
|
if (row) {
|
|
errors[ch.field] =
|
|
`Este ${ch.field.toUpperCase()} ya pertenece a ${row.first_name} ${row.last_name_p}`;
|
|
}
|
|
}
|
|
return c.json({ ok: Object.keys(errors).length === 0, errors });
|
|
});
|
|
|
|
app.get("/v1/workers", requireAuth, async (c) => {
|
|
const db = await getDb();
|
|
const tid = tenantScope(c.get("user"));
|
|
const q = (c.req.query("q") ?? "").trim();
|
|
const status = c.req.query("status");
|
|
const projectId = c.req.query("project_id");
|
|
let sql = `SELECT w.*, r.label AS risk_label, r.color AS risk_color, r.text_color AS risk_text,
|
|
c.name AS company_name, c.kind AS company_kind,
|
|
ic.name AS imss_company_name, ic.code AS imss_company_code, ic.registro_patronal AS imss_registro_patronal,
|
|
(SELECT GROUP_CONCAT(p.name, ', ') FROM assignments a JOIN projects p ON p.id = a.project_id
|
|
WHERE a.worker_id = w.id AND a.active = 1) AS proyectos,
|
|
(SELECT GROUP_CONCAT(p.code || '|' || replace(p.name, '|', '/'), ';;') FROM assignments a JOIN projects p ON p.id = a.project_id
|
|
WHERE a.worker_id = w.id AND a.active = 1) AS proyecto_pairs,
|
|
(SELECT GROUP_CONCAT(p.id, ',') FROM assignments a JOIN projects p ON p.id = a.project_id
|
|
WHERE a.worker_id = w.id AND a.active = 1) AS project_ids,
|
|
(SELECT COUNT(*) FROM document_types t
|
|
WHERE t.required = 1 AND NOT EXISTS (
|
|
SELECT 1 FROM documents d
|
|
WHERE d.worker_id = w.id AND d.type_code = t.code AND d.is_current = 1
|
|
)) AS missing_docs,
|
|
(SELECT COALESCE(SUM(l.balance), 0) FROM loans l WHERE l.worker_id = w.id AND l.balance > 0) AS loan_balance,
|
|
CASE WHEN EXISTS (SELECT 1 FROM assignments a WHERE a.worker_id = w.id AND a.active = 1) THEN 1 ELSE 0 END AS in_project
|
|
FROM workers w
|
|
JOIN risk_levels r ON r.code = w.risk_code
|
|
LEFT JOIN companies c ON c.id = w.company_id
|
|
LEFT JOIN companies ic ON ic.id = w.imss_company_id
|
|
WHERE 1=1`;
|
|
const params: (string | number)[] = [];
|
|
if (tid != null) {
|
|
sql += " AND w.tenant_id = ?";
|
|
params.push(tid);
|
|
}
|
|
if (status) {
|
|
sql += " AND w.status = ?";
|
|
params.push(status);
|
|
if (status === "activo") sql += " AND w.pipeline_status != 'baja'";
|
|
}
|
|
if (projectId) {
|
|
sql +=
|
|
" AND EXISTS (SELECT 1 FROM assignments a WHERE a.worker_id = w.id AND a.project_id = ? AND a.active = 1)";
|
|
params.push(Number(projectId));
|
|
}
|
|
if (q) {
|
|
sql +=
|
|
" AND (w.first_name LIKE ? OR w.last_name_p LIKE ? OR w.curp LIKE ? OR w.rfc LIKE ? OR w.nss LIKE ?)";
|
|
const like = `%${q}%`;
|
|
params.push(like, like, like, like, like);
|
|
}
|
|
sql += " ORDER BY w.last_name_p, w.first_name";
|
|
const rows = db.prepare(sql).all(...params) as Array<Record<string, unknown> & {
|
|
id: number;
|
|
status: string;
|
|
pipeline_status: string;
|
|
imss_status?: string;
|
|
in_project?: number;
|
|
}>;
|
|
const workers = rows.map((row) => {
|
|
const flags = imssFlagsFor(db, row.id);
|
|
return {
|
|
...row,
|
|
imss_status: flags.imss_status,
|
|
imss_ready: flags.imss_ready,
|
|
in_project_without_imss: flags.in_project_without_imss,
|
|
expediente_ok: flags.expediente_ok,
|
|
freshness_required: flags.freshness_required,
|
|
};
|
|
});
|
|
const active = workers.filter((w) => w.status === "activo" && w.pipeline_status !== "baja");
|
|
const withImss = active.filter((w) => w.imss_status === "alta").length;
|
|
const withoutImss = active.length - withImss;
|
|
const inProjectWithoutImss = active.filter((w) => w.in_project_without_imss).length;
|
|
const imssReady = active.filter((w) => w.imss_ready).length;
|
|
return c.json({
|
|
workers,
|
|
imss_stats: {
|
|
with_imss: withImss,
|
|
without_imss: withoutImss,
|
|
in_project_without_imss: inProjectWithoutImss,
|
|
imss_ready_count: imssReady,
|
|
},
|
|
});
|
|
});
|
|
|
|
app.get("/v1/workers/:id", requireAuth, async (c) => {
|
|
const id = Number(c.req.param("id"));
|
|
const db = await getDb();
|
|
const worker = db.prepare(
|
|
`SELECT w.*, r.label AS risk_label, r.color AS risk_color, r.text_color AS risk_text,
|
|
c.name AS company_name, c.kind AS company_kind,
|
|
ic.name AS imss_company_name, ic.code AS imss_company_code, ic.registro_patronal AS imss_registro_patronal
|
|
FROM workers w
|
|
JOIN risk_levels r ON r.code = w.risk_code
|
|
LEFT JOIN companies c ON c.id = w.company_id
|
|
LEFT JOIN companies ic ON ic.id = w.imss_company_id
|
|
WHERE w.id = ?`,
|
|
).get(id);
|
|
if (!worker) return c.json({ error: "No encontrado" }, 404);
|
|
const documents = db.prepare(
|
|
`SELECT id, type_code, original_name, mime, size_bytes, is_current, uploaded_at,
|
|
issued_at, expires_at, imss_company_id, imss_alta_at
|
|
FROM documents WHERE worker_id = ? ORDER BY uploaded_at DESC`,
|
|
).all(id);
|
|
const assignments = db.prepare(
|
|
`SELECT a.*, p.name AS project_name, p.code AS project_code FROM assignments a
|
|
JOIN projects p ON p.id = a.project_id WHERE a.worker_id = ?
|
|
ORDER BY a.active DESC, a.start_date DESC, a.id DESC`,
|
|
).all(id);
|
|
const loans = db.prepare("SELECT * FROM loans WHERE worker_id = ? ORDER BY id DESC").all(id);
|
|
const { items, freshness_required } = checklistItemsFor(db, id);
|
|
const flags = imssFlagsFor(db, id);
|
|
const docTypes = db.prepare(
|
|
`SELECT code, label, required, validity_mode, freshness_days, requires_issued_at, requires_expires_at
|
|
FROM document_types ORDER BY required DESC, label`,
|
|
).all();
|
|
return c.json({
|
|
worker: { ...worker, full_name: fullName(worker as never), ...flags },
|
|
documents,
|
|
assignments,
|
|
loans,
|
|
checklist: items,
|
|
document_types: docTypes,
|
|
freshness_required,
|
|
});
|
|
});
|
|
|
|
function conflict(db: Awaited<ReturnType<typeof getDb>>, n: ReturnType<typeof normalizeWorker>, excludeId = 0) {
|
|
const row = findExisting(db, n.curp, n.rfc, n.nss);
|
|
if (row && row.id !== excludeId) {
|
|
const field = row.curp === n.curp ? "CURP" : row.rfc === n.rfc ? "RFC" : "NSS";
|
|
return {
|
|
status: 409 as const,
|
|
body: {
|
|
error: `Este ${field} ya pertenece a ${row.first_name} ${row.last_name_p}`,
|
|
worker_id: row.id,
|
|
},
|
|
};
|
|
}
|
|
return null;
|
|
}
|
|
|
|
app.post("/v1/workers", requireAuth, async (c) => {
|
|
const body = await c.req.json<WorkerInput & { project_id?: number }>();
|
|
const errors = validateWorkerFields(body);
|
|
const db = await getDb();
|
|
if (Object.keys(errors).length) return c.json({ errors }, 400);
|
|
const n = normalizeWorker({ ...body, hire_type: "" });
|
|
const cf = conflict(db, n);
|
|
if (cf) return c.json(cf.body, cf.status);
|
|
if (body.project_id) {
|
|
const blocked = projectMustBe(
|
|
projectById(db, body.project_id),
|
|
["activo"],
|
|
"Solo se asigna personal a proyectos activos",
|
|
);
|
|
if (blocked) return c.json({ error: blocked.error }, blocked.status);
|
|
}
|
|
db.prepare(
|
|
`INSERT INTO workers
|
|
(first_name, middle_name, last_name_p, last_name_m, curp, rfc, nss, phone, email, address,
|
|
blood_type, hire_type, company_id, position, risk_code, work_type, daily_wage, needs_badge, status, tenant_id)
|
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
|
).run(
|
|
n.first_name, n.middle_name, n.last_name_p, n.last_name_m, n.curp, n.rfc, n.nss,
|
|
n.phone, n.email, n.address, n.blood_type, "", null, n.position, n.risk_code,
|
|
n.work_type, n.daily_wage, n.needs_badge, n.status, tenantScope(c.get("user")),
|
|
);
|
|
const id = lastInsertId(db);
|
|
if (body.project_id) {
|
|
assign(db, id, body.project_id);
|
|
}
|
|
refreshPipeline(db, id);
|
|
return c.json({ id }, 201);
|
|
});
|
|
|
|
app.patch("/v1/workers/:id", requireAuth, async (c) => {
|
|
const id = Number(c.req.param("id"));
|
|
const db = await getDb();
|
|
const cur = db.prepare("SELECT * FROM workers WHERE id = ?").get(id) as Record<string, unknown> | undefined;
|
|
if (!cur) return c.json({ error: "No encontrado" }, 404);
|
|
const body = await c.req.json<WorkerInput>();
|
|
const merged = { ...cur, ...body } as WorkerInput;
|
|
const errors = validateWorkerFields(merged);
|
|
if (Object.keys(errors).length) return c.json({ errors }, 400);
|
|
// Empresa/patrón solo cambia con alta/baja IMSS; no se sobrescribe desde el formulario.
|
|
const hireType = String(cur.hire_type || "");
|
|
const companyId = (cur.company_id as number | null) ?? null;
|
|
const n = normalizeWorker({ ...merged, hire_type: hireType });
|
|
const cf = conflict(db, n, id);
|
|
if (cf) return c.json(cf.body, cf.status);
|
|
db.prepare(
|
|
`UPDATE workers SET
|
|
first_name=?, middle_name=?, last_name_p=?, last_name_m=?, curp=?, rfc=?, nss=?,
|
|
phone=?, email=?, address=?, blood_type=?, hire_type=?, company_id=?, position=?, risk_code=?,
|
|
work_type=?, daily_wage=?, needs_badge=?, status=?, updated_at=datetime('now')
|
|
WHERE id=?`,
|
|
).run(
|
|
n.first_name, n.middle_name, n.last_name_p, n.last_name_m, n.curp, n.rfc, n.nss,
|
|
n.phone, n.email, n.address, n.blood_type, hireType, companyId, n.position, n.risk_code,
|
|
n.work_type, n.daily_wage, n.needs_badge, n.status, id,
|
|
);
|
|
refreshPipeline(db, id);
|
|
return c.json({ ok: true });
|
|
});
|
|
|
|
app.patch("/v1/workers/:id/pipeline", requireAuth, async (c) => {
|
|
const id = Number(c.req.param("id"));
|
|
const { pipeline_status } = await c.req.json<{ pipeline_status: string }>();
|
|
const allowed = ["incompleto", "listo_gafete", "impreso", "activo", "baja"];
|
|
if (!allowed.includes(pipeline_status)) return c.json({ error: "Estado inválido" }, 400);
|
|
const db = await getDb();
|
|
const prev = db.prepare("SELECT status FROM workers WHERE id=?").get(id) as { status: string } | undefined;
|
|
if (pipeline_status === "baja") {
|
|
db.prepare("UPDATE workers SET status='baja', pipeline_status='baja' WHERE id=?").run(id);
|
|
} else {
|
|
// Reactivar: si venía de baja, marca rehire para exigir docs frescos hasta nueva alta IMSS.
|
|
if (prev?.status === "baja") {
|
|
db.prepare(
|
|
"UPDATE workers SET status='activo', last_rehire_at=date('now') WHERE id=?",
|
|
).run(id);
|
|
} else {
|
|
db.prepare("UPDATE workers SET status='activo' WHERE id=?").run(id);
|
|
}
|
|
refreshPipeline(db, id);
|
|
}
|
|
const worker = db.prepare("SELECT status, pipeline_status, last_rehire_at, imss_status FROM workers WHERE id=?").get(id);
|
|
return c.json({ ok: true, worker });
|
|
});
|
|
|
|
app.post("/v1/workers/:id/assign", requireAuth, async (c) => {
|
|
const id = Number(c.req.param("id"));
|
|
const { project_id, active } = await c.req.json<{ project_id: number; active?: boolean }>();
|
|
const db = await getDb();
|
|
if (active === false) {
|
|
db.prepare(
|
|
"UPDATE assignments SET active=0, end_date=date('now') WHERE worker_id=? AND project_id=?",
|
|
).run(id, project_id);
|
|
} else {
|
|
const project = projectById(db, project_id);
|
|
if (!project) return c.json({ error: "Proyecto no encontrado" }, 404);
|
|
if (project.status !== "activo") {
|
|
return c.json({ error: "Solo se asigna personal a proyectos activos" }, 400);
|
|
}
|
|
assign(db, id, project_id);
|
|
}
|
|
refreshPipeline(db, id);
|
|
return c.json({ ok: true });
|
|
});
|
|
|
|
app.get("/v1/workers/import/template", requireAuth, async (c) => {
|
|
const db = await getDb();
|
|
const bytes = buildImportTemplate(listCompanies(db));
|
|
c.header("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
|
|
c.header("Content-Disposition", 'attachment; filename="plantilla-padron-arctec.xlsx"');
|
|
return c.body(bytes.buffer as ArrayBuffer);
|
|
});
|
|
|
|
app.post("/v1/workers/import", requireAuth, async (c) => {
|
|
const form = await c.req.formData();
|
|
const file = form.get("file");
|
|
const projectId = Number(form.get("project_id") || 0) || null;
|
|
if (!(file instanceof File)) return c.json({ error: "Archivo Excel requerido" }, 400);
|
|
const bytes = new Uint8Array(await file.arrayBuffer());
|
|
const db = await getDb();
|
|
if (projectId) {
|
|
const blocked = projectMustBe(
|
|
projectById(db, projectId),
|
|
["activo"],
|
|
"Solo se importan altas a proyectos activos",
|
|
);
|
|
if (blocked) return c.json({ error: blocked.error }, blocked.status);
|
|
}
|
|
const report = await importExcel(db, bytes, projectId, c.get("user").id || null);
|
|
return c.json(report);
|
|
});
|
|
|
|
app.post("/v1/workers/:id/documents", requireAuth, async (c) => {
|
|
const id = Number(c.req.param("id"));
|
|
const form = await c.req.formData();
|
|
const type = String(form.get("type") || "");
|
|
const file = form.get("file");
|
|
if (!(file instanceof File) || !type) return c.json({ error: "type y file requeridos" }, 400);
|
|
const db = await getDb();
|
|
const exists = db.prepare("SELECT id FROM workers WHERE id=?").get(id);
|
|
if (!exists) return c.json({ error: "No encontrado" }, 404);
|
|
|
|
const policy = db.prepare(
|
|
`SELECT validity_mode, requires_issued_at, requires_expires_at FROM document_types WHERE code = ?`,
|
|
).get(type) as {
|
|
validity_mode: string;
|
|
requires_issued_at: number;
|
|
requires_expires_at: number;
|
|
} | undefined;
|
|
if (!policy) return c.json({ error: "Tipo de documento no válido" }, 400);
|
|
|
|
const issuedAt = String(form.get("issued_at") || "").trim() || null;
|
|
const expiresAt = String(form.get("expires_at") || "").trim() || null;
|
|
const imssCompanyId = Number(form.get("imss_company_id") || 0) || null;
|
|
const imssAltaAt = String(form.get("imss_alta_at") || "").trim() || null;
|
|
const imssBajaAt = String(form.get("imss_baja_at") || "").trim() || null;
|
|
|
|
if (policy.requires_issued_at && !issuedAt) {
|
|
return c.json({ error: "Indique la fecha de emisión del documento" }, 400);
|
|
}
|
|
if (policy.requires_expires_at && !expiresAt) {
|
|
return c.json({ error: "Indique la fecha de vigencia / vencimiento" }, 400);
|
|
}
|
|
if (type === "alta_imss" && !imssCompanyId) {
|
|
return c.json({ error: "Seleccione la empresa patrón del alta IMSS" }, 400);
|
|
}
|
|
if (type === "alta_imss" && !imssAltaAt) {
|
|
return c.json({ error: "Indique la fecha de alta IMSS" }, 400);
|
|
}
|
|
if (type === "baja_imss" && !imssBajaAt) {
|
|
return c.json({ error: "Indique la fecha de baja IMSS" }, 400);
|
|
}
|
|
|
|
const bytes = new Uint8Array(await file.arrayBuffer());
|
|
try {
|
|
await storeDocument(db, id, type, file.name, file.type || "application/octet-stream", bytes, c.get("user").id, {
|
|
issued_at: issuedAt,
|
|
expires_at: expiresAt,
|
|
imss_company_id: imssCompanyId,
|
|
imss_alta_at: imssAltaAt,
|
|
imss_baja_at: imssBajaAt,
|
|
});
|
|
} catch (error) {
|
|
return c.json({ error: error instanceof Error ? error.message : "No se pudo guardar" }, 400);
|
|
}
|
|
return c.json({
|
|
ok: true,
|
|
checklist: checklistFor(db, id),
|
|
...imssFlagsFor(db, id),
|
|
});
|
|
});
|
|
|
|
app.get("/v1/workers/:id/documents/:docId", requireAuth, async (c) => {
|
|
const workerId = Number(c.req.param("id"));
|
|
const docId = Number(c.req.param("docId"));
|
|
const db = await getDb();
|
|
const doc = db.prepare(
|
|
"SELECT * FROM documents WHERE id=? AND worker_id=?",
|
|
).get(docId, workerId) as {
|
|
storage_name: string;
|
|
iv: string;
|
|
mime: string;
|
|
original_name: string;
|
|
} | undefined;
|
|
if (!doc) return c.json({ error: "Documento no encontrado" }, 404);
|
|
const enc = await Deno.readFile(join(workerDir(workerId), doc.storage_name));
|
|
const plain = await decryptBytes(doc.iv, enc);
|
|
c.header("Content-Type", doc.mime);
|
|
c.header("Content-Disposition", `inline; filename="${doc.original_name}"`);
|
|
return c.body(plain.buffer as ArrayBuffer);
|
|
});
|
|
|
|
app.get("/v1/workers/:id/photo", requireAuth, async (c) => {
|
|
const id = Number(c.req.param("id"));
|
|
const db = await getDb();
|
|
const bytes = await loadCurrentPhoto(db, id);
|
|
if (!bytes) return c.json({ error: "Sin foto" }, 404);
|
|
const jpeg = bytes[0] === 0xff && bytes[1] === 0xd8;
|
|
c.header("Content-Type", jpeg ? "image/jpeg" : "image/png");
|
|
c.header("Cache-Control", "private, max-age=30");
|
|
return c.body(bytes.buffer as ArrayBuffer);
|
|
});
|
|
|
|
app.get("/v1/badge-qr", requireAuth, async (c) => {
|
|
const curp = (c.req.query("curp") ?? "").trim().toUpperCase();
|
|
if (curp.length < 10) return c.json({ error: "CURP requerida" }, 400);
|
|
const png = await badgeQrPng(curp);
|
|
c.header("Content-Type", "image/png");
|
|
c.header("Cache-Control", "private, max-age=60");
|
|
return c.body(png.buffer as ArrayBuffer);
|
|
});
|
|
|
|
app.post("/v1/projects/:id/badge-jobs", requireAuth, async (c) => {
|
|
const projectId = Number(c.req.param("id"));
|
|
const body = await c.req.json<{ worker_ids?: number[] }>().catch(() => ({ worker_ids: [] as number[] }));
|
|
const db = await getDb();
|
|
const blocked = projectMustBe(
|
|
projectById(db, projectId),
|
|
["activo"],
|
|
"Solo se generan gafetes de proyectos activos",
|
|
);
|
|
if (blocked) return c.json({ error: blocked.error }, blocked.status);
|
|
let ids = body.worker_ids ?? [];
|
|
if (!ids.length) {
|
|
ids = (db.prepare(
|
|
`SELECT w.id FROM workers w
|
|
JOIN assignments a ON a.worker_id = w.id AND a.project_id = ? AND a.active = 1
|
|
WHERE w.status = 'activo' AND w.needs_badge = 1
|
|
AND EXISTS (SELECT 1 FROM documents d WHERE d.worker_id=w.id AND d.type_code='foto' AND d.is_current=1)`,
|
|
).all(projectId) as { id: number }[]).map((r) => r.id);
|
|
}
|
|
if (!ids.length) return c.json({ error: "No hay personal activo con foto para imprimir" }, 400);
|
|
const bytes = await generateBadgePdf(db, projectId, ids);
|
|
db.prepare(
|
|
"INSERT INTO badge_jobs (project_id, status, created_by) VALUES (?, 'done', ?)",
|
|
).run(projectId, c.get("user").id);
|
|
const jobId = lastInsertId(db);
|
|
const path = await saveJobPdf(bytes, jobId);
|
|
db.prepare("UPDATE badge_jobs SET pdf_path=? WHERE id=?").run(path, jobId);
|
|
const ins = db.prepare("INSERT INTO badge_job_people (job_id, worker_id) VALUES (?, ?)");
|
|
for (const wid of ids) {
|
|
ins.run(jobId, wid);
|
|
refreshPipeline(db, wid);
|
|
}
|
|
return c.json({ id: jobId, count: ids.length });
|
|
});
|
|
|
|
app.get("/v1/badge-jobs", requireAuth, async (c) => {
|
|
const db = await getDb();
|
|
const jobs = db.prepare(
|
|
`SELECT j.*, p.name AS project_name,
|
|
(SELECT COUNT(*) FROM badge_job_people x WHERE x.job_id=j.id) AS people,
|
|
(SELECT COUNT(*) FROM badge_job_people x WHERE x.job_id=j.id AND x.delivered=1) AS delivered
|
|
FROM badge_jobs j JOIN projects p ON p.id=j.project_id ORDER BY j.id DESC`,
|
|
).all();
|
|
return c.json({ jobs });
|
|
});
|
|
|
|
app.get("/v1/badge-jobs/:id", requireAuth, async (c) => {
|
|
const id = Number(c.req.param("id"));
|
|
const db = await getDb();
|
|
const job = db.prepare("SELECT * FROM badge_jobs WHERE id=?").get(id);
|
|
if (!job) return c.json({ error: "Job no encontrado" }, 404);
|
|
const people = db.prepare(
|
|
`SELECT p.*, w.first_name, w.last_name_p, w.position FROM badge_job_people p
|
|
JOIN workers w ON w.id = p.worker_id WHERE p.job_id=?`,
|
|
).all(id);
|
|
return c.json({ job, people });
|
|
});
|
|
|
|
app.get("/v1/badge-jobs/:id/pdf", requireAuth, async (c) => {
|
|
const id = Number(c.req.param("id"));
|
|
const db = await getDb();
|
|
const job = db.prepare("SELECT pdf_path FROM badge_jobs WHERE id=?").get(id) as { pdf_path: string } | undefined;
|
|
if (!job?.pdf_path) return c.json({ error: "PDF no encontrado" }, 404);
|
|
const bytes = await Deno.readFile(job.pdf_path);
|
|
c.header("Content-Type", "application/pdf");
|
|
c.header("Content-Disposition", `attachment; filename="gafetes-${id}.pdf"`);
|
|
return c.body(bytes.buffer as ArrayBuffer);
|
|
});
|
|
|
|
app.patch("/v1/badge-jobs/:id/people/:workerId", requireAuth, async (c) => {
|
|
const jobId = Number(c.req.param("id"));
|
|
const workerId = Number(c.req.param("workerId"));
|
|
const { delivered } = await c.req.json<{ delivered: boolean }>();
|
|
const db = await getDb();
|
|
db.prepare(
|
|
`UPDATE badge_job_people SET delivered=?, delivered_at=CASE WHEN ? THEN datetime('now') ELSE NULL END
|
|
WHERE job_id=? AND worker_id=?`,
|
|
).run(delivered ? 1 : 0, delivered ? 1 : 0, jobId, workerId);
|
|
return c.json({ ok: true });
|
|
});
|
|
|
|
app.get("/v1/attendance", requireAuth, async (c) => {
|
|
const projectId = Number(c.req.query("project_id"));
|
|
const from = c.req.query("from") ?? "";
|
|
const to = c.req.query("to") ?? "";
|
|
const db = await getDb();
|
|
const rows = db.prepare(
|
|
`SELECT a.*, w.first_name, w.last_name_p FROM attendance a
|
|
JOIN workers w ON w.id=a.worker_id
|
|
WHERE a.project_id=? AND a.work_date BETWEEN ? AND ?
|
|
ORDER BY a.work_date, w.last_name_p`,
|
|
).all(projectId, from, to);
|
|
return c.json({ attendance: rows });
|
|
});
|
|
|
|
app.put("/v1/attendance", requireAuth, async (c) => {
|
|
const body = await c.req.json<{
|
|
project_id: number;
|
|
worker_id: number;
|
|
work_date: string;
|
|
present: boolean;
|
|
}>();
|
|
const db = await getDb();
|
|
const blocked = projectMustBe(
|
|
projectById(db, body.project_id),
|
|
["activo", "pausado"],
|
|
"No se registra asistencia en un proyecto concluido o cancelado",
|
|
);
|
|
if (blocked) return c.json({ error: blocked.error }, blocked.status);
|
|
db.prepare(
|
|
`INSERT INTO attendance (worker_id, project_id, work_date, present)
|
|
VALUES (?, ?, ?, ?)
|
|
ON CONFLICT(worker_id, project_id, work_date) DO UPDATE SET present=excluded.present`,
|
|
).run(body.worker_id, body.project_id, body.work_date, body.present ? 1 : 0);
|
|
return c.json({ ok: true });
|
|
});
|
|
|
|
app.get("/v1/loans", requireAuth, async (c) => {
|
|
const db = await getDb();
|
|
const workerId = c.req.query("worker_id");
|
|
const sql = workerId
|
|
? "SELECT l.*, w.first_name, w.last_name_p FROM loans l JOIN workers w ON w.id=l.worker_id WHERE l.worker_id=? ORDER BY l.id DESC"
|
|
: "SELECT l.*, w.first_name, w.last_name_p FROM loans l JOIN workers w ON w.id=l.worker_id ORDER BY l.id DESC";
|
|
const loans = workerId ? db.prepare(sql).all(Number(workerId)) : db.prepare(sql).all();
|
|
return c.json({ loans });
|
|
});
|
|
|
|
app.post("/v1/loans", requireAuth, async (c) => {
|
|
const body = await c.req.json<{
|
|
worker_id: number;
|
|
amount: number;
|
|
weekly_payment: number;
|
|
note?: string;
|
|
}>();
|
|
if (!body.worker_id || !body.amount) return c.json({ error: "worker_id y amount requeridos" }, 400);
|
|
const db = await getDb();
|
|
db.prepare(
|
|
"INSERT INTO loans (worker_id, amount, balance, weekly_payment, note) VALUES (?, ?, ?, ?, ?)",
|
|
).run(body.worker_id, body.amount, body.amount, body.weekly_payment ?? 0, body.note ?? null);
|
|
return c.json({ id: lastInsertId(db) }, 201);
|
|
});
|
|
|
|
app.post("/v1/payroll/periods", requireAuth, async (c) => {
|
|
const body = await c.req.json<{
|
|
project_id: number;
|
|
week_start: string;
|
|
week_end: string;
|
|
extra_discounts?: Record<number, number>;
|
|
}>();
|
|
const db = await getDb();
|
|
const blocked = projectMustBe(
|
|
projectById(db, body.project_id),
|
|
["activo", "pausado"],
|
|
"No se genera nómina de un proyecto concluido o cancelado",
|
|
);
|
|
if (blocked) return c.json({ error: blocked.error }, blocked.status);
|
|
db.prepare(
|
|
"INSERT INTO payroll_periods (project_id, week_start, week_end, status) VALUES (?, ?, ?, 'draft')",
|
|
).run(body.project_id, body.week_start, body.week_end);
|
|
const periodId = lastInsertId(db);
|
|
const workers = db.prepare(
|
|
`SELECT w.id, w.daily_wage FROM workers w
|
|
JOIN assignments a ON a.worker_id=w.id AND a.project_id=? AND a.active=1
|
|
WHERE w.status='activo'`,
|
|
).all(body.project_id) as { id: number; daily_wage: number }[];
|
|
|
|
const ins = db.prepare(
|
|
`INSERT INTO payroll_lines (period_id, worker_id, days, daily_wage, gross, discounts, loan_payment, net)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
);
|
|
for (const w of workers) {
|
|
const att = db.prepare(
|
|
`SELECT COUNT(*) AS n FROM attendance
|
|
WHERE worker_id=? AND project_id=? AND work_date BETWEEN ? AND ? AND present=1`,
|
|
).get(w.id, body.project_id, body.week_start, body.week_end) as { n: number };
|
|
const days = att.n;
|
|
const gross = days * w.daily_wage;
|
|
const extra = Number(body.extra_discounts?.[w.id] ?? 0);
|
|
const loan = db.prepare(
|
|
"SELECT id, balance, weekly_payment FROM loans WHERE worker_id=? AND balance>0 ORDER BY id LIMIT 1",
|
|
).get(w.id) as { id: number; balance: number; weekly_payment: number } | undefined;
|
|
let loanPay = 0;
|
|
if (loan) {
|
|
loanPay = Math.min(loan.weekly_payment, loan.balance, Math.max(0, gross - extra));
|
|
db.prepare("UPDATE loans SET balance = balance - ? WHERE id=?").run(loanPay, loan.id);
|
|
}
|
|
const net = gross - extra - loanPay;
|
|
ins.run(periodId, w.id, days, w.daily_wage, gross, extra, loanPay, net);
|
|
}
|
|
return c.json({ id: periodId });
|
|
});
|
|
|
|
app.get("/v1/payroll/periods", requireAuth, async (c) => {
|
|
const db = await getDb();
|
|
const projectId = c.req.query("project_id");
|
|
const sql = projectId
|
|
? `SELECT pe.*, p.name AS project_name FROM payroll_periods pe JOIN projects p ON p.id=pe.project_id WHERE pe.project_id=? ORDER BY pe.id DESC`
|
|
: `SELECT pe.*, p.name AS project_name FROM payroll_periods pe JOIN projects p ON p.id=pe.project_id ORDER BY pe.id DESC`;
|
|
const periods = projectId ? db.prepare(sql).all(Number(projectId)) : db.prepare(sql).all();
|
|
return c.json({ periods });
|
|
});
|
|
|
|
app.get("/v1/payroll/periods/:id", requireAuth, async (c) => {
|
|
const id = Number(c.req.param("id"));
|
|
const db = await getDb();
|
|
const period = db.prepare(
|
|
`SELECT pe.*, p.name AS project_name FROM payroll_periods pe JOIN projects p ON p.id=pe.project_id WHERE pe.id=?`,
|
|
).get(id);
|
|
if (!period) return c.json({ error: "Periodo no encontrado" }, 404);
|
|
const lines = db.prepare(
|
|
`SELECT l.*, w.first_name, w.last_name_p, w.position FROM payroll_lines l
|
|
JOIN workers w ON w.id=l.worker_id WHERE l.period_id=? ORDER BY w.last_name_p`,
|
|
).all(id);
|
|
return c.json({ period, lines });
|
|
});
|
|
|
|
app.get("/v1/payroll/periods/:id/csv", requireAuth, async (c) => {
|
|
const id = Number(c.req.param("id"));
|
|
const db = await getDb();
|
|
const lines = db.prepare(
|
|
`SELECT w.first_name, w.last_name_p, l.days, l.daily_wage, l.gross, l.discounts, l.loan_payment, l.net
|
|
FROM payroll_lines l JOIN workers w ON w.id=l.worker_id WHERE l.period_id=?`,
|
|
).all(id) as Record<string, unknown>[];
|
|
const header = "Nombre,Apellido,Dias,Jornal,Bruto,Descuentos,Prestamo,Neto";
|
|
const rows = lines.map((l) =>
|
|
[l.first_name, l.last_name_p, l.days, l.daily_wage, l.gross, l.discounts, l.loan_payment, l.net].join(",")
|
|
);
|
|
const csv = [header, ...rows].join("\n");
|
|
c.header("Content-Type", "text/csv; charset=utf-8");
|
|
c.header("Content-Disposition", `attachment; filename="nomina-${id}.csv"`);
|
|
return c.body(csv);
|
|
});
|
|
|
|
app.patch("/v1/payroll/periods/:id", requireAuth, async (c) => {
|
|
const id = Number(c.req.param("id"));
|
|
const { status } = await c.req.json<{ status: string }>();
|
|
const db = await getDb();
|
|
db.prepare("UPDATE payroll_periods SET status=? WHERE id=?").run(status, id);
|
|
return c.json({ ok: true });
|
|
});
|
|
|
|
const port = config.port;
|
|
await getDb();
|
|
Deno.serve({ port, hostname: "0.0.0.0" }, app.fetch);
|
|
console.log(`API panel-obra en http://127.0.0.1:${port}`);
|