feat(verzeichnis): LDAP-Abgleich, Zeitplan, CSV-Import und Log (#30)
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s

This commit was merged in pull request #30.
This commit is contained in:
2026-09-26 00:21:56 +00:00
parent ab5777a57e
commit ae290345b7
57 changed files with 6834 additions and 61 deletions
+11 -13
View File
@@ -1,6 +1,7 @@
import { Building, Plus } from "lucide-react";
import { AbteilungArchivieren } from "@/components/abteilung-archivieren";
import { AbteilungFormular } from "@/components/abteilung-formular";
import { AbteilungVorschlaege } from "@/components/abteilung-vorschlaege";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import { kundenEbeneSichtbar } from "@/server/mandant";
import { kundenListe } from "@/server/queries/kunden";
@@ -44,19 +45,16 @@ export default async function AbteilungenSeite() {
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>
{/* Der Knopf je Vorschlag ruft dieselbe Server Action wie das Formular
unten (#016) — eine zweite Regel zum Anlegen gibt es nicht. */}
<AbteilungVorschlaege
vorschlaege={offene.map((eintrag) => ({
...eintrag,
kundeName: kundenEbene
? (kunden.find((kunde) => kunde.id === eintrag.kundeId)?.name ?? null)
: null,
}))}
/>
</section>
) : null}
+17 -5
View File
@@ -1,6 +1,8 @@
import Link from "next/link";
import { headers } from "next/headers";
import { Betriebsmodus } from "@/generated/prisma/enums";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import { betriebsmodus } from "@/server/mandant";
/**
* Der Verwaltungsbereich. Die Rechtepruefung steht hier **und** in jeder Seite
@@ -11,21 +13,31 @@ const REITER = [
{ pfad: "/admin", beschriftung: "Einstellungen" },
{ pfad: "/admin/benutzer", beschriftung: "Benutzer" },
{ pfad: "/admin/abteilungen", beschriftung: "Abteilungen" },
/*
* Das Verzeichnis gibt es nur im Modus INTERN: dort hat das Haus **ein** AD,
* aus dem seine Leute kommen. Im Modus DIENSTLEISTUNG waere es ein LDAP zu
* fremden Verzeichnissen — genau das ist ausgeschlossen (#008). Der Reiter
* verschwindet deshalb mit dem Modus, und die Seite selbst leitet ebenfalls
* um: ein Reiter ist eine Bequemlichkeit, keine Sperre (#016).
*/
{ pfad: "/admin/verzeichnis", beschriftung: "Verzeichnis", nurIntern: true },
] as const;
export default async function AdminLayout({ children }: { children: React.ReactNode }) {
await sitzungErforderlich("verwaltung", "/admin");
const pfad = (await headers()).get("x-pfad") ?? "/admin";
const intern = (await betriebsmodus()) === Betriebsmodus.INTERN;
const reiter = REITER.filter((eintrag) => intern || !("nurIntern" in eintrag && eintrag.nurIntern));
return (
<>
<nav aria-label="Verwaltung" className="mb-8 flex flex-wrap gap-2">
{REITER.map((reiter) => {
const aktiv = pfad === reiter.pfad;
{reiter.map((eintrag) => {
const aktiv = pfad === eintrag.pfad;
return (
<Link
key={reiter.pfad}
href={reiter.pfad}
key={eintrag.pfad}
href={eintrag.pfad}
aria-current={aktiv ? "page" : undefined}
className={`flex min-h-11 items-center rounded-pille px-4 text-sm transition-colors md:min-h-9 ${
aktiv
@@ -33,7 +45,7 @@ export default async function AdminLayout({ children }: { children: React.ReactN
: "border border-linie text-nebentext hover:bg-flaeche-hoch hover:text-text"
}`}
>
{reiter.beschriftung}
{eintrag.beschriftung}
</Link>
);
})}
+208
View File
@@ -0,0 +1,208 @@
import { redirect } from "next/navigation";
import { AlertTriangle, CheckCircle2, Clock, Server } from "lucide-react";
import { Betriebsmodus } from "@/generated/prisma/enums";
import { JetztAbgleichen } from "@/components/verzeichnis/jetzt-abgleichen";
import { QuelleFormular } from "@/components/verzeichnis/quelle-formular";
import { VerbindungTesten } from "@/components/verzeichnis/verbindung-testen";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import { betriebsmodus } from "@/server/mandant";
import { abgleichLog, type AbgleichLogZeile, quellenListe } from "@/server/queries/verzeichnis";
import type { QuellenSicht } from "@/server/services/verzeichnis";
/**
* Admin → Verzeichnis (#016).
*
* Nur im Modus INTERN: dort hat das Haus **ein** Active Directory, aus dem seine
* Leute kommen. Im Modus DIENSTLEISTUNG gäbe es Verzeichnisse fremder Kunden,
* und die bindet diese Anwendung ausdrücklich nicht an (#008) — die Seite leitet
* dann um, nicht nur der Reiter verschwindet.
*
* Die Reihenfolge auf der Seite ist die Reihenfolge der Arbeit: erst sehen, was
* da ist und wie der letzte Abgleich lief, dann prüfen, dann anlegen.
*/
const ZEITPUNKT = new Intl.DateTimeFormat("de-DE", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
function Status({ quelle }: { quelle: QuellenSicht }) {
if (quelle.letzterAbgleichAm === null) {
return (
<p className="mt-2 text-sm text-nebentext">
Noch nie abgeglichen. „Verbindung testen“ zeigt, was ankäme, ohne etwas zu schreiben.
</p>
);
}
const gescheitert = quelle.letzterAbgleichStatus === "FEHLER";
return (
<div className="mt-2 flex flex-col gap-1">
<p className="flex flex-wrap items-center gap-2 text-sm">
{gescheitert ? (
<AlertTriangle className="size-4 shrink-0 text-fehler" strokeWidth={2} aria-hidden="true" />
) : (
<CheckCircle2 className="size-4 shrink-0 text-erledigt" strokeWidth={2} aria-hidden="true" />
)}
<span className={gescheitert ? "text-fehler" : "text-nebentext"}>
{gescheitert ? "Letzter Abgleich gescheitert" : "Zuletzt abgeglichen"} am{" "}
{ZEITPUNKT.format(quelle.letzterAbgleichAm)}
</span>
</p>
{quelle.letzterAbgleichFehler ? (
<p className="max-w-prose text-sm break-words text-fehler">{quelle.letzterAbgleichFehler}</p>
) : null}
</div>
);
}
/**
* Das Abgleich-Log: was die letzten Läufe getan haben.
*
* Keine Tabelle, sondern eine Liste mit umbrechenden Zeilen — bei 390 px hätte
* eine Tabelle mit sechs Zahlenspalten quer geschoben werden müssen.
*
* Gezeigt werden auch Nullen: „0 angelegt, 0 aktualisiert“ ist die Auskunft, dass
* der Abgleich lief und das Verzeichnis sich nicht geändert hat. Fehlte die Zeile,
* sähe ein erfolgreicher Lauf ohne Änderung aus wie ein Lauf, der nie stattfand.
*/
function Log({ zeilen }: { zeilen: readonly AbgleichLogZeile[] }) {
if (zeilen.length === 0) {
return (
<p className="mt-3 text-sm text-nebentext">
Noch kein Lauf. Hier steht danach, was jeder Abgleich getan hat.
</p>
);
}
return (
<ul className="mt-3 flex flex-col gap-3">
{zeilen.map((zeile) => (
<li key={zeile.id} className="border-t border-linie pt-3 first:border-t-0 first:pt-0">
<p className="flex flex-wrap items-center gap-2 text-sm">
{zeile.gescheitert ? (
<AlertTriangle className="size-4 shrink-0 text-fehler" strokeWidth={2} aria-hidden="true" />
) : (
<CheckCircle2 className="size-4 shrink-0 text-erledigt" strokeWidth={2} aria-hidden="true" />
)}
<span>{ZEITPUNKT.format(zeile.zeitpunkt)}</span>
<span className="text-nebentext">
{/* Ohne Menschen war es der Zeitplan – das ist der Regelfall und keine Lücke. */}
{zeile.durch === null ? "Zeitplan" : zeile.durch}
</span>
</p>
<p className="mt-1 flex flex-wrap gap-x-3 gap-y-1 text-xs text-nebentext">
<span>{zeile.gelesen} gelesen</span>
<span>{zeile.angelegt} angelegt</span>
{zeile.uebernommen > 0 ? <span>{zeile.uebernommen} übernommen</span> : null}
<span>{zeile.aktualisiert} aktualisiert</span>
<span>{zeile.deaktiviert} deaktiviert</span>
{zeile.uebersprungen > 0 ? (
<span className="text-faellig">{zeile.uebersprungen} übersprungen</span>
) : null}
</p>
{zeile.fehler ? (
<p className="mt-1 max-w-prose text-xs break-words text-fehler">{zeile.fehler}</p>
) : null}
</li>
))}
</ul>
);
}
export default async function VerzeichnisSeite() {
const { benutzer } = await sitzungErforderlich("verwaltung", "/admin/verzeichnis");
if ((await betriebsmodus()) !== Betriebsmodus.INTERN) {
redirect("/admin");
}
// Der angemeldete Benutzer, nicht ein erfundener: die Mandantensicht haengt
// daran, und ein hart gesetzter „ADMIN“ waere eine Hintertuer.
const handelnder = { id: benutzer.id, rolle: benutzer.rolle };
const quellen = await quellenListe(handelnder);
// Je Quelle ein Log. Die Abfragen laufen parallel; es sind so viele wie Quellen,
// und das sind im Modus INTERN eine oder zwei.
const logs = new Map(
await Promise.all(
quellen.map(async (quelle) => [quelle.id, await abgleichLog(quelle.id, handelnder)] as const),
),
);
return (
<>
<h1 className="text-2xl font-bold">Verzeichnis</h1>
<p className="mt-2 max-w-prose text-nebentext">
Die Personen der eigenen Firma kommen aus dem Active Directory, damit beim Start eines Laufs nichts
abgetippt werden muss. Wer dort verschwindet oder deaktiviert wird, wird hier <strong>inaktiv</strong>{" "}
— gelöscht wird nie, sonst verlöre die Historie ihre Namen.
</p>
{quellen.length === 0 ? (
<p className="mt-6 rounded-karte border border-linie bg-flaeche p-5 text-sm text-nebentext">
Es ist keine Quelle eingerichtet. Personen lassen sich auch von Hand anlegen oder aus einer
CSV-Datei einlesen — ein Verzeichnis nimmt nur die Arbeit ab.
</p>
) : (
<ul className="mt-6 flex flex-col gap-4">
{quellen.map((quelle) => (
<li key={quelle.id} className="rounded-karte border border-linie bg-flaeche p-5">
<div className="flex flex-wrap items-center gap-2">
<Server className="size-5 shrink-0 text-akzent" strokeWidth={2} aria-hidden="true" />
<h2 className="text-lg font-semibold break-words">{quelle.name}</h2>
{quelle.aktiv ? (
<span className="flex items-center gap-1 rounded-pille bg-akzent-flaeche px-2.5 py-1 text-xs text-akzent">
<Clock className="size-3" strokeWidth={2} aria-hidden="true" />
alle {quelle.abgleichIntervallMinuten} min
</span>
) : (
<span className="rounded-pille bg-flaeche-hoch px-2.5 py-1 text-xs text-nebentext">
kein Zeitplan
</span>
)}
</div>
<p className="mono mt-2 text-xs break-all text-nebentext">
{quelle.url} · {quelle.basisDn}
</p>
<Status quelle={quelle} />
<div className="mt-4 flex flex-col gap-4">
<VerbindungTesten quelleId={quelle.id} />
<JetztAbgleichen quelleId={quelle.id} />
</div>
<details className="mt-4 border-t border-linie pt-4">
<summary className="flex min-h-11 cursor-pointer list-none items-center text-sm text-nebentext hover:text-text md:min-h-10">
Abgleich-Log
</summary>
<Log zeilen={logs.get(quelle.id) ?? []} />
</details>
<details className="mt-4 border-t border-linie pt-4">
<summary className="flex min-h-11 cursor-pointer list-none items-center text-sm text-nebentext hover:text-text md:min-h-10">
Einstellungen bearbeiten
</summary>
<div className="mt-3">
<QuelleFormular quelle={quelle} />
</div>
</details>
</li>
))}
</ul>
)}
<section className="mt-8 rounded-karte border border-linie bg-flaeche p-5">
<h2 className="text-lg font-semibold">Quelle anlegen</h2>
<p className="mt-1 mb-5 max-w-prose text-sm text-nebentext">
Ein Dienstkonto mit Leserechten genügt. Laufzettel schreibt nie ins Verzeichnis und speichert nur
die zugeordneten Felder — keine Passwörter, keine Gruppenmitgliedschaften.
</p>
<QuelleFormular />
</section>
</>
);
}
+24 -1
View File
@@ -1,6 +1,7 @@
import Link from "next/link";
import { Plus, User } from "lucide-react";
import { Plus, Upload, User } from "lucide-react";
import { MitarbeiterAnlegen } from "@/components/mitarbeiter-anlegen";
import { MitarbeiterImportieren } from "@/components/mitarbeiter-importieren";
import { MitarbeiterSuche } from "@/components/mitarbeiter-suche";
import { StammdatenReiter } from "@/components/stammdaten-reiter";
import { Herkunft } from "@/generated/prisma/enums";
@@ -9,6 +10,7 @@ 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";
import { MITARBEITER_IMPORT_HOECHSTZEILEN, MITARBEITER_IMPORT_SPALTEN } from "@/server/services/mitarbeiter";
/**
* Mitarbeiterliste (#011).
@@ -138,6 +140,27 @@ export default async function MitarbeiterSeite({
</div>
</details>
) : null}
{darfBearbeiten ? (
/*
* Der CSV-Import steht neben dem Formular und verlangt dasselbe Recht
* (#016): wer eine Person eintragen darf, darf auch zwanzig auf einmal
* eintragen. Zugeklappt, weil die tägliche Arbeit die Liste ist.
*/
<details className="mt-4 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">
<Upload className="size-4 text-akzent" strokeWidth={2} aria-hidden="true" />
Personen aus einer CSV-Datei einlesen
</summary>
<div className="px-5 pb-5">
<MitarbeiterImportieren
hoechstZeilen={MITARBEITER_IMPORT_HOECHSTZEILEN}
spalten={Object.values(MITARBEITER_IMPORT_SPALTEN).map((namen) => namen[0])}
kunden={kunden.map(({ id, name }) => ({ id, name }))}
/>
</div>
</details>
) : null}
</>
);
}