feat(laeufe): Service, Abarbeitung, Kommentare und Archivschutz
Prüfung / pruefung (push) Canceled after 0s
Prüfung / pruefung (pull_request) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s
Prüfung / e2e (pull_request) 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 is contained in:
Florian Wilde
2026-09-25 18:55:59 +02:00
co-authored by Claude Opus 5
parent eec5de404a
commit ed6e6b81eb
44 changed files with 7440 additions and 61 deletions
+139
View File
@@ -0,0 +1,139 @@
"use client";
import { useActionState, useState } from "react";
import { SchrittTyp, SchrittZustand } from "@/generated/prisma/enums";
import { FELD } from "@/lib/formular";
import { schrittergebnisSetzenAktion, type AktionsErgebnis } from "@/server/actions/laeufe";
/**
* Das Eingabefeld eines Schritts, passend zu seinem Typ (#013).
*
* Gespeichert wird mit einem eigenen Knopf und nicht bei jedem Tastendruck: ein
* halb getippter Wert soll nicht in die Datenbank, und ein Lauf wird oft am
* Handy mit wackligem Netz abgearbeitet.
*
* Ein `ZAHL`-Wert ausserhalb von Min/Max wird **markiert, nicht blockiert**. Die
* Grenzen sind eine Erwartung, kein Gesetz — ein Rechner mit 4 GB statt der
* erwarteten 8 ist ein Befund, den der Lauf festhalten soll, kein Eingabefehler.
*/
export function SchrittWert({
laufId,
vorlagenSchrittId,
typ,
konfig,
wert,
zustand,
lesend,
}: {
laufId: string;
vorlagenSchrittId: string;
typ: SchrittTyp;
konfig: Record<string, unknown>;
wert: unknown;
zustand: SchrittZustand;
lesend: boolean;
}) {
const [ergebnis, aktion, laeuft] = useActionState<AktionsErgebnis, FormData>(
schrittergebnisSetzenAktion,
{},
);
const [eingabe, setzeEingabe] = useState(wert === null || wert === undefined ? "" : String(wert));
if (typ === SchrittTyp.HAKEN || typ === SchrittTyp.BEFEHL) return null;
if (lesend) {
return eingabe ? (
<p className="mono text-sm text-text">{eingabe}</p>
) : (
<p className="text-sm text-nebentext">kein Wert erfasst</p>
);
}
const zahl = typ === SchrittTyp.ZAHL ? Number(eingabe) : Number.NaN;
const min = typeof konfig["min"] === "number" ? konfig["min"] : undefined;
const max = typeof konfig["max"] === "number" ? konfig["max"] : undefined;
const ausserhalb =
!Number.isNaN(zahl) && ((min !== undefined && zahl < min) || (max !== undefined && zahl > max));
return (
<form action={aktion} className="flex flex-col gap-2">
<input type="hidden" name="laufId" value={laufId} />
<input type="hidden" name="vorlagenSchrittId" value={vorlagenSchrittId} />
{/* Der Zustand bleibt, wie er ist – hier wird ein Wert erfasst, kein Haken gesetzt. */}
<input type="hidden" name="zustand" value={zustand} />
<div className="flex flex-wrap items-end gap-2">
<label className="flex min-w-0 flex-1 flex-col gap-1.5 text-sm md:max-w-sm">
<span className="text-nebentext">{beschriftungFuer(typ, konfig)}</span>
{typ === SchrittTyp.AUSWAHL ? (
<select
name="wert"
value={eingabe}
onChange={(ereignis) => setzeEingabe(ereignis.target.value)}
className={FELD}
>
<option value="">bitte wählen</option>
{optionenAus(konfig).map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
) : typ === SchrittTyp.TEXT && konfig["mehrzeilig"] === true ? (
<textarea
name="wert"
rows={3}
value={eingabe}
onChange={(ereignis) => setzeEingabe(ereignis.target.value)}
className={`${FELD} py-2`}
/>
) : (
<input
name="wert"
type={typ === SchrittTyp.ZAHL ? "number" : typ === SchrittTyp.DATUM ? "date" : "text"}
value={eingabe}
onChange={(ereignis) => setzeEingabe(ereignis.target.value)}
className={FELD}
/>
)}
</label>
<button
type="submit"
disabled={laeuft}
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"
>
{laeuft ? "Wird gespeichert …" : "Wert speichern"}
</button>
</div>
{ausserhalb ? (
<p className="text-xs text-faellig">
Erwartet werden {min ?? "–"} bis {max ?? "–"}
{typeof konfig["einheit"] === "string" ? ` ${konfig["einheit"]}` : ""}. Der Wert wird trotzdem
gespeichert — ein Befund ist kein Eingabefehler.
</p>
) : null}
{ergebnis.fehler ? (
<p role="alert" className="max-w-prose text-sm text-fehler">
{ergebnis.fehler}
</p>
) : null}
</form>
);
}
function beschriftungFuer(typ: SchrittTyp, konfig: Record<string, unknown>): string {
if (typ === SchrittTyp.ZAHL) {
const einheit = typeof konfig["einheit"] === "string" ? ` in ${konfig["einheit"]}` : "";
return `Wert${einheit}`;
}
if (typ === SchrittTyp.DATUM) return "Datum";
if (typ === SchrittTyp.AUSWAHL) return "Auswahl";
return "Wert";
}
function optionenAus(konfig: Record<string, unknown>): readonly string[] {
const roh = konfig["optionen"];
return Array.isArray(roh) ? roh.filter((eintrag): eintrag is string => typeof eintrag === "string") : [];
}