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
@@ -1,7 +1,7 @@
"use client";
import { useMemo, useRef, useState } from "react";
import { Clock, Plus, Trash2, Pencil, Printer, Eye, X, CheckCircle2, CircleDashed } from "lucide-react";
import { Clock, Plus, Trash2, Pencil, Download, Eye, X, CheckCircle2, CircleDashed } from "lucide-react";
import {
formatCurrency,
formatFechaRegistro,
@@ -97,6 +97,7 @@ export function RegistroHorasPanel({
const [from, setFrom] = useState("");
const [to, setTo] = useState("");
const [saving, setSaving] = useState(false);
const [descargando, setDescargando] = useState(false);
const [togglingId, setTogglingId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
// HTML de la nota congelado al abrir la previsualización (null = modal cerrado).
@@ -254,12 +255,35 @@ export function RegistroHorasPanel({
// Congela el HTML de la nota (con branding) y abre el modal de previsualización.
const abrirPreview = () => setPreviewHtml(construirHTMLNota());
// Imprime / guarda como PDF solo el contenido de la nota previsualizada.
const imprimirDesdePreview = () => {
const w = iframeRef.current?.contentWindow;
if (!w) return;
w.focus();
w.print();
// Descarga la nota como PDF generado en el SERVIDOR (pdfkit). A diferencia de imprimir
// el HTML desde el navegador, este PDF no lleva encabezados/pies del navegador (URL,
// fecha, no. de pagina). Respeta el filtro de estado, periodo y desglose actuales.
const descargarNotaPDF = async () => {
setDescargando(true);
try {
const res = await fetch(`/api/cotizaciones/${cotizacionId}/nota-horas`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ estado: estadoFiltro, modo, from, to }),
});
if (!res.ok) {
alert("No se pudo generar el PDF");
return;
}
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${TITULO_NOTA[estadoFiltro]} ${numero}.pdf`;
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 2000);
} catch {
alert("Error de conexion al generar el PDF");
} finally {
setDescargando(false);
}
};
const badgeEstado = (estadoPago: string) => {
@@ -715,11 +739,12 @@ export function RegistroHorasPanel({
<h3 className="font-semibold text-sm truncate">{TITULO_NOTA[estadoFiltro]} {numero}</h3>
<div className="flex items-center gap-2 shrink-0">
<button
onClick={imprimirDesdePreview}
className="flex items-center gap-2 px-3 py-2 border border-border rounded-lg text-sm hover:bg-gray-50"
onClick={descargarNotaPDF}
disabled={descargando}
className="flex items-center gap-2 px-3 py-2 border border-border rounded-lg text-sm hover:bg-gray-50 disabled:opacity-50"
>
<Printer className="w-4 h-4 text-primary" />
Imprimir / Guardar PDF
<Download className="w-4 h-4 text-primary" />
{descargando ? "Generando…" : "Descargar PDF"}
</button>
<button
onClick={() => setPreviewHtml(null)}