feat(kunden): Kundenverwaltung, Hostzuweisung und Token-Verwaltung (#006)

Kunden und Standorte pflegen, nicht zugeordnete Hosts in einem Schritt zuweisen,
Enrollment- und Host-Tokens verwalten, Audit-Log. Dashboard, Kundenseiten und
globale Suche laufen jetzt auf der Datenbank. 45 Tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-17 21:02:44 +02:00
co-authored by Claude Opus 5
parent f591b45aac
commit f9a5ad2daa
40 changed files with 2060 additions and 415 deletions
+118 -130
View File
@@ -7,69 +7,48 @@ import { CopyableCode } from "@/components/ui/CopyableCode";
import { SmallBox } from "@/components/ui/SmallBox";
import { TodoList, type Aufgabe } from "@/components/ui/TodoList";
import { formatDatum, formatEuro } from "@/lib/format";
import { HEUTE } from "@/mock/data";
import {
laufzeitZuAblauf,
dashboardKennzahlen,
getKunde,
hostMeldetNicht,
hostsNachAblauf,
nichtZugeordneteHosts,
offeneRechnungsLaufzeiten,
rechnungsFortschrittProKunde,
} from "@/mock/queries";
import { hostsNachAblauf, offeneAufgaben } from "@/server/queries/dashboard";
import { hostsOhneKunde, kennzahlen } from "@/server/queries/hosts";
import { kundenListe, offeneRechnungspunkteProKunde } from "@/server/queries/kunden";
export const metadata = { title: "Dashboard" };
function aufgabenliste(): Aufgabe[] {
return offeneRechnungsLaufzeiten().flatMap((laufzeit) => {
const host = hostsNachAblauf().find((h) => h.id === laufzeit.hostId);
const kunde = getKunde(host?.kundeId ?? null);
const basis = `${host?.hostname ?? "?"} · ${kunde?.name ?? "Nicht zugeordnet"}`;
const aufgaben: Aufgabe[] = [];
export default async function DashboardSeite() {
const heute = new Date();
const [zahlen, ohneKunde, ablaufListe, aufgabenRoh, kunden, rechnungspunkte] = await Promise.all([
kennzahlen(heute),
hostsOhneKunde(),
hostsNachAblauf(heute),
offeneAufgaben(),
kundenListe(),
offeneRechnungspunkteProKunde(),
]);
if (laufzeit.einkauf && !laufzeit.einkauf.bezahlt) {
aufgaben.push({
id: `${laufzeit.id}-einkauf`,
titel: `Proxmox-Rechnung ${laufzeit.einkauf.nummer} bezahlen`,
detail: `${basis} · ${formatEuro(laufzeit.einkauf.nettoBetrag)} netto · fällig am ${formatDatum(
laufzeit.einkauf.faelligAm,
)}`,
dringlichkeit: "gefahr",
badgeText: "Einkauf",
href: `/hosts/${laufzeit.hostId}`,
});
}
if (laufzeit.einkauf && (!laufzeit.verkauf || !laufzeit.verkauf.erstellt)) {
aufgaben.push({
id: `${laufzeit.id}-verkauf`,
titel: "Kundenrechnung erstellen",
detail: `${basis} · Laufzeit ${formatDatum(laufzeit.start)} – ${formatDatum(laufzeit.ende)}`,
dringlichkeit: "warnung",
badgeText: "Verkauf",
href: `/rechnungen`,
});
}
return aufgaben;
});
}
export default function DashboardSeite() {
const kennzahlen = dashboardKennzahlen();
const ohneKunde = nichtZugeordneteHosts();
const fortschritt = rechnungsFortschrittProKunde();
const aufgaben = aufgabenliste();
const nichtZugeordnetAufgaben: Aufgabe[] = ohneKunde.map((host) => ({
id: `zuweisen-${host.id}`,
titel: `${host.hostname} einem Kunden zuweisen`,
detail: `Server-ID ${host.serverId} · seit ${formatDatum(host.letzterReport)} gemeldet`,
dringlichkeit: "info",
badgeText: "Zuweisen",
href: "/hosts/nicht-zugeordnet",
}));
const aufgaben: Aufgabe[] = [
...aufgabenRoh.map((aufgabe) => ({
id: aufgabe.id,
titel: aufgabe.titel,
detail: [
aufgabe.detail,
aufgabe.betrag !== null ? `${formatEuro(aufgabe.betrag)} netto` : null,
aufgabe.faelligAm ? `fällig am ${formatDatum(aufgabe.faelligAm)}` : null,
`Laufzeit ${formatDatum(aufgabe.start)} – ${formatDatum(aufgabe.ende)}`,
]
.filter(Boolean)
.join(" · "),
dringlichkeit: aufgabe.dringlichkeit,
badgeText: aufgabe.badgeText,
href: aufgabe.href,
})),
...ohneKunde.map((host) => ({
id: `zuweisen-${host.id}`,
titel: `${host.hostname} einem Kunden zuweisen`,
detail: `Server-ID ${host.serverId ?? "unbekannt"} · seit ${formatDatum(host.erstmalsGesehenAm)} gemeldet`,
dringlichkeit: "info" as const,
badgeText: "Zuweisen",
href: "/hosts/nicht-zugeordnet",
})),
];
return (
<>
@@ -87,16 +66,16 @@ export default function DashboardSeite() {
}
>
Neu registrierte Hosts erscheinen hier, bis sie einem Kunden zugewiesen sind:{" "}
{ohneKunde.map((h) => h.hostname).join(", ")}
{ohneKunde.map((host) => host.hostname).join(", ")}
</Callout>
) : null}
<div className="row">
<div className="col-6 col-lg-3">
<SmallBox
wert={kennzahlen.hostsGesamt}
wert={zahlen.hostsGesamt}
text="Hosts gemeldet"
zusatz={`${kennzahlen.ohneMeldung} ohne Meldung`}
zusatz={`${zahlen.ohneMeldung} ohne Meldung`}
icon="bi-hdd-stack"
farbe="info"
href="/hosts"
@@ -104,17 +83,17 @@ export default function DashboardSeite() {
</div>
<div className="col-6 col-lg-3">
<SmallBox
wert={kennzahlen.aktivLizenziert}
wert={zahlen.aktivLizenziert}
text="Aktiv lizenziert"
zusatz={`${kennzahlen.ohneSubscription} ohne gültige Subscription`}
zusatz={`${zahlen.ohneSubscription} ohne gültige Subscription`}
icon="bi-patch-check"
farbe="success"
href="/hosts?status=active"
href="/hosts?status=ACTIVE"
/>
</div>
<div className="col-6 col-lg-3">
<SmallBox
wert={kennzahlen.baldAblaufend}
wert={zahlen.baldAblaufend}
text="Läuft in 30 Tagen ab"
zusatz="Verlängerung vorbereiten"
icon="bi-hourglass-split"
@@ -124,9 +103,9 @@ export default function DashboardSeite() {
</div>
<div className="col-6 col-lg-3">
<SmallBox
wert={kennzahlen.offeneRechnungspunkte}
wert={zahlen.offeneRechnungspunkte}
text="Offene Rechnungspunkte"
zusatz={`${kennzahlen.offeneEinkaufsrechnungen} Einkauf · ${kennzahlen.fehlendeKundenrechnungen} Verkauf`}
zusatz={`${zahlen.offeneEinkaufsrechnungen} Einkauf · ${zahlen.fehlendeKundenrechnungen} Verkauf`}
icon="bi-receipt"
farbe="danger"
href="/rechnungen"
@@ -150,44 +129,49 @@ export default function DashboardSeite() {
</tr>
</thead>
<tbody>
{hostsNachAblauf().map((host) => {
const kunde = getKunde(host.kundeId);
const laufzeit = laufzeitZuAblauf(host);
return (
<tr key={host.id}>
<td>
<Link href={`/hosts/${host.id}`} className="fw-semibold text-decoration-none">
{host.hostname}
{ablaufListe.map((host) => (
<tr key={host.id}>
<td>
<Link href={`/hosts/${host.id}`} className="fw-semibold text-decoration-none">
{host.hostname}
</Link>
{host.meldetNicht ? <KeineMeldungBadge /> : null}
</td>
<td>
{host.kunde ? (
<Link href={`/kunden/${host.kunde.id}`} className="text-decoration-none">
{host.kunde.name}
</Link>
{hostMeldetNicht(host) ? <KeineMeldungBadge /> : null}
</td>
<td>
{kunde ? (
<Link href={`/kunden/${kunde.id}`} className="text-decoration-none">
{kunde.name}
</Link>
) : (
<span className="badge pl-bg-neutral">Nicht zugeordnet</span>
)}
</td>
<td>
<CopyableCode wert={host.subscription.key} />
</td>
<td>
<AblaufBadge datum={host.subscription.nextduedate} heute={HEUTE} />
</td>
<td>
<EinkaufBadge bezahlt={laufzeit?.einkauf ? laufzeit.einkauf.bezahlt : null} />
</td>
<td>
<VerkaufBadge
erstellt={laufzeit?.verkauf?.erstellt ?? false}
bezahlt={laufzeit?.verkauf?.bezahlt ?? false}
/>
</td>
</tr>
);
})}
) : (
<Link
href="/hosts/nicht-zugeordnet"
className="badge pl-bg-neutral text-decoration-none"
>
Nicht zugeordnet
</Link>
)}
</td>
<td>
<CopyableCode wert={host.subKey} />
</td>
<td>
<AblaufBadge datum={host.nextduedate?.toISOString() ?? null} heute={heute} />
</td>
<td>
<EinkaufBadge bezahlt={host.einkaufBezahlt} />
</td>
<td>
<VerkaufBadge erstellt={host.verkaufErstellt} bezahlt={host.verkaufBezahlt} />
</td>
</tr>
))}
{ablaufListe.length === 0 ? (
<tr>
<td colSpan={6} className="text-center text-body-secondary py-4">
Noch kein Host mit Subscription gemeldet.
</td>
</tr>
) : null}
</tbody>
</table>
</div>
@@ -198,13 +182,9 @@ export default function DashboardSeite() {
<Card
titel="Aufgaben"
rahmen="warning"
werkzeuge={
<span className="badge pl-bg-warning">
{aufgaben.length + nichtZugeordnetAufgaben.length}
</span>
}
werkzeuge={<span className="badge pl-bg-warning">{aufgaben.length}</span>}
>
<TodoList aufgaben={[...aufgaben, ...nichtZugeordnetAufgaben]} />
<TodoList aufgaben={aufgaben} />
</Card>
</div>
</div>
@@ -212,22 +192,30 @@ export default function DashboardSeite() {
<div className="row">
<div className="col-lg-8">
<Card titel="Rechnungsstatus je Kunde" rahmen="info">
{fortschritt.map((eintrag) => (
<div className="mb-3" key={eintrag.kunde.id}>
<div className="d-flex justify-content-between small mb-1">
<Link href={`/kunden/${eintrag.kunde.id}`} className="text-decoration-none">
{eintrag.kunde.name}
</Link>
<span className="text-body-secondary">
{eintrag.erledigt} von {eintrag.gesamt} Punkten erledigt
</span>
</div>
<ProgressBar
prozent={eintrag.prozent}
beschriftung={`Rechnungsstatus ${eintrag.kunde.name}`}
/>
</div>
))}
{kunden.length === 0 ? (
<p className="mb-0 text-body-secondary">
Noch keine Kunden angelegt. <Link href="/kunden/neu">Ersten Kunden anlegen</Link>
</p>
) : (
kunden.map((kunde) => {
const punkte = rechnungspunkte.get(kunde.id) ?? { offen: 0, gesamt: 0 };
const erledigt = punkte.gesamt - punkte.offen;
const prozent = punkte.gesamt === 0 ? 100 : Math.round((erledigt / punkte.gesamt) * 100);
return (
<div className="mb-3" key={kunde.id}>
<div className="d-flex justify-content-between small mb-1">
<Link href={`/kunden/${kunde.id}`} className="text-decoration-none">
{kunde.name}
</Link>
<span className="text-body-secondary">
{erledigt} von {punkte.gesamt} Punkten erledigt
</span>
</div>
<ProgressBar prozent={prozent} beschriftung={`Rechnungsstatus ${kunde.name}`} />
</div>
);
})
)}
</Card>
</div>
<div className="col-lg-4">
@@ -235,15 +223,15 @@ export default function DashboardSeite() {
<ul className="list-unstyled mb-0 small">
<li className="mb-2">
<i className="bi bi-arrow-right-short" aria-hidden="true" />{" "}
<Link href="/kunden">Kundenübersicht öffnen</Link>
<Link href="/einstellungen/tokens">Agent auf einem Host ausrollen</Link>
</li>
<li className="mb-2">
<i className="bi bi-arrow-right-short" aria-hidden="true" />{" "}
<Link href="/rechnungen">Rechnungen pflegen</Link>
<Link href="/kunden">Kundenübersicht öffnen</Link>
</li>
<li>
<i className="bi bi-arrow-right-short" aria-hidden="true" />{" "}
<Link href="/jahresbahn">Abläufe der nächsten 12 Monate ansehen</Link>
<Link href="/rechnungen">Rechnungen pflegen</Link>
</li>
</ul>
</Card>