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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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" },
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user