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>
</>
);
}
+210
View File
@@ -0,0 +1,210 @@
import Link from "next/link";
import { CircleDot, Clock, ListChecks, Rocket } from "lucide-react";
import { LaufStatus } from "@/generated/prisma/enums";
import { FELD, KAESTCHEN } from "@/lib/formular";
import { darf } from "@/server/auth/rollen";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import { kundenEbeneSichtbar } from "@/server/mandant";
import { LISTE_HOECHSTENS, laufListe, type LaufZeile } from "@/server/queries/laeufe";
/**
* Die Laufliste (#013).
*
* Zwei Zustaende erklaeren den Alltag: `OFFEN` heisst vorbereitet und jemandem
* zugewiesen, `IN_ARBEIT` heisst, jemand sitzt gerade daran. Archivierte Laeufe
* stehen hier nicht — die gehoeren ins Archiv (#015), und eine Liste, die alles
* zeigt, zeigt nach einem halben Jahr nichts mehr.
*/
const STATUS_TEXT: Record<LaufStatus, string> = {
OFFEN: "offen",
IN_ARBEIT: "in Arbeit",
ABGESCHLOSSEN: "abgeschlossen",
ARCHIVIERT: "archiviert",
};
const DATUM = new Intl.DateTimeFormat("de-DE", { day: "2-digit", month: "2-digit", year: "numeric" });
function Fortschritt({ zeile }: { zeile: LaufZeile }) {
const anteil = zeile.anzahlSchritte === 0 ? 0 : zeile.anzahlErledigt / zeile.anzahlSchritte;
return (
<div className="mt-2 flex items-center gap-2">
<div
className="h-1.5 w-28 overflow-hidden rounded-pille bg-flaeche-hoch"
role="progressbar"
aria-valuemin={0}
aria-valuemax={zeile.anzahlSchritte}
aria-valuenow={zeile.anzahlErledigt}
aria-label="Fortschritt"
>
<div className="h-full bg-akzent" style={{ width: `${Math.round(anteil * 100)}%` }} />
</div>
<span className="text-xs text-nebentext">
{zeile.anzahlErledigt} von {zeile.anzahlSchritte}
</span>
</div>
);
}
function Zeile({ zeile, kundenEbene }: { zeile: LaufZeile; kundenEbene: boolean }) {
const ueberfaellig =
zeile.faelligAm !== null && zeile.faelligAm < new Date() && zeile.status !== LaufStatus.ABGESCHLOSSEN;
return (
<li className="rounded-karte border border-linie bg-flaeche">
<Link href={`/laeufe/${zeile.id}`} className="block p-4">
<div className="flex flex-wrap items-center gap-2">
<CircleDot
className={`size-[18px] shrink-0 ${
zeile.status === LaufStatus.IN_ARBEIT ? "text-akzent" : "text-nebentext"
}`}
strokeWidth={1.75}
aria-hidden="true"
/>
<span className="font-medium">{zeile.titel}</span>
<span className="rounded-pille bg-flaeche-hoch px-2.5 py-1 text-xs text-nebentext">
{STATUS_TEXT[zeile.status]}
</span>
{ueberfaellig ? (
<span className="flex items-center gap-1 rounded-pille bg-flaeche-hoch px-2.5 py-1 text-xs text-faellig">
<Clock className="size-3" strokeWidth={2} aria-hidden="true" />
seit {DATUM.format(zeile.faelligAm as Date)} fällig
</span>
) : null}
{kundenEbene ? (
<span className="rounded-pille bg-flaeche-hoch px-2.5 py-1 text-xs text-nebentext">
{zeile.kundeName}
</span>
) : null}
</div>
<p className="mt-1 text-xs text-nebentext">
{[
`${zeile.vorlagenName} v${zeile.version}`,
zeile.geraetName,
zeile.mitarbeiterName,
zeile.ticket,
zeile.zugewiesenAn ? `für ${zeile.zugewiesenAn}` : null,
]
.filter(Boolean)
.join(" · ")}
</p>
<Fortschritt zeile={zeile} />
</Link>
</li>
);
}
export default async function LaeufeSeite({
searchParams,
}: {
searchParams: Promise<Record<string, string | string[] | undefined>>;
}) {
const { benutzer } = await sitzungErforderlich("lesen", "/laeufe");
const parameter = await searchParams;
const einzeln = (name: string): string =>
(Array.isArray(parameter[name]) ? parameter[name][0] : parameter[name])?.trim() ?? "";
const status = einzeln("status");
const filter = {
suche: einzeln("suche"),
...(status === "OFFEN" || status === "IN_ARBEIT" || status === "ABGESCHLOSSEN"
? { status: status as LaufStatus }
: {}),
...(einzeln("meine") === "1" ? { zugewiesenAnId: benutzer.id } : {}),
};
const [zeilen, kundenEbene] = await Promise.all([laufListe(benutzer, filter), kundenEbeneSichtbar()]);
return (
<>
<div className="flex flex-wrap items-center justify-between gap-3">
<h1 className="flex items-center gap-2 text-2xl font-bold">
<ListChecks className="size-6 text-nebentext" strokeWidth={1.75} aria-hidden="true" />
Läufe
</h1>
{darf(benutzer.rolle, "laeufeBearbeiten") ? (
<Link
href="/laeufe/starten"
className="flex min-h-11 items-center gap-2 rounded-pille bg-akzent px-5 text-sm font-semibold text-auf-akzent md:min-h-10"
>
<Rocket className="size-[18px]" strokeWidth={2} aria-hidden="true" />
Lauf starten
</Link>
) : null}
</div>
<p className="mt-2 max-w-prose text-nebentext">
Ein Lauf wird vorbereitet, abgearbeitet und abgeschlossen. Archivierte Läufe stehen im Archiv — dort
ändert sich nichts mehr.
</p>
<form method="get" className="mt-6 flex flex-col gap-3 md:flex-row md:flex-wrap md:items-end">
<label className="flex min-w-0 flex-1 flex-col gap-1.5 text-sm md:max-w-xs">
<span className="text-nebentext">Suche</span>
<input
name="suche"
defaultValue={filter.suche}
placeholder="Titel, Ticket, Hostname oder Person"
className={FELD}
/>
</label>
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Status</span>
<select name="status" defaultValue={status} className={FELD}>
<option value="">alle</option>
<option value="OFFEN">offen</option>
<option value="IN_ARBEIT">in Arbeit</option>
<option value="ABGESCHLOSSEN">abgeschlossen</option>
</select>
</label>
<label className="flex min-h-11 items-center gap-2 text-sm text-nebentext">
<input
type="checkbox"
name="meine"
value="1"
defaultChecked={einzeln("meine") === "1"}
className={KAESTCHEN}
/>
nur meine
</label>
<button
type="submit"
className="min-h-11 rounded-pille border border-linie px-4 text-sm text-nebentext hover:bg-flaeche-hoch hover:text-text md:min-h-10"
>
Filtern
</button>
{filter.suche || status || einzeln("meine") ? (
<Link
href="/laeufe"
className="flex min-h-11 items-center px-2 text-sm text-nebentext underline underline-offset-2 hover:text-text md:min-h-10"
>
zurücksetzen
</Link>
) : null}
</form>
{zeilen.length === 0 ? (
<p className="mt-8 rounded-karte border border-linie bg-flaeche p-5 text-sm text-nebentext">
Kein Lauf passt zu diesen Filtern. Ein neuer Lauf beginnt bei einer Vorlage oder auf der
Geräteseite.
</p>
) : (
<>
<ul className="mt-6 flex flex-col gap-3">
{zeilen.map((zeile) => (
<Zeile key={zeile.id} zeile={zeile} kundenEbene={kundenEbene} />
))}
</ul>
{/* Eine gekappte Liste sagt das – sonst fehlte ein Lauf stillschweigend. */}
{zeilen.length === LISTE_HOECHSTENS ? (
<p className="mt-4 max-w-prose text-sm text-nebentext">
Es werden höchstens {LISTE_HOECHSTENS} Läufe gezeigt. Wenn deiner fehlt, such nach Titel,
Ticket, Hostname oder Person.
</p>
) : null}
</>
)}
</>
);
}
+74
View File
@@ -0,0 +1,74 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { ArrowLeft } from "lucide-react";
import { LaufStarten } from "@/components/laeufe/lauf-starten";
import { darf } from "@/server/auth/rollen";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import { kundenEbeneSichtbar } from "@/server/mandant";
import { kundenListe } from "@/server/queries/kunden";
import { startAuswahl, startbareVorlagen } from "@/server/queries/laeufe";
/**
* Einen Lauf starten (#013).
*
* Die Vorbelegung kommt aus der Adresse: von der Geräteseite (#014) mit
* `?geraet=…`, von einer Vorlage mit `?vorlage=…`. So bleibt der Dialog eine
* Seite und keine drei Varianten desselben Formulars.
*/
export default async function LaufStartenSeite({
searchParams,
}: {
searchParams: Promise<Record<string, string | string[] | undefined>>;
}) {
const { benutzer } = await sitzungErforderlich("lesen", "/laeufe/starten");
if (!darf(benutzer.rolle, "laeufeBearbeiten")) {
// Wer keine Läufe bearbeiten darf, soll die Seite nicht als vorhanden
// erleben — die Navigation zeigt sie ihm auch nicht.
redirect("/laeufe");
}
const parameter = await searchParams;
const einzeln = (name: string): string | undefined => {
const wert = Array.isArray(parameter[name]) ? parameter[name][0] : parameter[name];
return wert?.trim() || undefined;
};
const [vorlagen, auswahl, kundenEbene] = await Promise.all([
startbareVorlagen(benutzer),
startAuswahl(benutzer),
kundenEbeneSichtbar(),
]);
const kunden = kundenEbene ? await kundenListe(benutzer) : [];
return (
<>
<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 starten</h1>
<p className="mt-2 max-w-prose text-nebentext">
Ein Lauf hängt für immer an der Version, mit der er gestartet wurde. Eine spätere Veröffentlichung
ändert ihn nicht — deshalb steht hier jeweils die zuletzt veröffentlichte Fassung.
</p>
<div className="mt-6 max-w-2xl">
<LaufStarten
vorlagen={vorlagen}
auswahl={auswahl}
vorbelegung={{
...(einzeln("vorlage") ? { versionId: einzeln("vorlage") as string } : {}),
...(einzeln("geraet") ? { geraetId: einzeln("geraet") as string } : {}),
...(einzeln("person") ? { mitarbeiterId: einzeln("person") as string } : {}),
}}
kundenEbene={kundenEbene}
kunden={kunden.map(({ id, name }) => ({ id, name }))}
/>
</div>
</>
);
}
+49
View File
@@ -0,0 +1,49 @@
import { RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht";
import { laufZustand } from "@/server/queries/laeufe";
/**
* Der Zustand eines Laufs fuer die Aktualisierung alle zehn Sekunden (#013).
*
* **Route Handler pruefen selbst.** Die Huelle unter `src/app/(app)/layout.tsx`
* deckt sie nicht ab — sie liegt in einem anderen Zweig des Baums, und der Proxy
* sieht nur, **ob** ein Cookie da ist, nie was drinsteht (CLAUDE.md).
*
* Die Antwort enthaelt **keine Befehle**. Ein `{{generiertes_passwort}}`
* entsteht bei jeder Aufloesung neu; alle zehn Sekunden mitgeliefert, tauschte
* es dem Techniker das Passwort unter den Haenden weg, waehrend er es abtippt.
* Zurueck kommen nur Status und Schrittzustaende — genau das, was zwei Browser
* am selben Lauf voneinander wissen muessen.
*/
export async function GET(
_anfrage: Request,
{ params }: { params: Promise<{ id: string }> },
): Promise<Response> {
let benutzerId: string;
let rolle: Parameters<typeof laufZustand>[1]["rolle"];
try {
const { benutzer } = await rechtErforderlich("lesen");
benutzerId = benutzer.id;
rolle = benutzer.rolle;
} catch (fehler) {
if (fehler instanceof RechteFehler) {
// 401 und nicht 403: der Client soll die Aktualisierung einstellen und
// den Benutzer zur Anmeldung schicken, statt weiter zu fragen.
return Response.json({ fehler: "Nicht angemeldet." }, { status: 401 });
}
throw fehler;
}
const { id } = await params;
const zustand = await laufZustand(id, { id: benutzerId, rolle });
if (!zustand) {
// Nicht vorhanden und nicht sichtbar sind dieselbe Antwort – die
// Unterscheidung waere schon eine Auskunft ueber fremde Kunden.
return Response.json({ fehler: "Diesen Lauf gibt es nicht." }, { status: 404 });
}
return Response.json(zustand, {
// Nichts zwischenspeichern: die Antwort ist genau dann wertlos, wenn sie
// aus einem Zwischenspeicher kommt.
headers: { "Cache-Control": "no-store" },
});
}
@@ -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>
);
}
+96
View File
@@ -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>
);
}
+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>
);
}
@@ -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>
);
}
+366
View File
@@ -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 ?? "";
}
+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") : [];
}
+210
View File
@@ -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>
);
}
+1 -1
View File
@@ -32,7 +32,7 @@ export type NavEintrag = {
export const NAVIGATION: readonly NavEintrag[] = [
{ pfad: "/", beschriftung: "Übersicht", symbol: "LayoutDashboard", verfuegbar: true },
{ pfad: "/laeufe", beschriftung: "Läufe", symbol: "ListChecks", verfuegbar: false, ausIssue: 13 },
{ pfad: "/laeufe", beschriftung: "Läufe", symbol: "ListChecks", verfuegbar: true },
{ pfad: "/vorlagen", beschriftung: "Vorlagen", symbol: "FileText", verfuegbar: true },
{
pfad: "/mitarbeiter",
+16 -1
View File
@@ -19,6 +19,12 @@ import { PASSWORT_IM_DRUCK, passwortErzeugen } from "@/lib/passwort";
* „falsch geschrieben“ und „hier gibt es das nicht“.
* 3. **Das generierte Passwort entsteht erst beim Ersetzen** und wird nie
* zurueckgegeben. Es gibt keinen Weg, es nachtraeglich zu erfragen.
* 4. **In einem Text steht ein Passwort, nicht zwei.** Wer
* `{{generiertes_passwort}}` zweimal schreibt — einmal fuer `-AccountPassword`
* und einmal fuer die Zeile, die es dem Benutzer mitteilt —, meint dasselbe
* Passwort. Zwei verschiedene hiessen: das Konto bekommt A, der Benutzer
* erfaehrt B. Wer ueber einen ganzen Seitenaufbau hinweg dasselbe braucht,
* reicht es als `passwort` herein.
*/
/**
@@ -74,9 +80,17 @@ export function platzhalterErsetzen(
text: string,
werte: Readonly<Record<string, string>>,
verwendung: Verwendung = { art: "anzeige" },
/**
* Das Passwort dieses **Seitenaufbaus**. Fehlt es, entsteht eines je Aufruf —
* und dann gilt es fuer alle Vorkommen in diesem einen Text. Gespeichert wird
* es nirgends; der Aufrufer haelt es nur so lange, wie er die Seite baut.
*/
passwort?: string,
): ErsetzungsErgebnis {
const unbekannt = new Set<string>();
let mitPasswort = false;
// Erst beim ersten Vorkommen erzeugt: ein Text ohne Passwort erzeugt keines.
let diesesPasswort = passwort;
/*
* Erkannt wird **jede** doppelte Klammer, nicht nur die mit gueltigem
@@ -98,7 +112,8 @@ export function platzhalterErsetzen(
// herum, und das Passwort ist nirgends gespeichert, also auch nicht
// nachvollziehbar, wenn es dort stuende.
if (verwendung.art === "druck") return PASSWORT_IM_DRUCK;
return aufbereiten(passwortErzeugen(), verwendung);
diesesPasswort ??= passwortErzeugen();
return aufbereiten(diesesPasswort, verwendung);
}
const wert = werte[schluessel];
+11
View File
@@ -72,6 +72,17 @@ export function proxy(anfrage: NextRequest): NextResponse {
const pfad = anfrage.nextUrl.pathname;
if (!istOeffentlich(pfad) && !anfrage.cookies.has(SITZUNG_COOKIE)) {
/*
* Eine Abfrage unter `/api/` bekommt **401**, keine Weiterleitung (#013).
* Ein `fetch` folgt einer Weiterleitung von sich aus und bekaeme die
* Anmeldeseite als HTML mit Status 200 zurueck: fuer den Client saehe das
* aus wie eine gueltige Antwort, und die Aktualisierung fragte alle zehn
* Sekunden weiter, statt aufzuhoeren und den Benutzer zur Anmeldung zu
* schicken.
*/
if (pfad.startsWith("/api/")) {
return NextResponse.json({ fehler: "Nicht angemeldet." }, { status: 401 });
}
const ziel = pfad + anfrage.nextUrl.search;
const anmelden = oeffentlicheAdresse("/anmelden", anfrage.nextUrl.origin);
if (ziel !== "/") anmelden.searchParams.set("ziel", ziel);
+421
View File
@@ -0,0 +1,421 @@
"use server";
import { revalidatePath } from "next/cache";
import { SchrittZustand } from "@/generated/prisma/enums";
import { z } from "@/lib/validierung";
import { MandantFehler, type HandelnderBenutzer } from "@/server/mandant";
import type { Recht } from "@/server/auth/rollen";
import { RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht";
import {
LaufFehler,
laufAbschliessen,
laufAnlegen,
laufArchivieren,
laufMitAdKontoVerknuepfen,
laufZurBearbeitungOeffnen,
laufZurueckholen,
} from "@/server/services/laeufe";
import { namenVorschlagen, type NamensVorschlaege } from "@/server/services/befehle";
import { abschnittNichtZutreffend, schrittergebnisSetzen } from "@/server/services/schrittergebnisse";
import { kommentarHinzufuegen } from "@/server/services/kommentare";
/**
* Server Actions der Laeufe (#013).
*
* Duenn: Recht pruefen, Zod anwenden, Dienst rufen. Die fachlichen Regeln — das
* Archiv, die Notizpflicht, die Abschlussbedingung — stehen alle im Dienst, und
* zwar an genau einer Stelle. Eine Action, die eine davon nachbaut, waere eine
* zweite Wahrheit.
*/
export type AktionsErgebnis = { readonly fehler?: string; readonly erfolg?: string };
const RECHTE_MELDUNG =
"Deine Anmeldung gilt nicht mehr. Melde dich in einem zweiten Tab neu an und klicke dann hier " +
"noch einmal — was du eingegeben hast, steht weiterhin auf dieser Seite.";
/**
* Faengt den Rechtefehler ab, statt ihn werfen zu lassen.
*
* Ein ungefangener Wurf landet in Nexts Fehlergrenze und haengt die Seite aus —
* mitten in der Abarbeitung waere das der Verlust dessen, was gerade im Feld
* stand (#010).
*/
async function angemeldetOderMeldung(
recht: Recht,
): Promise<{ readonly benutzer: HandelnderBenutzer } | { readonly fehler: string }> {
try {
const { benutzer } = await rechtErforderlich(recht);
return { benutzer: { id: benutzer.id, rolle: benutzer.rolle } };
} catch (fehler) {
if (fehler instanceof RechteFehler) return { fehler: RECHTE_MELDUNG };
throw fehler;
}
}
function alsErgebnis(fehler: unknown, neutral: string): AktionsErgebnis {
if (fehler instanceof LaufFehler || fehler instanceof MandantFehler) {
return { fehler: fehler.message };
}
console.error(`${neutral}:`, fehler instanceof Error ? `${fehler.name}: ${fehler.message}` : fehler);
return { fehler: neutral };
}
/** Nach jeder Aenderung: Liste und Lauf neu rendern. */
function neuRendern(laufId: string): void {
revalidatePath("/laeufe");
revalidatePath(`/laeufe/${laufId}`);
}
// ---- Anlegen und Kopfdaten ------------------------------------------------
const AnlegenSchema = z.object({
vorlagenVersionId: z.string().min(1, "Bitte eine Vorlage wählen."),
titel: z.string().trim().optional(),
mitarbeiterId: z.string().trim().optional(),
mitarbeiterName: z.string().trim().optional(),
geraetId: z.string().trim().optional(),
abteilungId: z.string().trim().optional(),
ticket: z.string().trim().optional(),
zugewiesenAnId: z.string().trim().optional(),
faelligAm: z.string().trim().optional(),
kundeId: z.string().trim().optional(),
/** Die Variablen kommen als JSON – sie haengen an der gewaehlten Version. */
variablen: z.string().trim().optional(),
});
function variablenLesen(roh: string | undefined): Record<string, string> {
if (!roh) return {};
try {
const geparst: unknown = JSON.parse(roh);
if (typeof geparst !== "object" || geparst === null || Array.isArray(geparst)) return {};
const werte: Record<string, string> = {};
for (const [schluessel, wert] of Object.entries(geparst as Record<string, unknown>)) {
if (typeof wert === "string") werte[schluessel] = wert;
else if (typeof wert === "number" || typeof wert === "boolean") werte[schluessel] = String(wert);
}
return werte;
} catch {
// Ein kaputtes JSON darf den Start nicht verhindern – die Variablen sind
// Beiwerk, der Lauf ist die Hauptsache.
return {};
}
}
function datumLesen(roh: string | undefined): Date | null {
if (!roh) return null;
const datum = new Date(roh);
return Number.isNaN(datum.getTime()) ? null : datum;
}
export async function laufAnlegenAktion(
_vorher: AktionsErgebnis,
formular: FormData,
): Promise<AktionsErgebnis & { readonly laufId?: string }> {
const zugang = await angemeldetOderMeldung("laeufeBearbeiten");
if ("fehler" in zugang) return zugang;
const eingabe = AnlegenSchema.safeParse(Object.fromEntries(formular.entries()));
if (!eingabe.success) {
return { fehler: eingabe.error.issues[0]?.message ?? "Die Eingabe ist unvollständig." };
}
try {
const lauf = await laufAnlegen(
{
vorlagenVersionId: eingabe.data.vorlagenVersionId,
titel: eingabe.data.titel ?? null,
mitarbeiterId: eingabe.data.mitarbeiterId || null,
mitarbeiterName: eingabe.data.mitarbeiterName || null,
geraetId: eingabe.data.geraetId || null,
abteilungId: eingabe.data.abteilungId || null,
ticket: eingabe.data.ticket || null,
zugewiesenAnId: eingabe.data.zugewiesenAnId || null,
faelligAm: datumLesen(eingabe.data.faelligAm),
kundeId: eingabe.data.kundeId || null,
variablen: variablenLesen(eingabe.data.variablen),
},
zugang.benutzer,
);
revalidatePath("/laeufe");
return { erfolg: `„${lauf.titel}“ wurde angelegt.`, laufId: lauf.id };
} catch (fehler) {
return alsErgebnis(fehler, "Der Lauf konnte nicht angelegt werden.");
}
}
/**
* Vorschlaege fuer `upn`, `sam`, `mail` und `hostname` zu einem Namen (#013).
*
* Beim Onboarding steht die Person noch nicht im Verzeichnis; ihre Anmeldenamen
* entstehen erst hier. Die Regeln und die Eindeutigkeitspruefung liegen im
* Dienst (#012) — diese Action reicht nur durch, damit der Start-Dialog fragen
* kann, ohne die Regeln nachzubauen.
*
* Der Name kommt als ein Feld, weil der Dialog ihn so erfragt: „Jörg
* Müller-Lüdenscheid“. Das erste Wort ist der Vorname, der Rest der Nachname;
* steht dort nur ein Wort, ist es der **Nachname** — `{nachname}` kommt in jeder
* sinnvollen Regel vor, `{vorname}` nicht. Eine Annahme, die fuer die Faelle
* stimmt, fuer die dieser Dialog gebaut ist, und die der Techniker am Vorschlag
* sofort sieht.
*/
export async function namensvorschlaegeAktion(
name: string,
kundeId: string | null,
): Promise<{ readonly vorschlaege?: NamensVorschlaege; readonly fehler?: string }> {
const zugang = await angemeldetOderMeldung("laeufeBearbeiten");
if ("fehler" in zugang) return zugang;
const teile = name.trim().split(/\s+/u).filter(Boolean);
if (teile.length === 0) return { vorschlaege: {} };
const vorname = teile.length > 1 ? teile[0] : null;
const nachname = teile.length > 1 ? teile.slice(1).join(" ") : teile[0];
try {
const vorschlaege = await namenVorschlagen(
kundeId,
{ vorname: vorname ?? null, nachname: nachname ?? null },
zugang.benutzer,
);
return { vorschlaege };
} catch (fehler) {
return alsErgebnis(fehler, "Die Vorschläge ließen sich nicht bilden.");
}
}
/**
* Verknuepft einen Lauf nachtraeglich mit einem Konto aus dem Verzeichnis (#013).
*
* Der Onboarding-Fall: der Lauf beginnt mit einem Freitextnamen, weil es die
* Person im Verzeichnis noch nicht gibt. Sobald das Konto angelegt ist, gehoert
* der Lauf daran — sonst steht die Arbeit fuer immer neben der Person statt an
* ihr, und die Geraete- und Personenhistorie kennt sie nicht.
*/
export async function laufMitAdKontoVerknuepfenAktion(
_vorher: AktionsErgebnis,
formular: FormData,
): Promise<AktionsErgebnis> {
const zugang = await angemeldetOderMeldung("laeufeBearbeiten");
if ("fehler" in zugang) return zugang;
const eingabe = z
.object({
laufId: z.string().min(1),
mitarbeiterId: z.string().trim().optional(),
upn: z.string().trim().optional(),
})
.safeParse(Object.fromEntries(formular.entries()));
if (!eingabe.success) {
return { fehler: eingabe.error.issues[0]?.message ?? "Die Eingabe ist unvollständig." };
}
try {
const lauf = await laufMitAdKontoVerknuepfen(
eingabe.data.laufId,
{
mitarbeiterId: eingabe.data.mitarbeiterId || null,
upn: eingabe.data.upn || null,
},
zugang.benutzer,
);
neuRendern(lauf.id);
return { erfolg: "Der Lauf hängt jetzt an dieser Person." };
} catch (fehler) {
return alsErgebnis(fehler, "Die Verknüpfung konnte nicht gespeichert werden.");
}
}
// ---- Abarbeiten -----------------------------------------------------------
/**
* Oeffnet den Lauf zur Bearbeitung.
*
* Erst hier entstehen `gestartetAm` und `IN_ARBEIT` — nicht beim Ansehen. Ein
* `OFFEN`-Lauf ist ein zugewiesener Lauf, und wer ihn nur aufschlaegt, hat noch
* nicht angefangen.
*/
export async function laufOeffnenAktion(
_vorher: AktionsErgebnis,
formular: FormData,
): Promise<AktionsErgebnis> {
const zugang = await angemeldetOderMeldung("laeufeBearbeiten");
if ("fehler" in zugang) return zugang;
const laufId = z.string().min(1).safeParse(formular.get("laufId"));
if (!laufId.success) return { fehler: "Es fehlt der Lauf." };
try {
await laufZurBearbeitungOeffnen(laufId.data, zugang.benutzer);
neuRendern(laufId.data);
return { erfolg: "Los geht’s." };
} catch (fehler) {
return alsErgebnis(fehler, "Der Lauf konnte nicht geöffnet werden.");
}
}
const ErgebnisSchema = z.object({
laufId: z.string().min(1),
vorlagenSchrittId: z.string().min(1),
zustand: z.enum(SchrittZustand),
wert: z.string().optional(),
notiz: z.string().optional(),
});
export async function schrittergebnisSetzenAktion(
_vorher: AktionsErgebnis,
formular: FormData,
): Promise<AktionsErgebnis> {
const zugang = await angemeldetOderMeldung("laeufeBearbeiten");
if ("fehler" in zugang) return zugang;
const eingabe = ErgebnisSchema.safeParse(Object.fromEntries(formular.entries()));
if (!eingabe.success) {
return { fehler: eingabe.error.issues[0]?.message ?? "Die Eingabe ist unvollständig." };
}
try {
await schrittergebnisSetzen(
{
laufId: eingabe.data.laufId,
vorlagenSchrittId: eingabe.data.vorlagenSchrittId,
zustand: eingabe.data.zustand,
// Fehlt das Feld, bleibt der Wert stehen; ein leeres Feld loescht ihn.
...(eingabe.data.wert === undefined ? {} : { wert: eingabe.data.wert || null }),
...(eingabe.data.notiz === undefined ? {} : { notiz: eingabe.data.notiz || null }),
},
zugang.benutzer,
);
neuRendern(eingabe.data.laufId);
return {};
} catch (fehler) {
return alsErgebnis(fehler, "Der Schritt konnte nicht gespeichert werden.");
}
}
export async function abschnittNichtZutreffendAktion(
_vorher: AktionsErgebnis,
formular: FormData,
): Promise<AktionsErgebnis> {
const zugang = await angemeldetOderMeldung("laeufeBearbeiten");
if ("fehler" in zugang) return zugang;
const eingabe = z
.object({ laufId: z.string().min(1), abschnittId: z.string().min(1), notiz: z.string() })
.safeParse(Object.fromEntries(formular.entries()));
if (!eingabe.success) return { fehler: "Die Eingabe ist unvollständig." };
try {
const ergebnis = await abschnittNichtZutreffend(eingabe.data, zugang.benutzer);
neuRendern(eingabe.data.laufId);
const anzahl = ergebnis.uebersprungen.length;
// Deutsch, auch bei eins: „1 bereits erledigte Schritte“ liest sich wie ein
// Fehler und lenkt von der Meldung ab.
const uebersprungen =
anzahl === 0
? ""
: anzahl === 1
? " 1 bereits erledigter Schritt blieb, wie er war."
: ` ${anzahl} bereits erledigte Schritte blieben, wie sie waren.`;
return {
erfolg: `${ergebnis.gesetzt} ${
ergebnis.gesetzt === 1 ? "Schritt" : "Schritte"
} als nicht zutreffend vermerkt.${uebersprungen}`,
};
} catch (fehler) {
return alsErgebnis(fehler, "Der Abschnitt konnte nicht gesetzt werden.");
}
}
export async function kommentarHinzufuegenAktion(
_vorher: AktionsErgebnis,
formular: FormData,
): Promise<AktionsErgebnis> {
const zugang = await angemeldetOderMeldung("laeufeBearbeiten");
if ("fehler" in zugang) return zugang;
const eingabe = z
.object({
laufId: z.string().min(1),
schrittergebnisId: z.string().trim().optional(),
rumpf: z.string(),
})
.safeParse(Object.fromEntries(formular.entries()));
if (!eingabe.success) return { fehler: "Die Eingabe ist unvollständig." };
try {
await kommentarHinzufuegen(
{
laufId: eingabe.data.laufId,
schrittergebnisId: eingabe.data.schrittergebnisId || null,
rumpf: eingabe.data.rumpf,
},
zugang.benutzer,
);
neuRendern(eingabe.data.laufId);
return { erfolg: "Kommentar gespeichert." };
} catch (fehler) {
return alsErgebnis(fehler, "Der Kommentar konnte nicht gespeichert werden.");
}
}
// ---- Abschliessen, Archivieren, Zurueckholen ------------------------------
export async function laufAbschliessenAktion(
_vorher: AktionsErgebnis,
formular: FormData,
): Promise<AktionsErgebnis> {
const zugang = await angemeldetOderMeldung("laeufeBearbeiten");
if ("fehler" in zugang) return zugang;
const laufId = z.string().min(1).safeParse(formular.get("laufId"));
if (!laufId.success) return { fehler: "Es fehlt der Lauf." };
try {
await laufAbschliessen(laufId.data, zugang.benutzer);
neuRendern(laufId.data);
return { erfolg: "Lauf abgeschlossen." };
} catch (fehler) {
return alsErgebnis(fehler, "Der Lauf konnte nicht abgeschlossen werden.");
}
}
export async function laufArchivierenAktion(
_vorher: AktionsErgebnis,
formular: FormData,
): Promise<AktionsErgebnis> {
const zugang = await angemeldetOderMeldung("laeufeBearbeiten");
if ("fehler" in zugang) return zugang;
const laufId = z.string().min(1).safeParse(formular.get("laufId"));
if (!laufId.success) return { fehler: "Es fehlt der Lauf." };
try {
await laufArchivieren(laufId.data, zugang.benutzer);
neuRendern(laufId.data);
return { erfolg: "Lauf archiviert. Ab jetzt ändert sich daran nichts mehr." };
} catch (fehler) {
return alsErgebnis(fehler, "Der Lauf konnte nicht archiviert werden.");
}
}
/**
* Zurueckholen — nur `ADMIN`.
*
* Das Archiv ist die Zusage, dass ein abgeschlossener Lauf sich nicht mehr
* aendert. Diese Zusage darf nur eine Stelle aufheben; der Dienst prueft das
* Recht noch einmal selbst.
*/
export async function laufZurueckholenAktion(
_vorher: AktionsErgebnis,
formular: FormData,
): Promise<AktionsErgebnis> {
const zugang = await angemeldetOderMeldung("archivVerwalten");
if ("fehler" in zugang) return zugang;
const laufId = z.string().min(1).safeParse(formular.get("laufId"));
if (!laufId.success) return { fehler: "Es fehlt der Lauf." };
try {
await laufZurueckholen(laufId.data, zugang.benutzer);
neuRendern(laufId.data);
return { erfolg: "Lauf zurückgeholt — er steht wieder auf „abgeschlossen“." };
} catch (fehler) {
return alsErgebnis(fehler, "Der Lauf konnte nicht zurückgeholt werden.");
}
}
+729
View File
@@ -0,0 +1,729 @@
import type { Prisma } from "@/generated/prisma/client";
import { LaufStatus, SchrittTyp, SchrittZustand } from "@/generated/prisma/enums";
import { prisma } from "@/lib/db";
import { platzhalterErsetzen } from "@/lib/platzhalter-werte";
import { platzhalterAusserhalbAnfuehrungszeichen, skriptBauen, wertMaskieren, type Shell } from "@/lib/shell";
import { STANDARD_SHELL } from "@/lib/editor-aufbau";
import { PASSWORT_HINWEIS, passwortErzeugen } from "@/lib/passwort";
import { sichtbareKundenIds, type HandelnderBenutzer } from "@/server/mandant";
import { platzhalterWerteDesLaufs } from "@/server/services/platzhalter";
/**
* Lesemodelle der Laeufe (#013).
*
* Wie ueberall wenden sie die Mandantensicht selbst an. Zwei Dinge sind hier
* besonders:
*
* 1. **Ein Lauf zeigt die Version, mit der er gestartet wurde** — nie die
* neueste. Das ist die Kernzusage aus #009, und sie faellt hier an, weil die
* Abarbeitungsansicht die Schritte ueber `Lauf.vorlagenVersionId` liest.
* 2. **Das Zustands-Lesemodell fuers Polling enthaelt keine Befehle.** Ein
* `{{generiertes_passwort}}` entsteht bei **jeder** Aufloesung neu; ein
* Abruf alle zehn Sekunden wuerde dem Techniker das Passwort unter den
* Haenden wegtauschen, waehrend er es gerade abtippt.
*/
async function kundenBedingung(benutzer: HandelnderBenutzer): Promise<{ kundeId: { in: string[] } }[]> {
const sichtbar = await sichtbareKundenIds(benutzer);
return sichtbar === undefined ? [] : [{ kundeId: { in: [...sichtbar] } }];
}
export type LaufZeile = {
readonly id: string;
readonly titel: string;
readonly status: LaufStatus;
readonly vorlagenName: string;
readonly version: number;
readonly geraetName: string | null;
readonly mitarbeiterName: string | null;
/**
* Der Name steht nur als Freitext im Lauf – die Person gab es beim Start im
* Verzeichnis noch nicht. Die Abarbeitungsansicht bietet dann das Verknüpfen
* mit einem AD-Konto an.
*/
readonly nurFreitextName: boolean;
readonly ticket: string | null;
readonly faelligAm: Date | null;
readonly zugewiesenAn: string | null;
readonly kundeName: string;
readonly erstelltAm: Date;
readonly abgeschlossenAm: Date | null;
readonly anzahlSchritte: number;
readonly anzahlErledigt: number;
};
export type LaufFilter = {
readonly suche?: string;
readonly status?: LaufStatus;
/** Nur Laeufe, die dieser Person zugewiesen sind. */
readonly zugewiesenAnId?: string;
/** Standard: ohne archivierte – die stehen im Archiv (#015). */
readonly auchArchivierte?: boolean;
};
/** So viele Laeufe zeigt die Liste hoechstens; darueber hinaus wird gefiltert. */
export const LISTE_HOECHSTENS = 200;
const ZEILEN_AUSWAHL = {
id: true,
titel: true,
status: true,
ticket: true,
faelligAm: true,
erstelltAm: true,
abgeschlossenAm: true,
mitarbeiterName: true,
kunde: { select: { name: true } },
zugewiesenAn: { select: { name: true } },
mitarbeiter: { select: { anzeigename: true } },
geraet: { select: { hostname: true, bezeichnung: true, tagCode: true } },
vorlagenVersion: { select: { version: true, vorlage: { select: { name: true } } } },
ergebnisse: { select: { zustand: true } },
} satisfies Prisma.LaufSelect;
type Rohzeile = Prisma.LaufGetPayload<{ select: typeof ZEILEN_AUSWAHL }>;
/** Noch nichts geschehen: dann sagt auch der Zeitstempel der Zeile nichts. */
function istOffen(zustand: SchrittZustand): boolean {
return zustand === SchrittZustand.OFFEN;
}
/** Erledigt heisst hier: der Schritt ist vom Tisch – auch als „nicht zutreffend“. */
function istVomTisch(zustand: SchrittZustand): boolean {
return zustand === SchrittZustand.ERLEDIGT || zustand === SchrittZustand.NICHT_ZUTREFFEND;
}
function zeileBauen(zeile: Rohzeile): LaufZeile {
return {
id: zeile.id,
titel: zeile.titel,
status: zeile.status,
vorlagenName: zeile.vorlagenVersion.vorlage.name,
version: zeile.vorlagenVersion.version,
geraetName: zeile.geraet?.hostname ?? zeile.geraet?.bezeichnung ?? zeile.geraet?.tagCode ?? null,
// Freitext nur, wenn keine Person aus dem Verzeichnis dranhaengt – die
// Datenbank schliesst beides zugleich aus.
mitarbeiterName: zeile.mitarbeiter?.anzeigename ?? zeile.mitarbeiterName,
nurFreitextName: zeile.mitarbeiter === null && zeile.mitarbeiterName !== null,
ticket: zeile.ticket,
faelligAm: zeile.faelligAm,
zugewiesenAn: zeile.zugewiesenAn?.name ?? null,
kundeName: zeile.kunde.name,
erstelltAm: zeile.erstelltAm,
abgeschlossenAm: zeile.abgeschlossenAm,
anzahlSchritte: zeile.ergebnisse.length,
anzahlErledigt: zeile.ergebnisse.filter((ergebnis) => istVomTisch(ergebnis.zustand)).length,
};
}
export async function laufListe(
benutzer: HandelnderBenutzer,
filter: LaufFilter = {},
): Promise<readonly LaufZeile[]> {
const suche = filter.suche?.trim();
const bedingungen = [
...(await kundenBedingung(benutzer)),
filter.auchArchivierte ? undefined : { status: { not: LaufStatus.ARCHIVIERT } },
filter.status ? { status: filter.status } : undefined,
filter.zugewiesenAnId ? { zugewiesenAnId: filter.zugewiesenAnId } : undefined,
// Gesucht wird ueber das, was auf einem Zettel stuende: Titel, Ticket,
// Hostname und Name der Person.
suche
? {
OR: [
{ titel: { contains: suche, mode: "insensitive" as const } },
{ ticket: { contains: suche, mode: "insensitive" as const } },
{ mitarbeiterName: { contains: suche, mode: "insensitive" as const } },
{ geraet: { hostname: { contains: suche, mode: "insensitive" as const } } },
{ geraet: { tagCode: { contains: suche, mode: "insensitive" as const } } },
{ mitarbeiter: { anzeigename: { contains: suche, mode: "insensitive" as const } } },
],
}
: undefined,
].filter((bedingung) => bedingung !== undefined);
const zeilen = await prisma.lauf.findMany({
where: { AND: bedingungen },
orderBy: [{ faelligAm: { sort: "asc", nulls: "last" } }, { erstelltAm: "desc" }],
/*
* Gekappt, weil die Liste die **laufende Arbeit** zeigt: archivierte stehen
* hier ohnehin nicht, und wer 200 offene Laeufe hat, sucht oder filtert.
* Ohne Grenze baute ein Haus nach zwei Jahren eine Seite mit tausenden
* Karten — jede mit ihren Schrittergebnissen.
*/
take: LISTE_HOECHSTENS,
select: ZEILEN_AUSWAHL,
});
return zeilen.map(zeileBauen);
}
/** `undefined` heisst „gibt es nicht oder darfst du nicht sehen“. */
export async function laufHolen(
laufId: string,
benutzer: HandelnderBenutzer,
): Promise<LaufZeile | undefined> {
const zeile = await prisma.lauf.findFirst({
where: { AND: [{ id: laufId }, ...(await kundenBedingung(benutzer))] },
select: ZEILEN_AUSWAHL,
});
return zeile ? zeileBauen(zeile) : undefined;
}
export type AbarbeitungsSchritt = {
readonly schrittId: string;
readonly ergebnisId: string | null;
readonly nummer: number;
readonly titel: string;
readonly hilfetext: string | null;
readonly typ: SchrittTyp;
readonly pflicht: boolean;
readonly dokuUrl: string | null;
readonly konfig: Record<string, unknown>;
readonly zustand: SchrittZustand;
readonly wert: unknown;
readonly notiz: string | null;
readonly bearbeitetVon: string | null;
readonly bearbeitetAm: Date | null;
readonly anzahlKommentare: number;
/** Nur bei `BEFEHL`: der fertige Befehl mit eingesetzten Werten. */
readonly befehl: FertigerSchrittBefehl | null;
};
export type FertigerSchrittBefehl = {
readonly shell: Shell;
readonly text: string;
/** Platzhalter, die niemand kennt – sie stehen noch als `{{name}}` darin. */
readonly unbekannt: readonly string[];
readonly mitPasswort: boolean;
readonly hinweis: string | null;
/** Gesetzt, wenn sich der Befehl nicht sicher fuellen liess. */
readonly fehler: string | null;
};
export type AbarbeitungsAbschnitt = {
/** Die Kennung des `VorlagenAbschnitt`s – fuer „Abschnitt trifft nicht zu“. */
readonly id: string;
readonly titel: string;
readonly nummer: string;
readonly schritte: readonly AbarbeitungsSchritt[];
/** Alle Befehle des Abschnitts als ein Skript – leer, wenn es keine gibt. */
readonly skript: string;
};
export type LaufZumAbarbeiten = {
readonly lauf: LaufZeile;
readonly abschnitte: readonly AbarbeitungsAbschnitt[];
readonly anzahlPflicht: number;
readonly anzahlPflichtOffen: number;
};
/**
* Der ganze Lauf zum Abarbeiten: Abschnitte, Schritte, Ergebnisse und die
* fertigen Befehle.
*
* Gelesen wird ueber `Lauf.vorlagenVersionId` — die Version, mit der der Lauf
* gestartet wurde. Ein spaeteres Veroeffentlichen aendert hier nichts (#009).
*/
export async function laufZumAbarbeiten(
laufId: string,
benutzer: HandelnderBenutzer,
technikerName?: string | null,
): Promise<LaufZumAbarbeiten | undefined> {
const kopf = await laufHolen(laufId, benutzer);
if (!kopf) return undefined;
const lauf = await prisma.lauf.findUniqueOrThrow({
where: { id: laufId },
select: {
vorlagenVersion: {
select: {
abschnitte: {
orderBy: { position: "asc" },
select: {
id: true,
titel: true,
schritte: {
orderBy: { position: "asc" },
select: {
id: true,
titel: true,
hilfetext: true,
typ: true,
pflicht: true,
dokuUrl: true,
konfig: true,
},
},
},
},
},
},
ergebnisse: {
select: {
id: true,
vorlagenSchrittId: true,
zustand: true,
wert: true,
notiz: true,
aktualisiertAm: true,
aktualisiertVon: { select: { name: true } },
_count: { select: { kommentare: true } },
},
},
},
});
const werte = await platzhalterWerteDesLaufs({
laufId,
...(technikerName === undefined ? {} : { technikerName }),
});
const ergebnisse = new Map(lauf.ergebnisse.map((ergebnis) => [ergebnis.vorlagenSchrittId, ergebnis]));
/*
* **Ein** Passwort fuer diesen Seitenaufbau (Reviewbefund zu #013). Vorher
* bekam jedes Vorkommen ein eigenes: der Befehl, der das Konto anlegt, und die
* Zeile, die das Passwort mitteilt, trugen verschiedene — das Konto bekam A,
* der Benutzer erfuhr B, und weil nichts gespeichert wird, war A danach weg.
* Gespeichert wird es auch jetzt nirgends; beim naechsten Aufbau ist es ein
* anderes, und genau darum darf die Seite sich nicht von selbst neu bauen,
* solange eines darauf steht (siehe `LaufAktualisierung`).
*/
const passwort = passwortErzeugen();
let nummer = 0;
let anzahlPflicht = 0;
let anzahlPflichtOffen = 0;
const abschnitte = lauf.vorlagenVersion.abschnitte.map((abschnitt, index) => {
const befehle: { titel: string; shell: Shell; befehl: string }[] = [];
const schritte = abschnitt.schritte.map((schritt) => {
nummer += 1;
const ergebnis = ergebnisse.get(schritt.id);
const zustand = ergebnis?.zustand ?? SchrittZustand.OFFEN;
if (schritt.pflicht) {
anzahlPflicht += 1;
if (!istVomTisch(zustand)) anzahlPflichtOffen += 1;
}
const konfig = (schritt.konfig ?? {}) as Record<string, unknown>;
const befehl = schritt.typ === SchrittTyp.BEFEHL ? befehlFuellen(konfig, werte, passwort) : null;
if (befehl && befehl.fehler === null) {
befehle.push({ titel: schritt.titel, shell: befehl.shell, befehl: befehl.text });
}
return {
schrittId: schritt.id,
ergebnisId: ergebnis?.id ?? null,
nummer,
titel: platzhalterErsetzen(schritt.titel, werte).text,
hilfetext: schritt.hilfetext ? platzhalterErsetzen(schritt.hilfetext, werte).text : null,
typ: schritt.typ,
pflicht: schritt.pflicht,
dokuUrl: schritt.dokuUrl,
konfig,
zustand,
wert: ergebnis?.wert ?? null,
notiz: ergebnis?.notiz ?? null,
/*
* „Wer wann“, sobald der Schritt nicht mehr offen ist — nicht erst, wenn
* ein **Mensch** ihn angefasst hat. Die Ergebniszeilen entstehen beim
* Anlegen des Laufs, `aktualisiertAm` traegt also auch an einem
* unberuehrten Schritt einen Zeitstempel; der sagt nichts, deshalb der
* Zustand als Bedingung.
*
* Am Zustand und nicht am Urheber, weil `aktualisiertVonId` leer bleiben
* **darf**: ein Schritt vom Typ `AKTION` (Phase 4) erledigt sich selbst,
* und ausgerechnet dann fiele das „wann“ weg (CLAUDE.md,
* „Erweiterbarkeit“; Reviewbefund zu #013).
*/
bearbeitetVon: ergebnis?.aktualisiertVon?.name ?? (istOffen(zustand) ? null : "System"),
bearbeitetAm: istOffen(zustand) ? null : (ergebnis?.aktualisiertAm ?? null),
anzahlKommentare: ergebnis?._count.kommentare ?? 0,
befehl,
};
});
return {
id: abschnitt.id,
titel: abschnitt.titel,
nummer: String(index + 1).padStart(2, "0"),
schritte,
skript: skriptBauenAus(befehle),
};
});
return { lauf: kopf, abschnitte, anzahlPflicht, anzahlPflichtOffen };
}
/**
* Fuellt einen einzelnen Befehl.
*
* Bewusst hier und nicht ueber `befehleDesLaufs`: das Lesemodell braucht den
* Befehl **am Schritt**, und es darf ihn nur **einmal** fuellen — bei jedem
* Fuellen entsteht ein neues `{{generiertes_passwort}}`. Zweimal gefuellt hiesse
* zwei verschiedene Passwoerter in einer Antwort (Reviewbefund aus #012).
*/
function befehlFuellen(
konfig: Record<string, unknown>,
werte: Readonly<Record<string, string>>,
/** Das Passwort dieses Seitenaufbaus – dasselbe fuer jeden Befehl der Seite. */
passwort: string,
): FertigerSchrittBefehl {
const shell = shellAus(konfig);
const roh = typeof konfig["befehl"] === "string" ? konfig["befehl"] : "";
const nackt = platzhalterAusserhalbAnfuehrungszeichen(roh, shell);
if (nackt.length > 0) {
const zeichen = shell === "bash" ? "einfache" : "doppelte";
return {
shell,
text: roh,
unbekannt: [],
mitPasswort: false,
hinweis: null,
fehler:
`${nackt.map((name) => `„{{${name}}}“`).join(", ")} steht nicht in Anführungszeichen. ` +
`Der Wert lässt sich dann nicht sicher einsetzen — bitte ${zeichen} Anführungszeichen ` +
"um den Platzhalter setzen.",
};
}
try {
const ergebnis = platzhalterErsetzen(
roh,
werte,
{ art: "befehl", maskieren: (wert) => wertMaskieren(wert, shell) },
passwort,
);
return {
shell,
text: ergebnis.text,
unbekannt: ergebnis.unbekannt,
mitPasswort: ergebnis.mitPasswort,
hinweis: ergebnis.mitPasswort ? PASSWORT_HINWEIS : null,
fehler: null,
};
} catch (fehler) {
// `wertMaskieren` wirft fuer cmd, wenn ein Wert nicht sicher darstellbar
// ist. Der Befehl bleibt mit Platzhaltern stehen: sichtbar, nicht kopierbar.
return {
shell,
text: roh,
unbekannt: [],
mitPasswort: false,
hinweis: null,
fehler: fehler instanceof Error ? fehler.message : "Der Befehl ließ sich nicht füllen.",
};
}
}
function shellAus(konfig: Record<string, unknown>): Shell {
const wert = konfig["shell"];
return wert === "powershell" || wert === "bash" || wert === "cmd" ? wert : STANDARD_SHELL;
}
/** Das Skript eines Abschnitts – aus den **bereits gefuellten** Befehlen. */
function skriptBauenAus(befehle: readonly { titel: string; shell: Shell; befehl: string }[]): string {
if (befehle.length === 0) return "";
// `skriptBauen` liegt in src/lib/shell.ts und erwartet genau diese Form.
return skriptBauen(befehle);
}
export type LaufZustand = {
readonly status: LaufStatus;
readonly anzahlPflichtOffen: number;
/**
* Zahl der Kommentare und Zeitpunkt der letzten Aenderung an den Kopfdaten.
*
* Beides gehoert dazu, weil „zwei Browser sehen die Arbeit des anderen“ sonst
* nur fuer Schrittzustaende gilt: ein Kommentar des Kollegen und ein
* geaenderter Titel blieben unsichtbar, bis jemand von sich aus neu laedt
* (Reviewbefund zu #013).
*/
readonly anzahlKommentare: number;
readonly geaendertAm: Date;
readonly schritte: readonly {
readonly schrittId: string;
readonly zustand: SchrittZustand;
readonly bearbeitetVon: string | null;
readonly bearbeitetAm: Date | null;
}[];
};
/**
* Der schmale Zustand fuer das Polling alle zehn Sekunden.
*
* **Ohne Befehle, und das ist der Punkt.** Ein `{{generiertes_passwort}}`
* entsteht bei jeder Aufloesung neu — wuerde das Polling die Befehle
* mitliefern, tauschte es dem Techniker das Passwort unter den Haenden weg,
* waehrend er es abtippt.
*
* Mitgeliefert wird dagegen alles, was eine Aenderung **anzeigt**: Status,
* offene Pflichtschritte, Schrittzustaende, die Zahl der Kommentare und
* `geaendertAm` des Laufs. Der Client vergleicht die Antwort als Ganzes mit der
* vorigen; was hier fehlt, bleibt im anderen Browser unsichtbar.
*/
export async function laufZustand(
laufId: string,
benutzer: HandelnderBenutzer,
): Promise<LaufZustand | undefined> {
const lauf = await prisma.lauf.findFirst({
where: { AND: [{ id: laufId }, ...(await kundenBedingung(benutzer))] },
select: {
status: true,
geaendertAm: true,
_count: { select: { kommentare: true } },
ergebnisse: {
select: {
vorlagenSchrittId: true,
zustand: true,
aktualisiertAm: true,
aktualisiertVon: { select: { name: true } },
vorlagenSchritt: { select: { pflicht: true } },
},
},
},
});
if (!lauf) return undefined;
return {
status: lauf.status,
anzahlKommentare: lauf._count.kommentare,
geaendertAm: lauf.geaendertAm,
anzahlPflichtOffen: lauf.ergebnisse.filter(
(ergebnis) => ergebnis.vorlagenSchritt.pflicht && !istVomTisch(ergebnis.zustand),
).length,
schritte: lauf.ergebnisse.map((ergebnis) => ({
schrittId: ergebnis.vorlagenSchrittId,
zustand: ergebnis.zustand,
// Siehe `laufZumAbarbeiten`: an einem offenen Schritt ist der Zeitstempel
// nur der Anlegezeitpunkt der Zeile und sagt nichts.
bearbeitetVon: ergebnis.aktualisiertVon?.name ?? (istOffen(ergebnis.zustand) ? null : "System"),
bearbeitetAm: istOffen(ergebnis.zustand) ? null : ergebnis.aktualisiertAm,
})),
};
}
export type KommentarZeile = {
readonly id: string;
readonly rumpf: string;
readonly benutzer: string;
readonly erstelltAm: Date;
/** Leer: der Kommentar haengt am Lauf und nicht an einem Schritt. */
readonly schrittergebnisId: string | null;
};
/**
* Die Kommentare eines Laufs, aelteste zuerst.
*
* Alle auf einmal statt je Schritt: ein Lauf hat selten mehr als eine Handvoll,
* und eine Abfrage je Schritt waeren bei 150 Schritten 150 Abfragen.
*/
export async function kommentareDesLaufs(
laufId: string,
benutzer: HandelnderBenutzer,
): Promise<readonly KommentarZeile[]> {
const lauf = await prisma.lauf.findFirst({
where: { AND: [{ id: laufId }, ...(await kundenBedingung(benutzer))] },
select: { id: true },
});
if (!lauf) return [];
const zeilen = await prisma.kommentar.findMany({
where: { laufId },
orderBy: { erstelltAm: "asc" },
select: {
id: true,
rumpf: true,
erstelltAm: true,
schrittergebnisId: true,
benutzer: { select: { name: true } },
},
});
return zeilen.map((zeile) => ({
id: zeile.id,
rumpf: zeile.rumpf,
benutzer: zeile.benutzer.name,
erstelltAm: zeile.erstelltAm,
schrittergebnisId: zeile.schrittergebnisId,
}));
}
/**
* Die Personen, mit denen sich dieser Lauf verknuepfen laesst.
*
* Nur Personen **desselben Kunden**: der Dienst weist alles andere ohnehin ab,
* und eine Liste, die fremde Namen zeigt, verraet sie trotzdem. Gekappt bei 500,
* wie die Listen im Start-Dialog; wer die Person dort nicht findet, traegt den
* UPN ein.
*/
export async function personenZurVerknuepfung(
laufId: string,
benutzer: HandelnderBenutzer,
): Promise<readonly { id: string; anzeigename: string }[]> {
const lauf = await prisma.lauf.findFirst({
where: { AND: [{ id: laufId }, ...(await kundenBedingung(benutzer))] },
select: { kundeId: true },
});
if (!lauf) return [];
return prisma.mitarbeiter.findMany({
where: { kundeId: lauf.kundeId, aktiv: true },
orderBy: { anzeigename: "asc" },
take: 500,
select: { id: true, anzeigename: true },
});
}
export type StartbareVorlage = {
readonly versionId: string;
readonly vorlageId: string;
readonly name: string;
readonly version: number;
readonly kategorie: string | null;
readonly variablen: readonly {
readonly schluessel: string;
readonly beschriftung: string;
readonly typ: string;
readonly optionen: readonly string[];
readonly pflicht: boolean;
}[];
};
/**
* Die Vorlagen, an denen sich ein Lauf starten laesst.
*
* Genau die **zuletzt veroeffentlichte** Version je Vorlage — nicht alle. Wer
* einen Lauf startet, will die geltende Fassung; eine Liste mit allen Versionen
* waere eine Wahl, die niemand treffen will und bei der man sich vertun kann.
* Archivierte Vorlagen stehen nicht dabei.
*/
export async function startbareVorlagen(benutzer: HandelnderBenutzer): Promise<readonly StartbareVorlage[]> {
const sichtbar = await sichtbareKundenIds(benutzer);
const vorlagen = await prisma.vorlage.findMany({
where: {
AND: [
{ archiviert: false },
// Vorlagen ohne Kundenbezug gelten fuer alle (#009).
...(sichtbar === undefined ? [] : [{ OR: [{ kundeId: null }, { kundeId: { in: [...sichtbar] } }] }]),
],
},
orderBy: [{ kategorie: "asc" }, { name: "asc" }],
select: {
id: true,
name: true,
kategorie: true,
versionen: {
where: { status: "VEROEFFENTLICHT" },
orderBy: { version: "desc" },
take: 1,
select: {
id: true,
version: true,
variablen: {
orderBy: { position: "asc" },
select: {
schluessel: true,
beschriftung: true,
typ: true,
optionen: true,
pflicht: true,
},
},
},
},
},
});
const startbare: StartbareVorlage[] = [];
for (const vorlage of vorlagen) {
const version = vorlage.versionen[0];
// Eine Vorlage ohne veroeffentlichte Version laesst sich nicht starten – ein
// Entwurf ist noch niemandes Arbeitsanweisung.
if (!version) continue;
startbare.push({
versionId: version.id,
vorlageId: vorlage.id,
name: vorlage.name,
version: version.version,
kategorie: vorlage.kategorie,
variablen: version.variablen,
});
}
return startbare;
}
export type StartAuswahl = {
readonly personen: readonly { id: string; anzeigename: string }[];
readonly geraete: readonly { id: string; anzeige: string; besitzerId: string | null }[];
readonly abteilungen: readonly { id: string; name: string }[];
readonly bearbeiter: readonly { id: string; name: string }[];
};
/**
* Was im Start-Dialog zur Auswahl steht.
*
* Gekappt bei 500 je Liste — ein Auswahlfeld mit zehntausend Eintraegen ist
* keine Auswahl. **Der Dialog sagt das auch**, wenn eine Liste voll ist; die
* Suche ueber alle kommt mit der Geraeteseite (#014). Ohne den Hinweis fehlte
* das 501. Geraet stillschweigend, und niemand wuesste, wo es geblieben ist.
*/
export async function startAuswahl(benutzer: HandelnderBenutzer): Promise<StartAuswahl> {
const mandant = await kundenBedingung(benutzer);
const [personen, geraete, abteilungen, bearbeiter] = await Promise.all([
prisma.mitarbeiter.findMany({
where: { AND: [{ aktiv: true }, ...mandant] },
orderBy: { anzeigename: "asc" },
take: 500,
select: { id: true, anzeigename: true },
}),
prisma.geraet.findMany({
where: { AND: [{ archiviert: false }, ...mandant] },
orderBy: [{ hostname: "asc" }, { bezeichnung: "asc" }],
take: 500,
select: { id: true, hostname: true, bezeichnung: true, tagCode: true, mitarbeiterId: true },
}),
prisma.abteilung.findMany({
where: { AND: [{ archiviert: false }, ...mandant] },
orderBy: { name: "asc" },
take: 500,
select: { id: true, name: true },
}),
/*
* Zuweisen laesst sich nur an jemanden, der den Lauf auch oeffnen koennte
* (Reviewbefund zu #013). Vorher stand hier jeder Bearbeiter des Hauses:
* eine Technikerin von Kunde B konnte einen Lauf an den Techniker von Kunde
* A zuweisen, der ihn dann nicht einmal sah — und der Dienst lehnte es beim
* Anlegen mit einer Meldung ab, die niemand erwartet hatte.
*
* Wer an **keinen** Kunden gebunden ist, sieht alle und steht deshalb immer
* zur Auswahl; die Rollen `ADMIN` und `REDAKTEUR` sind ohnehin nicht
* gebunden (siehe `sichtbareKundenIds`).
*/
prisma.benutzer.findMany({
where: {
aktiv: true,
rolle: { in: ["ADMIN", "REDAKTEUR", "TECHNIKER"] },
...(mandant.length === 0
? {}
: { OR: [{ kundenbindungen: { none: {} } }, { kundenbindungen: { some: mandant[0] } }] }),
},
orderBy: { name: "asc" },
take: 500,
select: { id: true, name: true },
}),
]);
return {
personen,
geraete: geraete.map((geraet) => ({
id: geraet.id,
anzeige: `${geraet.hostname ?? geraet.bezeichnung ?? geraet.tagCode} · ${geraet.tagCode}`,
besitzerId: geraet.mitarbeiterId,
})),
abteilungen,
bearbeiter,
};
}
+31 -11
View File
@@ -7,7 +7,7 @@ import {
type Namenswerte,
type Zielformat,
} from "@/lib/namensregeln";
import { PASSWORT_HINWEIS } from "@/lib/passwort";
import { PASSWORT_HINWEIS, passwortErzeugen } from "@/lib/passwort";
import { platzhalterErsetzen, type ErsetzungsErgebnis } from "@/lib/platzhalter-werte";
import {
platzhalterAusserhalbAnfuehrungszeichen,
@@ -71,7 +71,16 @@ export type FertigerBefehl = {
* Der Titel wird **ohne** Shell-Maskierung ersetzt: er wird gelesen, nicht
* ausgefuehrt. Ein maskierter Titel stuende mit Backticks auf dem Bildschirm.
*/
export function befehlFuellen(schritt: BefehlsSchritt, werte: PlatzhalterWerte): FertigerBefehl {
export function befehlFuellen(
schritt: BefehlsSchritt,
werte: PlatzhalterWerte,
/**
* Das Passwort dieses Durchgangs. Ohne Angabe bekommt jeder Befehl sein
* eigenes — richtig fuer einen einzelnen, falsch fuer einen Lauf, in dem ein
* Schritt das Konto anlegt und der naechste das Passwort uebergibt.
*/
passwort?: string,
): FertigerBefehl {
const titel = platzhalterErsetzen(schritt.titel, werte).text;
/*
@@ -97,10 +106,12 @@ export function befehlFuellen(schritt: BefehlsSchritt, werte: PlatzhalterWerte):
}
try {
const befehl = platzhalterErsetzen(schritt.befehl, werte, {
art: "befehl",
maskieren: (wert) => wertMaskieren(wert, schritt.shell),
});
const befehl = platzhalterErsetzen(
schritt.befehl,
werte,
{ art: "befehl", maskieren: (wert) => wertMaskieren(wert, schritt.shell) },
passwort,
);
return {
titel,
shell: schritt.shell,
@@ -139,9 +150,10 @@ export function befehlFuellen(schritt: BefehlsSchritt, werte: PlatzhalterWerte):
/**
* Alle Befehle eines Abschnitts als ein Skript zum Kopieren.
*
* **Die Passwoerter darin entstehen genau jetzt.** Wer das Skript zweimal
* kopiert, bekommt zweimal verschiedene — das ist die ehrliche Folge der Regel
* „bei jeder Anzeige neu“ und der Grund, warum der Hinweis daneben steht.
* **Das Passwort darin entsteht genau jetzt.** Wer die Seite neu laedt, bekommt
* ein anderes — das ist die ehrliche Folge der Regel „bei jeder Anzeige neu“ und
* der Grund, warum der Hinweis daneben steht. Innerhalb eines Aufbaus ist es
* ueberall dasselbe.
*/
/**
* Ein Skript aus **bereits gefuellten** Schritten.
@@ -184,6 +196,7 @@ export function skriptAusFertigen(fertige: readonly FertigerBefehl[]): {
export function skriptFuerAbschnitt(
schritte: readonly BefehlsSchritt[],
werte: PlatzhalterWerte,
passwort?: string,
): {
readonly skript: string;
readonly mitPasswort: boolean;
@@ -191,7 +204,7 @@ export function skriptFuerAbschnitt(
/** Schritte, deren Werte sich nicht sicher maskieren liessen. */
readonly nichtMaskierbar: readonly string[];
} {
return skriptAusFertigen(schritte.map((schritt) => befehlFuellen(schritt, werte)));
return skriptAusFertigen(schritte.map((schritt) => befehlFuellen(schritt, werte, passwort)));
}
/**
@@ -234,6 +247,13 @@ export async function befehleDesLaufs(kontext: LaufKontext): Promise<
if (!lauf) return [];
const werte = await platzhalterWerteDesLaufs(kontext);
/*
* **Ein** Passwort fuer den ganzen Lauf, nicht eines je Befehl: der Schritt
* „Konto anlegen“ und der Schritt „Passwort uebergeben“ meinen dasselbe.
* Erzeugt wird es hier und nirgends gespeichert; beim naechsten Abruf ist es
* ein anderes.
*/
const passwort = passwortErzeugen();
return lauf.vorlagenVersion.abschnitte
.map((abschnitt) => {
@@ -244,7 +264,7 @@ export async function befehleDesLaufs(kontext: LaufKontext): Promise<
}));
// **Einmal** fuellen und daraus beides bauen: ein zweiter Durchgang
// erzeugte ein zweites Passwort (siehe skriptAusFertigen).
const gefuellt = schritte.map((schritt) => befehlFuellen(schritt, werte));
const gefuellt = schritte.map((schritt) => befehlFuellen(schritt, werte, passwort));
const skript = skriptAusFertigen(gefuellt);
return {
abschnitt: abschnitt.titel,
+80
View File
@@ -0,0 +1,80 @@
import type { Kommentar } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import type { HandelnderBenutzer } from "@/server/mandant";
import { LaufFehler, laufZumSchreibenHolen } from "@/server/services/laeufe";
/**
* Kommentare an Laeufen und Schrittergebnissen (#013).
*
* Der Ort fuer das, was in kein Feld passt: „Kunde war nicht erreichbar“,
* „Lizenz kommt am Montag“, „Netzteil defekt, Ersatz bestellt“. Ein Kommentar
* traegt seinen Urheber und seinen Zeitpunkt selbst, und er wird nie geaendert —
* deshalb gibt es hier nur einen Weg hinein und keinen hinaus. Das ist auch der
* Grund, warum ein Kommentar **keinen** Protokolleintrag bekommt: er ist schon
* einer. Ein zweiter Eintrag, der dasselbe noch einmal sagt, wuerde das Protokoll
* nur laenger machen.
*
* Der Schreibschutz gilt trotzdem: an einem archivierten Lauf kommt kein
* Kommentar mehr dazu. Der Weg dorthin ist derselbe wie bei den
* Schrittergebnissen — `laufZumSchreibenHolen`, und darin
* `laufMussVeraenderbarSein`.
*/
/**
* Obergrenze je Kommentar.
*
* Ohne Grenze landet ein versehentlich eingefuegtes Ereignisprotokoll in der
* Datenbank und in jeder Druckansicht. Viertausend Zeichen sind ungefaehr zwei
* DIN-A4-Seiten — mehr als genug fuer eine Notiz, und die Meldung nennt die Zahl.
*/
export const KOMMENTAR_HOECHSTLAENGE = 4000;
export type NeuerKommentar = {
readonly laufId: string;
/** Leer: der Kommentar haengt am Lauf und nicht an einem einzelnen Schritt. */
readonly schrittergebnisId?: string | null;
readonly rumpf: string;
};
/**
* Schreibt einen Kommentar.
*
* Ein Kommentar **startet den Lauf nicht**: eine Rueckfrage zu notieren ist keine
* Abarbeitung, und ein zugewiesener Lauf, der von einer Notiz auf `IN_ARBEIT`
* springt, saehe in der Liste aus wie begonnene Arbeit.
*/
export async function kommentarHinzufuegen(
eingabe: NeuerKommentar,
benutzer: HandelnderBenutzer,
): Promise<Kommentar> {
const rumpf = eingabe.rumpf.trim();
if (!rumpf) throw new LaufFehler("Ein leerer Kommentar hilft niemandem.");
if (rumpf.length > KOMMENTAR_HOECHSTLAENGE) {
throw new LaufFehler(
`Der Kommentar ist ${rumpf.length} Zeichen lang; erlaubt sind ${KOMMENTAR_HOECHSTLAENGE}. ` +
"Längeres gehört in die Notiz am Schritt oder ins Ticket.",
);
}
return prisma.$transaction(async (tx) => {
const lauf = await laufZumSchreibenHolen(tx, eingabe.laufId, benutzer);
const schrittergebnisId = eingabe.schrittergebnisId?.trim() || null;
if (schrittergebnisId) {
// Die Kennung kommt von aussen. Ohne diese Pruefung hinge der Kommentar an
// einem Schritt eines fremden Laufs – und damit moeglicherweise eines
// fremden Kunden, dessen Schreibschutz nie geprueft wurde.
const ergebnis = await tx.schrittergebnis.findUnique({
where: { id: schrittergebnisId },
select: { laufId: true },
});
if (!ergebnis || ergebnis.laufId !== lauf.id) {
throw new LaufFehler("Dieser Schritt gehört nicht zu diesem Lauf.");
}
}
return tx.kommentar.create({
data: { laufId: lauf.id, schrittergebnisId, benutzerId: benutzer.id, rumpf },
});
});
}
File diff suppressed because it is too large Load Diff
+314
View File
@@ -0,0 +1,314 @@
import { Prisma, type Schrittergebnis } from "@/generated/prisma/client";
import { SchrittZustand } from "@/generated/prisma/enums";
import { prisma } from "@/lib/db";
import type { HandelnderBenutzer } from "@/server/mandant";
import {
LaufFehler,
abschlussPruefenNachAenderung,
laufStartenFallsOffen,
laufZumSchreibenHolen,
} from "@/server/services/laeufe";
/**
* Schrittergebnisse (#013).
*
* Ein Schritt wird **einzeln** gesetzt, nicht als Gesamtformular: der Techniker
* arbeitet einen Laufzettel von oben nach unten ab, oft am Handy, oft
* unterbrochen. Ein Formular, das am Ende alles auf einmal schickt, verliert
* genau dann die Arbeit, wenn es darauf ankommt.
*
* Drei Regeln stehen hier:
*
* 1. **`NICHT_ZUTREFFEND` und `FEHLGESCHLAGEN` verlangen eine Notiz.** Sonst
* stuende im Archiv ein uebersprungener Schritt ohne Begruendung — und genau
* das ist die Luecke, die ein Archiv wertlos macht. Die Bedingung
* `Schrittergebnis_begruendung_noetig` haelt dasselbe in der Datenbank fest.
* 2. **Jede Aenderung bekommt einen Protokolleintrag**, in derselben
* Transaktion wie die Aenderung selbst.
* 3. **Der Schreibschutz des Archivs laeuft durch `laufZumSchreibenHolen`** —
* die eine Stelle, die `laufMussVeraenderbarSein` ruft und die Laufzeile
* sperrt.
*/
/** Wie ein Zustand im Protokoll heisst. Die Oberflaeche hat ihre eigenen Texte. */
const ZUSTAND_AKTION: Readonly<Record<SchrittZustand, string>> = {
OFFEN: "Schritt zurückgesetzt",
ERLEDIGT: "Schritt erledigt",
NICHT_ZUTREFFEND: "Schritt als nicht zutreffend eingetragen",
FEHLGESCHLAGEN: "Schritt als fehlgeschlagen eingetragen",
};
export type SchrittergebnisEingabe = {
readonly laufId: string;
readonly vorlagenSchrittId: string;
readonly zustand: SchrittZustand;
/**
* Der erfasste Wert. Fehlt das Feld, bleibt der vorhandene Wert stehen;
* `null` loescht ihn. Ein Haken, der einen Text mitbringt, und ein Zustand, der
* ohne Wert wechselt, sind verschiedene Vorgaenge.
*/
readonly wert?: Prisma.InputJsonValue | null;
/** Fehlt das Feld, bleibt die vorhandene Notiz stehen; `null` loescht sie. */
readonly notiz?: string | null;
};
function text(wert: string | null | undefined): string | null {
return wert?.trim() || null;
}
/**
* Die Notizpflicht, an einer Stelle.
*
* Geprueft wird der **Zustand danach**: wer einen Schritt mit vorhandener Notiz
* auf `NICHT_ZUTREFFEND` setzt, muss sie nicht noch einmal eintippen; wer die
* Notiz im selben Zug leert, faellt hierauf.
*/
function notizPflichtPruefen(zustand: SchrittZustand, notiz: string | null): void {
if (notiz !== null) return;
if (zustand === SchrittZustand.NICHT_ZUTREFFEND) {
throw new LaufFehler(
"Ein Schritt, der nicht zutrifft, braucht eine Begründung — sonst steht im Archiv ein " +
"übersprungener Schritt, und niemand weiß mehr, warum.",
);
}
if (zustand === SchrittZustand.FEHLGESCHLAGEN) {
throw new LaufFehler(
"Ein fehlgeschlagener Schritt braucht eine Notiz: was ist passiert und was ist zu tun?",
);
}
}
/**
* Prueft, dass dieser Schritt zu diesem Lauf gehoert.
*
* Die Schrittkennung kommt von aussen. Ohne diese Pruefung liesse sich ein
* Schritt einer fremden Version — und damit eines fremden Kunden — an diesen Lauf
* haengen; in der Abarbeitungsansicht tauchte er nie auf, im Archiv aber schon.
*/
async function schrittDesLaufsHolen(
tx: Prisma.TransactionClient,
vorlagenSchrittId: string,
vorlagenVersionId: string,
): Promise<{ readonly id: string; readonly titel: string; readonly pflicht: boolean }> {
const schritt = await tx.vorlagenSchritt.findUnique({
where: { id: vorlagenSchrittId },
select: { id: true, titel: true, pflicht: true, abschnitt: { select: { versionId: true } } },
});
if (!schritt || schritt.abschnitt.versionId !== vorlagenVersionId) {
throw new LaufFehler("Dieser Schritt gehört nicht zu diesem Lauf.");
}
return schritt;
}
/**
* Setzt ein Schrittergebnis.
*
* Nebenwirkung mit Absicht: ein `OFFEN`er Lauf wechselt dabei auf `IN_ARBEIT`.
* Wer einen Schritt eintraegt, arbeitet den Lauf ab — ein Lauf mit
* Schrittergebnissen, aber ohne `gestartetAm`, waere eine Zeitleiste, die luegt.
*
* Aendert die Eingabe nichts, wird auch nichts geschrieben: ein Doppelklick soll
* keine zwei Protokolleintraege ergeben.
*/
export async function schrittergebnisSetzen(
eingabe: SchrittergebnisEingabe,
benutzer: HandelnderBenutzer,
): Promise<Schrittergebnis> {
return prisma.$transaction(async (tx) => {
const lauf = await laufZumSchreibenHolen(tx, eingabe.laufId, benutzer);
const schritt = await schrittDesLaufsHolen(tx, eingabe.vorlagenSchrittId, lauf.vorlagenVersionId);
const vorher = await tx.schrittergebnis.findUnique({
where: { laufId_vorlagenSchrittId: { laufId: lauf.id, vorlagenSchrittId: schritt.id } },
});
const notiz = eingabe.notiz === undefined ? (vorher?.notiz ?? null) : text(eingabe.notiz);
notizPflichtPruefen(eingabe.zustand, notiz);
const wertGeaendert =
eingabe.wert !== undefined &&
JSON.stringify(eingabe.wert ?? null) !== JSON.stringify(vorher?.wert ?? null);
if (vorher && vorher.zustand === eingabe.zustand && vorher.notiz === notiz && !wertGeaendert) {
return vorher;
}
const wert: Prisma.InputJsonValue | typeof Prisma.DbNull | undefined =
eingabe.wert === undefined ? undefined : (eingabe.wert ?? Prisma.DbNull);
const nachher = await tx.schrittergebnis.upsert({
where: { laufId_vorlagenSchrittId: { laufId: lauf.id, vorlagenSchrittId: schritt.id } },
create: {
laufId: lauf.id,
vorlagenSchrittId: schritt.id,
zustand: eingabe.zustand,
notiz,
...(wert === undefined ? {} : { wert }),
aktualisiertVonId: benutzer.id,
},
update: {
zustand: eingabe.zustand,
notiz,
...(wert === undefined ? {} : { wert }),
aktualisiertVonId: benutzer.id,
},
});
await tx.protokoll.create({
data: {
laufId: lauf.id,
entitaet: "Schrittergebnis",
entitaetId: nachher.id,
aktion: ZUSTAND_AKTION[eingabe.zustand],
benutzerId: benutzer.id,
vorher: vorher
? { zustand: vorher.zustand, wert: vorher.wert ?? null, notiz: vorher.notiz }
: Prisma.DbNull,
nachher: {
schritt: schritt.titel,
pflicht: schritt.pflicht,
zustand: nachher.zustand,
wert: nachher.wert ?? null,
notiz: nachher.notiz,
},
},
});
const status = await laufStartenFallsOffen(tx, lauf, benutzer.id);
await abschlussPruefenNachAenderung(tx, { id: lauf.id, status }, benutzer.id);
return nachher;
});
}
export type AbschnittEingabe = {
readonly laufId: string;
readonly abschnittId: string;
/** Gilt fuer jeden Schritt des Abschnitts – einmal eingetippt, einmal gespeichert. */
readonly notiz: string;
};
export type AbschnittErgebnis = {
readonly gesetzt: number;
/** Schritte, die schon `ERLEDIGT` waren und deshalb geblieben sind. */
readonly uebersprungen: readonly string[];
};
/**
* Setzt einen ganzen Abschnitt auf `NICHT_ZUTREFFEND`.
*
* Der Fall aus dem Auftrag: im Onboarding gibt es zwei Konto-Abschnitte, und je
* nach Kunde trifft einer davon gar nicht zu. Achtzehn Haken einzeln mit
* derselben Begruendung zu versehen macht niemand — und wer es nicht macht, laesst
* sie offen.
*
* **Schon `ERLEDIGT`e Schritte bleiben `ERLEDIGT`.** Das ist die interessante
* Entscheidung, deshalb der Grund: „erledigt“ ist die Aussage, dass jemand etwas
* wirklich getan hat, mit Namen und Zeitstempel. „Nicht zutreffend“ ist die
* Aussage, dass es nichts zu tun gab. Die zweite ueber die erste zu schreiben
* waere eine Tatsache durch eine Behauptung zu ersetzen und wuerde das „wer wann“
* dieser Arbeit loeschen. Wer wirklich einen erledigten Schritt zurueckdrehen
* will, tut das einzeln und begruendet es dort. Die Zahl der uebersprungenen
* Schritte kommt zurueck, damit die Oberflaeche es sagen kann.
*
* `FEHLGESCHLAGEN` wird dagegen ueberschrieben: ein Schritt, der nicht zutrifft,
* kann nicht fehlgeschlagen sein — er haette gar nicht versucht werden muessen.
*/
export async function abschnittNichtZutreffend(
eingabe: AbschnittEingabe,
benutzer: HandelnderBenutzer,
): Promise<AbschnittErgebnis> {
const notiz = text(eingabe.notiz);
if (!notiz) {
throw new LaufFehler(
"Ein ganzer Abschnitt, der nicht zutrifft, braucht eine Begründung — sie steht danach an " +
"jedem Schritt des Abschnitts.",
);
}
return prisma.$transaction(async (tx) => {
const lauf = await laufZumSchreibenHolen(tx, eingabe.laufId, benutzer);
const abschnitt = await tx.vorlagenAbschnitt.findUnique({
where: { id: eingabe.abschnittId },
select: {
titel: true,
versionId: true,
schritte: { orderBy: { position: "asc" }, select: { id: true, titel: true, pflicht: true } },
},
});
if (!abschnitt || abschnitt.versionId !== lauf.vorlagenVersionId) {
throw new LaufFehler("Dieser Abschnitt gehört nicht zu diesem Lauf.");
}
const vorhandene = new Map(
(
await tx.schrittergebnis.findMany({
where: { laufId: lauf.id, vorlagenSchrittId: { in: abschnitt.schritte.map((s) => s.id) } },
})
).map((ergebnis) => [ergebnis.vorlagenSchrittId, ergebnis]),
);
const uebersprungen: string[] = [];
let gesetzt = 0;
for (const schritt of abschnitt.schritte) {
const vorher = vorhandene.get(schritt.id);
if (vorher?.zustand === SchrittZustand.ERLEDIGT) {
uebersprungen.push(schritt.titel);
continue;
}
if (vorher?.zustand === SchrittZustand.NICHT_ZUTREFFEND && vorher.notiz === notiz) {
// Schon so eingetragen, mit derselben Begruendung: nichts zu tun und kein
// zweiter Protokolleintrag.
continue;
}
const nachher = await tx.schrittergebnis.upsert({
where: { laufId_vorlagenSchrittId: { laufId: lauf.id, vorlagenSchrittId: schritt.id } },
create: {
laufId: lauf.id,
vorlagenSchrittId: schritt.id,
zustand: SchrittZustand.NICHT_ZUTREFFEND,
notiz,
aktualisiertVonId: benutzer.id,
},
update: {
zustand: SchrittZustand.NICHT_ZUTREFFEND,
notiz,
aktualisiertVonId: benutzer.id,
},
});
gesetzt += 1;
// Je Schritt ein Eintrag: „jede Aenderung an einem Schrittergebnis steht im
// Protokoll“ gilt auch, wenn achtzehn davon aus einem Klick entstehen. Der
// Abschnitt steht dabei, damit die Herkunft erkennbar bleibt.
await tx.protokoll.create({
data: {
laufId: lauf.id,
entitaet: "Schrittergebnis",
entitaetId: nachher.id,
aktion: "Abschnitt als nicht zutreffend eingetragen",
benutzerId: benutzer.id,
vorher: vorher
? { zustand: vorher.zustand, notiz: vorher.notiz }
: { zustand: SchrittZustand.OFFEN, notiz: null },
nachher: {
abschnitt: abschnitt.titel,
schritt: schritt.titel,
pflicht: schritt.pflicht,
zustand: SchrittZustand.NICHT_ZUTREFFEND,
notiz,
},
},
});
}
if (gesetzt > 0) {
const status = await laufStartenFallsOffen(tx, lauf, benutzer.id);
await abschlussPruefenNachAenderung(tx, { id: lauf.id, status }, benutzer.id);
}
return { gesetzt, uebersprungen };
});
}