Files
Laufzettel/src/app/(app)/laeufe/[id]/page.tsx
T
Florian WildeandClaude Opus 5 d30bb6d235
Prüfung / pruefung (push) Waiting to run
Prüfung / e2e (push) Blocked by required conditions
feat(laeufe): Vier-Augen-Freigabe (#040)
Verlangt eine Vorlagen-Version eine Freigabe, laesst sich ein Lauf erst
abschliessen, wenn ein Zweiter ihn freigegeben hat. Das braucht zweierlei, und
beides zusammen: das neue Recht `freigeben` (ADMIN und REDAKTEUR, nicht
TECHNIKER) und dass man nicht an dem Lauf gearbeitet hat.

Als gearbeitet zaehlt: angelegt, einen Schritt gesetzt oder abgeschlossen. Eine
Zuweisung allein nicht — waere die Regel hier strenger, blockierte sie kleine
Teams ohne Gewinn.

Auch ein Administrator kann sich nicht selbst freigeben, und das ist der ganze
Sinn. Die Pruefung sitzt in `hatDaranGearbeitet` an einer Stelle, wie
`laufMussVeraenderbarSein`; die wichtigste Haelfte — der Ersteller — haelt
zusaetzlich ein CHECK in der Datenbank.

Zurueckweisen verlangt eine Begruendung, setzt den Lauf auf IN_ARBEIT und nimmt
eine erteilte Freigabe zurueck. Kein fuenfter LaufStatus: ein neuer Enum-Wert
haette jeden Codepfad betroffen, der heute vier Faelle kennt.

Sobald jemand abzuschliessen versucht und die Freigabe fehlt, bekommen die
Freigabeberechtigten eine Mail — ein anderer Empfaengerkreis als bei jeder
anderen Meldung, und ohne die, die gearbeitet haben. Die Aufforderung entsteht
ausserhalb der Transaktion: innerhalb wuerde sie mit dem Fehler zurueckgerollt,
und dann wartet ein Lauf auf etwas, von dem niemand weiss. Ein partieller
Unique-Index haelt sie bei mehreren Versuchen auf einmal.

Die Pflicht haengt an der Version, nicht an der Vorlage: eine veroeffentlichte
Version ist unveraenderlich, ein laufender Lauf behaelt also seine Regel. Beim
Bearbeiten und Duplizieren wandert sie mit — das war die unmittelbare Lehre aus
#039, wo die tiefe Kopie zwei Felder stillschweigend verworfen hatte.

1526 Vitest-Tests (vorher 1496), 115 E2E-Ablaeufe gruen. Klickprobe am gebauten
Stand mit elf Pruefungen; drei rote Punkte darin waren meine eigenen
Testfehler, einer davon lehrreich: ein reload() direkt nach einer Server Action
trifft den Router-Cache von Next und zeigt kurz den alten Stand.

Closes #40

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-24 01:14:04 +02:00

519 lines
20 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, MessageSquare, Printer, ScrollText, Star } from "lucide-react";
import { LaufStatus, SchrittTyp, SchrittZustand } from "@/generated/prisma/enums";
import { Markdown } from "@/components/markdown";
import { AbschnittHuelle } from "@/components/laeufe/abschnitt-huelle";
import { Befehlsblock } from "@/components/laeufe/befehlsblock";
import { LaufAktualisierung } from "@/components/laeufe/lauf-aktualisierung";
import {
AbschnittNichtZutreffend,
KommentarSchreiben,
LaufAbschliessen,
LaufArchivieren,
LaufFreigabe,
LaufBeginnen,
LaufMitAdKontoVerknuepfen,
LaufZurueckholen,
} from "@/components/laeufe/lauf-aktionen";
import { SchrittAnhaenge, type SchrittAnhang } from "@/components/laeufe/schritt-anhaenge";
import { SchrittWert } from "@/components/laeufe/schritt-wert";
import { SchrittZustandWaehlen, ZustandKennzeichen } from "@/components/laeufe/schritt-zustand";
import { darf } from "@/server/auth/rollen";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import { anhaengeDesLaufs } from "@/server/queries/anhaenge";
import { protokollDesLaufs } from "@/server/queries/protokoll";
import {
kommentareDesLaufs,
laufZumAbarbeiten,
personenZurVerknuepfung,
type AbarbeitungsAbschnitt,
type AbarbeitungsSchritt,
type KommentarZeile,
} from "@/server/queries/laeufe";
/**
* Die Abarbeitungsansicht (#013).
*
* Das ist die Seite, die ein Techniker stundenlang offen hat, oft am Handy, oft
* neben einem aufgeschraubten Rechner. Drei Dinge folgen daraus:
*
* 1. **Jeder Schritt speichert einzeln.** Ein Gesamtformular hiesse, dass ein
* verlorener Klick zwei Stunden Haken kostet.
* 2. **Die Seite aktualisiert sich alle zehn Sekunden** — zwei Techniker teilen
* sich ein Onboarding und sollen die Arbeit des anderen sehen.
* 3. **„Kompakt“ und „Ausführlich“** sind zwei Lesarten desselben Laufs: beim
* dritten Mal will niemand mehr die Hilfetexte lesen, beim ersten Mal sind
* sie der eigentliche Wert.
*/
const ZEITPUNKT = new Intl.DateTimeFormat("de-DE", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
function Fortschrittsbalken({ erledigt, gesamt }: { erledigt: number; gesamt: number }) {
const anteil = gesamt === 0 ? 0 : erledigt / gesamt;
return (
<div className="mt-4 flex items-center gap-3">
<div
className="h-2 min-w-0 flex-1 overflow-hidden rounded-pille bg-flaeche-hoch"
role="progressbar"
aria-valuemin={0}
aria-valuemax={gesamt}
aria-valuenow={erledigt}
aria-label="Fortschritt des Laufs"
>
<div
className="h-full bg-akzent transition-[width]"
style={{ width: `${Math.round(anteil * 100)}%` }}
/>
</div>
<span className="shrink-0 text-sm text-nebentext">
{erledigt} von {gesamt}
</span>
</div>
);
}
function Schritt({
schritt,
laufId,
lesend,
ausfuehrlich,
kommentare,
anhaenge,
}: {
schritt: AbarbeitungsSchritt;
laufId: string;
lesend: boolean;
ausfuehrlich: boolean;
kommentare: readonly KommentarZeile[];
anhaenge: readonly SchrittAnhang[];
}) {
const vomTisch =
schritt.zustand === SchrittZustand.ERLEDIGT || schritt.zustand === SchrittZustand.NICHT_ZUTREFFEND;
const brauchtBegruendung =
schritt.zustand === SchrittZustand.NICHT_ZUTREFFEND || schritt.zustand === SchrittZustand.FEHLGESCHLAGEN;
return (
<li className="border-t border-linie py-4 first:border-t-0">
<div className="flex items-start gap-3">
<span className="mono mt-0.5 shrink-0 text-xs text-nebentext">
{String(schritt.nummer).padStart(2, "0")}
</span>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className={vomTisch ? "text-nebentext line-through" : "font-medium"}>{schritt.titel}</span>
{schritt.pflicht ? (
<Star className="size-3 shrink-0 text-fehler" strokeWidth={3} aria-label="Pflichtschritt" />
) : null}
{/* Im Lesemodus traegt `SchrittZustandWaehlen` das Kennzeichen selbst. */}
{lesend ? null : <ZustandKennzeichen zustand={schritt.zustand} />}
</div>
{/*
* „Kompakt“ blendet **alle** Hilfetexte aus, nicht nur die der
* erledigten Schritte. Beim dritten Mal kennt der Techniker sie, und
* eine halbe Regel („nur bei erledigten“) haette ihm am Handy
* trotzdem die halbe Seite mit Text gefuellt (Reviewbefund zu #013).
*/}
{schritt.hilfetext && ausfuehrlich ? (
<div className="mt-2">
<Markdown text={schritt.hilfetext} />
</div>
) : null}
{schritt.dokuUrl && /^https?:\/\//iu.test(schritt.dokuUrl) ? (
<a
href={schritt.dokuUrl}
target="_blank"
rel="noreferrer"
className="mt-2 inline-flex min-h-11 items-center text-sm text-akzent underline underline-offset-2 md:min-h-10"
>
Anleitung öffnen
</a>
) : null}
{schritt.befehl ? (
<div className="mt-3">
<Befehlsblock
text={schritt.befehl.text}
unbekannt={schritt.befehl.unbekannt}
hinweis={schritt.befehl.hinweis}
fehler={schritt.befehl.fehler}
/>
</div>
) : null}
{schritt.typ !== SchrittTyp.HAKEN && schritt.typ !== SchrittTyp.BEFEHL ? (
<div className="mt-3">
<SchrittWert
laufId={laufId}
vorlagenSchrittId={schritt.schrittId}
typ={schritt.typ}
konfig={schritt.konfig}
wert={schritt.wert}
zustand={schritt.zustand}
lesend={lesend}
/>
</div>
) : null}
<div className="mt-3">
<SchrittZustandWaehlen
laufId={laufId}
vorlagenSchrittId={schritt.schrittId}
zustand={schritt.zustand}
notiz={schritt.notiz}
lesend={lesend}
/>
</div>
{/*
* Die Notiz als Text — aber nur, wenn sie nicht ohnehin im
* Begruendungsfeld steht. `NICHT_ZUTREFFEND` und `FEHLGESCHLAGEN`
* blenden dieses Feld gefuellt ein; ein Absatz darunter waere
* dieselbe Zeile ein zweites Mal.
*/}
{schritt.notiz && !lesend && !brauchtBegruendung ? (
<p className="mt-2 max-w-prose text-sm text-nebentext">{schritt.notiz}</p>
) : null}
{schritt.bearbeitetVon && schritt.bearbeitetAm ? (
<p className="mt-2 text-xs text-nebentext">
{schritt.bearbeitetVon}, {ZEITPUNKT.format(schritt.bearbeitetAm)}
</p>
) : null}
{kommentare.length > 0 ? (
<ul className="mt-3 flex flex-col gap-2">
{kommentare.map((kommentar) => (
<li key={kommentar.id} className="rounded-karte bg-flaeche-hoch p-3 text-sm">
<p className="text-text">{kommentar.rumpf}</p>
<p className="mt-1 text-xs text-nebentext">
{kommentar.benutzer}, {ZEITPUNKT.format(kommentar.erstelltAm)}
</p>
</li>
))}
</ul>
) : null}
{/*
* Anhaenge (#032). Immer sichtbar, wenn es welche gibt — auch an einem
* archivierten Lauf, nur dann ohne Knoepfe. Ein Foto vom Typenschild
* ist Teil des Belegs.
*/}
{anhaenge.length > 0 || (!lesend && schritt.ergebnisId) ? (
<div className="mt-3">
<SchrittAnhaenge
laufId={laufId}
vorlagenSchrittId={schritt.schrittId}
anhaenge={anhaenge}
lesend={lesend || !schritt.ergebnisId}
/>
</div>
) : null}
{lesend || !schritt.ergebnisId ? null : (
<details className="mt-2">
<summary className="flex min-h-11 cursor-pointer list-none items-center gap-2 text-xs text-nebentext hover:text-text md:min-h-9">
<MessageSquare className="size-3.5" strokeWidth={1.75} aria-hidden="true" />
Kommentar zu diesem Schritt
</summary>
<div className="mt-2">
<KommentarSchreiben laufId={laufId} schrittergebnisId={schritt.ergebnisId} />
</div>
</details>
)}
</div>
</div>
</li>
);
}
function Abschnitt({
abschnitt,
laufId,
lesend,
ausfuehrlich,
kommentare,
anhaenge,
}: {
abschnitt: AbarbeitungsAbschnitt;
laufId: string;
lesend: boolean;
ausfuehrlich: boolean;
kommentare: readonly KommentarZeile[];
anhaenge: readonly (SchrittAnhang & { readonly schrittergebnisId: string })[];
}) {
const erledigt = abschnitt.schritte.filter(
(schritt) =>
schritt.zustand === SchrittZustand.ERLEDIGT || schritt.zustand === SchrittZustand.NICHT_ZUTREFFEND,
).length;
// Zu, sobald im Abschnitt nichts mehr zu tun ist – aber nur beim ersten Aufbau.
const alleVomTisch = erledigt === abschnitt.schritte.length && abschnitt.schritte.length > 0;
return (
<AbschnittHuelle
offenZuBeginn={!alleVomTisch}
nummer={abschnitt.nummer}
titel={abschnitt.titel}
erledigt={erledigt}
gesamt={abschnitt.schritte.length}
>
{abschnitt.skript ? (
<details className="mt-3">
<summary className="flex min-h-11 cursor-pointer list-none items-center text-sm text-nebentext hover:text-text md:min-h-10">
Alle Befehle als Skript kopieren
</summary>
<div className="mt-2">
<Befehlsblock text={abschnitt.skript} beschriftung="Skript kopieren" />
</div>
</details>
) : null}
<ul className="mt-2 flex flex-col">
{abschnitt.schritte.map((schritt) => (
<Schritt
key={schritt.schrittId}
schritt={schritt}
laufId={laufId}
lesend={lesend}
ausfuehrlich={ausfuehrlich}
kommentare={kommentare.filter((kommentar) => kommentar.schrittergebnisId === schritt.ergebnisId)}
anhaenge={anhaenge.filter((anhang) => anhang.schrittergebnisId === schritt.ergebnisId)}
/>
))}
</ul>
{lesend ? null : (
<div className="mt-3">
<AbschnittNichtZutreffend laufId={laufId} abschnittId={abschnitt.id} abschnitt={abschnitt.titel} />
</div>
)}
</AbschnittHuelle>
);
}
export default async function LaufSeite({
params,
searchParams,
}: {
params: Promise<{ id: string }>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
}) {
const { id } = await params;
const { benutzer } = await sitzungErforderlich("lesen", `/laeufe/${id}`);
const ansicht = await searchParams;
const ausfuehrlich =
(Array.isArray(ansicht["ansicht"]) ? ansicht["ansicht"][0] : ansicht["ansicht"]) !== "kompakt";
const daten = await laufZumAbarbeiten(id, benutzer, benutzer.name);
// Nicht vorhanden und nicht sichtbar sind dieselbe Antwort.
if (!daten) notFound();
const kommentare = await kommentareDesLaufs(id, benutzer);
// Die Sicht hat `laufHolen` oben schon angewandt; hier geht es nur noch um die
// Einträge dieses Laufs (#023).
const laufProtokoll = await protokollDesLaufs(id);
// Die Sicht hat `laufHolen` oben schon angewandt; hier nur die Metadaten der
// Anhaenge dieses Laufs (#032).
const anhaenge = await anhaengeDesLaufs(id, benutzer);
/*
* Die Personenliste nur fuer den Onboarding-Fall holen — sonst stuende in
* jedem Seitenaufbau eine Abfrage, die niemand braucht.
*/
const personen =
daten.lauf.nurFreitextName && darf(benutzer.rolle, "laeufeBearbeiten")
? await personenZurVerknuepfung(id, benutzer)
: [];
const { lauf, abschnitte, anzahlPflichtOffen } = daten;
const darfArbeiten = darf(benutzer.rolle, "laeufeBearbeiten");
const archiviert = lauf.status === LaufStatus.ARCHIVIERT;
const lesend = archiviert || !darfArbeiten;
const laufKommentare = kommentare.filter((kommentar) => kommentar.schrittergebnisId === null);
/*
* Steht ein gefuelltes `{{generiertes_passwort}}` auf der Seite, darf die
* Aktualisierung sie nicht von selbst neu bauen — beim naechsten Aufbau
* entstuende ein anderes Passwort als das, das der Techniker gerade liest.
*/
const passwortSichtbar = abschnitte.some((abschnitt) =>
abschnitt.schritte.some((schritt) => schritt.befehl?.mitPasswort === true),
);
return (
<>
{archiviert ? null : <LaufAktualisierung laufId={lauf.id} passwortSichtbar={passwortSichtbar} />}
<Link
href="/laeufe"
className="inline-flex min-h-11 items-center gap-2 text-sm text-nebentext hover:text-text"
>
<ArrowLeft className="size-4" strokeWidth={1.75} aria-hidden="true" />
Alle Läufe
</Link>
<h1 className="mt-2 text-2xl font-bold">{lauf.titel}</h1>
<p className="mt-2 text-sm text-nebentext">
{[`${lauf.vorlagenName} v${lauf.version}`, lauf.geraetName, lauf.mitarbeiterName, lauf.ticket]
.filter(Boolean)
.join(" · ")}
</p>
{archiviert ? (
<p className="mt-4 max-w-prose rounded-karte border border-linie bg-flaeche p-4 text-sm text-nebentext">
Dieser Lauf ist archiviert. Er zeigt für immer, was damals abgearbeitet wurde — geändert wird daran
nichts mehr.
</p>
) : null}
{lauf.nurFreitextName && lauf.mitarbeiterName && !lesend ? (
<LaufMitAdKontoVerknuepfen laufId={lauf.id} name={lauf.mitarbeiterName} personen={personen} />
) : null}
<Fortschrittsbalken erledigt={lauf.anzahlErledigt} gesamt={lauf.anzahlSchritte} />
{/*
* Die Freigabe-Karte (#040), und zwar **ueber** der Knopfreihe: sie ist
* eine Entscheidung und kein Knopf unter anderen.
*
* Gezeigt wird sie, wenn die Version eine Freigabe verlangt und der Lauf
* noch nicht archiviert ist — danach aendert sich nichts mehr. Wer kein
* `freigeben` hat, sieht sie nur als Auskunft; das Formular bleibt ihm
* verborgen, denn ein Knopf, der nichts tut, ist kein Knopf.
*/}
{lauf.freigabePflicht && !archiviert ? (
darf(benutzer.rolle, "freigeben") ? (
<div className="mt-4">
<LaufFreigabe
laufId={lauf.id}
bereitsFreigegeben={lauf.freigegebenAm !== null}
letzteNotiz={lauf.freigabeNotiz}
/>
</div>
) : (
<p className="mt-4 max-w-prose rounded-karte border border-linie bg-flaeche p-4 text-sm text-nebentext">
{lauf.freigegebenAm
? `Freigegeben${lauf.freigegebenVon ? ` von ${lauf.freigegebenVon}` : ""}.`
: "Dieser Lauf verlangt eine Freigabe durch einen Zweiten, bevor er abgeschlossen " +
"werden kann. Die Freigabeberechtigten sind benachrichtigt, sobald du ihn " +
"abzuschließen versuchst."}
{lauf.freigabeNotiz ? ` Letzte Begründung: ${lauf.freigabeNotiz}` : ""}
</p>
)
) : null}
<div className="mt-4 flex flex-wrap items-center gap-2">
{lauf.status === LaufStatus.OFFEN && darfArbeiten ? <LaufBeginnen laufId={lauf.id} /> : null}
{lauf.status === LaufStatus.IN_ARBEIT && darfArbeiten ? (
<LaufAbschliessen laufId={lauf.id} offen={anzahlPflichtOffen} />
) : null}
{lauf.status === LaufStatus.ABGESCHLOSSEN && darfArbeiten ? (
<LaufArchivieren laufId={lauf.id} />
) : null}
{archiviert && darf(benutzer.rolle, "archivVerwalten") ? <LaufZurueckholen laufId={lauf.id} /> : null}
<Link
href={`/laeufe/${lauf.id}?ansicht=${ausfuehrlich ? "kompakt" : "ausfuehrlich"}`}
className="flex min-h-11 items-center rounded-pille border border-linie px-4 text-sm text-nebentext hover:bg-flaeche-hoch hover:text-text md:min-h-10"
>
{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">
{abschnitte.map((abschnitt) => (
<Abschnitt
key={abschnitt.id}
abschnitt={abschnitt}
laufId={lauf.id}
lesend={lesend}
ausfuehrlich={ausfuehrlich}
kommentare={kommentare}
anhaenge={anhaenge}
/>
))}
</div>
<section className="mt-4 rounded-karte border border-linie bg-flaeche p-4 md:p-5">
<h2 className="text-lg font-semibold">Kommentare zum Lauf</h2>
{laufKommentare.length === 0 ? (
<p className="mt-2 text-sm text-nebentext">Noch kein Kommentar.</p>
) : (
<ul className="mt-3 flex flex-col gap-2">
{laufKommentare.map((kommentar) => (
<li key={kommentar.id} className="rounded-karte bg-flaeche-hoch p-3 text-sm">
<p className="text-text">{kommentar.rumpf}</p>
<p className="mt-1 text-xs text-nebentext">
{kommentar.benutzer}, {ZEITPUNKT.format(kommentar.erstelltAm)}
</p>
</li>
))}
</ul>
)}
{lesend ? null : (
<div className="mt-4">
<KommentarSchreiben laufId={lauf.id} />
</div>
)}
</section>
{/*
* Die Geschichte dieses Laufs, eingeklappt (#023). Sie steht hier und
* nicht nur unter Verwaltung → Protokoll, weil die Frage „wer hat das
* zurückgeholt“ am Lauf aufkommt und nicht in der Verwaltung. Eingeklappt,
* weil ein abgearbeiteter Lauf Dutzende Einträge hat und sie die Schritte
* sonst aus dem Bild schieben.
*
* Kein eigenes Recht: wer den Lauf sehen darf, darf auch sehen, was an ihm
* geschehen ist. Die Mandantensicht hat `laufHolen` oben schon angewandt.
*/}
{laufProtokoll.length > 0 ? (
<section className="mt-4 rounded-karte border border-linie bg-flaeche p-4 md:p-5">
<details>
<summary className="flex min-h-11 cursor-pointer items-center gap-2 text-lg font-semibold md:min-h-0">
<ScrollText className="size-5 text-nebentext" strokeWidth={1.75} aria-hidden="true" />
Protokoll dieses Laufs
<span className="text-sm font-normal text-nebentext">({laufProtokoll.length})</span>
</summary>
<ol className="mt-3 flex flex-col gap-2">
{laufProtokoll.map((eintrag) => (
<li key={eintrag.id} className="flex flex-wrap items-baseline gap-x-3 gap-y-1 text-sm">
<span className="mono shrink-0 text-xs text-nebentext">
{ZEITPUNKT.format(eintrag.erstelltAm)}
</span>
<span className="min-w-0 break-words">{eintrag.aktion}</span>
<span className="text-xs text-nebentext">
{eintrag.urheber ? eintrag.urheber.name : "Systemlauf"}
</span>
</li>
))}
</ol>
</details>
</section>
) : null}
</>
);
}