feat(uebersicht): echte Zahlen statt Beispieldaten (#026)

Kennzahlen, Hosts mit Abweichung, bald ablaufende Schluessel und letzte
Aktionen kommen aus der Datenbank; die Beispieldaten des Prototyps sind
entfernt. Befunde werden je Host und Benutzer in fester Reihenfolge
zusammengefasst, abgelaufene und noch ausgerollte Schluessel stehen in
Koralle. Anmeldungen bleiben dem Audit-Log vorbehalten, sonst
verdraengten sie auf der Uebersicht alles andere.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 16:33:11 +02:00
co-authored by Claude Opus 5.5
parent 83bd9e0922
commit ca61ca5b41
7 changed files with 383 additions and 156 deletions
+92 -63
View File
@@ -1,15 +1,6 @@
import { ArrowRight, Plus, Route } from "lucide-react";
import type { Metadata } from "next";
import Link from "next/link";
import {
abweichungen,
BEISPIEL_JETZT,
baldAblaufend,
kennzahlen,
letzteAktionen,
letzterAbgleich,
type AktionsArt,
} from "@/beispiel/uebersicht";
import { AlleAbgleichenKnopf } from "@/components/abgleich/AlleAbgleichenKnopf";
import { Badge } from "@/components/ui/Badge";
import { Button } from "@/components/ui/Button";
@@ -19,7 +10,10 @@ import { Seitenkopf } from "@/components/ui/Seitenkopf";
import { Statuspunkt } from "@/components/ui/Statuspunkt";
import { Tabelle, TabellenZeile, TabellenZelle } from "@/components/ui/Tabelle";
import { datum, datumZeit, relativ, tageBis, uhrzeit } from "@/lib/format";
import { Leerzustand } from "@/components/ui/Leerzustand";
import { darf } from "@/server/auth/rechte";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import { uebersicht, type AktionsArt } from "@/server/queries/uebersicht";
export const metadata: Metadata = { title: "Übersicht" };
@@ -29,36 +23,45 @@ const AKTION: Record<AktionsArt, { text: string; farbe: string }> = {
erstellt: { text: "Erstellt", farbe: "text-text" },
abgleich: { text: "Abgleich", farbe: "text-info" },
rotiert: { text: "Rotiert", farbe: "text-accent" },
anmeldung: { text: "Anmeldung", farbe: "text-muted" },
verweigert: { text: "Verweigert", farbe: "text-danger" },
sonstiges: { text: "Aktion", farbe: "text-text" },
};
/**
* Prototyp der Übersicht mit Beispieldaten (#002). Die echte Übersicht mit Lesemodellen
* folgt in #026; Aufbau und Komponenten bleiben.
*/
/** Übersicht (#026): Aufbau aus dem Prototyp (#002), Zahlen aus der Datenbank. */
export default async function Uebersicht() {
// Jede Seite prueft selbst; die Huelle ist nur das zweite Netz (CLAUDE.md).
await sitzungErforderlich();
const { benutzer } = await sitzungErforderlich();
const jetzt = new Date();
const { letzterAbgleich, kennzahlen, mitAbweichung, weitereAbweichungen, baldAblaufend, letzteAktionen } =
await uebersicht(jetzt);
const verwalten = darf(benutzer.rolle, "hostsVerwalten");
return (
<div className="flex flex-col gap-6 sm:gap-7">
<Seitenkopf
titel="Übersicht"
badges={<Badge variante="messing">Prototyp · Beispieldaten</Badge>}
untertitel={
<>
Letzter Abgleich {relativ(letzterAbgleich, BEISPIEL_JETZT)} ·{" "}
<time dateTime={letzterAbgleich} className="font-mono text-[13px]">
{datumZeit(letzterAbgleich)}
</time>
</>
letzterAbgleich ? (
<>
Letzter Abgleich {relativ(letzterAbgleich, jetzt)} ·{" "}
<time dateTime={letzterAbgleich.toISOString()} className="font-mono text-[13px]">
{datumZeit(letzterAbgleich)}
</time>
</>
) : (
"Noch kein Abgleich gelaufen."
)
}
aktionen={
<>
<AlleAbgleichenKnopf />
<Button href="/assistent" icon={<Route className="size-4" />}>
Host-zu-Host-Assistent
</Button>
{verwalten && <AlleAbgleichenKnopf />}
{darf(benutzer.rolle, "privateKeyAusrollen") && (
<Button href="/assistent" icon={<Route className="size-4" />}>
Host-zu-Host-Assistent
</Button>
)}
<Button
href="/schluessel"
href="/schluessel/neu"
variante="primaer"
icon={<Plus className="size-4" strokeWidth={2.4} />}
>
@@ -98,46 +101,60 @@ export default async function Uebersicht() {
</div>
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.55fr)_minmax(0,1fr)]">
<Karte titel="Hosts mit Abweichung" untertitel="Ergebnis des letzten Abgleichs">
<Tabelle
beschriftung="Hosts mit Abweichung"
spalten={[
{ titel: "Host" },
{ titel: "Benutzer" },
{ titel: "Befund" },
{ titel: "Aktion", rechts: true, versteckt: true },
]}
>
{abweichungen.map((a) => (
<TabellenZeile key={`${a.host}-${a.benutzer}`}>
<TabellenZelle mono className="whitespace-nowrap">
{a.host}
</TabellenZelle>
<TabellenZelle mono>{a.benutzer}</TabellenZelle>
<TabellenZelle className="whitespace-nowrap">
<Statuspunkt status={a.status} text={a.befund} />
</TabellenZelle>
<TabellenZelle rechts>
<Link
href="/hosts"
className="text-accent -my-2 inline-flex min-h-11 items-center px-1 font-medium hover:underline"
>
Prüfen<span className="sr-only"> – {a.host}</span>
</Link>
</TabellenZelle>
</TabellenZeile>
))}
</Tabelle>
<Karte
titel="Hosts mit Abweichung"
untertitel={
weitereAbweichungen
? `Ergebnis des letzten Abgleichs · ${weitereAbweichungen} weitere`
: "Ergebnis des letzten Abgleichs"
}
>
{mitAbweichung.length === 0 ? (
<p className="text-ok text-[14px]">● Alle Hosts synchron – keine offenen Abweichungen.</p>
) : (
<Tabelle
beschriftung="Hosts mit Abweichung"
spalten={[
{ titel: "Host" },
{ titel: "Benutzer" },
{ titel: "Befund" },
{ titel: "Aktion", rechts: true, versteckt: true },
]}
>
{mitAbweichung.map((a) => (
<TabellenZeile key={`${a.hostId}-${a.benutzer}-${a.status}`}>
<TabellenZelle mono className="whitespace-nowrap">
{a.host}
</TabellenZelle>
<TabellenZelle mono>{a.benutzer}</TabellenZelle>
<TabellenZelle className="whitespace-nowrap">
<Statuspunkt status={a.status} text={a.befund} />
</TabellenZelle>
<TabellenZelle rechts>
<Link
href={`/hosts/${a.hostId}`}
className="text-accent -my-2 inline-flex min-h-11 items-center px-1 font-medium hover:underline"
>
Prüfen<span className="sr-only"> – {a.host}</span>
</Link>
</TabellenZelle>
</TabellenZeile>
))}
</Tabelle>
)}
</Karte>
<Karte titel="Bald ablaufend" untertitel="Schlüssel mit Ablaufdatum in den nächsten 30 Tagen">
{baldAblaufend.length === 0 && (
<p className="text-muted text-[14px]">Kein Schlüssel läuft in den nächsten 30 Tagen ab.</p>
)}
<ul className="flex flex-col">
{baldAblaufend.map((s) => {
const tage = tageBis(s.ablauf, BEISPIEL_JETZT);
const tage = tageBis(s.ablauf, jetzt);
return (
<li key={s.zweck} className="border-border border-b last:border-b-0">
<li key={s.id} className="border-border border-b last:border-b-0">
<Link
href="/schluessel"
href={`/schluessel/${s.id}`}
className="hover:bg-raised/50 rounded-box -mx-2 flex min-h-11 items-center justify-between gap-3 px-2 py-2.5 transition-colors"
>
<span className="min-w-0">
@@ -146,8 +163,16 @@ export default async function Uebersicht() {
{s.person} · <span className="font-mono">{s.typ}</span>
</span>
</span>
<Badge variante="messing" className="shrink-0">
<time dateTime={s.ablauf}>{tage === 1 ? "morgen" : `in ${tage} Tagen`}</time>
<Badge variante={tage < 0 ? "gefahr" : "messing"} className="shrink-0">
<time dateTime={s.ablauf.toISOString()}>
{tage < 0
? `abgelaufen${s.ausgerollt ? " · noch ausgerollt" : ""}`
: tage === 0
? "heute"
: tage === 1
? "morgen"
: `in ${tage} Tagen`}
</time>
</Badge>
</Link>
</li>
@@ -168,13 +193,17 @@ export default async function Uebersicht() {
</Link>
}
>
{letzteAktionen.length === 0 && <Leerzustand titel="Noch keine Aktionen" />}
<ol className="flex flex-col">
{letzteAktionen.map((a) => (
<li
key={a.zeit}
key={a.id}
className="border-border grid grid-cols-[auto_1fr] gap-x-4 gap-y-0.5 border-b py-3 text-[14px] last:border-b-0 md:grid-cols-[8.5rem_6.5rem_1fr_auto] md:items-baseline"
>
<time dateTime={a.zeit} className="text-muted font-mono text-[12.5px] whitespace-nowrap">
<time
dateTime={a.zeit.toISOString()}
className="text-muted font-mono text-[12.5px] whitespace-nowrap"
>
{datum(a.zeit)} {uhrzeit(a.zeit)}
</time>
<span className={`${AKTION[a.art].farbe} font-medium md:order-none`}>{AKTION[a.art].text}</span>