Adding a Printable Monthly Report & SLA Indicators to Albaida‑Tickets (Next.js 13)
TL;DR: I added a client‑side “Print / Save PDF” button and SLA‑based priority alerts to the admin panel. The change required a tiny UI component, a few CSS tweaks, and extending the priority catalog with max‑response times, all while keeping the app fully server‑rendered.
The Problem
The committee needed a monthly report they could hand‑out in meetings. The existing /panel/reporte page rendered a table of tickets, but there was no way to export it as a PDF or a printer‑friendly view.
A second, related pain point was the lack of SLA visibility: tickets have priorities (urgente, alta, media, baja) with defined maximum resolution days, but the UI never highlighted tickets that were overdue, nor did the daily summary emit alerts.
Symptoms:
- Clicking “Print” in the browser printed the whole layout, including navigation and footers – not a clean report.
- No visual cue when a ticket exceeded its allowed response time, causing the support team to miss deadlines.
What I Tried First
Server‑side PDF with Puppeteer
I first attempted to generate a PDF on the server:
// lib/pdf.ts (initial attempt)
import puppeteer from "puppeteer";
export async function generateReportHTML() {
const html = await renderToString(<ReportPage />);
const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.setContent(html);
const pdf = await page.pdf({ format: "A4" });
await browser.close();
return pdf;
}
Why it failed:
- The app runs on Vercel’s serverless functions; launching a headless Chromium instance exceeds the memory/time limits.
- The PDF generation added latency to the request and blocked other admin actions.
CSS @media print Hacks
Next I tried to hide navigation with a global @media print rule, but the rule leaked into other pages, breaking the public site’s print styles.
Result: Inconsistent output and a maintenance nightmare.
The Implementation
1. Client‑side Print Button (no server cost)
I moved the print trigger to a client component (app/panel/reporte/PrintButton.tsx). This isolates the window.print() call to the browser, avoiding any server overhead.
// app/panel/reporte/PrintButton.tsx
"use client";
export function PrintButton() {
return (
<button
type="button"
className="btn"
onClick={() => window.print()}
>
Imprimir / Guardar PDF
</button>
);
}
-
use clienttells Next.js that this file should be bundled for the browser. - The button lives next to the report table, so the UI stays cohesive.
2. Printable Layout with Scoped CSS
I added a small CSS block in app/globals.css that only applies when printing the report page:
/* app/globals.css */
@media print {
/* Hide the main navigation and footer */
.no-print, .footer, nav { display: none !important; }
/* Expand the table to full width */
.report-table { width: 100%; }
/* Page break after each KPI section */
.kpi { page-break-inside: avoid; }
}
Only the report page imports the no-print class on its wrapper, so other pages remain unaffected.
3. Wiring the Button into the Report Page
The new component is imported into app/panel/reporte/page.tsx. I only needed a few lines; the rest of the file (≈200 LOC) already builds the ticket list.
// app/panel/reporte/page.tsx (excerpt)
import Link from "next/link";
import { sql, type Ticket } from "@/lib/db";
import { requireStaff } from "@/lib/session";
import { PrintButton } from "./PrintButton";
export default async function ReportPage() {
await requireStaff();
const tickets = await sql<Ticket>`
SELECT * FROM tickets
WHERE created_at >= (CURRENT_DATE - INTERVAL '30 days')
ORDER BY created_at DESC
`;
return (
<section className="report-page no-print">
<h1>Reporte Mensual</h1>
<PrintButton />
<TicketTable tickets={tickets} />
</section>
);
}
- The wrapper
section.report-page.no-printgets hidden when printing, leaving only the table and header.
4. Adding SLA Data to the Priority Catalog
I extended lib/catalog.ts with a new PRIORITY_SLA_DAYS map:
// lib/catalog.ts
export const PRIORITY: Record<string, string> = {
urgente: "Urgente",
alta: "Alta",
media: "Media",
baja: "Baja",
};
/** Maximum response time per priority (in days). */
export const PRIORITY_SLA_DAYS: Record<string, number> = {
urgente: 1,
alta: 3,
media: 7,
baja: 14,
};
Now the server can compute whether a ticket is overdue.
5. Highlighting Overdue Tickets
In app/panel/page.tsx I added a helper that flags tickets past their SLA:
// app/panel/page.tsx (excerpt)
import { PRIORITY_SLA_DAYS } from "@/lib/catalog";
function isOverdue(ticket: Ticket): boolean {
const slaDays = PRIORITY_SLA_DAYS[ticket.priority];
const created = new Date(ticket.created_at);
const deadline = new Date(created);
deadline.setDate(created.getDate() + slaDays);
return new Date() > deadline && ticket.status !== "resuelto";
}
The UI now renders a red badge next to overdue rows:
// Inside the table row component
<tr className={isOverdue(ticket) ? "overdue" : ""}>
<td>{ticket.id}</td>
<td>{PRIORITY[ticket.priority]}</td>
<td>{ticket.subject}</td>
{/* … */}
</tr>
Corresponding CSS:
/* app/globals.css */
tr.overdue { background-color: #ffe5e5; }
tr.overdue td { color: #c00; }
6. Daily Summary Alert
The daily summary page (app/panel/page.tsx) now aggregates overdue counts and displays a banner:
// After fetching tickets
const overdueCount = tickets.filter(isOverdue).length;
return (
<>
{overdueCount > 0 && (
<div className="alert alert-danger">
⚠️ {overdueCount} tickets están fuera de SLA.
</div>
)}
{/* Rest of the dashboard */}
</>
);
7. Minimal Impact on Build Size
Because the print button lives in a separate client component, the server bundle remains unchanged. I verified the bundle size with next build – the increase is < 5 KB gzipped.
Key Takeaway
When you need a quick “export” feature in a Next.js app, avoid server‑side PDF generation unless you have a dedicated worker. A simple client‑side window.print() combined with scoped @media print styles gives you a printable view with zero server cost, and you can keep the rest of your app fully server‑rendered.
What's Next
- Replace the browser‑only print with a headless PDF service (e.g., Chrome‑as‑a‑service) for automated email delivery.
- Add a progress bar that visualizes remaining SLA days per ticket.
- Write unit tests for
isOverduecovering edge cases (leap years, timezone shifts).
Roberto Luna Osorio – Full Stack Developer & Project Lead
Playa del Carmen, México
vibecoding
Part of my Build in Public series — sharing the real process of building SaaS projects from Playa del Carmen, México.
Repo: zaerohell/albaida-tickets · 2026-09-30
#playadev #buildinpublic
Top comments (0)