Files
Laufzettel/src/app/(app)/admin/verzeichnis/page.tsx
T
florian.wilde ae290345b7
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s
feat(verzeichnis): LDAP-Abgleich, Zeitplan, CSV-Import und Log (#30)
2026-09-26 00:21:56 +00:00

209 lines
8.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
</>
);
}