Adding a Printable Monthly Report and SLA Indicators to Albaida‑Tickets (Next.js 14)
TL;DR: I added a client‑side “Print / Save PDF” button and a server‑side SLA calculation for ticket priorities. The changes involve a new PrintButton component, CSS tweaks for print media, and extending lib/catalog.ts with priority time‑outs that feed into the panel UI.
The Problem
The committee needed a monthly PDF report of ticket statistics that could be printed directly from the browser. The existing panel page (app/panel/page.tsx) displayed the data, but there was no way to generate a clean, printable version.
At the same time, we wanted to surface service‑level expectations (SLA) per ticket priority (Urgente = 1 day, Alta = 3 days, Media = 7 days, Baja = 14 days). The UI showed priority labels, but there was no visual cue when a ticket was approaching or exceeding its SLA, nor any filter to isolate overdue items.
Both requirements had to be solved without breaking the existing server‑rendered pages or introducing heavy third‑party PDF libraries.
What I Tried First
Server‑side PDF generation with
puppeteer.
I added a/api/reportendpoint that launched a headless Chrome instance, rendered the panel route, and streamed a PDF. The approach worked locally, but it blew up on Vercel’s serverless environment: the function timed out (errorERR_CHILD_PROCESS_STDIO_MAXBUFFER) and the container size limit was exceeded.Using CSS
@media printonly.
I tried adding aprint.cssfile that hid navigation and adjusted table widths. The page printed, but the layout still included interactive elements (buttons, links) and the pagination controls broke the page flow.Hard‑coding SLA thresholds in the UI component.
I duplicated the priority map insideapp/panel/page.tsx. This worked for the visual cue, but it made the logic impossible to reuse elsewhere (e.g., nightly email alerts) and introduced a maintenance risk.
All three attempts either failed in production or violated our DRY principle, so I reverted the changes and went back to the drawing board.
The Implementation
1. Client‑side Print Button
A lightweight solution is to let the browser handle PDF creation via window.print(). I created a client component app/panel/reporte/PrintButton.tsx:
// app/panel/reporte/PrintButton.tsx
"use client";
export function PrintButton() {
return (
<button
type="button"
className="btn"
onClick={() => window.print()}
>
Imprimir / Guardar PDF
</button>
);
}
Because the component lives under app/panel/reporte, it’s automatically a client component (thanks to the "use client" directive). The button is now rendered on the report page (app/panel/reporte/page.tsx) alongside the existing table.
2. Print‑friendly CSS
I extended app/globals.css with a dedicated @media print block and a few utility classes that hide UI noise and enforce a compact layout:
/* app/globals.css */
@media print {
/* Hide navigation and interactive controls */
nav, .btn, .filter-bar, .pagination {
display: none !important;
}
/* Ensure tables break cleanly across pages */
table {
page-break-inside: avoid;
width: 100%;
}
/* Adjust KPI cards for print */
.kpi {
border: 1px solid #333;
background: #fff;
color: #000;
}
}
/* Minor tweak for progress percentages */
.conv-progress .pct {
font-size: 0.8rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
The @media print rules strip out navigation, buttons, and pagination, leaving only the report content. The KPI card styling ensures the printed version is still readable.
3. Centralizing SLA Definitions
I moved the priority‑to‑SLA mapping into lib/catalog.ts so it can be consumed by both UI and background jobs:
// lib/catalog.ts
export const PRIORITY: Record<string, string> = {
urgente: "Urgente",
alta: "Alta",
media: "Media",
baja: "Baja",
};
/** Maximum response time (in days) per priority. */
export const PRIORITY_SLA_DAYS: Record<string, number> = {
urgente: 1,
alta: 3,
media: 7,
baja: 14,
};
Adding the JSDoc comment clarifies intent and enables IDE autocomplete.
4. SLA Calculation in the Panel
In app/panel/page.tsx I imported the new constants and computed an isOverdue flag for each ticket:
// app/panel/page.tsx (excerpt)
import { PRIORITY, PRIORITY_SLA_DAYS } from "@/lib/catalog";
import { differenceInCalendarDays } from "date-fns";
...
{tickets.map((t) => {
const slaDays = PRIORITY_SLA_DAYS[t.priority];
const age = differenceInCalendarDays(new Date(), new Date(t.created_at));
const isOverdue = age > slaDays;
return (
<tr key={t.id} className={isOverdue ? "overdue" : ""}>
<td>{t.id}</td>
<td>{PRIORITY[t.priority]}</td>
<td>{age} d</td>
<td>{isOverdue && "⚠️"}</td>
</tr>
);
})}
The overdue CSS class (added in globals.css) highlights rows in red:
/* globals.css */
.overdue {
background-color: #ffe5e5;
}
5. Nightly Job Integration
The nightly maintenance script (lib/nightly.ts) now uses the same SLA map to flag tickets for email alerts. I added a small import and a loop that pushes overdue tickets into a pendingAlerts array:
// lib/nightly.ts (excerpt)
import { PRIORITY_SLA_DAYS } from "./catalog";
import { differenceInCalendarDays } from "date-fns";
const pendingAlerts: Ticket[] = [];
for (const ticket of tickets) {
const sla = PRIORITY_SLA_DAYS[ticket.priority];
const age = differenceInCalendarDays(new Date(), new Date(ticket.created_at));
if (age > sla) pendingAlerts.push(ticket);
}
// later: send alerts via lib/mail.ts
Because the constants live in a single source of truth, the nightly job and the UI stay perfectly in sync.
6. Wire‑up the Report Page
The new report page (app/panel/reporte/page.tsx) pulls data from the DB, renders the same KPI components, and includes the PrintButton:
tsx
// app/panel/reporte/page.tsx
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 ORDER BY created_at DESC`;
return (
<section className="report">
<h1>Reporte Mensual – Comité</h1>
<PrintButton />
---
*Part of my [Build in Public](https://dev.to/zaerohell) 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)