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 }) {