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
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useMemo, useRef, useState } from "react";
|
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 {
|
import {
|
||||||
formatCurrency,
|
formatCurrency,
|
||||||
formatFechaRegistro,
|
formatFechaRegistro,
|
||||||
@@ -97,6 +97,7 @@ export function RegistroHorasPanel({
|
|||||||
const [from, setFrom] = useState("");
|
const [from, setFrom] = useState("");
|
||||||
const [to, setTo] = useState("");
|
const [to, setTo] = useState("");
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [descargando, setDescargando] = useState(false);
|
||||||
const [togglingId, setTogglingId] = useState<string | null>(null);
|
const [togglingId, setTogglingId] = useState<string | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
// HTML de la nota congelado al abrir la previsualización (null = modal cerrado).
|
// 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.
|
// Congela el HTML de la nota (con branding) y abre el modal de previsualización.
|
||||||
const abrirPreview = () => setPreviewHtml(construirHTMLNota());
|
const abrirPreview = () => setPreviewHtml(construirHTMLNota());
|
||||||
|
|
||||||
// Imprime / guarda como PDF solo el contenido de la nota previsualizada.
|
// Descarga la nota como PDF generado en el SERVIDOR (pdfkit). A diferencia de imprimir
|
||||||
const imprimirDesdePreview = () => {
|
// el HTML desde el navegador, este PDF no lleva encabezados/pies del navegador (URL,
|
||||||
const w = iframeRef.current?.contentWindow;
|
// fecha, no. de pagina). Respeta el filtro de estado, periodo y desglose actuales.
|
||||||
if (!w) return;
|
const descargarNotaPDF = async () => {
|
||||||
w.focus();
|
setDescargando(true);
|
||||||
w.print();
|
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) => {
|
const badgeEstado = (estadoPago: string) => {
|
||||||
@@ -715,11 +739,12 @@ export function RegistroHorasPanel({
|
|||||||
<h3 className="font-semibold text-sm truncate">{TITULO_NOTA[estadoFiltro]} — {numero}</h3>
|
<h3 className="font-semibold text-sm truncate">{TITULO_NOTA[estadoFiltro]} — {numero}</h3>
|
||||||
<div className="flex items-center gap-2 shrink-0">
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
<button
|
<button
|
||||||
onClick={imprimirDesdePreview}
|
onClick={descargarNotaPDF}
|
||||||
className="flex items-center gap-2 px-3 py-2 border border-border rounded-lg text-sm hover:bg-gray-50"
|
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" />
|
<Download className="w-4 h-4 text-primary" />
|
||||||
Imprimir / Guardar PDF
|
{descargando ? "Generando…" : "Descargar PDF"}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setPreviewHtml(null)}
|
onClick={() => setPreviewHtml(null)}
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -71,12 +71,12 @@ function fmtDate(d: Date): string {
|
|||||||
|
|
||||||
async function toPngBuffer(base64: string, mime: string): Promise<Buffer> {
|
async function toPngBuffer(base64: string, mime: string): Promise<Buffer> {
|
||||||
const buf = Buffer.from(base64, "base64");
|
const buf = Buffer.from(base64, "base64");
|
||||||
if (mime === "image/svg+xml") {
|
// pdfkit solo soporta PNG/JPEG; cualquier otro formato (svg, webp, ...) se convierte
|
||||||
|
// a PNG con sharp para que el logo si se renderice.
|
||||||
|
if (mime === "image/png" || mime === "image/jpeg" || mime === "image/jpg") return buf;
|
||||||
const sharp = (await import("sharp")).default;
|
const sharp = (await import("sharp")).default;
|
||||||
return sharp(buf).png().toBuffer();
|
return sharp(buf).png().toBuffer();
|
||||||
}
|
}
|
||||||
return buf;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function generateCotizacionPDF(data: CotizacionPDFData): Promise<Buffer> {
|
export async function generateCotizacionPDF(data: CotizacionPDFData): Promise<Buffer> {
|
||||||
const logoBuffer = data.logoBase64 && data.logoMime
|
const logoBuffer = data.logoBase64 && data.logoMime
|
||||||
|
|||||||
Reference in New Issue
Block a user