feat(archiv): Uebersicht, Volltextsuche und Druckansicht
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s

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>
This commit was merged in pull request #29.
This commit is contained in:
2026-09-25 20:59:38 +00:00
co-authored by Claude Opus 5
parent e7a2e2d5c7
commit ab5777a57e
32 changed files with 4202 additions and 38 deletions
+127
View File
@@ -0,0 +1,127 @@
import { Archive } from "lucide-react";
import { ArchivFilter, type ArchivFilterfelder } from "@/components/archiv/archiv-filter";
import { ArchivTreffer } from "@/components/archiv/archiv-treffer";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import { kundenEbeneSichtbar } from "@/server/mandant";
import {
ARCHIV_HOECHSTENS,
SUCHE_MINDESTZEICHEN,
archivFilterwerte,
archivSuche,
datumAusText,
type ArchivFilter as ArchivFilterwerte,
} from "@/server/queries/archiv";
/**
* Das Archiv (#015).
*
* Das Gedaechtnis der Anwendung: durchsucht werden abgeschlossene und
* archivierte Laeufe, und zwar in `Lauf.titel`, `Schrittergebnis.notiz`,
* `Schrittergebnis.wert` und `Kommentar.rumpf`. Was genau dabei geschieht, steht
* in `src/server/queries/archiv.ts`; diese Seite liest die Adresszeile, prueft
* ihr Recht und stellt dar.
*
* **Sie prueft selbst.** Die Huelle unter `(app)/layout.tsx` ist das zweite Netz
* und nicht das erste: Next rendert Layouts bei Navigation innerhalb desselben
* Layouts nicht neu (#007).
*/
export default async function ArchivSeite({
searchParams,
}: {
searchParams: Promise<Record<string, string | string[] | undefined>>;
}) {
const { benutzer } = await sitzungErforderlich("lesen", "/archiv");
const parameter = await searchParams;
const einzeln = (name: string): string =>
(Array.isArray(parameter[name]) ? parameter[name][0] : parameter[name])?.trim() ?? "";
const felder: ArchivFilterfelder = {
suche: einzeln("suche"),
vorlage: einzeln("vorlage"),
mitarbeiter: einzeln("mitarbeiter"),
abteilung: einzeln("abteilung"),
geraet: einzeln("geraet"),
bearbeiter: einzeln("bearbeiter"),
kunde: einzeln("kunde"),
von: einzeln("von"),
bis: einzeln("bis"),
laufende: einzeln("laufende") === "1",
};
/*
* Ein vertippter Parameter filtert nicht, statt eine Fehlerseite zu zeigen –
* dieselbe Entscheidung wie bei der Geraeteliste (#011). Eine erfundene
* Kennung bleibt dagegen stehen: sie ist kein Formfehler, sondern eine Suche,
* die nichts findet. Ueber die Mandantengrenze kommt sie nicht (siehe
* archivSuche).
*/
const von = datumAusText(felder.von);
const bis = datumAusText(felder.bis);
const filter: ArchivFilterwerte = {
...(felder.suche ? { suche: felder.suche } : {}),
...(felder.vorlage ? { vorlageId: felder.vorlage } : {}),
...(felder.mitarbeiter ? { mitarbeiterId: felder.mitarbeiter } : {}),
...(felder.abteilung ? { abteilungId: felder.abteilung } : {}),
...(felder.geraet ? { geraetId: felder.geraet } : {}),
...(felder.bearbeiter ? { bearbeiterId: felder.bearbeiter } : {}),
...(felder.kunde ? { kundeId: felder.kunde } : {}),
...(von ? { von } : {}),
...(bis ? { bis } : {}),
...(felder.laufende ? { auchLaufende: true } : {}),
};
const [ergebnis, werte, kundenEbene] = await Promise.all([
archivSuche(benutzer, filter),
archivFilterwerte(benutzer),
kundenEbeneSichtbar(),
]);
// Hervorgehoben wird nur, wonach auch gesucht wurde: ein verworfener Begriff
// hat keine Fundstellen und darf keine markieren.
const begriff = ergebnis.sucheZuKurz ? "" : felder.suche;
return (
<>
<h1 className="flex items-center gap-2 text-2xl font-bold">
<Archive className="size-6 text-nebentext" strokeWidth={1.75} aria-hidden="true" />
Archiv
</h1>
<p className="mt-2 max-w-prose text-nebentext">
Durchsucht werden abgeschlossene und archivierte Läufe — ihr Titel, die Notizen und Werte der Schritte
und die Kommentare. Ein archivierter Lauf ändert sich nicht mehr; zurückholen kann ihn nur ein
Administrator.
</p>
<ArchivFilter felder={felder} werte={werte} kundenEbene={kundenEbene} />
{ergebnis.sucheZuKurz ? (
<p className="mt-6 rounded-karte border border-linie bg-flaeche p-5 text-sm text-nebentext">
„{felder.suche}“ ist als Suchbegriff zu kurz — es braucht mindestens {SUCHE_MINDESTZEICHEN} Zeichen.
Gezeigt werden deshalb die Treffer der übrigen Filter.
</p>
) : null}
{ergebnis.zeilen.length === 0 ? (
<p className="mt-6 rounded-karte border border-linie bg-flaeche p-5 text-sm text-nebentext">
Kein Lauf passt dazu. Laufende Läufe stehen erst mit dem Häkchen „auch offene und laufende Läufe“
dabei — bis zum Abschluss gehören sie in die Laufliste.
</p>
) : (
<>
<p className="mt-6 max-w-prose text-sm text-nebentext">
{ergebnis.zeilen.length === 1 ? "Ein Lauf gefunden" : `${ergebnis.zeilen.length} Läufe gefunden`}
{/* Eine gekappte Liste sagt das – sonst fehlte ein Lauf stillschweigend. */}
{ergebnis.gekappt
? `. Es sind mehr als ${ARCHIV_HOECHSTENS}; gezeigt werden die zuletzt abgelegten. Ein engerer Zeitraum oder ein genauerer Begriff holt die übrigen hervor.`
: "."}
</p>
<ul className="mt-3 flex flex-col gap-3">
{ergebnis.zeilen.map((zeile) => (
<ArchivTreffer key={zeile.id} zeile={zeile} begriff={begriff} kundenEbene={kundenEbene} />
))}
</ul>
</>
)}
</>
);
}
+16 -1
View File
@@ -1,6 +1,6 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { ArrowLeft, MessageSquare, Star } from "lucide-react";
import { ArrowLeft, MessageSquare, Printer, Star } from "lucide-react";
import { LaufStatus, SchrittTyp, SchrittZustand } from "@/generated/prisma/enums";
import { Markdown } from "@/components/markdown";
import { AbschnittHuelle } from "@/components/laeufe/abschnitt-huelle";
@@ -364,6 +364,21 @@ export default async function LaufSeite({
>
{ausfuehrlich ? "Kompakt" : "Ausführlich"}
</Link>
{/*
* Die Druckansicht liegt ausserhalb der Huelle und traegt deshalb kein
* `Link`-Vorabladen: sie ist hell, ohne Navigation und fuer Papier
* gemacht (#015). Auf dem Blatt steht **kein** Passwort — auch dort
* nicht, wo im Lauf eines stuende.
*/}
<Link
href={`/drucken/lauf/${lauf.id}`}
prefetch={false}
className="flex min-h-11 items-center gap-2 rounded-pille border border-linie px-4 text-sm text-nebentext hover:bg-flaeche-hoch hover:text-text md:min-h-10"
>
<Printer className="size-4" strokeWidth={1.75} aria-hidden="true" />
Drucken
</Link>
</div>
<div className="mt-6 flex flex-col gap-4">
+284 -18
View File
@@ -1,27 +1,293 @@
export default function Startseite() {
import Link from "next/link";
import { ArrowRight, Clock, ListChecks, Rocket, TriangleAlert } from "lucide-react";
import { LaufStatus } from "@/generated/prisma/enums";
import { fristVerstrichen } from "@/lib/fristen";
import { darf } from "@/server/auth/rollen";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import {
UEBERSICHT_JE_BLOCK,
haeufigeVorlagen,
uebersicht,
type HaeufigeVorlage,
type UebersichtsLauf,
} from "@/server/queries/uebersicht";
/**
* Die Uebersicht (#015).
*
* Der Einstieg am Schreibtisch — das Gegenstueck zum Aufkleber am Geraet. Sie
* zeigt vier Dinge und sonst nichts: was ueberfaellig ist, was bei mir liegt,
* woran zuletzt gearbeitet wurde, und was man haeufig anfaengt.
*
* **Ueberfaellig steht oben und ist das Einzige in Warnfarbe.** Alles andere auf
* dieser Seite ist Auskunft; das hier ist eine Aufforderung. Wuerde jeder Block
* rufen, riefe keiner.
*
* Leere Bloecke verschwinden nicht, sie sagen es: eine Seite, deren Abschnitte
* je nach Datenlage kommen und gehen, laesst sich nicht lesen.
*/
const DATUM = new Intl.DateTimeFormat("de-DE", { day: "2-digit", month: "2-digit", year: "numeric" });
const STATUS_TEXT: Record<LaufStatus, string> = {
OFFEN: "offen",
IN_ARBEIT: "in Arbeit",
ABGESCHLOSSEN: "abgeschlossen",
ARCHIVIERT: "archiviert",
};
function Fortschritt({ lauf }: { lauf: UebersichtsLauf }) {
const anteil = lauf.anzahlSchritte === 0 ? 0 : lauf.anzahlErledigt / lauf.anzahlSchritte;
return (
<div className="mt-2 flex items-center gap-2">
<div
className="h-1.5 w-24 overflow-hidden rounded-pille bg-flaeche-hoch"
role="progressbar"
aria-valuemin={0}
aria-valuemax={lauf.anzahlSchritte}
aria-valuenow={lauf.anzahlErledigt}
aria-label="Fortschritt"
>
<div className="h-full bg-akzent" style={{ width: `${Math.round(anteil * 100)}%` }} />
</div>
<span className="text-xs text-nebentext">
{lauf.anzahlErledigt} von {lauf.anzahlSchritte}
</span>
</div>
);
}
function LaufZeile({
lauf,
zeigeFaellig,
jetzt,
}: {
lauf: UebersichtsLauf;
zeigeFaellig?: boolean;
jetzt: Date;
}) {
/*
* Rot nur fuer **verstrichene** Fristen, und verstrichen heisst „vor heute“.
* „Mir zugewiesen“ zeigt auch Laeufe, die erst naechste Woche faellig sind;
* ein „seit 26.09. faellig“ in Warnfarbe waere dort schlicht falsch — und wer
* eine Warnung zweimal umsonst sieht, liest die dritte nicht mehr. Dieselbe
* Regel wie im Lesemodell, aus `src/lib/fristen.ts`.
*/
const ueberfaellig = fristVerstrichen(lauf.faelligAm, jetzt);
const untertitel = [
lauf.vorlagenName,
lauf.geraetName,
lauf.mitarbeiterName,
lauf.zugewiesenAn ? `für ${lauf.zugewiesenAn}` : null,
]
.filter((stueck): stueck is string => Boolean(stueck))
.join(" · ");
return (
<li className="border-t border-linie first:border-t-0">
<Link href={`/laeufe/${lauf.id}`} className="block py-3">
<div className="flex flex-wrap items-center gap-2">
<span className="min-w-0 font-medium break-words">{lauf.titel}</span>
<span className="rounded-pille bg-flaeche-hoch px-2.5 py-1 text-xs text-nebentext">
{STATUS_TEXT[lauf.status]}
</span>
{zeigeFaellig && lauf.faelligAm ? (
<span
className={`flex items-center gap-1 rounded-pille px-2.5 py-1 text-xs ${
ueberfaellig ? "bg-fehler/15 text-fehler" : "bg-flaeche-hoch text-nebentext"
}`}
>
<Clock className="size-3" strokeWidth={2} aria-hidden="true" />
{ueberfaellig ? "seit" : "fällig am"} {DATUM.format(lauf.faelligAm)}
{ueberfaellig ? " fällig" : ""}
</span>
) : null}
</div>
<p className="mt-1 text-xs break-words text-nebentext">{untertitel}</p>
<Fortschritt lauf={lauf} />
</Link>
</li>
);
}
function Block({
titel,
symbol: Symbol,
laeufe,
leer,
mehr,
zeigeFaellig,
warnend,
jetzt,
}: {
titel: string;
symbol: typeof ListChecks;
laeufe: readonly UebersichtsLauf[];
/** Was dasteht, wenn nichts dasteht. Ein leerer Kasten erklärt nichts. */
leer: string;
/**
* Der Weg zum Rest.
*
* Die Beschriftung sagt, wohin es geht — die Laufliste kennt keinen Filter
* „ueberfaellig“, und „weitere in der Liste“ haette Genaueres versprochen, als
* dort zu finden ist (Reviewbefund zu #015).
*/
mehr?: { readonly anzahl: number; readonly pfad: string; readonly beschriftung: string };
zeigeFaellig?: boolean;
warnend?: boolean;
jetzt: Date;
}) {
return (
<section
className={`rounded-karte border bg-flaeche p-5 ${warnend && laeufe.length > 0 ? "border-fehler/40" : "border-linie"}`}
>
<h2 className="flex items-center gap-2 text-lg font-semibold">
{/*
* Ein leerer Block ruft nicht: das Warndreieck ueber „nichts ist
* ueberfaellig“ in Signal-Lime waere ein Ausrufezeichen ohne Satz.
*/}
<Symbol
className={`size-5 ${
laeufe.length === 0 ? "text-nebentext" : warnend ? "text-fehler" : "text-akzent"
}`}
strokeWidth={2}
aria-hidden="true"
/>
{titel}
</h2>
{laeufe.length === 0 ? (
<p className="mt-3 max-w-prose text-sm text-nebentext">{leer}</p>
) : (
<>
<ul className="mt-2 flex flex-col">
{laeufe.map((lauf) => (
<LaufZeile key={lauf.id} lauf={lauf} zeigeFaellig={zeigeFaellig} jetzt={jetzt} />
))}
</ul>
{mehr && mehr.anzahl > 0 ? (
<Link
href={mehr.pfad}
className="mt-3 flex min-h-11 w-fit items-center gap-2 text-sm text-akzent underline underline-offset-2 md:min-h-10"
>
{mehr.anzahl} {mehr.anzahl === 1 ? "weiterer" : "weitere"} — {mehr.beschriftung}
<ArrowRight className="size-4" strokeWidth={1.75} aria-hidden="true" />
</Link>
) : null}
</>
)}
</section>
);
}
function Schnellstart({ vorlagen }: { vorlagen: readonly HaeufigeVorlage[] }) {
if (vorlagen.length === 0) return null;
return (
<section className="rounded-karte border border-linie bg-flaeche p-5">
<h2 className="flex items-center gap-2 text-lg font-semibold">
<Rocket className="size-5 text-akzent" strokeWidth={2} aria-hidden="true" />
Schnellstart
</h2>
<p className="mt-1 max-w-prose text-sm text-nebentext">
Die {vorlagen.length === 1 ? "Vorlage" : `${vorlagen.length} Vorlagen`}, aus denen hier am häufigsten
Läufe entstehen.{" "}
<Link href="/vorlagen" className="underline underline-offset-2 hover:text-text">
Alle Vorlagen
</Link>
</p>
<ul className="mt-4 grid gap-3 sm:grid-cols-2">
{vorlagen.map((vorlage) => (
<li key={vorlage.versionId}>
<Link
href={`/laeufe/starten?vorlage=${vorlage.versionId}`}
className="flex min-h-11 flex-col justify-center rounded-karte border border-linie px-4 py-3 hover:bg-flaeche-hoch md:min-h-10"
>
<span className="font-medium break-words">{vorlage.name}</span>
<span className="mt-0.5 text-xs text-nebentext">
{[
vorlage.kategorie,
`v${vorlage.version}`,
vorlage.anzahlLaeufe === 0 ? "noch nie gestartet" : `${vorlage.anzahlLaeufe}× gestartet`,
]
.filter(Boolean)
.join(" · ")}
</span>
</Link>
</li>
))}
</ul>
</section>
);
}
export default async function Uebersichtsseite() {
const { benutzer } = await sitzungErforderlich("lesen", "/");
const darfArbeiten = darf(benutzer.rolle, "laeufeBearbeiten");
/*
* **Ein** Zeitpunkt fuer die ganze Seite: die Abfrage und die Anzeige muessen
* dieselbe Grenze benutzen, sonst stuende ein Lauf im Block „Ueberfaellig“ und
* traege daneben eine unauffaellige Frist.
*/
const jetzt = new Date();
const [daten, vorlagen] = await Promise.all([
uebersicht(benutzer, jetzt),
// Schnellstart nur für die, die auch starten dürfen.
darfArbeiten ? haeufigeVorlagen(benutzer) : Promise.resolve([]),
]);
return (
<>
<h1 className="text-2xl font-bold">Übersicht</h1>
<p className="mt-2 max-w-prose text-nebentext">
Datenmodell und Anmeldung stehen. Als Nächstes kommen die Betriebsmodi und darauf die Vorlagen —
danach lässt sich der erste Lauf wirklich abarbeiten.
Guten Tag, {benutzer.name}. Was hier steht, ist nach Dringlichkeit sortiert — überfällig zuerst.
</p>
<section className="mt-8 grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
{[
{ nummer: "01", titel: "Betriebsmodus und Kunden", issue: "#008" },
{ nummer: "02", titel: "Vorlagen-Versionierung", issue: "#009" },
{ nummer: "03", titel: "Vorlagen-Editor", issue: "#010" },
].map((karte) => (
<article key={karte.nummer} className="rounded-karte border border-linie bg-flaeche p-5">
<p className="mono text-xs text-nebentext">
<span className="text-akzent">{karte.nummer}</span> · als Nächstes
</p>
<h2 className="mt-2 text-lg font-semibold">{karte.titel}</h2>
<p className="mono mt-1 text-xs text-nebentext">{karte.issue}</p>
</article>
))}
</section>
<div className="mt-6 flex flex-col gap-4">
<Block
titel="Überfällig"
symbol={TriangleAlert}
laeufe={daten.ueberfaellig}
leer="Nichts ist überfällig. Ein Lauf gilt als überfällig, sobald sein Fälligkeitsdatum verstrichen ist und er noch offen oder in Arbeit ist."
mehr={{
anzahl: daten.mehrUeberfaellig,
pfad: "/laeufe",
beschriftung: "die Laufliste sortiert nach Fälligkeit",
}}
zeigeFaellig
warnend
jetzt={jetzt}
/>
<Block
titel="Mir zugewiesen"
symbol={ListChecks}
laeufe={daten.meine}
leer="Dir ist gerade nichts zugewiesen. Zugewiesen wird beim Anlegen eines Laufs oder auf der Geräteseite."
mehr={{ anzahl: daten.mehrMeine, pfad: "/laeufe?meine=1", beschriftung: "alle meine Läufe" }}
zeigeFaellig
jetzt={jetzt}
/>
<Block
titel="Zuletzt bearbeitet"
symbol={Clock}
laeufe={daten.zuletzt}
leer="Hier steht, woran zuletzt gearbeitet wurde — auch von Kolleginnen und Kollegen. Noch gibt es keinen Lauf."
jetzt={jetzt}
/>
{darfArbeiten ? <Schnellstart vorlagen={vorlagen} /> : null}
</div>
<p className="mt-4 text-xs text-nebentext">
Je Block höchstens {UEBERSICHT_JE_BLOCK} Läufe.{" "}
<Link href="/laeufe" className="underline underline-offset-2 hover:text-text">
Alle Läufe
</Link>
</p>
</>
);
}
+129
View File
@@ -0,0 +1,129 @@
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>
</>
);
}
+16
View File
@@ -0,0 +1,16 @@
/**
* Eigene, helle Huelle fuer die Druckansicht eines Laufs (#015).
*
* Bewusst **ausserhalb** von `src/app/(app)`: die Huelle dort bringt Seitenleiste,
* Kopfzeile und den dunklen Grund mit, und das gehoert auf kein Blatt. Die
* Rechtepruefung uebernimmt diese Huelle deshalb **nicht** — die Seite darunter
* prueft selbst, so wie es CLAUDE.md fuer alle Seiten verlangt. Denselben Aufbau
* hat der Etikettendruck (#014).
*
* `druck-hell` stellt die Farbtoken dieses Teilbaums auf die helle Fassung um
* (globals.css); `min-h-screen` deckt den dunklen Grund des Wurzel-Layouts auch
* am Bildschirm ab, damit die Vorschau aussieht wie das Papier.
*/
export default function LaufDruckLayout({ children }: { children: React.ReactNode }) {
return <div className="druck-hell min-h-screen">{children}</div>;
}