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
+309
View File
@@ -0,0 +1,309 @@
"use client";
import { useActionState, useState } from "react";
import { Archive, CheckCircle2, Link2, PlayCircle, Undo2 } from "lucide-react";
import {
abschnittNichtZutreffendAktion,
kommentarHinzufuegenAktion,
laufAbschliessenAktion,
laufArchivierenAktion,
laufMitAdKontoVerknuepfenAktion,
laufOeffnenAktion,
laufZurueckholenAktion,
type AktionsErgebnis,
} from "@/server/actions/laeufe";
import { FELD } from "@/lib/formular";
const KNOPF =
"flex min-h-11 w-fit 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";
function Meldung({ ergebnis }: { ergebnis: AktionsErgebnis }) {
if (ergebnis.fehler) {
return (
<p role="alert" className="mt-2 max-w-prose text-sm text-fehler">
{ergebnis.fehler}
</p>
);
}
if (ergebnis.erfolg) {
return (
<p role="status" className="mt-2 max-w-prose text-sm text-erledigt">
{ergebnis.erfolg}
</p>
);
}
return null;
}
/**
* „Lauf beginnen“ – der Übergang von `OFFEN` zu `IN_ARBEIT`.
*
* Erst hier entsteht `gestartetAm`. Ein zugewiesener Lauf, den jemand nur
* aufschlaegt, hat noch nicht begonnen — sonst saehe die Liste nach Arbeit aus,
* die niemand tut.
*/
export function LaufBeginnen({ laufId }: { laufId: string }) {
const [ergebnis, aktion, laeuft] = useActionState<AktionsErgebnis, FormData>(laufOeffnenAktion, {});
return (
<div>
<form action={aktion}>
<input type="hidden" name="laufId" value={laufId} />
<button
type="submit"
disabled={laeuft}
className="flex min-h-11 w-fit items-center gap-2 rounded-pille bg-akzent px-5 text-sm font-semibold text-auf-akzent md:min-h-10"
>
<PlayCircle className="size-[18px]" strokeWidth={2} aria-hidden="true" />
{laeuft ? "Wird geöffnet …" : "Lauf beginnen"}
</button>
</form>
<Meldung ergebnis={ergebnis} />
</div>
);
}
export function LaufAbschliessen({ laufId, offen }: { laufId: string; offen: number }) {
const [ergebnis, aktion, laeuft] = useActionState<AktionsErgebnis, FormData>(laufAbschliessenAktion, {});
return (
<div>
<form action={aktion}>
<input type="hidden" name="laufId" value={laufId} />
<button
type="submit"
disabled={laeuft || offen > 0}
/*
* Kein `title`: der Knopf traegt `pointer-events-none`, solange er
* gesperrt ist — ein Tooltip erschiene nie. Der Grund steht als Satz
* darunter, wo ihn auch jemand am Handy liest.
*/
className="flex min-h-11 w-fit items-center gap-2 rounded-pille bg-akzent px-5 text-sm font-semibold text-auf-akzent disabled:pointer-events-none disabled:opacity-40 md:min-h-10"
>
<CheckCircle2 className="size-[18px]" strokeWidth={2} aria-hidden="true" />
{laeuft ? "Wird abgeschlossen …" : "Lauf abschließen"}
</button>
</form>
{offen > 0 ? (
<p className="mt-2 max-w-prose text-sm text-nebentext">
Noch {offen} {offen === 1 ? "Pflichtschritt ist" : "Pflichtschritte sind"} offen. Erledigt oder
„nicht zutreffend“ zählen beide — fehlgeschlagen nicht.
</p>
) : null}
<Meldung ergebnis={ergebnis} />
</div>
);
}
/**
* Archivieren – die zweite, ausdrueckliche Handlung.
*
* Mit Rueckfrage, weil danach nichts mehr geht: kein Kommentar, keine
* nachgetragene Notiz, keine Korrektur. Nur ein Administrator holt den Lauf
* zurueck.
*/
export function LaufArchivieren({ laufId }: { laufId: string }) {
const [ergebnis, aktion, laeuft] = useActionState<AktionsErgebnis, FormData>(laufArchivierenAktion, {});
const [sicher, setzeSicher] = useState(false);
if (!sicher) {
return (
<button type="button" onClick={() => setzeSicher(true)} className={KNOPF}>
<Archive className="size-4" strokeWidth={1.75} aria-hidden="true" />
archivieren
</button>
);
}
return (
<div className="flex flex-col gap-2">
<p className="max-w-prose text-sm text-faellig">
Nach dem Archivieren ändert sich an diesem Lauf nichts mehr — kein Kommentar, keine Notiz, kein
Schritt. Nur ein Administrator holt ihn zurück.
</p>
<div className="flex flex-wrap gap-2">
<form action={aktion}>
<input type="hidden" name="laufId" value={laufId} />
<button
type="submit"
disabled={laeuft}
className="flex min-h-11 items-center rounded-pille border border-linie px-4 text-sm text-text hover:bg-flaeche-hoch md:min-h-10"
>
{laeuft ? "Wird archiviert …" : "Ja, archivieren"}
</button>
</form>
<button type="button" onClick={() => setzeSicher(false)} className={KNOPF}>
Abbrechen
</button>
</div>
<Meldung ergebnis={ergebnis} />
</div>
);
}
/** Zurueckholen – nur fuer Administratoren, mit Protokolleintrag. */
export function LaufZurueckholen({ laufId }: { laufId: string }) {
const [ergebnis, aktion, laeuft] = useActionState<AktionsErgebnis, FormData>(laufZurueckholenAktion, {});
return (
<div>
<form action={aktion}>
<input type="hidden" name="laufId" value={laufId} />
<button type="submit" disabled={laeuft} className={KNOPF}>
<Undo2 className="size-4" strokeWidth={1.75} aria-hidden="true" />
{laeuft ? "Wird zurückgeholt …" : "aus dem Archiv zurückholen"}
</button>
</form>
<Meldung ergebnis={ergebnis} />
</div>
);
}
/**
* Einen ganzen Abschnitt als nicht zutreffend vermerken.
*
* Der Fall aus dem Auftrag: im Onboarding gibt es zwei Konto-Abschnitte, und je
* nach Kunde trifft einer gar nicht zu. Achtzehn Haken einzeln mit derselben
* Begruendung zu versehen, macht niemand — und wer es nicht macht, laesst sie weg.
*/
export function AbschnittNichtZutreffend({
laufId,
abschnittId,
abschnitt,
}: {
laufId: string;
abschnittId: string;
abschnitt: string;
}) {
const [ergebnis, aktion, laeuft] = useActionState<AktionsErgebnis, FormData>(
abschnittNichtZutreffendAktion,
{},
);
const [offen, setzeOffen] = useState(false);
if (!offen) {
return (
<button type="button" onClick={() => setzeOffen(true)} className={`${KNOPF} text-xs`}>
Abschnitt trifft nicht zu
</button>
);
}
return (
<form action={aktion} className="flex flex-col gap-2 rounded-karte border border-linie bg-grund p-3">
<input type="hidden" name="laufId" value={laufId} />
<input type="hidden" name="abschnittId" value={abschnittId} />
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">
Warum trifft „{abschnitt}“ nicht zu? <span className="text-fehler">*</span>
</span>
<textarea
name="notiz"
rows={2}
required
placeholder="Die Begründung steht danach an jedem Schritt des Abschnitts."
className={`${FELD} py-2`}
/>
</label>
<div className="flex flex-wrap gap-2">
<button
type="submit"
disabled={laeuft}
className="flex min-h-11 items-center rounded-pille border border-linie px-4 text-sm text-text hover:bg-flaeche-hoch md:min-h-10"
>
{laeuft ? "Wird gesetzt …" : "Abschnitt vermerken"}
</button>
<button type="button" onClick={() => setzeOffen(false)} className={KNOPF}>
Abbrechen
</button>
</div>
<Meldung ergebnis={ergebnis} />
</form>
);
}
/** Ein Kommentar am Lauf oder an einem Schritt. */
export function KommentarSchreiben({
laufId,
schrittergebnisId,
}: {
laufId: string;
schrittergebnisId?: string | null;
}) {
const [ergebnis, aktion, laeuft] = useActionState<AktionsErgebnis, FormData>(
kommentarHinzufuegenAktion,
{},
);
return (
<form action={aktion} className="flex flex-col gap-2">
<input type="hidden" name="laufId" value={laufId} />
{schrittergebnisId ? <input type="hidden" name="schrittergebnisId" value={schrittergebnisId} /> : null}
<textarea
name="rumpf"
rows={2}
required
placeholder="Was sollte man später wissen?"
className={`${FELD} py-2`}
/>
<button type="submit" disabled={laeuft} className={`${KNOPF} self-start`}>
{laeuft ? "Wird gespeichert …" : "Kommentar hinzufügen"}
</button>
<Meldung ergebnis={ergebnis} />
</form>
);
}
/**
* Einen Lauf nachtraeglich an ein Konto aus dem Verzeichnis haengen (#013).
*
* Sichtbar nur im Onboarding-Fall: der Lauf traegt einen Freitextnamen, weil es
* die Person beim Start noch nicht gab. Danach gehoert die Arbeit an die Person
* — sonst findet sie in ihrer Historie nichts wieder.
*
* Zwei Wege, weil es zwei Lagen gibt: die Person steht schon in der Liste (dann
* waehlt man sie), oder sie ist gerade erst im Verzeichnis entstanden und noch
* nicht abgeglichen (dann traegt man den UPN ein, so wie er dort steht).
*/
export function LaufMitAdKontoVerknuepfen({
laufId,
name,
personen,
}: {
laufId: string;
name: string;
personen: readonly { id: string; anzeigename: string }[];
}) {
const [ergebnis, aktion, laeuft] = useActionState<AktionsErgebnis, FormData>(
laufMitAdKontoVerknuepfenAktion,
{},
);
return (
<details className="mt-4 rounded-karte border border-linie bg-flaeche p-4">
<summary className="flex min-h-11 cursor-pointer list-none items-center gap-2 text-sm text-nebentext hover:text-text md:min-h-10">
<Link2 className="size-4" strokeWidth={1.75} aria-hidden="true" />„{name}“ mit einem AD-Konto
verknüpfen
</summary>
<form action={aktion} className="mt-3 flex flex-col gap-3">
<input type="hidden" name="laufId" value={laufId} />
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Person aus dem Verzeichnis</span>
<select name="mitarbeiterId" defaultValue="" className={FELD}>
<option value="">bitte wählen</option>
{personen.map((person) => (
<option key={person.id} value={person.id}>
{person.anzeigename}
</option>
))}
</select>
</label>
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">… oder UPN, wenn das Konto neu ist</span>
<input name="upn" placeholder="vorname.nachname@firma.de" className={FELD} />
</label>
<button type="submit" disabled={laeuft} className={KNOPF}>
{laeuft ? "Wird verknüpft …" : "Verknüpfen"}
</button>
<Meldung ergebnis={ergebnis} />
</form>
</details>
);
}