- Las notas "por pagar" ahora incluyen los datos bancarios de cobro configurados (Transferencia Nacional/Internacional: cuenta, CLABE, beneficiario, RFC, banco, SWIFT), igual que el PDF de cotizacion. Solo se muestran en las notas por pagar. - El nombre del PDF incluye el periodo cobrado (fecha minima a maxima de los registros), formato DD-MM-YYYY_DD-MM-YYYY. Ej: "Nota de horas por pagar UJ2606AG777 - 24-06-2026_24-06-2026". Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
273 lines
12 KiB
TypeScript
273 lines
12 KiB
TypeScript
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<Buffer | undefined> {
|
|
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<Buffer> {
|
|
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();
|
|
});
|
|
}
|