209 lines
8.8 KiB
TypeScript
209 lines
8.8 KiB
TypeScript
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>
|
||
</>
|
||
);
|
||
}
|