feat(verzeichnis): LDAP-Abgleich, Zeitplan, CSV-Import und Log (#30)
This commit was merged in pull request #30.
This commit is contained in:
@@ -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}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user