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" },
});
}