feat(laeufe): Service, Abarbeitung, Kommentare und Archivschutz
Ein Lauf wird vorbereitet, abgearbeitet, abgeschlossen und archiviert. Der Archiv-Schreibschutz sitzt in genau einer Funktion und wird vor jeder Aenderung an Kopfdaten, Schrittergebnis und Kommentar gerufen; abgeschlossen bleibt aenderbar, archiviert nicht. Die Abarbeitungsansicht speichert jeden Schritt einzeln, frischt sich alle zehn Sekunden auf und pausiert im Hintergrund. Sie traegt in der Polling-Antwort keine Befehle und laedt sich auch nicht von selbst neu, solange ein generiertes Passwort auf der Seite steht: beides tauschte es dem Techniker zwischen Lesen und Einfuegen aus. Der unabhaengige Review fand zwei Blocker: ein Befehl konnte zwei verschiedene Passwoerter tragen (Konto bekommt A, Benutzer erfaehrt B), und „nicht zutreffend“ liess sich nicht zuruecknehmen, waehrend die Oberflaeche behauptete, nichts sei gewaehlt. Beides behoben und in Tests festgehalten. Closes #13 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit was merged in pull request #27.
This commit is contained in:
@@ -0,0 +1,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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user