feat(stammdaten): Mitarbeiter, Abteilungen und Geraete (#25)
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s

This commit was merged in pull request #25.
This commit is contained in:
2026-09-25 10:52:44 +00:00
parent e3d80e5982
commit f85bc87324
81 changed files with 6784 additions and 59 deletions
+126
View File
@@ -0,0 +1,126 @@
import { Building, Plus } from "lucide-react";
import { AbteilungArchivieren } from "@/components/abteilung-archivieren";
import { AbteilungFormular } from "@/components/abteilung-formular";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import { kundenEbeneSichtbar } from "@/server/mandant";
import { kundenListe } from "@/server/queries/kunden";
import { abteilungsListe, ungepflegteAbteilungen } from "@/server/queries/abteilungen";
/**
* Abteilungen (#011).
*
* An einer Abteilung haengen Ziel-OU und Standardgruppen — daraus entstehen in
* #012 die Befehle, die ein Konto anlegen. Deshalb verlangt diese Seite
* `verwaltung` und nicht bloss Lesen: wer sie sieht, sieht die Struktur des
* Verzeichnisses.
*/
export default async function AbteilungenSeite() {
// Jede Seite prueft selbst – das Admin-Layout darueber ist das zweite Netz,
// nicht das erste (siehe CLAUDE.md).
const { benutzer } = await sitzungErforderlich("verwaltung", "/admin/abteilungen");
const [zeilen, offene, kundenEbene] = await Promise.all([
abteilungsListe(benutzer),
ungepflegteAbteilungen(benutzer),
kundenEbeneSichtbar(),
]);
const kunden = kundenEbene ? await kundenListe(benutzer) : [];
return (
<>
<h1 className="text-2xl font-bold">Abteilungen</h1>
<p className="mt-2 max-w-prose text-nebentext">
Ziel-OU und Standardgruppen einer Abteilung bestimmen, wo ein neues Konto landet und in welchen
Gruppen es liegt. Die Zuordnung läuft über den Abteilungsnamen der Person — ein Wert aus dem
Verzeichnis bleibt deshalb auch dann erhalten, wenn es dazu noch keine gepflegte Abteilung gibt.
</p>
{offene.length > 0 ? (
<section className="mt-6 rounded-karte border border-linie bg-flaeche p-5">
<h2 className="text-lg font-semibold text-faellig">Noch nicht gepflegt</h2>
<p className="mt-1 max-w-prose text-sm text-nebentext">
Diese Abteilungsnamen kommen bei Personen vor, es gibt aber keine Abteilung dazu. Ohne Ziel-OU und
Standardgruppen bleiben die Platzhalter <code className="mono">{"{{ou}}"}</code> und{" "}
<code className="mono">{"{{gruppen}}"}</code> im Lauf leer.
</p>
<ul className="mt-3 flex flex-wrap gap-2">
{offene.map((eintrag) => (
<li
key={eintrag.name}
className="rounded-pille bg-flaeche-hoch px-3 py-1.5 text-sm text-nebentext"
>
{eintrag.name}{" "}
<span className="text-xs">
({eintrag.anzahl} {eintrag.anzahl === 1 ? "Person" : "Personen"})
</span>
</li>
))}
</ul>
</section>
) : null}
<ul className="mt-6 flex flex-col gap-3">
{zeilen.map((zeile) => (
<li key={zeile.id} 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">
<div className="flex flex-wrap items-center gap-2">
<Building
className="size-[18px] shrink-0 text-nebentext"
strokeWidth={1.75}
aria-hidden="true"
/>
<span className={zeile.archiviert ? "text-nebentext line-through" : "font-medium"}>
{zeile.name}
</span>
<span className="text-xs text-nebentext">
{zeile.anzahlMitarbeiter} {zeile.anzahlMitarbeiter === 1 ? "Person" : "Personen"}
</span>
</div>
{zeile.zielOu ? <p className="mono mt-1 text-xs text-nebentext">{zeile.zielOu}</p> : null}
{zeile.standardGruppen.length > 0 ? (
<p className="mono mt-1 text-xs text-nebentext">{zeile.standardGruppen.join(", ")}</p>
) : null}
{zeile.mailDomain ? <p className="mt-1 text-xs text-nebentext">@{zeile.mailDomain}</p> : null}
{zeile.notizen ? (
<p className="mt-2 max-w-prose text-sm text-nebentext">{zeile.notizen}</p>
) : null}
</div>
<AbteilungArchivieren abteilungId={zeile.id} archiviert={zeile.archiviert} />
</div>
<details className="group">
<summary className="flex min-h-11 cursor-pointer list-none items-center px-4 text-sm text-nebentext hover:text-text">
Bearbeiten
</summary>
<div className="px-4 pb-4">
<AbteilungFormular
werte={{
id: zeile.id,
name: zeile.name,
zielOu: zeile.zielOu,
standardGruppen: zeile.standardGruppen,
mailDomain: zeile.mailDomain,
notizen: zeile.notizen,
}}
/>
</div>
</details>
</li>
))}
</ul>
<section className="mt-8 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" />
Abteilung anlegen
</h2>
<p className="mt-1 mb-5 max-w-prose text-sm text-nebentext">
Der Name muss genauso lauten wie im Verzeichnis, sonst findet die Zuordnung ihn nicht.
</p>
<AbteilungFormular kunden={kunden.map(({ id, name }) => ({ id, name }))} />
</section>
</>
);
}
+1
View File
@@ -10,6 +10,7 @@ import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
const REITER = [
{ pfad: "/admin", beschriftung: "Einstellungen" },
{ pfad: "/admin/benutzer", beschriftung: "Benutzer" },
{ pfad: "/admin/abteilungen", beschriftung: "Abteilungen" },
] as const;
export default async function AdminLayout({ children }: { children: React.ReactNode }) {
+282
View File
@@ -0,0 +1,282 @@
import Link from "next/link";
import { Plus, QrCode } from "lucide-react";
import { GeraetAktionen } from "@/components/geraet-aktionen";
import { GeraetAnlegen } from "@/components/geraet-anlegen";
import { GeraetBearbeiten } from "@/components/geraet-bearbeiten";
import { FELD, KAESTCHEN } from "@/lib/formular";
import { GERAETE_TYP_LISTE, GERAETE_TYP_TEXT } from "@/lib/geraete-text";
import { GeraetImportieren } from "@/components/geraet-importieren";
import { StammdatenReiter } from "@/components/stammdaten-reiter";
import { darf } from "@/server/auth/rollen";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import { kundenEbeneSichtbar } from "@/server/mandant";
import { kundenListe } from "@/server/queries/kunden";
import {
besitzerKandidaten,
geraeteListe,
type BesitzerAuswahl,
type GeraeteZeile,
} from "@/server/queries/geraete";
import { GERAETE_IMPORT_HOECHSTZEILEN, GERAETE_IMPORT_SPALTEN } from "@/server/services/geraete";
/**
* Geraeteliste (#011).
*
* 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 wendet das Lesemodell selbst an.
*
* Karten statt Tabelle: ein Geraet hat sieben Angaben, von denen am Handy keine
* wegfallen darf — eine Tabelle waere dort nur seitwaerts lesbar, und
* horizontales Scrollen ist ausgeschlossen.
*/
const PILLE = "rounded-pille bg-flaeche-hoch px-2.5 py-1 text-xs text-nebentext";
function Kennzeichen({ zeile, kundenEbene }: { zeile: GeraeteZeile; kundenEbene: boolean }) {
return (
<div className="flex flex-wrap items-center gap-2">
<span className={PILLE}>{GERAETE_TYP_TEXT[zeile.typ]}</span>
{/* Im Modus Intern gibt es genau eine Organisation, und die erscheint
nirgends (#008). Im Modus Dienstleistung ist der Kunde dagegen die
wichtigste Angabe der Zeile. */}
{kundenEbene ? <span className={PILLE}>{zeile.kundeName}</span> : null}
{zeile.registriertAm === null ? <span className={PILLE}>Tag ohne Zuordnung</span> : null}
{zeile.archiviert ? <span className={PILLE}>archiviert</span> : null}
{zeile.anzahlLaeufe > 0 ? (
<span className={PILLE}>
{zeile.anzahlLaeufe} {zeile.anzahlLaeufe === 1 ? "Lauf" : "Läufe"}
</span>
) : null}
</div>
);
}
function Karte({
zeile,
darfBearbeiten,
darfLoeschen,
besitzer,
kundenEbene,
}: {
zeile: GeraeteZeile;
darfBearbeiten: boolean;
darfLoeschen: boolean;
besitzer: BesitzerAuswahl;
kundenEbene: boolean;
}) {
const angaben = [
zeile.besitzerName
? `${zeile.besitzerName}${zeile.besitzerAktiv === false ? " (deaktiviert)" : ""}`
: null,
zeile.modell,
zeile.seriennummer ? `S/N ${zeile.seriennummer}` : null,
zeile.inventarnummer ? `Inv. ${zeile.inventarnummer}` : null,
].filter((angabe): angabe is string => Boolean(angabe));
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">
<div className="flex flex-wrap items-center gap-2">
<span className="mono flex items-center gap-1.5 rounded-pille bg-flaeche-hoch px-2.5 py-1 text-xs text-nebentext">
<QrCode className="size-3.5" strokeWidth={1.75} aria-hidden="true" />
{zeile.tagCode}
</span>
<p className={zeile.archiviert ? "text-nebentext line-through" : "font-medium"}>
{zeile.anzeige}
</p>
</div>
<div className="mt-2">
<Kennzeichen zeile={zeile} kundenEbene={kundenEbene} />
</div>
{angaben.length > 0 ? (
<p className="mt-2 text-xs break-words text-nebentext">{angaben.join(" · ")}</p>
) : null}
</div>
{darfBearbeiten ? (
<GeraetAktionen
geraetId={zeile.id}
archiviert={zeile.archiviert}
anzahlLaeufe={zeile.anzahlLaeufe}
anzeige={zeile.anzeige}
darfLoeschen={darfLoeschen}
/>
) : null}
</div>
{darfBearbeiten ? (
<details>
<summary className="flex min-h-11 cursor-pointer list-none items-center border-t border-linie px-4 text-sm text-nebentext hover:text-text">
Stammdaten bearbeiten
</summary>
<GeraetBearbeiten
geraet={{
id: zeile.id,
tagCode: zeile.tagCode,
hostname: zeile.hostname,
bezeichnung: zeile.bezeichnung,
typ: zeile.typ,
modell: zeile.modell,
seriennummer: zeile.seriennummer,
inventarnummer: zeile.inventarnummer,
notizen: zeile.notizen,
besitzerId: zeile.besitzerId,
besitzerName: zeile.besitzerName,
...(typeof zeile.besitzerAktiv === "boolean" ? { besitzerAktiv: zeile.besitzerAktiv } : {}),
}}
/*
* Nur Personen **dieses** Kunden. Die Liste wird einmal fuer die
* ganze Seite geholt; boete die Karte sie unveraendert an, koennte
* jemand im Modus Dienstleistung eine Person eines anderen Kunden
* waehlen — der Dienst weist das ab, aber erst nach dem Klick.
*/
besitzer={{
kandidaten: besitzer.kandidaten.filter((kandidat) => kandidat.kundeId === zeile.kundeId),
gekuerzt: besitzer.gekuerzt,
}}
/>
</details>
) : null}
</li>
);
}
export default async function GeraeteSeite({
searchParams,
}: {
searchParams: Promise<Record<string, string | string[] | undefined>>;
}) {
const { benutzer } = await sitzungErforderlich("lesen", "/geraete");
const parameter = await searchParams;
const einzeln = (name: string): string =>
(Array.isArray(parameter[name]) ? parameter[name][0] : parameter[name])?.trim() ?? "";
const typWunsch = einzeln("typ");
// Ein unbekannter Typ in der Adresse filtert nicht, sondern wird verworfen –
// eine Fehlerseite fuer einen vertippten Parameter waere die falsche Antwort.
const typ = GERAETE_TYP_LISTE.find((wert) => wert === typWunsch);
const filter = {
suche: einzeln("suche"),
...(typ ? { typ } : {}),
auchArchivierte: einzeln("archiviert") === "1",
};
const darfBearbeiten = darf(benutzer.rolle, "geraeteBearbeiten");
// Loeschen ist nicht ruecknehmbar und verbrennt den Tag-Code fuer immer –
// deshalb `verwaltung` und nicht `geraeteBearbeiten` (#011, nach dem Review).
const darfLoeschen = darf(benutzer.rolle, "verwaltung");
const [zeilen, kundenEbene] = await Promise.all([geraeteListe(benutzer, filter), kundenEbeneSichtbar()]);
// Besitzerauswahl und Kundenwahl braucht nur, wer auch pflegen darf.
const besitzer = darfBearbeiten ? await besitzerKandidaten(benutzer) : { kandidaten: [], gekuerzt: false };
const kunden = darfBearbeiten && kundenEbene ? await kundenListe(benutzer) : [];
const kundenWahl = kunden.filter((kunde) => !kunde.archiviert).map(({ id, name }) => ({ id, name }));
return (
<>
<StammdatenReiter aktiv="/geraete" />
<h1 className="text-2xl font-bold">Geräte</h1>
<p className="mt-2 max-w-prose text-nebentext">
Nicht nur PCs: ein Serverschrank, ein Raum oder eine Firewall bekommen genauso einen Aufkleber. Der
Tag-Code eines Geräts wird nie geändert und nie neu vergeben — auch nicht, nachdem das Gerät
archiviert oder gelöscht wurde.
</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="Hostname, Bezeichnung, Seriennummer, Tag-Code"
className={FELD}
/>
</label>
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Typ</span>
<select name="typ" defaultValue={typ ?? ""} className={FELD}>
<option value="">alle</option>
{GERAETE_TYP_LISTE.map((wert) => (
<option key={wert} value={wert}>
{GERAETE_TYP_TEXT[wert]}
</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}
className={KAESTCHEN}
/>
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 || typ || filter.auchArchivierte ? (
<Link
href="/geraete"
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 Gerät passt zu diesen Filtern.
</p>
) : (
<ul className="mt-6 flex flex-col gap-3">
{zeilen.map((zeile) => (
<Karte
key={zeile.id}
zeile={zeile}
darfBearbeiten={darfBearbeiten}
darfLoeschen={darfLoeschen}
besitzer={besitzer}
kundenEbene={kundenEbene}
/>
))}
</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" />
Gerät anlegen
</h2>
<p className="mt-1 mb-5 max-w-prose text-sm text-nebentext">
Der Tag-Code entsteht beim Anlegen und steht danach in der Meldung — er kommt auf den Aufkleber.
</p>
<GeraetAnlegen besitzer={besitzer} kunden={kundenWahl} />
</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">
Geräte aus einer CSV-Datei einlesen
</summary>
<div className="px-5 pb-5">
<GeraetImportieren
hoechstZeilen={GERAETE_IMPORT_HOECHSTZEILEN}
spalten={Object.values(GERAETE_IMPORT_SPALTEN).map((namen) => namen[0])}
kunden={kundenWahl}
/>
</div>
</details>
</div>
) : null}
</>
);
}
+123
View File
@@ -0,0 +1,123 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { ArrowLeft, Laptop } from "lucide-react";
import { MitarbeiterAktiv } from "@/components/mitarbeiter-aktiv";
import { MitarbeiterStammdaten } from "@/components/mitarbeiter-stammdaten";
import { Herkunft } from "@/generated/prisma/enums";
import { darf } from "@/server/auth/rollen";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import { kundenEbeneSichtbar } from "@/server/mandant";
import { geraeteDesMitarbeiters, mitarbeiterHolen, vorgesetzterVon } from "@/server/queries/mitarbeiter";
/**
* Die Seite einer Person (#011).
*
* Sie beantwortet drei Fragen: wer ist das, welche Geraete gehoeren ihr, und was
* ist mit ihr passiert. Die Laufhistorie kommt mit #013 dazu — bis dahin steht
* hier, dass sie kommt, statt eine leere Ueberschrift zu zeigen.
*/
export default async function MitarbeiterSeite({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const { benutzer } = await sitzungErforderlich("lesen", `/mitarbeiter/${id}`);
const person = await mitarbeiterHolen(id, benutzer);
// Nicht vorhanden und nicht sichtbar sind dieselbe Antwort – die
// Unterscheidung waere schon eine Auskunft ueber fremde Kunden (#009).
if (!person) notFound();
const darfBearbeiten = darf(benutzer.rolle, "mitarbeiterBearbeiten");
const [geraete, kundenEbene, vorgesetzter] = await Promise.all([
geraeteDesMitarbeiters(person.id, benutzer),
kundenEbeneSichtbar(),
vorgesetzterVon(person.id, benutzer),
]);
return (
<>
<Link
href="/mitarbeiter"
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 Personen
</Link>
<h1 className="mt-2 text-2xl font-bold">{person.anzeigename}</h1>
<div className="mt-2 flex flex-wrap items-center gap-2 text-sm text-nebentext">
{person.titel ? <span>{person.titel}</span> : null}
{person.abteilung ? <span>· {person.abteilung}</span> : null}
{kundenEbene ? <span>· {person.kundeName}</span> : null}
{person.aktiv ? null : <span className="text-faellig">· deaktiviert</span>}
{person.herkunft === Herkunft.ABGLEICH ? <span>· aus dem Verzeichnis</span> : null}
</div>
{vorgesetzter ? (
<p className="mt-1 text-sm text-nebentext">
Vorgesetzter:{" "}
<Link href={`/mitarbeiter/${vorgesetzter.id}`} className="text-akzent underline underline-offset-2">
{vorgesetzter.anzeigename}
</Link>
</p>
) : null}
{darfBearbeiten ? (
<div className="mt-6">
<MitarbeiterAktiv mitarbeiterId={person.id} aktiv={person.aktiv} />
</div>
) : null}
<section className="mt-8 rounded-karte border border-linie bg-flaeche p-5">
<h2 className="flex items-center gap-2 text-lg font-semibold">
<Laptop className="size-5 text-nebentext" strokeWidth={1.75} aria-hidden="true" />
Geräte
</h2>
{geraete.length === 0 ? (
<p className="mt-2 text-sm text-nebentext">Dieser Person ist kein Gerät zugeordnet.</p>
) : (
<ul className="mt-4 flex flex-col gap-2">
{geraete.map((geraet) => (
<li
key={geraet.id}
className="flex flex-wrap items-center gap-2 border-t border-linie py-3 first:border-t-0"
>
<span className="mono rounded-pille bg-flaeche-hoch px-2.5 py-1 text-xs text-nebentext">
{geraet.tagCode}
</span>
<span className={geraet.archiviert ? "text-nebentext line-through" : ""}>{geraet.name}</span>
<span className="text-xs text-nebentext">{geraet.typ}</span>
</li>
))}
</ul>
)}
</section>
<section className="mt-4 rounded-karte border border-linie bg-flaeche p-5">
<h2 className="text-lg font-semibold">Stammdaten</h2>
<div className="mt-4">
<MitarbeiterStammdaten
person={{
id: person.id,
vorname: person.vorname,
nachname: person.nachname,
anzeigename: person.anzeigename,
mail: person.mail,
upn: person.upn,
sam: person.sam,
abteilung: person.abteilung,
titel: person.titel,
ausVerzeichnis: person.herkunft === Herkunft.ABGLEICH,
}}
darfBearbeiten={darfBearbeiten}
/>
</div>
</section>
<section className="mt-4 rounded-karte border border-linie bg-flaeche p-5">
<h2 className="text-lg font-semibold">Läufe</h2>
<p className="mt-2 max-w-prose text-sm text-nebentext">
Onboarding, Übergaben und Offboarding erscheinen hier chronologisch, sobald es Läufe gibt — sie
kommen mit dem nächsten Meilenstein.
</p>
</section>
</>
);
}
+143
View File
@@ -0,0 +1,143 @@
import Link from "next/link";
import { Plus, User } from "lucide-react";
import { MitarbeiterAnlegen } from "@/components/mitarbeiter-anlegen";
import { MitarbeiterSuche } from "@/components/mitarbeiter-suche";
import { StammdatenReiter } from "@/components/stammdaten-reiter";
import { Herkunft } from "@/generated/prisma/enums";
import { darf } from "@/server/auth/rollen";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import { kundenEbeneSichtbar } from "@/server/mandant";
import { kundenListe } from "@/server/queries/kunden";
import { abteilungsNamen, mitarbeiterListe, type MitarbeiterZeile } from "@/server/queries/mitarbeiter";
/**
* Mitarbeiterliste (#011).
*
* Gesucht wird ueber Anzeigename, Mail, UPN und `sam` — im Ticket steht mal das
* eine, mal das andere. Standardmaessig erscheinen nur aktive Personen; fuers
* Offboarding gibt es den Umschalter.
*
* Die Filter laufen als GET-Formular ueber die Adresse: ein gesetzter Filter ist
* dann verlinkbar, der Zurueck-Knopf tut das Erwartete, und die Seite bleibt
* eine Server-Komponente, die nur die sichtbaren Zeilen laedt.
*/
function Zeile({ zeile, kundenEbene }: { zeile: MitarbeiterZeile; kundenEbene: boolean }) {
const kennungen = [zeile.mail, zeile.upn === zeile.mail ? null : zeile.upn, zeile.sam]
.filter(Boolean)
.join(" · ");
return (
<li className="rounded-karte border border-linie bg-flaeche">
<Link
href={`/mitarbeiter/${zeile.id}`}
className="flex flex-col gap-2 p-4 md:flex-row md:items-center md:justify-between"
>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<User className="size-[18px] shrink-0 text-nebentext" strokeWidth={1.75} aria-hidden="true" />
<span className={`font-medium ${zeile.aktiv ? "" : "text-nebentext line-through"}`}>
{zeile.anzeigename}
</span>
{zeile.aktiv ? null : (
<span className="rounded-pille bg-flaeche-hoch px-2.5 py-1 text-xs text-nebentext">
deaktiviert
</span>
)}
{zeile.herkunft === Herkunft.ABGLEICH ? (
<span className="rounded-pille bg-flaeche-hoch px-2.5 py-1 text-xs text-nebentext">
aus dem Verzeichnis
</span>
) : null}
{kundenEbene ? (
<span className="rounded-pille bg-flaeche-hoch px-2.5 py-1 text-xs text-nebentext">
{zeile.kundeName}
</span>
) : null}
</div>
{kennungen ? <p className="mono mt-1 truncate text-xs text-nebentext">{kennungen}</p> : null}
<p className="mt-1 text-xs text-nebentext">
{[
zeile.abteilung,
zeile.titel,
`${zeile.anzahlGeraete} ${zeile.anzahlGeraete === 1 ? "Gerät" : "Geräte"}`,
`${zeile.anzahlLaeufe} ${zeile.anzahlLaeufe === 1 ? "Lauf" : "Läufe"}`,
]
.filter(Boolean)
.join(" · ")}
</p>
</div>
</Link>
</li>
);
}
export default async function MitarbeiterSeite({
searchParams,
}: {
searchParams: Promise<Record<string, string | string[] | undefined>>;
}) {
const { benutzer } = await sitzungErforderlich("lesen", "/mitarbeiter");
const parameter = await searchParams;
const einzeln = (name: string): string =>
(Array.isArray(parameter[name]) ? parameter[name][0] : parameter[name])?.trim() ?? "";
const filter = {
suche: einzeln("suche"),
abteilung: einzeln("abteilung"),
auchDeaktivierte: einzeln("deaktivierte") === "1",
};
const darfBearbeiten = darf(benutzer.rolle, "mitarbeiterBearbeiten");
const [zeilen, abteilungen, kundenEbene] = await Promise.all([
mitarbeiterListe(benutzer, filter),
abteilungsNamen(benutzer),
kundenEbeneSichtbar(),
]);
const kunden = kundenEbene && darfBearbeiten ? await kundenListe(benutzer) : [];
return (
<>
<StammdatenReiter aktiv="/mitarbeiter" />
<h1 className="text-2xl font-bold">{kundenEbene ? "Personen" : "Mitarbeiter"}</h1>
<p className="mt-2 max-w-prose text-nebentext">
Personen werden nie gelöscht, nur deaktiviert — ein archivierter Lauf muss auch in zehn Jahren noch
sagen können, für wen er lief.
</p>
<div className="mt-6">
<MitarbeiterSuche werte={filter} abteilungen={abteilungen} ziel="/mitarbeiter" />
</div>
{zeilen.length === 0 ? (
<p className="mt-8 rounded-karte border border-linie bg-flaeche p-5 text-sm text-nebentext">
Keine Person passt zu diesen Filtern.
</p>
) : (
<ul className="mt-6 flex flex-col gap-3">
{zeilen.map((zeile) => (
<Zeile key={zeile.id} zeile={zeile} kundenEbene={kundenEbene} />
))}
</ul>
)}
{darfBearbeiten ? (
<details className="mt-8 rounded-karte border border-linie bg-flaeche">
<summary className="flex min-h-11 cursor-pointer list-none items-center gap-2 px-5 text-sm text-nebentext hover:text-text">
<Plus className="size-4 text-akzent" strokeWidth={2} aria-hidden="true" />
Person von Hand anlegen
</summary>
<div className="px-5 pb-5">
<p className="mb-5 max-w-prose text-sm text-nebentext">
Für das Onboarding, bevor der nächste Verzeichnisabgleich gelaufen ist. Eine so angelegte Person
bleibt vollständig änderbar.
</p>
<MitarbeiterAnlegen
kunden={kunden.map(({ id, name }) => ({ id, name }))}
abteilungen={abteilungen}
/>
</div>
</details>
) : null}
</>
);
}
+1 -3
View File
@@ -7,6 +7,7 @@ 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).
@@ -17,9 +18,6 @@ import { vorlagenFilterwerte, vorlagenListe, type VorlagenZeile } from "@/server
* 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">