feat(laeufe): Service, Abarbeitung, Kommentare und Archivschutz
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:
@@ -0,0 +1,70 @@
|
||||
"use client";
|
||||
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
|
||||
/**
|
||||
* Die auf- und zuklappbare Huelle eines Abschnitts (#013).
|
||||
*
|
||||
* Drei Entscheidungen, jede gegen einen ersten Entwurf:
|
||||
*
|
||||
* 1. **Client-Komponente mit eigenem Zustand.** Die Abarbeitungsansicht baut
|
||||
* sich nach jeder gespeicherten Aenderung neu auf. Ein `offen`, das aus den
|
||||
* Daten kommt, klappte dabei jedes Mal zurueck — wer einen fertigen Abschnitt
|
||||
* aufklappt, um eine Notiz nachzutragen, saehe ihn beim ersten Klick wieder
|
||||
* zugehen. Der Zustand im Browser ueberlebt das Neuaufbauen.
|
||||
* 2. **Kein `details`.** Ein `details` klappt auch **ohne** React auf, und genau
|
||||
* zwischen Auslieferung und Hydrierung geraten beide aneinander: der Browser
|
||||
* oeffnet, React schliesst gleich darauf wieder zu. Ein Knopf mit
|
||||
* `aria-expanded` tut vor der Hydrierung nichts — wie jeder andere Knopf
|
||||
* dieser Seite, und damit vorhersehbar.
|
||||
* 3. **Die Ueberschrift umschliesst den Knopf, nicht umgekehrt.** In einem
|
||||
* `button` sind alle Nachfahren nur noch Beschriftung; ein `h2` darin waere
|
||||
* fuer einen Screenreader keine Ueberschrift mehr, und ein Lauf verloere
|
||||
* seine Gliederung. So bleibt die Ueberschrift eine Ueberschrift und der
|
||||
* ganze Kopf trotzdem anklickbar.
|
||||
*/
|
||||
export function AbschnittHuelle({
|
||||
offenZuBeginn,
|
||||
nummer,
|
||||
titel,
|
||||
erledigt,
|
||||
gesamt,
|
||||
children,
|
||||
}: {
|
||||
/** Nur der erste Aufbau: offen, solange im Abschnitt etwas zu tun ist. */
|
||||
offenZuBeginn: boolean;
|
||||
nummer: string;
|
||||
titel: string;
|
||||
erledigt: number;
|
||||
gesamt: number;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const [offen, setzeOffen] = useState(offenZuBeginn);
|
||||
|
||||
return (
|
||||
<section className="rounded-karte border border-linie bg-flaeche p-4 md:p-5">
|
||||
<h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setzeOffen((vorher) => !vorher)}
|
||||
aria-expanded={offen}
|
||||
className="flex min-h-11 w-full flex-wrap items-center gap-2 text-left md:min-h-10"
|
||||
>
|
||||
<ChevronDown
|
||||
className={`size-4 shrink-0 text-nebentext transition-transform ${offen ? "" : "-rotate-90"}`}
|
||||
strokeWidth={1.75}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="mono text-sm font-semibold text-akzent">{nummer}</span>
|
||||
<span className="text-nebentext">·</span>
|
||||
<span className="text-lg font-semibold">{titel}</span>
|
||||
<span className="text-xs font-normal text-nebentext">
|
||||
{erledigt} von {gesamt}
|
||||
</span>
|
||||
</button>
|
||||
</h2>
|
||||
{offen ? children : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Copy, KeyRound, TriangleAlert } from "lucide-react";
|
||||
|
||||
/**
|
||||
* Ein Befehl zum Kopieren (#013).
|
||||
*
|
||||
* Dunkler Grund, Lime-Schrift, Monospace — er soll sich vom Fliesstext absetzen,
|
||||
* weil er das einzige auf der Seite ist, das jemand **ausfuehrt**.
|
||||
*
|
||||
* Drei Dinge stehen daneben, wenn sie zutreffen:
|
||||
*
|
||||
* - **Ein unaufgeloester Platzhalter.** Er steht als `{{name}}` im Befehl und
|
||||
* wuerde so auf dem Domaenencontroller landen. Eine Warnung ist hier richtig
|
||||
* und eine Sperre falsch: es kann sein, dass der Techniker den Wert selbst
|
||||
* einsetzt.
|
||||
* - **Ein Passwort.** Es entsteht bei jeder Anzeige neu und wird nirgends
|
||||
* gespeichert — wer es nicht sofort weiterreicht, hat es verloren.
|
||||
* - **Ein Befehl, der sich nicht sicher fuellen liess.** Dann gibt es keinen
|
||||
* Kopier-Knopf. Einen halb maskierten Befehl anzubieten waere die
|
||||
* gefaehrlichste aller Antworten.
|
||||
*/
|
||||
export function Befehlsblock({
|
||||
text,
|
||||
unbekannt,
|
||||
hinweis,
|
||||
fehler,
|
||||
beschriftung = "Befehl kopieren",
|
||||
}: {
|
||||
text: string;
|
||||
unbekannt?: readonly string[];
|
||||
hinweis?: string | null;
|
||||
fehler?: string | null;
|
||||
beschriftung?: string;
|
||||
}) {
|
||||
const [kopiert, setzeKopiert] = useState(false);
|
||||
|
||||
async function kopieren() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setzeKopiert(true);
|
||||
window.setTimeout(() => setzeKopiert(false), 2000);
|
||||
} catch {
|
||||
// Ohne Zwischenablage-Recht bleibt der Text markierbar – deshalb steht er
|
||||
// ohnehin vollstaendig da, statt hinter einem Knopf zu verschwinden.
|
||||
setzeKopiert(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{fehler ? (
|
||||
<p className="flex items-start gap-2 text-sm text-fehler">
|
||||
<TriangleAlert className="mt-0.5 size-4 shrink-0" strokeWidth={1.75} aria-hidden="true" />
|
||||
<span className="max-w-prose">{fehler}</span>
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<pre className="mono overflow-x-auto rounded-karte border border-linie bg-grund p-3 text-xs leading-relaxed text-akzent">
|
||||
{text}
|
||||
</pre>
|
||||
|
||||
{unbekannt && unbekannt.length > 0 ? (
|
||||
<p className="flex items-start gap-2 text-xs text-faellig">
|
||||
<TriangleAlert className="mt-0.5 size-3.5 shrink-0" strokeWidth={2} aria-hidden="true" />
|
||||
<span className="max-w-prose">
|
||||
{unbekannt.map((name) => `{{${name}}}`).join(", ")} konnte nicht eingesetzt werden und steht so im
|
||||
Befehl. Bitte vor dem Ausführen von Hand ersetzen.
|
||||
</span>
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{hinweis ? (
|
||||
<p className="flex items-start gap-2 text-xs text-faellig">
|
||||
<KeyRound className="mt-0.5 size-3.5 shrink-0" strokeWidth={2} aria-hidden="true" />
|
||||
<span className="max-w-prose">
|
||||
{hinweis} — es steht nirgends gespeichert. Sobald diese Seite sich neu aufbaut, auch nach dem
|
||||
Speichern eines Schritts, steht hier ein anderes.
|
||||
</span>
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{fehler ? null : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void kopieren()}
|
||||
className="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"
|
||||
>
|
||||
<Copy className="size-4" strokeWidth={1.75} aria-hidden="true" />
|
||||
{kopiert ? "Kopiert." : beschriftung}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { RefreshCw } from "lucide-react";
|
||||
|
||||
/**
|
||||
* Holt alle zehn Sekunden den Zustand des Laufs (#013).
|
||||
*
|
||||
* Damit sehen zwei Browser am selben Lauf die Arbeit des anderen — der Fall aus
|
||||
* dem Auftrag: zwei Techniker teilen sich ein Onboarding.
|
||||
*
|
||||
* Vier Entscheidungen stecken darin:
|
||||
*
|
||||
* 1. **Pause bei verstecktem Tab.** Ein Lauf bleibt oft stundenlang offen im
|
||||
* Hintergrund; ohne Pause waeren das 360 Abfragen je Stunde und Tab, fuer
|
||||
* niemanden.
|
||||
* 2. **`router.refresh()` statt eigener Zustandsverwaltung.** Die Seite ist eine
|
||||
* Server-Komponente; ein Neuladen ihrer Daten ist genau das, was gebraucht
|
||||
* wird, und es gibt keine zweite Wahrheit im Browser. Aktualisiert wird nur,
|
||||
* wenn sich wirklich etwas geaendert hat — sonst wuerde die Seite alle zehn
|
||||
* Sekunden neu rendern und dabei Eingaben verlieren, an denen jemand tippt.
|
||||
* 3. **Steht ein generiertes Passwort auf der Seite, wird nicht von selbst
|
||||
* neu geladen**, sondern gefragt. Das Passwort entsteht bei jedem Aufbau der
|
||||
* Seite neu; ein Neuladen im Hintergrund tauschte es dem Techniker zwischen
|
||||
* Lesen und Einfuegen aus. Die Antwort des Pollings traegt deshalb keine
|
||||
* Befehle (siehe `laufZustand`) — und diese Zusage waere nichts wert, wenn
|
||||
* das Polling stattdessen die ganze Seite neu bauen liesse.
|
||||
* 4. **Abbruch bei 401 und 404.** Eine abgelaufene Anmeldung oder ein
|
||||
* verschwundener Lauf sind kein Grund weiterzufragen.
|
||||
*/
|
||||
/**
|
||||
* Was ein geaenderter Zustand ausloesen soll.
|
||||
*
|
||||
* Eine eigene Funktion, damit die Entscheidung geprueft werden kann: fuer eine
|
||||
* Client-Komponente gibt es in diesem Projekt bewusst keine DOM-Umgebung, und
|
||||
* eine Regel, die nur im Effekt steht, ist eine Regel ohne Test.
|
||||
*/
|
||||
export function reaktionAufAenderung(passwortSichtbar: boolean): "neu-laden" | "fragen" {
|
||||
return passwortSichtbar ? "fragen" : "neu-laden";
|
||||
}
|
||||
|
||||
export function LaufAktualisierung({
|
||||
laufId,
|
||||
passwortSichtbar = false,
|
||||
}: {
|
||||
laufId: string;
|
||||
/** Auf der Seite steht ein gefuelltes `{{generiertes_passwort}}`. */
|
||||
passwortSichtbar?: boolean;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
/*
|
||||
* Der zuletzt gesehene Zustand als Referenz und nicht als State: ein
|
||||
* `useState` daraus loeste bei jedem Abruf ein Rendern aus — auch dann, wenn
|
||||
* sich nichts geaendert hat, und genau das soll es nicht.
|
||||
*/
|
||||
const letzterStand = useRef<string | null>(null);
|
||||
const [neues, setzeNeues] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let abgebrochen = false;
|
||||
let zeitgeber: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
async function holen(): Promise<void> {
|
||||
if (abgebrochen || document.visibilityState === "hidden") {
|
||||
plane();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const antwort = await fetch(`/api/laeufe/${laufId}/zustand`, { cache: "no-store" });
|
||||
if (antwort.status === 401 || antwort.status === 404) {
|
||||
abgebrochen = true;
|
||||
return;
|
||||
}
|
||||
if (antwort.ok) {
|
||||
const stand = JSON.stringify(await antwort.json());
|
||||
if (letzterStand.current !== null && letzterStand.current !== stand) {
|
||||
if (reaktionAufAenderung(passwortSichtbar) === "fragen") setzeNeues(true);
|
||||
else router.refresh();
|
||||
}
|
||||
letzterStand.current = stand;
|
||||
}
|
||||
} catch {
|
||||
// Netz weg, Rechner im Standby: einfach beim naechsten Mal wieder.
|
||||
}
|
||||
plane();
|
||||
}
|
||||
|
||||
function plane(): void {
|
||||
if (abgebrochen) return;
|
||||
zeitgeber = setTimeout(() => void holen(), 10_000);
|
||||
}
|
||||
|
||||
void holen();
|
||||
// Kommt der Tab zurueck, sofort fragen statt bis zu zehn Sekunden zu warten.
|
||||
const beiSichtbarkeit = () => {
|
||||
if (document.visibilityState === "visible") void holen();
|
||||
};
|
||||
document.addEventListener("visibilitychange", beiSichtbarkeit);
|
||||
|
||||
return () => {
|
||||
abgebrochen = true;
|
||||
if (zeitgeber) clearTimeout(zeitgeber);
|
||||
document.removeEventListener("visibilitychange", beiSichtbarkeit);
|
||||
};
|
||||
}, [laufId, router, passwortSichtbar]);
|
||||
|
||||
// Ohne Passwort auf der Seite ist die Wirkung unsichtbar: sie erneuert sich.
|
||||
if (!neues) return null;
|
||||
|
||||
return (
|
||||
<div className="sticky top-2 z-10 mb-4 flex flex-wrap items-center gap-3 rounded-karte border border-akzent/40 bg-akzent-flaeche p-3 text-sm">
|
||||
<span className="min-w-0 flex-1">
|
||||
Jemand anderes hat an diesem Lauf etwas geändert. Beim Neuladen entsteht ein{" "}
|
||||
<strong>neues Passwort</strong> — kopiere das angezeigte vorher, wenn du es noch brauchst.
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setzeNeues(false);
|
||||
router.refresh();
|
||||
}}
|
||||
className="flex min-h-11 shrink-0 items-center gap-2 rounded-pille bg-akzent px-4 font-semibold text-auf-akzent md:min-h-10"
|
||||
>
|
||||
<RefreshCw className="size-4" strokeWidth={2} aria-hidden="true" />
|
||||
Neu laden
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
"use client";
|
||||
|
||||
import { useActionState, useEffect, useMemo, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Rocket, Wand } from "lucide-react";
|
||||
import { FELD } from "@/lib/formular";
|
||||
import { laufAnlegenAktion, namensvorschlaegeAktion, type AktionsErgebnis } from "@/server/actions/laeufe";
|
||||
import type { StartAuswahl, StartbareVorlage } from "@/server/queries/laeufe";
|
||||
|
||||
/**
|
||||
* Der Start-Dialog (#013).
|
||||
*
|
||||
* Er fragt in der Reihenfolge, in der jemand denkt: **Was** wird gemacht, **für
|
||||
* wen**, **an welchem Gerät** — und erst dann das Beiwerk. Der Titel bleibt
|
||||
* leer, wenn niemand ihn ändert; der Dienst bildet ihn dann aus Vorlage, Gerät
|
||||
* oder Person und Datum. Ein Vorschlag, den die Oberfläche selbst zusammenbaut,
|
||||
* wäre eine zweite Wahrheit, die irgendwann von der ersten abweicht.
|
||||
*
|
||||
* **Der Besitzer des Geräts wird als Person vorgeschlagen.** Wer ein Gerät
|
||||
* auswählt, meint fast immer die Person, der es gehört — und wer das nicht
|
||||
* meint, ändert es mit einem Klick.
|
||||
*
|
||||
* **Anmeldenamen schlägt der Server vor, nicht der Browser.** Die Namensregeln
|
||||
* hängen am Kunden und die Eindeutigkeit an der Datenbank; nachgebaut im Client
|
||||
* wären sie eine zweite Wahrheit, die beim ersten abweichenden Kundenschema
|
||||
* auffliegt. Gefragt wird erst auf Klick — jeder Tastendruck im Namensfeld wäre
|
||||
* eine Abfrage zu viel.
|
||||
*/
|
||||
export function LaufStarten({
|
||||
vorlagen,
|
||||
auswahl,
|
||||
vorbelegung,
|
||||
kundenEbene,
|
||||
kunden,
|
||||
}: {
|
||||
vorlagen: readonly StartbareVorlage[];
|
||||
auswahl: StartAuswahl;
|
||||
/** Vorbelegung je Aufrufort: von der Geräteseite kommt das Gerät mit. */
|
||||
vorbelegung: { versionId?: string; geraetId?: string; mitarbeiterId?: string };
|
||||
kundenEbene: boolean;
|
||||
kunden: readonly { id: string; name: string }[];
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [ergebnis, aktion, laeuft] = useActionState<AktionsErgebnis & { laufId?: string }, FormData>(
|
||||
laufAnlegenAktion,
|
||||
{},
|
||||
);
|
||||
|
||||
const [versionId, setzeVersion] = useState(vorbelegung.versionId ?? "");
|
||||
const [geraetId, setzeGeraet] = useState(vorbelegung.geraetId ?? "");
|
||||
const [mitarbeiterId, setzeMitarbeiter] = useState(vorbelegung.mitarbeiterId ?? "");
|
||||
const [freitext, setzeFreitext] = useState("");
|
||||
const [kundeId, setzeKunde] = useState("");
|
||||
const [variablen, setzeVariablen] = useState<Record<string, string>>({});
|
||||
|
||||
const vorlage = useMemo(
|
||||
() => vorlagen.find((eintrag) => eintrag.versionId === versionId),
|
||||
[vorlagen, versionId],
|
||||
);
|
||||
|
||||
// Nach dem Anlegen direkt in den Lauf – wer ihn startet, will ihn abarbeiten.
|
||||
useEffect(() => {
|
||||
if (ergebnis.laufId) router.push(`/laeufe/${ergebnis.laufId}`);
|
||||
}, [ergebnis.laufId, router]);
|
||||
|
||||
/** Die Variablen der Vorlage, für die es Namensregeln gibt. */
|
||||
const namensfelder = useMemo(
|
||||
() =>
|
||||
(vorlage?.variablen ?? [])
|
||||
.map((variable) => variable.schluessel)
|
||||
.filter((schluessel): schluessel is "upn" | "sam" | "mail" | "hostname" =>
|
||||
["upn", "sam", "mail", "hostname"].includes(schluessel),
|
||||
),
|
||||
[vorlage],
|
||||
);
|
||||
const [holt, setzeHolt] = useState(false);
|
||||
const [vorschlagFehler, setzeVorschlagFehler] = useState<string | null>(null);
|
||||
|
||||
async function vorschlaegeHolen() {
|
||||
setzeHolt(true);
|
||||
setzeVorschlagFehler(null);
|
||||
try {
|
||||
const name = freitext.trim() || personName(auswahl, mitarbeiterId);
|
||||
const antwort = await namensvorschlaegeAktion(name, kundeId || null);
|
||||
if (antwort.fehler) {
|
||||
setzeVorschlagFehler(antwort.fehler);
|
||||
return;
|
||||
}
|
||||
const vorschlaege = antwort.vorschlaege ?? {};
|
||||
setzeVariablen((alt) => {
|
||||
const neu = { ...alt };
|
||||
for (const feld of namensfelder) {
|
||||
const wert = vorschlaege[feld];
|
||||
// Nur füllen, was leer ist: eine eingetippte Adresse ist eine
|
||||
// Entscheidung, ein Vorschlag nur ein Angebot.
|
||||
if (wert && !neu[feld]) neu[feld] = wert;
|
||||
}
|
||||
return neu;
|
||||
});
|
||||
if (Object.keys(vorschlaege).length === 0) {
|
||||
setzeVorschlagFehler(
|
||||
"Für diesen Kunden sind keine Namensregeln hinterlegt — die stellt ein Administrator unter Einstellungen ein.",
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
setzeHolt(false);
|
||||
}
|
||||
}
|
||||
|
||||
function geraetWaehlen(neu: string) {
|
||||
setzeGeraet(neu);
|
||||
const besitzer = auswahl.geraete.find((geraet) => geraet.id === neu)?.besitzerId;
|
||||
// Nur vorschlagen, nicht überschreiben: wer schon eine Person gewählt hat,
|
||||
// hat sich etwas dabei gedacht.
|
||||
if (besitzer && mitarbeiterId === "" && freitext === "") setzeMitarbeiter(besitzer);
|
||||
}
|
||||
|
||||
return (
|
||||
<form action={aktion} className="flex flex-col gap-5">
|
||||
<input type="hidden" name="variablen" value={JSON.stringify(variablen)} />
|
||||
|
||||
<label className="flex flex-col gap-1.5 text-sm">
|
||||
<span className="text-nebentext">Vorlage</span>
|
||||
<select
|
||||
name="vorlagenVersionId"
|
||||
required
|
||||
value={versionId}
|
||||
onChange={(ereignis) => setzeVersion(ereignis.target.value)}
|
||||
className={FELD}
|
||||
>
|
||||
<option value="">bitte wählen</option>
|
||||
{vorlagen.map((eintrag) => (
|
||||
<option key={eintrag.versionId} value={eintrag.versionId}>
|
||||
{eintrag.kategorie ? `${eintrag.kategorie} · ` : ""}
|
||||
{eintrag.name} (v{eintrag.version})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{vorlagen.length === 0 ? (
|
||||
<span className="text-xs text-faellig">
|
||||
Es gibt noch keine veröffentlichte Vorlage. Ein Entwurf ist noch niemandes Arbeitsanweisung — erst
|
||||
das Veröffentlichen macht ihn dazu.
|
||||
</span>
|
||||
) : null}
|
||||
</label>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<label className="flex flex-col gap-1.5 text-sm">
|
||||
<span className="text-nebentext">Gerät (optional)</span>
|
||||
<select
|
||||
name="geraetId"
|
||||
value={geraetId}
|
||||
onChange={(ereignis) => geraetWaehlen(ereignis.target.value)}
|
||||
className={FELD}
|
||||
>
|
||||
<option value="">kein Gerät</option>
|
||||
{auswahl.geraete.map((geraet) => (
|
||||
<option key={geraet.id} value={geraet.id}>
|
||||
{geraet.anzeige}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="flex flex-col gap-1.5 text-sm">
|
||||
<span className="text-nebentext">Person (optional)</span>
|
||||
<select
|
||||
name="mitarbeiterId"
|
||||
value={mitarbeiterId}
|
||||
onChange={(ereignis) => {
|
||||
setzeMitarbeiter(ereignis.target.value);
|
||||
if (ereignis.target.value) setzeFreitext("");
|
||||
}}
|
||||
className={FELD}
|
||||
>
|
||||
<option value="">keine Person aus dem Verzeichnis</option>
|
||||
{auswahl.personen.map((person) => (
|
||||
<option key={person.id} value={person.id}>
|
||||
{person.anzeigename}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label className="flex flex-col gap-1.5 text-sm">
|
||||
<span className="text-nebentext">… oder Name als Freitext</span>
|
||||
<input
|
||||
name="mitarbeiterName"
|
||||
value={freitext}
|
||||
onChange={(ereignis) => {
|
||||
setzeFreitext(ereignis.target.value);
|
||||
if (ereignis.target.value) setzeMitarbeiter("");
|
||||
}}
|
||||
placeholder="Für ein Onboarding, bevor die Person im Verzeichnis steht"
|
||||
className={FELD}
|
||||
/>
|
||||
<span className="text-xs text-nebentext">
|
||||
Eines von beidem. Der Freitextname lässt sich später mit dem AD-Konto verknüpfen.
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{vorlage && vorlage.variablen.length > 0 ? (
|
||||
<fieldset className="flex flex-col gap-4 rounded-karte border border-linie p-4">
|
||||
<legend className="px-2 text-sm text-nebentext">Angaben zu diesem Lauf</legend>
|
||||
{vorlage.variablen.map((variable) => (
|
||||
<label key={variable.schluessel} className="flex flex-col gap-1.5 text-sm">
|
||||
<span className="text-nebentext">
|
||||
{variable.beschriftung}
|
||||
{variable.pflicht ? <span className="text-fehler"> *</span> : null}
|
||||
</span>
|
||||
{variable.optionen.length > 0 ? (
|
||||
<select
|
||||
required={variable.pflicht}
|
||||
value={variablen[variable.schluessel] ?? ""}
|
||||
onChange={(ereignis) =>
|
||||
setzeVariablen((alt) => ({ ...alt, [variable.schluessel]: ereignis.target.value }))
|
||||
}
|
||||
className={FELD}
|
||||
>
|
||||
<option value="">bitte wählen</option>
|
||||
{variable.optionen.map((option) => (
|
||||
<option key={option} value={option}>
|
||||
{option}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
type={variable.typ === "ZAHL" ? "number" : variable.typ === "DATUM" ? "date" : "text"}
|
||||
required={variable.pflicht}
|
||||
value={variablen[variable.schluessel] ?? ""}
|
||||
onChange={(ereignis) =>
|
||||
setzeVariablen((alt) => ({ ...alt, [variable.schluessel]: ereignis.target.value }))
|
||||
}
|
||||
className={FELD}
|
||||
/>
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
|
||||
{namensfelder.length > 0 ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void vorschlaegeHolen()}
|
||||
disabled={holt || (freitext.trim() === "" && mitarbeiterId === "")}
|
||||
className="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 disabled:pointer-events-none disabled:opacity-40 md:min-h-10"
|
||||
>
|
||||
<Wand className="size-4" strokeWidth={1.75} aria-hidden="true" />
|
||||
{holt ? "Wird gebildet …" : "Namen aus den Regeln vorschlagen"}
|
||||
</button>
|
||||
<p className="max-w-prose text-xs text-nebentext">
|
||||
Füllt {namensfelder.map((feld) => `„${feld}“`).join(", ")} nach den Namensregeln — ausgefüllte
|
||||
Felder bleiben stehen. Verbindlich wird ein Name erst beim Anlegen im Verzeichnis.
|
||||
</p>
|
||||
{vorschlagFehler ? (
|
||||
<p role="alert" className="max-w-prose text-xs text-faellig">
|
||||
{vorschlagFehler}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</fieldset>
|
||||
) : null}
|
||||
|
||||
<details>
|
||||
<summary className="flex min-h-11 cursor-pointer list-none items-center text-sm text-nebentext hover:text-text md:min-h-10">
|
||||
Titel, Ticket, Abteilung, Zuweisung und Fälligkeit
|
||||
</summary>
|
||||
<div className="mt-3 flex flex-col gap-4">
|
||||
<label className="flex flex-col gap-1.5 text-sm">
|
||||
<span className="text-nebentext">Titel</span>
|
||||
<input
|
||||
name="titel"
|
||||
placeholder="Bleibt leer: Vorlage – Gerät oder Person – Datum"
|
||||
className={FELD}
|
||||
/>
|
||||
</label>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<label className="flex flex-col gap-1.5 text-sm">
|
||||
<span className="text-nebentext">Ticket</span>
|
||||
<input name="ticket" placeholder="INC-4711" className={FELD} />
|
||||
</label>
|
||||
<label className="flex flex-col gap-1.5 text-sm">
|
||||
<span className="text-nebentext">Abteilung</span>
|
||||
<select name="abteilungId" defaultValue="" className={FELD}>
|
||||
<option value="">keine</option>
|
||||
{auswahl.abteilungen.map((abteilung) => (
|
||||
<option key={abteilung.id} value={abteilung.id}>
|
||||
{abteilung.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1.5 text-sm">
|
||||
<span className="text-nebentext">Zuweisen an</span>
|
||||
<select name="zugewiesenAnId" defaultValue="" className={FELD}>
|
||||
<option value="">niemanden</option>
|
||||
{auswahl.bearbeiter.map((person) => (
|
||||
<option key={person.id} value={person.id}>
|
||||
{person.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1.5 text-sm">
|
||||
<span className="text-nebentext">Fällig am</span>
|
||||
<input name="faelligAm" type="date" className={FELD} />
|
||||
</label>
|
||||
</div>
|
||||
<p className="max-w-prose text-xs text-nebentext">
|
||||
Die Abteilung speist <code className="mono">{"{{ou}}"}</code> und{" "}
|
||||
<code className="mono">{"{{gruppen}}"}</code> in den Befehlen dieses Laufs.
|
||||
</p>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
{kundenEbene && kunden.length > 0 ? (
|
||||
<label className="flex flex-col gap-1.5 text-sm">
|
||||
<span className="text-nebentext">Kunde</span>
|
||||
<select
|
||||
name="kundeId"
|
||||
required
|
||||
value={kundeId}
|
||||
onChange={(ereignis) => setzeKunde(ereignis.target.value)}
|
||||
className={FELD}
|
||||
>
|
||||
<option value="" disabled>
|
||||
bitte wählen
|
||||
</option>
|
||||
{kunden.map((kunde) => (
|
||||
<option key={kunde.id} value={kunde.id}>
|
||||
{kunde.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
) : null}
|
||||
|
||||
{ergebnis.fehler ? (
|
||||
<p role="alert" className="max-w-prose text-sm text-fehler">
|
||||
{ergebnis.fehler}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={laeuft || ergebnis.laufId !== undefined}
|
||||
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"
|
||||
>
|
||||
<Rocket className="size-[18px]" strokeWidth={2} aria-hidden="true" />
|
||||
{laeuft || ergebnis.laufId ? "Wird angelegt …" : "Lauf anlegen"}
|
||||
</button>
|
||||
<p className="max-w-prose text-xs text-nebentext">
|
||||
Der Lauf beginnt als <strong>offen</strong> — das ist die Zuweisung. Gestartet wird er erst, wenn
|
||||
jemand ihn zur Bearbeitung öffnet.
|
||||
</p>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
/** Der Anzeigename der gewaehlten Person – die Grundlage der Namensvorschlaege. */
|
||||
function personName(auswahl: StartAuswahl, mitarbeiterId: string): string {
|
||||
return auswahl.personen.find((person) => person.id === mitarbeiterId)?.anzeigename ?? "";
|
||||
}
|
||||
@@ -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") : [];
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
"use client";
|
||||
|
||||
import { useActionState, useState } from "react";
|
||||
import { Check, CircleSlash, TriangleAlert } from "lucide-react";
|
||||
import { SchrittZustand } from "@/generated/prisma/enums";
|
||||
import { FELD } from "@/lib/formular";
|
||||
import { schrittergebnisSetzenAktion, type AktionsErgebnis } from "@/server/actions/laeufe";
|
||||
|
||||
/**
|
||||
* Der Zustand eines Schritts – einzeln gespeichert, nicht als Gesamtformular
|
||||
* (#013).
|
||||
*
|
||||
* Der Grund steht im Auftrag und ist handfest: ein Techniker arbeitet einen Lauf
|
||||
* ueber Stunden ab, oft am Handy, oft mit wackligem WLAN. Ein Gesamtformular
|
||||
* hiesse, dass ein verlorener Klick auf „Speichern“ zwei Stunden Haken kostet.
|
||||
*
|
||||
* `NICHT_ZUTREFFEND` und `FEHLGESCHLAGEN` verlangen eine Notiz. Das Feld
|
||||
* erscheint deshalb, **bevor** gespeichert wird — sonst liefe der Benutzer in
|
||||
* eine Absage des Dienstes, ohne zu wissen, was fehlt.
|
||||
*
|
||||
* **Ein Klick auf die bereits gewaehlte Pille nimmt zurueck, und zwar sofort.**
|
||||
* Das Zuruecknehmen braucht keine Begruendung — es entfernt ja gerade die
|
||||
* Aussage, die eine gebraucht haette. Vorher war diese Pille in dem Fall ein
|
||||
* `type="button"`: der Klick setzte den Zustand nur im Browser zurueck, schickte
|
||||
* nichts ab, und mit ihm verschwand der Speichern-Knopf. Auf dem Bildschirm war
|
||||
* nichts mehr gewaehlt, in der Datenbank stand weiter `NICHT_ZUTREFFEND`
|
||||
* (Reviewbefund zu #013).
|
||||
*
|
||||
* Der Zustand haengt am **Knopf**, nicht an einem versteckten Feld: ein
|
||||
* abschickender Knopf gibt `name`/`value` mit, und damit steht im Formular
|
||||
* genau das, was der Klick bedeutet — ohne auf ein erneutes Rendern zu warten.
|
||||
*/
|
||||
export function SchrittZustandWaehlen({
|
||||
laufId,
|
||||
vorlagenSchrittId,
|
||||
zustand,
|
||||
notiz,
|
||||
lesend,
|
||||
}: {
|
||||
laufId: string;
|
||||
vorlagenSchrittId: string;
|
||||
zustand: SchrittZustand;
|
||||
notiz: string | null;
|
||||
lesend: boolean;
|
||||
}) {
|
||||
const [ergebnis, aktion, laeuft] = useActionState<AktionsErgebnis, FormData>(
|
||||
schrittergebnisSetzenAktion,
|
||||
{},
|
||||
);
|
||||
const [gewaehlt, setzeGewaehlt] = useState<SchrittZustand>(zustand);
|
||||
const [notizText, setzeNotiz] = useState(notiz ?? "");
|
||||
|
||||
/*
|
||||
* Ein Zustand, der von aussen kommt — die Aktualisierung alle zehn Sekunden,
|
||||
* ein zweiter Techniker am selben Lauf —, gewinnt gegen die eigene
|
||||
* Vorauswahl.
|
||||
*
|
||||
* Angeglichen wird **waehrend des Renderns** und nicht in einem Effekt: React
|
||||
* verwirft den begonnenen Durchlauf und rendert sofort neu, ohne dass
|
||||
* dazwischen etwas Falsches auf dem Bildschirm steht. Ein `useEffect` dafuer
|
||||
* loest eine zweite Renderwelle aus — und ESLint weist ihn zu Recht ab.
|
||||
*/
|
||||
const [zuletztVonAussen, setzeZuletztVonAussen] = useState(zustand);
|
||||
if (zustand !== zuletztVonAussen) {
|
||||
setzeZuletztVonAussen(zustand);
|
||||
setzeGewaehlt(zustand);
|
||||
}
|
||||
|
||||
/** Eine Wahl, die noch nicht gespeichert ist – das Begruendungsfeld steht offen. */
|
||||
const offeneWahl = gewaehlt !== zustand;
|
||||
const brauchtNotiz =
|
||||
gewaehlt === SchrittZustand.NICHT_ZUTREFFEND || gewaehlt === SchrittZustand.FEHLGESCHLAGEN;
|
||||
|
||||
if (lesend) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm text-nebentext">
|
||||
<ZustandKennzeichen zustand={zustand} />
|
||||
{notiz ? <span className="max-w-prose">{notiz}</span> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form action={aktion} className="flex flex-col gap-2">
|
||||
<input type="hidden" name="laufId" value={laufId} />
|
||||
<input type="hidden" name="vorlagenSchrittId" value={vorlagenSchrittId} />
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{(
|
||||
[
|
||||
[SchrittZustand.ERLEDIGT, "erledigt", Check],
|
||||
[SchrittZustand.NICHT_ZUTREFFEND, "nicht zutreffend", CircleSlash],
|
||||
[SchrittZustand.FEHLGESCHLAGEN, "fehlgeschlagen", TriangleAlert],
|
||||
] as const
|
||||
).map(([wert, beschriftung, Symbol]) => {
|
||||
const aktiv = gewaehlt === wert;
|
||||
/*
|
||||
* Drei Faelle, und die Reihenfolge ist wichtig:
|
||||
*
|
||||
* 1. **Abbrechen** — dieser Zustand ist gewaehlt, aber noch nicht
|
||||
* gespeichert: der Klick nimmt die Wahl zurueck und raeumt das
|
||||
* Begruendungsfeld weg.
|
||||
* 2. **Nachfragen** — `NICHT_ZUTREFFEND` und `FEHLGESCHLAGEN` brauchen
|
||||
* eine Begruendung, also erst das Feld zeigen, dann speichern.
|
||||
* 3. **Abschicken** — alles andere, und zwar sofort. Ist der Zustand
|
||||
* schon gespeichert, schickt der Klick `OFFEN`: das Zuruecknehmen
|
||||
* braucht keine Begruendung, es entfernt ja gerade eine.
|
||||
*/
|
||||
const abbrechen = offeneWahl && aktiv;
|
||||
const fragtNach = !abbrechen && wert !== SchrittZustand.ERLEDIGT && zustand !== wert;
|
||||
const schicktAb = !abbrechen && !fragtNach;
|
||||
/*
|
||||
* Der abgeschickte Wert haengt am **gespeicherten** Zustand, und
|
||||
* `onClick` ruehrt ihn nicht an. React baut das Formular erst nach dem
|
||||
* erneuten Rendern zusammen: stellte der Klick den Knopf selbst um,
|
||||
* kaeme der umgestellte Knopf an — im gebauten Stand nachgestellt, und
|
||||
* es kam wahlweise „offen“ oder gar kein Feld `zustand` an.
|
||||
*/
|
||||
return (
|
||||
<button
|
||||
key={wert}
|
||||
type={schicktAb ? "submit" : "button"}
|
||||
name={schicktAb ? "zustand" : undefined}
|
||||
value={schicktAb ? (zustand === wert ? SchrittZustand.OFFEN : wert) : undefined}
|
||||
onClick={schicktAb ? undefined : () => setzeGewaehlt(abbrechen ? zustand : wert)}
|
||||
disabled={laeuft}
|
||||
aria-pressed={aktiv}
|
||||
/*
|
||||
* „Erledigt“ ist die Lime-Kachel mit dunklem Haken, die der
|
||||
* Auftrag verlangt: der Akzent gehoert dem Fortschritt.
|
||||
* „Nicht zutreffend“ ist auch vom Tisch, aber kein Fortschritt —
|
||||
* deshalb neutral, sonst saehen beide gleich nach Erfolg aus.
|
||||
*/
|
||||
className={`flex min-h-11 items-center gap-2 rounded-pille px-4 text-sm md:min-h-10 ${
|
||||
aktiv
|
||||
? wert === SchrittZustand.ERLEDIGT
|
||||
? "bg-akzent font-semibold text-auf-akzent"
|
||||
: wert === SchrittZustand.FEHLGESCHLAGEN
|
||||
? "bg-fehler/15 font-semibold text-fehler"
|
||||
: "bg-flaeche-hoch font-semibold text-text"
|
||||
: "border border-linie text-nebentext hover:bg-flaeche-hoch hover:text-text"
|
||||
}`}
|
||||
>
|
||||
<Symbol className="size-4" strokeWidth={2} aria-hidden="true" />
|
||||
{beschriftung}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{brauchtNotiz ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="flex flex-col gap-1.5 text-sm">
|
||||
<span className="text-nebentext">
|
||||
Begründung <span className="text-fehler">*</span>
|
||||
</span>
|
||||
<textarea
|
||||
name="notiz"
|
||||
rows={2}
|
||||
required
|
||||
value={notizText}
|
||||
onChange={(ereignis) => setzeNotiz(ereignis.target.value)}
|
||||
placeholder="Warum trifft dieser Schritt nicht zu, oder woran ist er gescheitert?"
|
||||
className={`${FELD} py-2`}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="submit"
|
||||
name="zustand"
|
||||
value={gewaehlt}
|
||||
disabled={laeuft || notizText.trim() === ""}
|
||||
className="flex min-h-11 w-fit items-center rounded-pille bg-akzent px-5 text-sm font-semibold text-auf-akzent disabled:pointer-events-none disabled:opacity-40 md:min-h-10"
|
||||
>
|
||||
{laeuft ? "Wird gespeichert …" : "Mit Begründung speichern"}
|
||||
</button>
|
||||
<p className="max-w-prose text-xs text-nebentext">
|
||||
Ohne Begründung steht im Archiv ein übersprungener Schritt ohne Grund — und genau das macht ein
|
||||
Archiv wertlos.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{ergebnis.fehler ? (
|
||||
<p role="alert" className="max-w-prose text-sm text-fehler">
|
||||
{ergebnis.fehler}
|
||||
</p>
|
||||
) : null}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export function ZustandKennzeichen({ zustand }: { zustand: SchrittZustand }) {
|
||||
if (zustand === SchrittZustand.OFFEN) return null;
|
||||
const text: Record<Exclude<SchrittZustand, "OFFEN">, string> = {
|
||||
ERLEDIGT: "erledigt",
|
||||
NICHT_ZUTREFFEND: "nicht zutreffend",
|
||||
FEHLGESCHLAGEN: "fehlgeschlagen",
|
||||
};
|
||||
return (
|
||||
<span
|
||||
className={`rounded-pille px-2.5 py-1 text-xs ${
|
||||
zustand === SchrittZustand.FEHLGESCHLAGEN
|
||||
? "bg-fehler/15 text-fehler"
|
||||
: "bg-flaeche-hoch text-nebentext"
|
||||
}`}
|
||||
>
|
||||
{text[zustand]}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user