feat(vorlagen): Editor, Liste und Versionsgeschichte (#24)
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s

This commit was merged in pull request #24.
This commit is contained in:
2026-09-25 08:23:22 +00:00
parent d8d5444e9f
commit e3d80e5982
65 changed files with 5454 additions and 103 deletions
+208
View File
@@ -0,0 +1,208 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { ArrowLeft, Eye } from "lucide-react";
import { VorlageKopfdaten } from "@/components/vorlage-kopfdaten";
import { VorlageBearbeitenKnopf } from "@/components/vorlage-bearbeiten-knopf";
import {
EntwurfVerwerfen,
VersionExportieren,
VorlageArchivieren,
VorlageDuplizieren,
} from "@/components/vorlage-aktionen";
import { VersionStatus } from "@/generated/prisma/enums";
import { darf } from "@/server/auth/rollen";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import { kundenEbeneSichtbar } from "@/server/mandant";
import { kundeHolen, kundenListe } from "@/server/queries/kunden";
import { versionsHistorie, vorlageHolen, type VersionsZeile } from "@/server/queries/vorlagen";
/**
* Eine Vorlage mit ihrer Versionsgeschichte (#010).
*
* Die Seite beantwortet drei Fragen auf einen Blick: was ist gerade gueltig, wird
* gerade daran gearbeitet, und was ist wann veroeffentlicht worden. Der Aufbau
* selbst steht im Editor — eine veroeffentlichte Version zeigt er nur lesend.
*/
const DATUM = new Intl.DateTimeFormat("de-DE", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
function HistorienZeile({ zeile, darfBearbeiten }: { zeile: VersionsZeile; darfBearbeiten: boolean }) {
const istEntwurf = zeile.status === VersionStatus.ENTWURF;
return (
<li className="flex flex-col gap-2 border-t border-linie py-3 first:border-t-0 md:flex-row md:items-start md:justify-between">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="mono text-sm text-text">v{zeile.version}</span>
{istEntwurf ? (
<span className="rounded-pille bg-akzent-flaeche px-2.5 py-1 text-xs text-akzent">Entwurf</span>
) : (
<span className="text-xs text-nebentext">
veröffentlicht {zeile.veroeffentlichtAm ? DATUM.format(zeile.veroeffentlichtAm) : "–"}
{zeile.veroeffentlichtVon ? ` von ${zeile.veroeffentlichtVon}` : ""}
</span>
)}
<span className="text-xs text-nebentext">
{zeile.anzahlLaeufe} {zeile.anzahlLaeufe === 1 ? "Lauf" : "Läufe"}
</span>
</div>
{zeile.aenderungsnotiz ? (
<p className="mt-1 max-w-prose text-sm text-nebentext">{zeile.aenderungsnotiz}</p>
) : null}
</div>
<div className="flex shrink-0 flex-wrap items-center gap-2">
<Link
href={`/vorlagen/version/${zeile.id}`}
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"
>
{istEntwurf ? (
"Im Editor öffnen"
) : (
<>
<Eye className="size-4" strokeWidth={1.75} aria-hidden="true" />
Ansehen
</>
)}
</Link>
{darfBearbeiten ? <VersionExportieren versionId={zeile.id} /> : null}
</div>
</li>
);
}
/**
* Die Kunden fuer das Kopfdaten-Formular — einschliesslich des zugeordneten,
* auch wenn er archiviert ist.
*
* Sonst faellt genau dieser Eintrag aus der Liste, `defaultValue` trifft nichts,
* der Browser waehlt die erste Option („für alle Kunden“), und ein Klick auf
* „Kopfdaten speichern“ loest die Zuordnung — aus einer Vorlage fuer einen
* Kunden wird eine fuer alle. Ein archivierter Kunde ist nicht geloescht; seine
* Vorlagen bleiben ihm zugeordnet.
*/
async function kundenAuswahl(
zugeordnet: string | null,
benutzer: Parameters<typeof kundenListe>[0],
): Promise<readonly { id: string; name: string }[]> {
const aktive = (await kundenListe(benutzer)).map(({ id, name }) => ({ id, name }));
if (!zugeordnet || aktive.some((kunde) => kunde.id === zugeordnet)) return aktive;
const archivierter = await kundeHolen(zugeordnet, benutzer);
/*
* Sieht der Benutzer den zugeordneten Kunden ueberhaupt nicht, laesst sich
* keine richtige Auswahl anbieten. Dann gibt es gar keine: eine leere Liste
* bedeutet „kein Feld im Formular“, und ein fehlendes Feld laesst die
* Zuordnung unangetastet (siehe actions/vorlagen.ts). Lieber nicht aenderbar
* als stillschweigend geloest.
*/
if (!archivierter) return [];
return [{ id: archivierter.id, name: `${archivierter.name} (archiviert)` }, ...aktive];
}
export default async function VorlagenSeite({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const { benutzer } = await sitzungErforderlich("lesen", `/vorlagen/${id}`);
const vorlage = await vorlageHolen(id, benutzer);
// Nicht vorhanden und nicht sichtbar sind absichtlich dieselbe Antwort – die
// Unterscheidung waere schon eine Auskunft ueber fremde Kunden (#009).
if (!vorlage) notFound();
const darfBearbeiten = darf(benutzer.rolle, "vorlagenBearbeiten");
const [historie, kundenEbene] = await Promise.all([
versionsHistorie(vorlage.id, benutzer),
kundenEbeneSichtbar(),
]);
const kunden = kundenEbene && darfBearbeiten ? await kundenAuswahl(vorlage.kundeId, benutzer) : [];
const entwurf = historie.find((zeile) => zeile.status === VersionStatus.ENTWURF);
return (
<>
<Link
href="/vorlagen"
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 Vorlagen
</Link>
<h1 className="mt-2 text-2xl font-bold">{vorlage.name}</h1>
<div className="mt-2 flex flex-wrap items-center gap-2 text-sm text-nebentext">
{vorlage.veroeffentlichteVersion === null ? (
<span>noch keine veröffentlichte Version</span>
) : (
<span>
gültig ist <span className="mono text-text">v{vorlage.veroeffentlichteVersion}</span>
</span>
)}
{vorlage.kundeName ? <span>· nur {vorlage.kundeName}</span> : null}
{vorlage.archiviert ? <span>· archiviert</span> : null}
</div>
{vorlage.beschreibung ? (
<p className="mt-2 max-w-prose text-nebentext">{vorlage.beschreibung}</p>
) : null}
{darfBearbeiten ? (
<div className="mt-6 flex flex-col gap-3">
<VorlageBearbeitenKnopf vorlageId={vorlage.id} hatEntwurf={vorlage.hatEntwurf} />
{vorlage.archiviert ? (
<p className="max-w-prose text-sm text-faellig">
Diese Vorlage ist archiviert. Ein Entwurf lässt sich noch bearbeiten, veröffentlichen lässt er
sich erst nach dem Freigeben.
</p>
) : null}
</div>
) : null}
<section className="mt-8 rounded-karte border border-linie bg-flaeche p-5">
<h2 className="text-lg font-semibold">Versionen</h2>
<p className="mt-1 max-w-prose text-sm text-nebentext">
Eine veröffentlichte Version wird nie mehr verändert. Läufe halten sich an die Version, mit der sie
gestartet wurden.
</p>
<ul className="mt-4 flex flex-col">
{historie.map((zeile) => (
<HistorienZeile key={zeile.id} zeile={zeile} darfBearbeiten={darfBearbeiten} />
))}
</ul>
</section>
{darfBearbeiten ? (
<>
<section className="mt-4 rounded-karte border border-linie bg-flaeche p-5">
<h2 className="text-lg font-semibold">Kopfdaten</h2>
<p className="mt-1 mb-5 max-w-prose text-sm text-nebentext">
Name, Kategorie und Schlagworte hängen an der Vorlage, nicht an einer Version — sie lassen sich
jederzeit ändern, ohne etwas neu zu veröffentlichen.
</p>
<VorlageKopfdaten
vorlage={{
id: vorlage.id,
name: vorlage.name,
beschreibung: vorlage.beschreibung,
kategorie: vorlage.kategorie,
schlagworte: vorlage.schlagworte,
kundeId: vorlage.kundeId,
}}
kunden={kunden}
/>
</section>
<section className="mt-4 rounded-karte border border-linie bg-flaeche p-5">
<h2 className="text-lg font-semibold">Weiteres</h2>
<div className="mt-4 flex flex-col gap-3">
<VorlageDuplizieren vorlageId={vorlage.id} name={vorlage.name} />
<VorlageArchivieren vorlageId={vorlage.id} archiviert={vorlage.archiviert} />
{entwurf ? <EntwurfVerwerfen versionId={entwurf.id} /> : null}
</div>
</section>
</>
) : null}
</>
);
}
+230
View File
@@ -0,0 +1,230 @@
import Link from "next/link";
import { FileText, Pencil, Plus } from "lucide-react";
import { VorlageAnlegen } from "@/components/vorlage-anlegen";
import { VorlageImportieren } from "@/components/vorlage-importieren";
import { darf } from "@/server/auth/rollen";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import { kundenEbeneSichtbar } from "@/server/mandant";
import { kundenListe } from "@/server/queries/kunden";
import { vorlagenFilterwerte, vorlagenListe, type VorlagenZeile } from "@/server/queries/vorlagen";
/**
* Vorlagenliste (#010).
*
* Die Filter laufen als GET-Formular ueber die Adresse, nicht ueber React-State:
* ein gesetzter Filter ist dann verlinkbar, der Zurueck-Knopf tut das Erwartete,
* und die Seite bleibt eine Server-Komponente, die nur die sichtbaren Zeilen
* ueberhaupt laedt. Die Mandantensicht wenden die Lesemodelle selbst an (#009).
*/
const FELD =
"min-h-11 rounded-karte border border-linie bg-grund px-3 text-sm text-text outline-none focus-visible:border-akzent";
function Kennzeichen({ zeile }: { zeile: VorlagenZeile }) {
return (
<div className="flex flex-wrap items-center gap-2">
{zeile.veroeffentlichteVersion === null ? (
<span className="rounded-pille bg-flaeche-hoch px-2.5 py-1 text-xs text-nebentext">
noch nicht veröffentlicht
</span>
) : (
<span className="mono rounded-pille bg-flaeche-hoch px-2.5 py-1 text-xs text-nebentext">
v{zeile.veroeffentlichteVersion}
</span>
)}
{zeile.hatEntwurf ? (
<span className="rounded-pille bg-akzent-flaeche px-2.5 py-1 text-xs text-akzent">
Entwurf vorhanden
</span>
) : null}
{zeile.archiviert ? (
<span className="rounded-pille bg-flaeche-hoch px-2.5 py-1 text-xs text-nebentext">archiviert</span>
) : null}
{zeile.kundeName ? (
<span className="rounded-pille bg-flaeche-hoch px-2.5 py-1 text-xs text-nebentext">
nur {zeile.kundeName}
</span>
) : null}
</div>
);
}
function Zeile({ zeile, darfBearbeiten }: { zeile: VorlagenZeile; darfBearbeiten: boolean }) {
return (
<li className="rounded-karte border border-linie bg-flaeche">
<div className="flex flex-col gap-3 p-4 md:flex-row md:items-start md:justify-between">
<div className="min-w-0">
<Link href={`/vorlagen/${zeile.id}`} className="group flex items-start gap-2">
<FileText
className="mt-0.5 size-[18px] shrink-0 text-nebentext"
strokeWidth={1.75}
aria-hidden="true"
/>
<span
className={`font-medium group-hover:text-akzent ${
zeile.archiviert ? "text-nebentext line-through" : ""
}`}
>
{zeile.name}
</span>
</Link>
{zeile.beschreibung ? (
<p className="mt-1 max-w-prose text-sm text-nebentext">{zeile.beschreibung}</p>
) : null}
<div className="mt-2">
<Kennzeichen zeile={zeile} />
</div>
<p className="mt-2 text-xs text-nebentext">
{[
zeile.kategorie,
...zeile.schlagworte,
`${zeile.anzahlLaeufe} ${zeile.anzahlLaeufe === 1 ? "Lauf" : "Läufe"}`,
]
.filter(Boolean)
.join(" · ")}
</p>
</div>
{/*
* Der Entwurf wird direkt verlinkt, wenn es einen gibt. Erst ueber die
* Vorlagenseite zu gehen waere ein Klick mehr fuer den haeufigsten Fall:
* weiterarbeiten an dem, was man gestern angefangen hat.
*/}
{darfBearbeiten && zeile.entwurfVersionId ? (
<Link
href={`/vorlagen/version/${zeile.entwurfVersionId}`}
className="flex min-h-11 shrink-0 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"
>
<Pencil className="size-4" strokeWidth={1.75} aria-hidden="true" />
Entwurf öffnen
</Link>
) : null}
</div>
</li>
);
}
export default async function VorlagenSeite({
searchParams,
}: {
searchParams: Promise<Record<string, string | string[] | undefined>>;
}) {
const { benutzer } = await sitzungErforderlich("lesen", "/vorlagen");
const parameter = await searchParams;
const einzeln = (name: string): string =>
(Array.isArray(parameter[name]) ? parameter[name][0] : parameter[name])?.trim() ?? "";
const filter = {
suche: einzeln("suche"),
kategorie: einzeln("kategorie"),
schlagwort: einzeln("schlagwort"),
auchArchivierte: einzeln("archiviert") === "1",
};
const darfBearbeiten = darf(benutzer.rolle, "vorlagenBearbeiten");
const [zeilen, filterwerte, kundenEbene] = await Promise.all([
vorlagenListe(benutzer, filter),
vorlagenFilterwerte(benutzer),
kundenEbeneSichtbar(),
]);
// Die Kundenwahl im Anlegen-Formular gibt es nur im Modus Dienstleistung.
const kunden = kundenEbene ? await kundenListe(benutzer) : [];
return (
<>
<h1 className="text-2xl font-bold">Vorlagen</h1>
<p className="mt-2 max-w-prose text-nebentext">
Eine Vorlage wird veröffentlicht und ist danach unveränderlich. Ein Lauf hängt immer an der Version,
mit der er gestartet wurde — deshalb ändert eine neue Veröffentlichung nie einen laufenden oder
archivierten Lauf.
</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="Name oder Beschreibung"
className={FELD}
/>
</label>
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Kategorie</span>
<select name="kategorie" defaultValue={filter.kategorie} className={FELD}>
<option value="">alle</option>
{filterwerte.kategorien.map((kategorie) => (
<option key={kategorie} value={kategorie}>
{kategorie}
</option>
))}
</select>
</label>
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Schlagwort</span>
<select name="schlagwort" defaultValue={filter.schlagwort} className={FELD}>
<option value="">alle</option>
{filterwerte.schlagworte.map((wort) => (
<option key={wort} value={wort}>
{wort}
</option>
))}
</select>
</label>
<label className="flex min-h-11 items-center gap-2 text-sm text-nebentext">
<input type="checkbox" name="archiviert" value="1" defaultChecked={filter.auchArchivierte} />
auch archivierte
</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 || filter.kategorie || filter.schlagwort || filter.auchArchivierte ? (
<Link
href="/vorlagen"
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">
Keine Vorlage passt zu diesen Filtern.
</p>
) : (
<ul className="mt-6 flex flex-col gap-3">
{zeilen.map((zeile) => (
<Zeile key={zeile.id} zeile={zeile} darfBearbeiten={darfBearbeiten} />
))}
</ul>
)}
{darfBearbeiten ? (
<div className="mt-8 flex flex-col gap-4">
<section className="rounded-karte border border-linie bg-flaeche p-5">
<h2 className="flex items-center gap-2 text-lg font-semibold">
<Plus className="size-5 text-akzent" strokeWidth={2} aria-hidden="true" />
Vorlage anlegen
</h2>
<p className="mt-1 mb-5 max-w-prose text-sm text-nebentext">
Die neue Vorlage beginnt als Entwurf. Erst das Veröffentlichen macht sie für Läufe verfügbar.
</p>
<VorlageAnlegen kunden={kunden.map(({ id, name }) => ({ id, name }))} />
</section>
<details className="rounded-karte border border-linie bg-flaeche">
<summary className="flex min-h-11 cursor-pointer list-none items-center px-5 text-sm text-nebentext hover:text-text">
Vorlage aus einer Datei einspielen
</summary>
<div className="px-5 pb-5">
<VorlageImportieren />
</div>
</details>
</div>
) : null}
</>
);
}
@@ -0,0 +1,53 @@
import { notFound } from "next/navigation";
import { Editor } from "@/components/vorlagen/editor";
import { VersionStatus } from "@/generated/prisma/enums";
import { darf } from "@/server/auth/rollen";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import { versionMitAufbau } from "@/server/queries/vorlagen";
/**
* Eine Version einer Vorlage im Editor (#010).
*
* Die Seite prueft ihr Recht selbst — die Huelle darunter ist das zweite Netz,
* nicht das erste. `lesen` genuegt zum Ansehen; geaendert werden darf nur ein
* Entwurf und nur mit `vorlagenBearbeiten`. Beides entscheidet hier der Server,
* der `lesend`-Schalter im Editor ist nur die Anzeige davon: die Server Actions
* pruefen dasselbe noch einmal.
*
* `undefined` aus `versionMitAufbau` heisst „gibt es nicht oder darfst du nicht
* sehen“ — die Unterscheidung waere schon die Auskunft, die wir nicht geben.
*/
export default async function VersionSeite({ params }: { params: Promise<{ versionId: string }> }) {
const { versionId } = await params;
const { benutzer } = await sitzungErforderlich("lesen", `/vorlagen/version/${versionId}`);
const version = await versionMitAufbau(versionId, benutzer);
if (!version) notFound();
const darfBearbeiten = darf(benutzer.rolle, "vorlagenBearbeiten");
const veroeffentlicht = version.status === VersionStatus.VEROEFFENTLICHT;
return (
<Editor
version={{
id: version.id,
vorlageId: version.vorlageId,
vorlageName: version.vorlageName,
version: version.version,
veroeffentlicht,
struktur: version.struktur,
stand: version.stand.toISOString(),
aenderungsnotiz: version.aenderungsnotiz,
}}
lesend={veroeffentlicht || !darfBearbeiten}
darfBearbeiten={darfBearbeiten}
// Auf dem Server formatiert: im Browser waere es eine zweite Uhrzeit und
// damit ein Unterschied zwischen Vorrendern und Hydrieren.
beispielDatum={new Date().toLocaleDateString("de-DE", {
day: "2-digit",
month: "2-digit",
year: "numeric",
})}
/>
);
}
+53
View File
@@ -0,0 +1,53 @@
"use client";
import Link from "next/link";
import { useEffect } from "react";
/**
* Die Fehlergrenze der Anwendung – auf Deutsch und mit einem Weg zurueck.
*
* Ohne sie zeigt Next seine englische Standardseite („This page couldn’t load“),
* und der Benutzer steht ohne Hinweis da. Sie ist bewusst die **zweite**
* Verteidigungslinie: Aktionen fangen ihre Fehler selbst und geben eine Meldung
* zurueck, damit die Seite gar nicht erst ausgehaengt wird und Eingaben stehen
* bleiben. Was hier ankommt, ist Unvorhergesehenes.
*/
export default function Fehlergrenze({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
// Der Text des Fehlers gehoert nicht auf den Bildschirm – er kann Interna
// enthalten. In die Browserkonsole gehoert er, fuer die Fehlersuche.
console.error("Unbehandelter Fehler:", error);
}, [error]);
return (
<main className="flex min-h-dvh flex-col items-center justify-center gap-4 p-6 text-center">
<h1 className="text-2xl font-bold">Da ist etwas schiefgegangen</h1>
<p className="max-w-prose text-nebentext">
Der Vorgang ließ sich nicht zu Ende bringen. Ein zweiter Versuch hilft oft; bleibt es dabei, hilft der
Hinweis unten bei der Fehlersuche.
</p>
{error.digest ? <p className="mono text-xs text-nebentext">Kennung: {error.digest}</p> : null}
<div className="flex flex-wrap items-center justify-center gap-3">
<button
type="button"
onClick={reset}
className="flex min-h-11 items-center rounded-pille bg-akzent px-5 text-sm font-semibold text-auf-akzent md:min-h-10"
>
Noch einmal versuchen
</button>
<Link
href="/"
className="flex min-h-11 items-center rounded-pille border border-linie px-5 text-sm text-nebentext hover:bg-flaeche-hoch hover:text-text md:min-h-10"
>
Zur Übersicht
</Link>
</div>
</main>
);
}