mirror of
https://origin.cursor.com/mrdevmx/panels.git
synced 2026-10-09 10:43:18 +00:00
396 lines
11 KiB
TypeScript
396 lines
11 KiB
TypeScript
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;
|
|
}
|
|
|
|
function wrap(text: string, font: PDFFont, size: number, max: number): string[] {
|
|
const words = text.split(/\s+/);
|
|
const lines: string[] = [];
|
|
let cur = "";
|
|
for (const word of words) {
|
|
const next = cur ? `${cur} ${word}` : word;
|
|
if (font.widthOfTextAtSize(next, size) > max && cur) {
|
|
lines.push(cur);
|
|
cur = word;
|
|
} else {
|
|
cur = next;
|
|
}
|
|
}
|
|
if (cur) lines.push(cur);
|
|
return lines;
|
|
}
|
|
|
|
export async function generateLoanReceiptPdf(opts: {
|
|
title: string;
|
|
subtitle: string;
|
|
person: string;
|
|
lines: { label: string; value: string }[];
|
|
}): Promise<Uint8Array> {
|
|
const pdf = await PDFDocument.create();
|
|
const page = pdf.addPage([612, 792]);
|
|
const font = await pdf.embedFont(StandardFonts.Helvetica);
|
|
const bold = await pdf.embedFont(StandardFonts.HelveticaBold);
|
|
page.drawText("ARCTEC / PANEL OBRA", { x: 56, y: 740, size: 11, font: bold, color: rgb(0.12, 0.18, 0.28) });
|
|
page.drawText(opts.title, { x: 56, y: 710, size: 18, font: bold });
|
|
page.drawText(opts.subtitle, { x: 56, y: 688, size: 10, font, color: rgb(0.3, 0.3, 0.3) });
|
|
page.drawText(opts.person, { x: 56, y: 650, size: 14, font: bold });
|
|
let y = 610;
|
|
for (const row of opts.lines) {
|
|
page.drawText(row.label, { x: 56, y, size: 10, font, color: rgb(0.35, 0.35, 0.35) });
|
|
const valueLines = wrap(row.value, bold, 11, 280);
|
|
let vy = y;
|
|
for (const line of valueLines) {
|
|
page.drawText(line, { x: 280, y: vy, size: 11, font: bold });
|
|
vy -= 14;
|
|
}
|
|
y = Math.min(y, vy) - 18;
|
|
}
|
|
page.drawText("Recibo interno. No es un CFDI.", { x: 56, y: 80, size: 9, font, color: rgb(0.4, 0.4, 0.4) });
|
|
return await pdf.save();
|
|
}
|