238 lines
9.4 KiB
TypeScript
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}
|
|
</>
|
|
);
|
|
}
|