DEV Community

Roberto Luna
Roberto Luna

Posted on

Adding a Printable Monthly Report and SLA Indicators to Albaida‑Tickets (Next.js 14)

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

  1. Server‑side PDF generation with puppeteer.

    I added a /api/report endpoint 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 (error ERR_CHILD_PROCESS_STDIO_MAXBUFFER) and the container size limit was exceeded.

  2. Using CSS @media print only.

    I tried adding a print.css file 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.

  3. Hard‑coding SLA thresholds in the UI component.

    I duplicated the priority map inside app/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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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,
};
Enter fullscreen mode Exit fullscreen mode

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>
  );
})}
Enter fullscreen mode Exit fullscreen mode

The overdue CSS class (added in globals.css) highlights rows in red:

/* globals.css */
.overdue {
  background-color: #ffe5e5;
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Top comments (0)