import PDFDocument from "pdfkit"; import { formatCurrency, formatFechaRegistro } from "./calculators"; export interface NotaHorasRegistro { fecha: Date; horaInicio: string; horaFin: string; horas: number; tarifaHora: number; descripcion: string; estadoPago: string; } export interface NotaHorasGrupo { etiqueta: string; count: number; horas: number; importe: number; } export interface NotaHorasPDFData { numero: string; proyecto: string; clienteNombre: string; clienteEmpresa: string | null; titulo: string; // "Nota de horas por pagar" | "Recibo de horas pagadas" | "Estado de cuenta de horas" emitida: Date; periodoTexto: string; modoLabel: string; modo: "detalle" | "dia" | "semana" | "mes"; mostrarColEstado: boolean; detalle: NotaHorasRegistro[]; grupos: NotaHorasGrupo[]; incluirIva: boolean; totalHoras: number; subtotal: number; iva: number; total: number; etiquetaTotal: string; // "Total a pagar" | "Total pagado" | "Total" // Datos bancarios para el cobro (se muestran solo en las notas "por pagar"). datosPago?: { razonSocial: string; rfc: string; cuentaNacional: string; clabeNacional: string; bancoNacional: string; clabeInternacional: string; swift: string; }; colorPrimario?: string; colorSecundario?: string; logoBase64?: string; logoMime?: string; } // pdfkit solo soporta PNG/JPEG. Cualquier otro formato (webp, svg, ...) se convierte // a PNG con sharp para que el logo si aparezca en el PDF. async function toPngBuffer(base64: string, mime?: string): Promise { try { const buf = Buffer.from(base64, "base64"); if (mime === "image/png" || mime === "image/jpeg" || mime === "image/jpg") return buf; const sharp = (await import("sharp")).default; return await sharp(buf).png().toBuffer(); } catch { return undefined; } } export async function generateNotaHorasPDF(data: NotaHorasPDFData): Promise { const logoBuffer = data.logoBase64 ? await toPngBuffer(data.logoBase64, data.logoMime) : undefined; return new Promise((resolve, reject) => { const PRIMARY = data.colorPrimario || "#2563eb"; const DARK = data.colorSecundario || "#111827"; const MUTED = "#6b7280"; const BORDER = "#d1d5db"; const HEAD_BG = "#f3f4f6"; const doc = new PDFDocument({ size: "LETTER", margins: { top: 45, bottom: 55, left: 50, right: 50 } }); const chunks: Buffer[] = []; doc.on("data", (c: Buffer) => chunks.push(c)); doc.on("end", () => resolve(Buffer.concat(chunks))); doc.on("error", reject); const pw = doc.page.width; const ph = doc.page.height; const m = doc.page.margins; const W = pw - m.left - m.right; const L = m.left; const maxY = ph - m.bottom; function need(h: number, y: number): number { if (y + h > maxY) { doc.addPage(); return m.top; } return y; } // ── ENCABEZADO ──────────────────────────────── let y = m.top; if (logoBuffer) { try { doc.image(logoBuffer, L, y, { fit: [46, 46] }); } catch {} } const txtX = logoBuffer ? L + 58 : L; doc.font("Helvetica-Bold").fontSize(20).fillColor(DARK).text(data.titulo, txtX, y + 2, { width: W - 170 }); doc.font("Helvetica").fontSize(9).fillColor(MUTED) .text(`Cotizacion ${data.numero} · ${data.proyecto}`, txtX, doc.y + 1, { width: W - 170 }); doc.font("Helvetica").fontSize(9).fillColor(MUTED) .text(`Emitida: ${formatFechaRegistro(data.emitida)}`, L, y + 2, { width: W, align: "right" }); y = Math.max(y + 50, doc.y + 6); doc.moveTo(L, y).lineTo(L + W, y).strokeColor(PRIMARY).lineWidth(2.5).stroke(); y += 14; // ── META ────────────────────────────────────── const cliente = data.clienteEmpresa || data.clienteNombre; doc.font("Helvetica-Bold").fontSize(9.5).fillColor(DARK).text("Cliente: ", L, y, { continued: true }); doc.font("Helvetica").fillColor(DARK).text(cliente); y = doc.y + 2; if (data.clienteEmpresa) { doc.font("Helvetica-Bold").fontSize(9.5).fillColor(DARK).text("Contacto: ", L, y, { continued: true }); doc.font("Helvetica").fillColor(DARK).text(data.clienteNombre); y = doc.y + 2; } doc.font("Helvetica").fontSize(8.5).fillColor(MUTED) .text(`${data.periodoTexto} · Desglose ${data.modoLabel.toLowerCase()}`, L, y); y = doc.y + 12; // ── TABLA ───────────────────────────────────── const money = (n: number) => formatCurrency(n); if (data.modo === "detalle") { const wEstado = data.mostrarColEstado ? 58 : 0; const cFecha = L; const wFecha = 62, wHorario = 72, wHoras = 42, wTarifa = 52, wImporte = 58; const cHorario = cFecha + wFecha; const cHoras = cHorario + wHorario; const cTarifa = cHoras + wHoras; const cImporte = cTarifa + wTarifa; const cDesc = cImporte + wImporte + 6; const wDesc = L + W - cDesc - wEstado - (wEstado ? 6 : 0); const cEstado = L + W - wEstado; const header = (yy: number) => { doc.save(); doc.rect(L, yy, W, 16).fill(HEAD_BG); doc.restore(); doc.font("Helvetica-Bold").fontSize(7).fillColor(DARK); doc.text("FECHA", cFecha + 4, yy + 5); doc.text("HORARIO", cHorario + 4, yy + 5); doc.text("HORAS", cHoras, yy + 5, { width: wHoras - 2, align: "right" }); doc.text("TARIFA", cTarifa, yy + 5, { width: wTarifa - 2, align: "right" }); doc.text("IMPORTE", cImporte, yy + 5, { width: wImporte - 2, align: "right" }); doc.text("DESCRIPCION", cDesc, yy + 5, { width: wDesc }); if (wEstado) doc.text("ESTADO", cEstado, yy + 5, { width: wEstado, align: "left" }); return yy + 16; }; y = header(y); doc.font("Helvetica").fontSize(8).fillColor(DARK); for (const r of data.detalle) { const descH = doc.font("Helvetica").fontSize(8).heightOfString(r.descripcion, { width: wDesc }); const rowH = Math.max(16, descH + 8); if (y + rowH > maxY) { y = header(need(rowH + 16, y)); } doc.font("Helvetica").fontSize(8).fillColor(DARK); doc.text(formatFechaRegistro(r.fecha), cFecha + 4, y + 4, { width: wFecha - 4, lineBreak: false }); doc.text(`${r.horaInicio}-${r.horaFin}`, cHorario + 4, y + 4, { width: wHorario - 4, lineBreak: false }); doc.text(r.horas.toFixed(2), cHoras, y + 4, { width: wHoras - 2, align: "right" }); doc.text(money(r.tarifaHora), cTarifa, y + 4, { width: wTarifa - 2, align: "right" }); doc.text(money(r.horas * r.tarifaHora), cImporte, y + 4, { width: wImporte - 2, align: "right" }); doc.text(r.descripcion, cDesc, y + 4, { width: wDesc }); if (wEstado) doc.text(r.estadoPago === "pagada" ? "Pagada" : "Por pagar", cEstado, y + 4, { width: wEstado }); doc.moveTo(L, y + rowH).lineTo(L + W, y + rowH).strokeColor(BORDER).lineWidth(0.3).stroke(); y += rowH; } } else { const cPer = L, wPer = W - 90 - 90 - 90; const cReg = cPer + wPer, wReg = 90; const cHor = cReg + wReg, wHor = 90; const cImp = cHor + wHor, wImp = 90; const header = (yy: number) => { doc.save(); doc.rect(L, yy, W, 16).fill(HEAD_BG); doc.restore(); doc.font("Helvetica-Bold").fontSize(7).fillColor(DARK); doc.text(data.modoLabel.toUpperCase(), cPer + 4, yy + 5); doc.text("REGISTROS", cReg, yy + 5, { width: wReg - 2, align: "right" }); doc.text("HORAS", cHor, yy + 5, { width: wHor - 2, align: "right" }); doc.text("IMPORTE", cImp, yy + 5, { width: wImp - 2, align: "right" }); return yy + 16; }; y = header(y); doc.font("Helvetica").fontSize(8).fillColor(DARK); for (const g of data.grupos) { const rowH = 16; if (y + rowH > maxY) { y = header(need(rowH + 16, y)); } doc.font("Helvetica").fontSize(8).fillColor(DARK); doc.text(g.etiqueta, cPer + 4, y + 4, { width: wPer - 6 }); doc.text(String(g.count), cReg, y + 4, { width: wReg - 2, align: "right" }); doc.text(g.horas.toFixed(2), cHor, y + 4, { width: wHor - 2, align: "right" }); doc.text(money(g.importe), cImp, y + 4, { width: wImp - 2, align: "right" }); doc.moveTo(L, y + rowH).lineTo(L + W, y + rowH).strokeColor(BORDER).lineWidth(0.3).stroke(); y += rowH; } } // ── TOTALES ─────────────────────────────────── y += 12; const totW = 240; const totX = L + W - totW; const line = (label: string, val: string, bold = false, top = false) => { y = need(18, y); if (top) { doc.moveTo(totX, y).lineTo(totX + totW, y).strokeColor(PRIMARY).lineWidth(1.2).stroke(); y += 5; } doc.font(bold ? "Helvetica-Bold" : "Helvetica").fontSize(bold ? 12 : 9).fillColor(bold ? DARK : MUTED); doc.text(label, totX, y, { width: totW - 90 }); doc.font(bold ? "Helvetica-Bold" : "Helvetica").fontSize(bold ? 12 : 9).fillColor(bold ? DARK : DARK); doc.text(val, totX + totW - 90, y, { width: 90, align: "right" }); y += bold ? 18 : 15; }; line("Total de horas", `${data.totalHoras.toFixed(2)} h`); line("Subtotal", money(data.subtotal)); if (data.incluirIva) line("IVA (16%)", money(data.iva)); line(data.etiquetaTotal, money(data.total), true, true); // ── DATOS PARA EL PAGO (solo notas por pagar) ── const dp = data.datosPago; if (dp) { const boxH = 52; y = need(boxH + 30, y) + 16; doc.rect(L, y, 3, 10).fill(PRIMARY); doc.font("Helvetica-Bold").fontSize(10).fillColor(DARK).text("Datos para el pago", L + 10, y); y += 16; // Transferencia Nacional doc.save(); doc.rect(L, y, W, boxH).fill(HEAD_BG).stroke(BORDER); doc.font("Helvetica-Bold").fontSize(8).fillColor(DARK).text("Transferencia Nacional", L + 8, y + 5); doc.font("Helvetica").fontSize(8.5).fillColor(DARK); if (dp.cuentaNacional) doc.text(`Cuenta: ${dp.cuentaNacional}`, L + 8, y + 18, { lineBreak: false }); if (dp.clabeNacional) doc.text(`CLABE: ${dp.clabeNacional}`, L + W * 0.5, y + 18, { lineBreak: false }); doc.text(`Beneficiario: ${dp.razonSocial}`, L + 8, y + 30, { lineBreak: false }); doc.text(`RFC: ${dp.rfc}`, L + W * 0.5, y + 30, { lineBreak: false }); if (dp.bancoNacional) doc.text(`Banco: ${dp.bancoNacional}`, L + 8, y + 42, { lineBreak: false }); doc.restore(); y += boxH + 8; // Transferencia Internacional (si hay datos) if (dp.clabeInternacional || dp.swift) { y = need(boxH, y); doc.save(); doc.rect(L, y, W, boxH).fill(HEAD_BG).stroke(BORDER); doc.font("Helvetica-Bold").fontSize(8).fillColor(DARK).text("Transferencia Internacional", L + 8, y + 5); doc.font("Helvetica").fontSize(8.5).fillColor(DARK); doc.text(`Beneficiario: ${dp.razonSocial}`, L + 8, y + 18, { lineBreak: false }); if (dp.clabeInternacional) doc.text(`CLABE: ${dp.clabeInternacional}`, L + W * 0.5, y + 18, { lineBreak: false }); doc.text(`Banco: BBVA Mexico`, L + 8, y + 30, { lineBreak: false }); if (dp.swift) doc.text(`SWIFT: ${dp.swift}`, L + W * 0.5, y + 30, { lineBreak: false }); doc.restore(); y += boxH + 8; } } // ── PIE ─────────────────────────────────────── y = need(30, y) + 14; doc.font("Helvetica").fontSize(8).fillColor(MUTED).text( "Documento generado desde el Cotizador. Conteo de horas trabajadas para cobro del proyecto.", L, y, { width: W, align: "center" } ); doc.end(); }); }