Nota de horas: generar PDF en el servidor (sin URL del navegador)

Imprimir el HTML de la nota desde el navegador siempre inserta encabezado/pie con la
URL de la cotizacion, fecha y no. de pagina (el @page{margin:0} no lo evita al imprimir
un iframe). Se reemplaza por un PDF generado en el servidor con pdfkit, que no lleva
ningun encabezado del navegador.

- src/lib/nota-horas-pdf.ts: generateNotaHorasPDF (logo, titulo segun estado, tabla
  detalle/agrupada, columna Estado en "todas", totales con IVA opcional).
- API POST /api/cotizaciones/[id]/nota-horas: filtra por estado/periodo, agrupa y
  calcula totales del lado servidor y devuelve el PDF.
- Panel: el boton del modal pasa de "Imprimir" a "Descargar PDF" (baja el PDF del
  servidor); se conserva la vista previa HTML en pantalla.
- pdf-generator: toPngBuffer ahora convierte webp/otros a PNG con sharp, así el logo
  (webp) tambien aparece en el PDF de la cotizacion.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
urieljareth
2026-07-08 10:59:04 -06:00
co-authored by Claude Opus 4.8
parent 89d7028044
commit feb88de203
4 changed files with 405 additions and 16 deletions
+224
View File
@@ -0,0 +1,224 @@
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"
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);
// ── 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();
});
}