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:
co-authored by
Claude Opus 4.8
parent
89d7028044
commit
feb88de203
@@ -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();
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user