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
@@ -0,0 +1,140 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { getConfigBranding } from "@/lib/config-helpers";
import { generateNotaHorasPDF, type NotaHorasGrupo } from "@/lib/nota-horas-pdf";
import {
IVA_RATE,
MODOS_AGRUPACION,
periodoAgrupacion,
formatFechaRegistro,
esPagada,
sanitizeFilename,
type ModoAgrupacion,
} from "@/lib/calculators";
const TITULO: Record<string, string> = {
por_pagar: "Nota de horas por pagar",
pagada: "Recibo de horas pagadas",
todas: "Estado de cuenta de horas",
};
const ETIQUETA_TOTAL: Record<string, string> = {
por_pagar: "Total a pagar",
pagada: "Total pagado",
todas: "Total",
};
const MODOS = new Set(["detalle", "dia", "semana", "mes"]);
const ESTADOS = new Set(["por_pagar", "pagada", "todas"]);
const ISO_RE = /^\d{4}-\d{2}-\d{2}$/;
// POST /api/cotizaciones/:id/nota-horas
// Genera un PDF de servidor de la nota de horas (sin encabezados de navegador).
// body: { estado, modo, from?, to? } — replica el filtro/agrupacion del panel.
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const body = await request.json().catch(() => ({}));
const estado: string = ESTADOS.has(body?.estado) ? body.estado : "por_pagar";
const modo: ModoAgrupacion = MODOS.has(body?.modo) ? body.modo : "detalle";
const from: string = ISO_RE.test(body?.from) ? body.from : "";
const to: string = ISO_RE.test(body?.to) ? body.to : "";
const [cot, branding] = await Promise.all([
prisma.cotizacion.findUnique({
where: { id },
include: { cliente: true, registrosHoras: true },
}),
getConfigBranding(),
]);
if (!cot) return NextResponse.json({ error: "No encontrada" }, { status: 404 });
// Filtro por fecha + estado (misma logica que el panel)
let regs = cot.registrosHoras.filter((r) => {
const d = r.fecha.toISOString().slice(0, 10);
if (from && d < from) return false;
if (to && d > to) return false;
if (estado === "pagada") return esPagada(r.estadoPago);
if (estado === "por_pagar") return !esPagada(r.estadoPago);
return true;
});
regs = regs.sort((a, b) =>
a.fecha.getTime() === b.fecha.getTime()
? a.horaInicio.localeCompare(b.horaInicio)
: a.fecha.getTime() - b.fecha.getTime()
);
// Totales
const totalHoras = Math.round(regs.reduce((a, r) => a + r.horas, 0) * 100) / 100;
const subtotal = Math.round(regs.reduce((a, r) => a + r.horas * r.tarifaHora, 0) * 100) / 100;
const iva = cot.incluirIva ? Math.round(subtotal * IVA_RATE * 100) / 100 : 0;
const total = Math.round((subtotal + iva) * 100) / 100;
// Agrupacion (si aplica)
let grupos: NotaHorasGrupo[] = [];
if (modo !== "detalle") {
const map = new Map<string, NotaHorasGrupo>();
for (const r of regs) {
const { clave, etiqueta } = periodoAgrupacion(r.fecha, modo);
const g = map.get(clave) ?? { etiqueta, horas: 0, importe: 0, count: 0 };
g.horas += r.horas;
g.importe += r.horas * r.tarifaHora;
g.count += 1;
map.set(clave, g);
}
grupos = [...map.entries()]
.sort((a, b) => a[0].localeCompare(b[0]))
.map(([, g]) => ({ ...g, horas: Math.round(g.horas * 100) / 100, importe: Math.round(g.importe * 100) / 100 }));
}
const periodoTexto =
from || to
? `Periodo: ${from ? formatFechaRegistro(new Date(`${from}T12:00:00Z`)) : "inicio"} al ${
to ? formatFechaRegistro(new Date(`${to}T12:00:00Z`)) : "hoy"
}`
: "Periodo: todos los registros";
const modoLabel = MODOS_AGRUPACION.find((mm) => mm.value === modo)?.label ?? "Detalle";
const buffer = await generateNotaHorasPDF({
numero: cot.numero,
proyecto: cot.proyecto,
clienteNombre: cot.cliente.nombre,
clienteEmpresa: cot.cliente.empresa,
titulo: TITULO[estado],
emitida: new Date(),
periodoTexto,
modoLabel,
modo,
mostrarColEstado: estado === "todas" && modo === "detalle",
detalle: regs.map((r) => ({
fecha: r.fecha,
horaInicio: r.horaInicio,
horaFin: r.horaFin,
horas: r.horas,
tarifaHora: r.tarifaHora,
descripcion: r.descripcion,
estadoPago: r.estadoPago,
})),
grupos,
incluirIva: cot.incluirIva,
totalHoras,
subtotal,
iva,
total,
etiquetaTotal: ETIQUETA_TOTAL[estado],
...branding,
});
const nombre = sanitizeFilename(`${TITULO[estado]} ${cot.numero}`);
return new NextResponse(new Uint8Array(buffer), {
headers: {
"Content-Type": "application/pdf",
"Content-Disposition": `attachment; filename="${nombre}.pdf"`,
},
});
} catch (error: unknown) {
const msg = error instanceof Error ? error.message : "Error interno";
return NextResponse.json({ error: msg }, { status: 500 });
}
}