Files
Laufzettel/src/app/drucken/lauf/[id]/page.tsx
T
florian.wildeandClaude Opus 5 ab5777a57e
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s
feat(archiv): Uebersicht, Volltextsuche und Druckansicht
Der Einstieg am Schreibtisch und das Gedaechtnis der Anwendung. Die Uebersicht
sortiert nach Dringlichkeit — ueberfaellig zuerst, und das ist das Einzige in
Warnfarbe; alles andere auf der Seite ist Auskunft.

Das Archiv sucht ueber Titel, Notizen, erfasste Werte und Kommentare in einer
SQL-Anweisung: `Schrittergebnis.wert` ist ein Json, und nur `("wert")::text`
trifft den Trigramm-Index aus #006. Vor allem aber gehoert die Fundstelle zum
Treffer — ein Treffer ohne sichtbaren Grund ist fuer den Suchenden wertlos.

Die Druckansicht ist ein eigenes Lesemodell und kein Schalter an
`laufZumAbarbeiten`: dort entsteht beim Fuellen der Befehle ein Passwort, und auf
einem Blatt, das jemand abheftet, darf keines stehen. Vom Mitarbeiter holt sie
nur Name, UPN und Abteilung — nicht alles und dann gefiltert.

Der unabhaengige Review fand zwei Blocker, beide im Dashboard: „ueberfaellig“
verglich mit dem Augenblick statt mit dem Tagesbeginn, und der Schnellstart
zaehlte Laeufe fremder Kunden mit. Was „ueberfaellig“ heisst, steht seitdem in
`src/lib/fristen.ts` — eine Regel fuer Lesemodell und Anzeige.

Closes #15

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-25 20:59:38 +00:00

130 lines
5.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import Link from "next/link";
import { notFound } from "next/navigation";
import { ArrowLeft } from "lucide-react";
import type { Metadata } from "next";
import { DruckenKnopf } from "@/components/druck/drucken-knopf";
import { LaufBeleg } from "@/components/druck/lauf-beleg";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import { laufZumDrucken } from "@/server/queries/druck";
/**
* Die Druckansicht eines Laufs (#015).
*
* Sie ersetzt im MVP den PDF-Export: hell, ohne Navigation, auf A4 — ein
* Ausdruck, den jemand abheftet. Wer ein PDF braucht, druckt in eine Datei.
*
* **Die Seite prueft ihr Recht selbst.** Sie liegt ausserhalb von `(app)`, also
* deckt die Huelle dort sie nicht ab. `lesen` genuegt: die Laufseite zeigt
* denselben Lauf mit demselben Recht, und UPN und Abteilung der Person stehen
* unter `lesen` auch in den Stammdaten (`/mitarbeiter/[id]`). Die Mandantensicht
* wendet das Lesemodell an — ein Lauf eines fremden Kunden ist hier nicht
* erreichbar, und „gibt es nicht“ ist dieselbe Antwort wie „darfst du nicht“.
*
* **Es entsteht kein Passwort.** `{{generiertes_passwort}}` in einem
* Vorlagentext — Schritt-Titel oder Befehl — wird zu `PASSWORT_IM_DRUCK`, weil
* `laufZumDrucken` mit `{ art: "druck" }` aufloest; erzeugt wird keines, und
* gespeichert war ohnehin nie eines. Der Beleg dafuer ist ein Vergleich: zwei
* Ausdrucke desselben Laufs sind Zeichen fuer Zeichen gleich
* (`tests/druckansicht.test.ts`).
*
* Genau formuliert, weil die Zusage sonst mehr behauptet, als sie haelt: hat
* jemand die **Zeichenfolge** `{{generiertes_passwort}}` von Hand in eine Notiz
* oder einen Kommentar geschrieben, steht sie dort auch auf dem Blatt. Das ist
* kein Geheimnis, sondern ein Platzhalter, den niemand aufgeloest hat — und der
* Ausdruck gibt Notizen und Kommentare wortgetreu wieder, wie es ein Beleg tun
* muss (Reviewbefund zu #015).
*/
export const metadata: Metadata = {
// Bewusst ohne Lauftitel: ein Titel aus den Daten verlangte eine zweite
// Abfrage in `generateMetadata`, also den ganzen Lauf ein zweites Mal.
title: "Lauf drucken",
};
/**
* Das Seitenformat.
*
* `@page` laesst sich nicht auf eine Klasse einschraenken und kann deshalb nicht
* in globals.css stehen — dort galte es fuer jede Seite, auch fuer die
* Etikettenbogen mit ihren eigenen Massen (#014). Also schreibt diese Seite ihre
* eigene Regel.
*
* Links mehr Rand als rechts: das Blatt wird gelocht und abgeheftet, und ein
* Locher frisst die ersten Millimeter.
*/
const DRUCKSTIL = `
@page { size: A4; margin: 14mm 14mm 14mm 20mm; }
@media print {
/*
* Der dunkle Grund kommt vom Wurzel-Layout und liegt **hinter** der hellen
* Huelle. Am Bildschirm deckt min-h-screen ihn ab; auf Papier gibt es
* keinen Bildschirm, und ein Browser, der den Hintergrund des body
* mitdruckt, faerbte das ganze Blatt (#015).
*/
html, body { background: #ffffff; }
/* Ein Schritt gehoert zusammen – Zustand, Wert und Notiz auf einer Seite. */
[data-schritt] { break-inside: avoid; }
/* Eine Abschnittsueberschrift allein am Seitenfuss zeigt auf nichts. */
h2 { break-after: avoid; }
/*
* Voreingestellt ist "economy": dort darf der Browser Farben und Flaechen
* anpassen, um Toner zu sparen. "exact" verlangt, sie zu drucken. Betroffen
* sind auf diesem Blatt die Haarlinien zwischen den Schritten und der
* Akzentpunkt der Wortmarke — sonst ist nichts eingefaerbt.
*/
* { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
}
`;
const GEDRUCKT_AM = new Intl.DateTimeFormat("de-DE", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
export default async function LaufDruckSeite({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const { benutzer } = await sitzungErforderlich("lesen", `/drucken/lauf/${id}`);
const daten = await laufZumDrucken(id, { id: benutzer.id, rolle: benutzer.rolle });
// Nicht vorhanden und nicht sichtbar sind dieselbe Antwort.
if (!daten) notFound();
return (
<>
<style>{DRUCKSTIL}</style>
<div className="mx-auto w-full max-w-[210mm] px-4 py-6 print:max-w-none print:px-0 print:py-0">
<div className="nicht-drucken mb-6 flex flex-wrap items-center gap-3">
<Link
href={`/laeufe/${daten.kopf.id}`}
className="flex min-h-11 items-center gap-2 text-sm text-nebentext hover:text-text md:min-h-10"
>
<ArrowLeft className="size-4" strokeWidth={1.75} aria-hidden="true" />
Zum Lauf
</Link>
<DruckenKnopf />
<p className="text-xs text-nebentext">
Im Druckdialog Kopf- und Fußzeilen abschalten — das Blatt bringt beides selbst mit.
</p>
</div>
<LaufBeleg daten={daten} />
{/*
* „Gedruckt am“ steht bewusst darauf: ein Lauf in Arbeit kann sich nach
* dem Ausdruck weiter aendern, und dann sagt dieses Datum, wie alt das
* Blatt in der Mappe ist. Die Kennung steht dabei, weil man mit ihr den
* Lauf wiederfindet — der Lauftitel ist nicht eindeutig.
*/}
<footer className="mt-6 border-t border-linie pt-3 text-xs text-nebentext">
Gedruckt am {GEDRUCKT_AM.format(new Date())} · Lauf <span className="mono">{daten.kopf.id}</span>
</footer>
</div>
</>
);
}