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>
310 lines
11 KiB
TypeScript
310 lines
11 KiB
TypeScript
"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>
|
||
);
|
||
}
|