feat(laeufe): Service, Abarbeitung, Kommentare und Archivschutz
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s

Ein Lauf wird vorbereitet, abgearbeitet, abgeschlossen und archiviert. Der
Archiv-Schreibschutz sitzt in genau einer Funktion und wird vor jeder Aenderung
an Kopfdaten, Schrittergebnis und Kommentar gerufen; abgeschlossen bleibt
aenderbar, archiviert nicht.

Die Abarbeitungsansicht speichert jeden Schritt einzeln, frischt sich alle zehn
Sekunden auf und pausiert im Hintergrund. Sie traegt in der Polling-Antwort
keine Befehle und laedt sich auch nicht von selbst neu, solange ein generiertes
Passwort auf der Seite steht: beides tauschte es dem Techniker zwischen Lesen
und Einfuegen aus.

Der unabhaengige Review fand zwei Blocker: ein Befehl konnte zwei verschiedene
Passwoerter tragen (Konto bekommt A, Benutzer erfaehrt B), und „nicht
zutreffend“ liess sich nicht zuruecknehmen, waehrend die Oberflaeche
behauptete, nichts sei gewaehlt. Beides behoben und in Tests festgehalten.

Closes #13

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit was merged in pull request #27.
This commit is contained in:
2026-09-25 16:56:33 +00:00
co-authored by Claude Opus 5
parent eec5de404a
commit c78a6ac1af
44 changed files with 7440 additions and 61 deletions
+406
View File
@@ -0,0 +1,406 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { ArrowLeft, MessageSquare, 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,
LaufBeginnen,
LaufMitAdKontoVerknuepfen,
LaufZurueckholen,
} from "@/components/laeufe/lauf-aktionen";
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 {
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,
}: {
schritt: AbarbeitungsSchritt;
laufId: string;
lesend: boolean;
ausfuehrlich: boolean;
kommentare: readonly KommentarZeile[];
}) {
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}
{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,
}: {
abschnitt: AbarbeitungsAbschnitt;
laufId: string;
lesend: boolean;
ausfuehrlich: boolean;
kommentare: readonly KommentarZeile[];
}) {
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)}
/>
))}
</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 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} />
<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>
</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}
/>
))}
</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>
</>
);
}