Files
Laufzettel/src/app/(app)/vorlagen/page.tsx
T
Florian WildeandClaude Opus 5 86d55917f9
Prüfung / pruefung (pull_request) Canceled after 0s
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (pull_request) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s
feat(stammdaten): Mitarbeiter, Abteilungen und Geraete
Die Stammdaten, an denen die Historie haengt. Personen werden deaktiviert,
Geraete und Abteilungen archiviert — geloescht wird nichts, woran ein Lauf
haengen kann.

- Mitarbeiterliste mit Suche ueber Anzeigename, Mail, UPN und sAMAccountName,
  Filter nach Abteilung, standardmaessig nur aktive; die Suche ist ein eigenes
  Bauteil, weil #013 und #014 sie wieder brauchen
- Personenseite mit Stammdaten, Geraeten, Vorgesetztem; was aus dem Verzeichnis
  kommt, ist gesperrt — der naechste Abgleich wuerde es ohnehin zurueckdrehen
- Abteilungen unter Admin: Ziel-OU, Standardgruppen, Mail-Domain, dazu die
  Namen, die bei Personen vorkommen, aber nicht gepflegt sind
- Geraete mit Suche ueber Hostname, Bezeichnung, Seriennummer und Tag-Code;
  CSV-Import mit einem Ergebnis je Zeile
- Neues Recht mitarbeiterBearbeiten; die Seitenleiste fuehrt Personen und
  Geraete unter einem Eintrag, im Modus Dienstleistung anders beschriftet

Der unabhaengige Review fand zwei Blocker. Der schwerere meldete Erfolg: ein
nicht geschlossenes Anfuehrungszeichen verschluckte im CSV-Import alle
Folgezeilen bis zum naechsten `"`, und der Import sagte „3 von 3 Zeilen
angelegt". Ein Wert reicht jetzt nicht mehr ueber die Zeile hinaus — fuer
Hostnamen und Seriennummern ist ein Umbruch praktisch immer ein vergessenes
Anfuehrungszeichen, und so endet der Schaden an seiner Zeile. Der zweite:
ungepflegte Abteilungen wurden ueber alle Kunden hinweg gruppiert.

Dazu sieben weitere Befunde, alle behoben — darunter eine Besitzerauswahl, die
Personen fremder Kunden anbot, und ein Techniker, der ein Geraet endgueltig
loeschen konnte.

610 Tests gruen, 18 E2E-Ablaeufe gegen den gebauten Stand.

Schliesst #011

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-25 12:51:54 +02:00

229 lines
8.9 KiB
TypeScript

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";
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 (
<>
<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}
</>
);
}