Files
Laufzettel/src/app/(app)/vorlagen/page.tsx
T
florian.wilde 80870133b8
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s
feat(bausteine): Abschnitte, die mehrere Vorlagen teilen (#57)
2026-10-04 17:07:12 +00:00

238 lines
9.4 KiB
TypeScript

import Link from "next/link";
import { Blocks, 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";
import { FELD } from "@/lib/formular";
/**
* 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).
*/
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 (
<>
<div className="flex flex-wrap items-baseline justify-between gap-3">
<h1 className="text-2xl font-bold">Vorlagen</h1>
<Link
href="/vorlagen/bausteine"
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"
>
<Blocks className="size-4" strokeWidth={1.75} aria-hidden="true" />
Bausteine
</Link>
</div>
<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}
</>
);
}