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