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 (
{erledigt} von {gesamt}
); } 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 (
  • {String(schritt.nummer).padStart(2, "0")}
    {schritt.titel} {schritt.pflicht ? ( ) : null} {/* Im Lesemodus traegt `SchrittZustandWaehlen` das Kennzeichen selbst. */} {lesend ? null : }
    {/* * „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 ? (
    ) : null} {schritt.dokuUrl && /^https?:\/\//iu.test(schritt.dokuUrl) ? ( Anleitung öffnen ) : null} {schritt.befehl ? (
    ) : null} {schritt.typ !== SchrittTyp.HAKEN && schritt.typ !== SchrittTyp.BEFEHL ? (
    ) : null}
    {/* * 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 ? (

    {schritt.notiz}

    ) : null} {schritt.bearbeitetVon && schritt.bearbeitetAm ? (

    {schritt.bearbeitetVon}, {ZEITPUNKT.format(schritt.bearbeitetAm)}

    ) : null} {kommentare.length > 0 ? (
      {kommentare.map((kommentar) => (
    • {kommentar.rumpf}

      {kommentar.benutzer}, {ZEITPUNKT.format(kommentar.erstelltAm)}

    • ))}
    ) : 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) ? (
    ) : null} {lesend || !schritt.ergebnisId ? null : (
    )}
  • ); } 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 ( {abschnitt.skript ? (
    Alle Befehle als Skript kopieren
    ) : null} {lesend ? null : (
    )}
    ); } export default async function LaufSeite({ params, searchParams, }: { params: Promise<{ id: string }>; searchParams: Promise>; }) { 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 : }