Verlangt eine Vorlagen-Version eine Freigabe, laesst sich ein Lauf erst abschliessen, wenn ein Zweiter ihn freigegeben hat. Das braucht zweierlei, und beides zusammen: das neue Recht `freigeben` (ADMIN und REDAKTEUR, nicht TECHNIKER) und dass man nicht an dem Lauf gearbeitet hat. Als gearbeitet zaehlt: angelegt, einen Schritt gesetzt oder abgeschlossen. Eine Zuweisung allein nicht — waere die Regel hier strenger, blockierte sie kleine Teams ohne Gewinn. Auch ein Administrator kann sich nicht selbst freigeben, und das ist der ganze Sinn. Die Pruefung sitzt in `hatDaranGearbeitet` an einer Stelle, wie `laufMussVeraenderbarSein`; die wichtigste Haelfte — der Ersteller — haelt zusaetzlich ein CHECK in der Datenbank. Zurueckweisen verlangt eine Begruendung, setzt den Lauf auf IN_ARBEIT und nimmt eine erteilte Freigabe zurueck. Kein fuenfter LaufStatus: ein neuer Enum-Wert haette jeden Codepfad betroffen, der heute vier Faelle kennt. Sobald jemand abzuschliessen versucht und die Freigabe fehlt, bekommen die Freigabeberechtigten eine Mail — ein anderer Empfaengerkreis als bei jeder anderen Meldung, und ohne die, die gearbeitet haben. Die Aufforderung entsteht ausserhalb der Transaktion: innerhalb wuerde sie mit dem Fehler zurueckgerollt, und dann wartet ein Lauf auf etwas, von dem niemand weiss. Ein partieller Unique-Index haelt sie bei mehreren Versuchen auf einmal. Die Pflicht haengt an der Version, nicht an der Vorlage: eine veroeffentlichte Version ist unveraenderlich, ein laufender Lauf behaelt also seine Regel. Beim Bearbeiten und Duplizieren wandert sie mit — das war die unmittelbare Lehre aus #039, wo die tiefe Kopie zwei Felder stillschweigend verworfen hatte. 1526 Vitest-Tests (vorher 1496), 115 E2E-Ablaeufe gruen. Klickprobe am gebauten Stand mit elf Pruefungen; drei rote Punkte darin waren meine eigenen Testfehler, einer davon lehrreich: ein reload() direkt nach einer Server Action trifft den Router-Cache von Next und zeigt kurz den alten Stand. Closes #40 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
519 lines
20 KiB
TypeScript
519 lines
20 KiB
TypeScript
import Link from "next/link";
|
||
import { notFound } from "next/navigation";
|
||
import { ArrowLeft, MessageSquare, Printer, ScrollText, 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,
|
||
LaufFreigabe,
|
||
LaufBeginnen,
|
||
LaufMitAdKontoVerknuepfen,
|
||
LaufZurueckholen,
|
||
} from "@/components/laeufe/lauf-aktionen";
|
||
import { SchrittAnhaenge, type SchrittAnhang } from "@/components/laeufe/schritt-anhaenge";
|
||
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 { anhaengeDesLaufs } from "@/server/queries/anhaenge";
|
||
import { protokollDesLaufs } from "@/server/queries/protokoll";
|
||
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,
|
||
anhaenge,
|
||
}: {
|
||
schritt: AbarbeitungsSchritt;
|
||
laufId: string;
|
||
lesend: boolean;
|
||
ausfuehrlich: boolean;
|
||
kommentare: readonly KommentarZeile[];
|
||
anhaenge: readonly SchrittAnhang[];
|
||
}) {
|
||
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}
|
||
|
||
{/*
|
||
* Anhaenge (#032). Immer sichtbar, wenn es welche gibt — auch an einem
|
||
* archivierten Lauf, nur dann ohne Knoepfe. Ein Foto vom Typenschild
|
||
* ist Teil des Belegs.
|
||
*/}
|
||
{anhaenge.length > 0 || (!lesend && schritt.ergebnisId) ? (
|
||
<div className="mt-3">
|
||
<SchrittAnhaenge
|
||
laufId={laufId}
|
||
vorlagenSchrittId={schritt.schrittId}
|
||
anhaenge={anhaenge}
|
||
lesend={lesend || !schritt.ergebnisId}
|
||
/>
|
||
</div>
|
||
) : 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,
|
||
anhaenge,
|
||
}: {
|
||
abschnitt: AbarbeitungsAbschnitt;
|
||
laufId: string;
|
||
lesend: boolean;
|
||
ausfuehrlich: boolean;
|
||
kommentare: readonly KommentarZeile[];
|
||
anhaenge: readonly (SchrittAnhang & { readonly schrittergebnisId: string })[];
|
||
}) {
|
||
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)}
|
||
anhaenge={anhaenge.filter((anhang) => anhang.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 Sicht hat `laufHolen` oben schon angewandt; hier geht es nur noch um die
|
||
// Einträge dieses Laufs (#023).
|
||
const laufProtokoll = await protokollDesLaufs(id);
|
||
// Die Sicht hat `laufHolen` oben schon angewandt; hier nur die Metadaten der
|
||
// Anhaenge dieses Laufs (#032).
|
||
const anhaenge = await anhaengeDesLaufs(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} />
|
||
|
||
{/*
|
||
* Die Freigabe-Karte (#040), und zwar **ueber** der Knopfreihe: sie ist
|
||
* eine Entscheidung und kein Knopf unter anderen.
|
||
*
|
||
* Gezeigt wird sie, wenn die Version eine Freigabe verlangt und der Lauf
|
||
* noch nicht archiviert ist — danach aendert sich nichts mehr. Wer kein
|
||
* `freigeben` hat, sieht sie nur als Auskunft; das Formular bleibt ihm
|
||
* verborgen, denn ein Knopf, der nichts tut, ist kein Knopf.
|
||
*/}
|
||
{lauf.freigabePflicht && !archiviert ? (
|
||
darf(benutzer.rolle, "freigeben") ? (
|
||
<div className="mt-4">
|
||
<LaufFreigabe
|
||
laufId={lauf.id}
|
||
bereitsFreigegeben={lauf.freigegebenAm !== null}
|
||
letzteNotiz={lauf.freigabeNotiz}
|
||
/>
|
||
</div>
|
||
) : (
|
||
<p className="mt-4 max-w-prose rounded-karte border border-linie bg-flaeche p-4 text-sm text-nebentext">
|
||
{lauf.freigegebenAm
|
||
? `Freigegeben${lauf.freigegebenVon ? ` von ${lauf.freigegebenVon}` : ""}.`
|
||
: "Dieser Lauf verlangt eine Freigabe durch einen Zweiten, bevor er abgeschlossen " +
|
||
"werden kann. Die Freigabeberechtigten sind benachrichtigt, sobald du ihn " +
|
||
"abzuschließen versuchst."}
|
||
{lauf.freigabeNotiz ? ` Letzte Begründung: ${lauf.freigabeNotiz}` : ""}
|
||
</p>
|
||
)
|
||
) : null}
|
||
|
||
<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>
|
||
|
||
{/*
|
||
* Die Druckansicht liegt ausserhalb der Huelle und traegt deshalb kein
|
||
* `Link`-Vorabladen: sie ist hell, ohne Navigation und fuer Papier
|
||
* gemacht (#015). Auf dem Blatt steht **kein** Passwort — auch dort
|
||
* nicht, wo im Lauf eines stuende.
|
||
*/}
|
||
<Link
|
||
href={`/drucken/lauf/${lauf.id}`}
|
||
prefetch={false}
|
||
className="flex min-h-11 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"
|
||
>
|
||
<Printer className="size-4" strokeWidth={1.75} aria-hidden="true" />
|
||
Drucken
|
||
</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}
|
||
anhaenge={anhaenge}
|
||
/>
|
||
))}
|
||
</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>
|
||
|
||
{/*
|
||
* Die Geschichte dieses Laufs, eingeklappt (#023). Sie steht hier und
|
||
* nicht nur unter Verwaltung → Protokoll, weil die Frage „wer hat das
|
||
* zurückgeholt“ am Lauf aufkommt und nicht in der Verwaltung. Eingeklappt,
|
||
* weil ein abgearbeiteter Lauf Dutzende Einträge hat und sie die Schritte
|
||
* sonst aus dem Bild schieben.
|
||
*
|
||
* Kein eigenes Recht: wer den Lauf sehen darf, darf auch sehen, was an ihm
|
||
* geschehen ist. Die Mandantensicht hat `laufHolen` oben schon angewandt.
|
||
*/}
|
||
{laufProtokoll.length > 0 ? (
|
||
<section className="mt-4 rounded-karte border border-linie bg-flaeche p-4 md:p-5">
|
||
<details>
|
||
<summary className="flex min-h-11 cursor-pointer items-center gap-2 text-lg font-semibold md:min-h-0">
|
||
<ScrollText className="size-5 text-nebentext" strokeWidth={1.75} aria-hidden="true" />
|
||
Protokoll dieses Laufs
|
||
<span className="text-sm font-normal text-nebentext">({laufProtokoll.length})</span>
|
||
</summary>
|
||
<ol className="mt-3 flex flex-col gap-2">
|
||
{laufProtokoll.map((eintrag) => (
|
||
<li key={eintrag.id} className="flex flex-wrap items-baseline gap-x-3 gap-y-1 text-sm">
|
||
<span className="mono shrink-0 text-xs text-nebentext">
|
||
{ZEITPUNKT.format(eintrag.erstelltAm)}
|
||
</span>
|
||
<span className="min-w-0 break-words">{eintrag.aktion}</span>
|
||
<span className="text-xs text-nebentext">
|
||
{eintrag.urheber ? eintrag.urheber.name : "Systemlauf"}
|
||
</span>
|
||
</li>
|
||
))}
|
||
</ol>
|
||
</details>
|
||
</section>
|
||
) : null}
|
||
</>
|
||
);
|
||
}
|