diff --git a/CHANGELOG.md b/CHANGELOG.md index 997f880..141a85f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,20 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/). ### Sicherheit +## [0.3.0] – 2026-09-17 + +### Hinzugefügt +- Kunden anlegen, bearbeiten und löschen; Standorte je Kunde verwalten (#006) +- Seite „Nicht zugeordnete Hosts“ mit Mehrfachauswahl und Zuweisung in einem Schritt (#006) +- Enrollment-Tokens erzeugen (mit Gültigkeit, Nutzungsgrenze und optionaler Kundenbindung), anzeigen und widerrufen; Host-Tokens widerrufen (#006) +- Audit-Log-Einträge für alle Änderungen an Kunden, Standorten, Zuweisungen und Tokens (#006) + +### Geändert +- Dashboard, Kundenübersicht, Kunden-Detail und globale Suche arbeiten mit echten Daten statt Beispieldaten (#006) + +### Behoben +- Prisma-Client öffnete in der Produktionsumgebung bei jedem Zugriff einen neuen Verbindungspool (#006) + ## [0.2.0] – 2026-09-17 ### Hinzugefügt diff --git a/DEVLOG.md b/DEVLOG.md index a392487..4a192d6 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -2,7 +2,7 @@ ## Zeitübersicht -**Gesamt:** 1 h 38 min +**Gesamt:** 2 h 13 min | Issue | Zeit | |---|---| @@ -11,17 +11,19 @@ | #003 Datenmodell und Migrationen | 0 h 25 min | | #004 Agent-API | 0 h 20 min | | #005 Agent-Skript | 0 h 25 min | +| #006 Kunden und Hostzuweisung | 0 h 35 min | | Meilenstein | Zeit | |---|---| | v0.1.0 | 1 h 13 min | **abgeschlossen** | v0.2.0 | 0 h 25 min | **abgeschlossen** +| v0.3.0 | 0 h 35 min | **abgeschlossen** --- ## 2026-09-17 · Sitzung 1 · 19:36–laufend -**Issue:** #001–#005 (Meilensteine v0.1.0 und v0.2.0) -**Version:** 0.2.0 +**Issue:** #001–#006 (Meilensteine v0.1.0 bis v0.3.0) +**Version:** 0.3.0 ### Erledigt - Auftrag gelesen, Rückfragen gestellt @@ -36,6 +38,9 @@ - Meilenstein v0.1.0 abgeschlossen und getaggt - #005 abgeschlossen: Bash-Agent mit Produkterkennung, JSON ohne jq, Retry mit Backoff, Spool, systemd-Timer, Installer, Uninstall und Update; Dashboard liefert `/install.sh` und `/agent.sh` aus - Meilenstein v0.2.0 abgeschlossen und getaggt; 38 Tests +- #006 abgeschlossen: Kunden und Standorte pflegen, Mehrfachzuweisung nicht zugeordneter Hosts, Enrollment- und Host-Tokens verwalten, Audit-Log; Dashboard, Kundenseiten und globale Suche laufen auf der Datenbank +- Durchlauf gegen den laufenden Server geprüft: Token anlegen → `enroll` → Agent-Report → Laufzeit-Vorschlag → Host erscheint als „nicht zugeordnet“ → `ping` +- Meilenstein v0.3.0 abgeschlossen und getaggt; 45 Tests ### Entscheidungen - **Screenshots mit Firefox headless** statt Playwright: Firefox ist vorhanden, es muss kein Browser samt Systembibliotheken nachinstalliert werden (dafür wäre root nötig). @@ -51,12 +56,15 @@ - **Prisma-Client wird lazy erzeugt** (Proxy), damit `next build` ohne gesetzte `DATABASE_URL` durchläuft. - **Report-Idempotenz über eine vom Agent erzeugte `reportId`**: nachgesendete Reports aus dem Spool dürfen beliebig oft ankommen. - **Unbekannte Subscription-Status werden zu `UNKNOWN`** statt abgelehnt – ein Report darf nie an einem neuen Statuswort scheitern. +- **Dashboard gleich mit auf die Datenbank umgestellt** (eigentlich #009): Eine frisch zugewiesene Zuordnung hätte dort sonst weiter als „nicht zugeordnet“ gestanden. +- **Server Actions rufen Services auf**, damit die Regeln ohne Next.js-Laufzeit testbar bleiben. ### Probleme / offene Fragen +- Prisma-Client erzeugte in der Produktionsumgebung bei jedem Zugriff einen neuen Verbindungspool → „too many clients“. Behoben: Client wird im Modul festgehalten. - Docker wurde von Florian installiert; die Entwicklungsdatenbank läuft auf Port 5433 (5432 bleibt für lokale Instanzen frei). - Favicon-Dateien (.ico/PNG) fehlen noch, weil kein SVG-Konverter installiert ist. - Der Agent wurde noch auf keinem echten Proxmox-Host installiert; die Tests arbeiten mit nachgebauten Befehlen. - Offen: Rolle „readonly“ zusätzlich zu Admin? (vorläufig vorgesehen) ### Nächster Schritt -- #006 Kunden, Standorte und Hostzuweisung (erste Oberfläche mit echten Daten) +- #007 Host-Tabelle mit Filtern, globale Suche auf Datenbankbasis und Host-Detailansicht diff --git a/docs/anleitung/bilder/dashboard.png b/docs/anleitung/bilder/dashboard.png index edf657b..04138a6 100644 Binary files a/docs/anleitung/bilder/dashboard.png and b/docs/anleitung/bilder/dashboard.png differ diff --git a/docs/anleitung/bilder/kunde-detail.png b/docs/anleitung/bilder/kunde-detail.png index 6a3e72d..40bb489 100644 Binary files a/docs/anleitung/bilder/kunde-detail.png and b/docs/anleitung/bilder/kunde-detail.png differ diff --git a/docs/anleitung/bilder/kunde-neu.png b/docs/anleitung/bilder/kunde-neu.png new file mode 100644 index 0000000..7d70393 Binary files /dev/null and b/docs/anleitung/bilder/kunde-neu.png differ diff --git a/docs/anleitung/bilder/kunden.png b/docs/anleitung/bilder/kunden.png new file mode 100644 index 0000000..beae084 Binary files /dev/null and b/docs/anleitung/bilder/kunden.png differ diff --git a/docs/anleitung/bilder/nicht-zugeordnet.png b/docs/anleitung/bilder/nicht-zugeordnet.png index 5ca3c50..bbe2081 100644 Binary files a/docs/anleitung/bilder/nicht-zugeordnet.png and b/docs/anleitung/bilder/nicht-zugeordnet.png differ diff --git a/docs/anleitung/bilder/tokens.png b/docs/anleitung/bilder/tokens.png new file mode 100644 index 0000000..a56abc6 Binary files /dev/null and b/docs/anleitung/bilder/tokens.png differ diff --git a/docs/issues/006-kunden-hostzuweisung.md b/docs/issues/006-kunden-hostzuweisung.md index c5fa649..d3c63e0 100644 --- a/docs/issues/006-kunden-hostzuweisung.md +++ b/docs/issues/006-kunden-hostzuweisung.md @@ -1,27 +1,34 @@ # #006 Kunden, Standorte und Hostzuweisung -**Status:** offen +**Status:** erledigt **Meilenstein:** v0.3.0 **Labels:** ui, backend -**Geschätzt:** 6 h **Tatsächlich:** – +**Geschätzt:** 6 h **Tatsächlich:** 0 h 35 min ## Ziel Kunden und Standorte pflegen, neu registrierte Hosts schnell Kunden zuweisen und Enrollment-Tokens verwalten. ## Aufgaben -- [ ] Kunden anlegen/bearbeiten/löschen (Name, Kundennummer, Ansprechpartner, E-Mail, Notizen) -- [ ] Standorte je Kunde -- [ ] Seite „Nicht zugeordnete Hosts“ mit Mehrfachauswahl und Schnellzuweisung (Kunde + Standort) -- [ ] Callout auf dem Dashboard, solange nicht zugeordnete Hosts existieren -- [ ] Enrollment-Tokens: erzeugen (Ablauf, max. Nutzungen, optional Kunde/Standort), anzeigen (einmalig), widerrufen; Install-One-Liner zum Kopieren -- [ ] Host-Tokens widerrufen -- [ ] Audit-Log-Einträge für alle Änderungen +- [x] Kunden anlegen/bearbeiten/löschen (Name, Kundennummer, Ansprechpartner, E-Mail, Notizen) +- [x] Standorte je Kunde +- [x] Seite „Nicht zugeordnete Hosts“ mit Mehrfachauswahl und Schnellzuweisung (Kunde + Standort) +- [x] Callout auf dem Dashboard, solange nicht zugeordnete Hosts existieren +- [x] Enrollment-Tokens: erzeugen (Ablauf, max. Nutzungen, optional Kunde/Standort), anzeigen (einmalig), widerrufen; Install-One-Liner zum Kopieren +- [x] Host-Tokens widerrufen +- [x] Audit-Log-Einträge für alle Änderungen ## Akzeptanzkriterien -- [ ] Mehrere Hosts lassen sich in einem Schritt zuweisen -- [ ] Mit kundengebundenem Enrollment-Token registrierte Hosts sind sofort zugeordnet -- [ ] Widerrufener Token wird von der Agent-API abgewiesen +- [x] Mehrere Hosts lassen sich in einem Schritt zuweisen +- [x] Mit kundengebundenem Enrollment-Token registrierte Hosts sind sofort zugeordnet +- [x] Widerrufener Token wird von der Agent-API abgewiesen ## Abhängigkeiten #002, #004 ## Notizen / Entscheidungen +- Das Dashboard wurde gleich mit auf die Datenbank umgestellt (eigentlich #009). Sonst hätte eine gerade + vorgenommene Zuweisung dort weiter als „nicht zugeordnet“ gestanden – ein irreführender Zwischenzustand. +- Die Zuweisungslogik liegt als Service (`src/server/services/zuweisung.ts`) neben der Server Action, + damit sie ohne Next.js-Laufzeit testbar ist. +- Alle Dashboard-Seiten rendern dynamisch (`force-dynamic`); beim Vorrendern öffnete sonst jeder Build-Worker + eigene Datenbankverbindungen und der Build brach mit „TooManyConnections“ ab. +- Noch auf Beispieldaten: Host-Tabelle, Host-Detail, Rechnungen und Jahresbahn (folgen in #007 bis #009). diff --git a/docs/issues/README.md b/docs/issues/README.md index 5fef197..007b32f 100644 --- a/docs/issues/README.md +++ b/docs/issues/README.md @@ -9,8 +9,8 @@ Details und Architektur: [../architektur.md](../architektur.md). Zeiten werden a | [#003](003-datenmodell.md) | Datenmodell und Migrationen | erledigt | v0.1.0 | 5 h | 0 h 25 min | | [#004](004-agent-api.md) | Agent-API | erledigt | v0.1.0 | 6 h | 0 h 20 min | | [#005](005-agent-skript.md) | Agent-Skript | erledigt | v0.2.0 | 8 h | 0 h 25 min | -| [#006](006-kunden-hostzuweisung.md) | Kunden, Standorte und Hostzuweisung | in Arbeit | v0.3.0 | 6 h | – | -| [#007](007-hosts-detailansichten.md) | Host-Tabelle, Suche und Detailansichten | offen | v0.4.0 | 8 h | – | +| [#006](006-kunden-hostzuweisung.md) | Kunden, Standorte und Hostzuweisung | erledigt | v0.3.0 | 6 h | 0 h 35 min | +| [#007](007-hosts-detailansichten.md) | Host-Tabelle, Suche und Detailansichten | in Arbeit | v0.4.0 | 8 h | – | | [#008](008-laufzeiten-rechnungen.md) | Laufzeiten und Rechnungen | offen | v0.5.0 | 10 h | – | | [#009](009-kundenuebersicht-dashboard-jahresbahn.md) | Kundenübersicht, Dashboard und Jahresbahn | offen | v0.6.0 | 8 h | – | | [#010](010-export.md) | Export CSV und PDF | offen | v0.6.0 | 5 h | – | @@ -20,4 +20,4 @@ Details und Architektur: [../architektur.md](../architektur.md). Zeiten werden a | [#014](014-deployment.md) | Deployment und Dokumentation | offen | v0.10.0 | 8 h | – | | [#015](015-feinschliff-v1.md) | Feinschliff für 1.0 | offen | v1.0.0 | 6 h | – | | [#016](016-anleitung-screenshots.md) | Benutzerhandbuch mit Screenshots | offen | v0.10.0 | 5 h | – | -| | **Summe** | | | **106 h** | **1 h 38 min** | +| | **Summe** | | | **106 h** | **2 h 13 min** | diff --git a/package.json b/package.json index ad7538d..5d0d9c8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "proxledger", - "version": "0.2.0", + "version": "0.3.0", "private": true, "scripts": { "dev": "next dev", diff --git a/src/app/(dashboard)/einstellungen/tokens/page.tsx b/src/app/(dashboard)/einstellungen/tokens/page.tsx index b8e4e9a..a3b8008 100644 --- a/src/app/(dashboard)/einstellungen/tokens/page.tsx +++ b/src/app/(dashboard)/einstellungen/tokens/page.tsx @@ -1,34 +1,175 @@ +import Link from "next/link"; import { ContentHeader } from "@/components/layout/ContentHeader"; +import { TokenFormular } from "@/components/tokens/TokenFormular"; +import { Badge } from "@/components/ui/Badges"; import { Card } from "@/components/ui/Card"; -import { CopyableCode } from "@/components/ui/CopyableCode"; +import { formatDatum, formatDatumZeit } from "@/lib/format"; +import { prisma } from "@/lib/db"; +import { enrollmentTokenWiderrufen, hostTokenWiderrufen } from "@/server/actions/tokens"; export const metadata = { title: "Agent-Tokens" }; -export default function TokensSeite() { +export default async function TokensSeite() { + const dashboardUrl = (process.env.APP_URL ?? "https://dashboard.example.de").replace(/\/$/, ""); + + const [kunden, enrollmentTokens, hostTokens] = await Promise.all([ + prisma.kunde.findMany({ orderBy: { name: "asc" }, select: { id: true, name: true } }), + prisma.enrollmentToken.findMany({ + orderBy: { erstelltAm: "desc" }, + include: { kunde: { select: { name: true } } }, + }), + prisma.agentToken.findMany({ + orderBy: { erstelltAm: "desc" }, + include: { host: { select: { id: true, hostname: true } } }, + }), + ]); + + const jetzt = new Date(); + + function zustand(token: { + widerrufenAm: Date | null; + gueltigBis: Date | null; + maxNutzungen: number | null; + nutzungen: number; + }) { + if (token.widerrufenAm) return { text: "Widerrufen", farbe: "neutral" as const }; + if (token.gueltigBis && token.gueltigBis < jetzt) return { text: "Abgelaufen", farbe: "danger" as const }; + if (token.maxNutzungen !== null && token.nutzungen >= token.maxNutzungen) + return { text: "Aufgebraucht", farbe: "warning" as const }; + return { text: "Gültig", farbe: "success" as const }; + } + return ( <> +
- -

- Auf dem Proxmox-Host als root ausführen. Der Enrollment-Token gilt bis zum angegebenen Datum und - kann mehrfach verwendet werden. + +

+ Mit einem Enrollment-Token registriert sich ein Agent einmalig und erhält dabei seinen eigenen + Host-Token. Ist ein Kunde hinterlegt, sind neue Hosts sofort zugeordnet und tauchen nicht unter{" "} + Nicht zugeordnet auf.

-
-              
-                curl -fsSL https://proxledger.example.de/install.sh | bash -s -- --token plx_e_BEISPIEL
-              
-            
- +
- -

- Ausgabe, Ablauf und Widerruf von Enrollment- und Host-Tokens folgen in #006. -

+ + +
+ + + + + + + + + + + + + + {enrollmentTokens.map((token) => { + const status = zustand(token); + return ( + + + + + + + + + + ); + })} + {enrollmentTokens.length === 0 ? ( + + + + ) : null} + +
BezeichnungAnfangKundeGültig bisNutzungenZustand + Aktion +
{token.bezeichnung ?? "–"}{token.praefix}…{token.kunde?.name ?? "–"}{formatDatum(token.gueltigBis)} + {token.nutzungen} + {token.maxNutzungen !== null ? ` / ${token.maxNutzungen}` : ""} + + {status.text} + + {token.widerrufenAm ? null : ( +
+ + +
+ )} +
+ Noch kein Enrollment-Token erzeugt. +
+
+
+ + +
+ + + + + + + + + + + + + {hostTokens.map((token) => ( + + + + + + + + + ))} + {hostTokens.length === 0 ? ( + + + + ) : null} + +
HostAnfangErstelltZuletzt benutztZustand + Aktion +
+ + {token.host.hostname} + + {token.praefix}…{formatDatum(token.erstelltAm)}{formatDatumZeit(token.letzteNutzungAm)} + {token.widerrufenAm ? ( + Widerrufen + ) : ( + Aktiv + )} + + {token.widerrufenAm ? null : ( +
+ + + +
+ )} +
+ Noch kein Host registriert. +
+
diff --git a/src/app/(dashboard)/hosts/[id]/page.tsx b/src/app/(dashboard)/hosts/[id]/page.tsx index 3a88aa1..312272c 100644 --- a/src/app/(dashboard)/hosts/[id]/page.tsx +++ b/src/app/(dashboard)/hosts/[id]/page.tsx @@ -13,7 +13,7 @@ import { Card } from "@/components/ui/Card"; import { CopyableCode } from "@/components/ui/CopyableCode"; import { Tabs } from "@/components/ui/Tabs"; import { formatDatum, formatDatumZeit, formatEuro, formatUptime, formatZahl } from "@/lib/format"; -import { HEUTE, hosts } from "@/mock/data"; +import { HEUTE } from "@/mock/data"; import { PRODUKT_NAME, alertsVonHost, @@ -24,10 +24,6 @@ import { reportsVonHost, } from "@/mock/queries"; -export function generateStaticParams() { - return hosts.map((host) => ({ id: host.id })); -} - export async function generateMetadata({ params }: { params: Promise<{ id: string }> }) { const { id } = await params; return { title: getHost(id)?.hostname ?? "Host" }; diff --git a/src/app/(dashboard)/hosts/nicht-zugeordnet/page.tsx b/src/app/(dashboard)/hosts/nicht-zugeordnet/page.tsx index c0856ce..b55f6bf 100644 --- a/src/app/(dashboard)/hosts/nicht-zugeordnet/page.tsx +++ b/src/app/(dashboard)/hosts/nicht-zugeordnet/page.tsx @@ -1,15 +1,25 @@ +import Link from "next/link"; import { ContentHeader } from "@/components/layout/ContentHeader"; +import { ZuweisungsFormular } from "@/components/hosts/ZuweisungsFormular"; import { Callout } from "@/components/ui/Callout"; import { Card } from "@/components/ui/Card"; -import { CopyableCode } from "@/components/ui/CopyableCode"; -import { formatDatumZeit } from "@/lib/format"; -import { kunden } from "@/mock/data"; -import { PRODUKT_NAME, nichtZugeordneteHosts } from "@/mock/queries"; +import { prisma } from "@/lib/db"; +import { hostsOhneKunde } from "@/server/queries/hosts"; export const metadata = { title: "Nicht zugeordnete Hosts" }; -export default function NichtZugeordnetSeite() { - const liste = nichtZugeordneteHosts(); +export default async function NichtZugeordnetSeite() { + const [hosts, kunden] = await Promise.all([ + hostsOhneKunde(), + prisma.kunde.findMany({ + orderBy: { name: "asc" }, + select: { + id: true, + name: true, + standorte: { select: { id: true, name: true }, orderBy: { name: "asc" } }, + }, + }), + ]); return ( <> @@ -20,83 +30,40 @@ export default function NichtZugeordnetSeite() {
- - Mehrere Hosts auswählen und gemeinsam einem Kunden zuweisen. Im Prototyp ist das Formular noch - ohne Funktion – die Zuweisung folgt in #006. - + {hosts.length === 0 ? ( + + Jeder gemeldete Host gehört zu einem Kunden. Neu registrierte Hosts erscheinen automatisch hier. + + ) : ( + + Hosts auswählen, Kunde wählen und in einem Schritt zuweisen. Wer Hosts direkt vorzuordnen + möchte, erzeugt unter Agent-Tokens einen + Enrollment-Token mit Kundenbindung – damit landen neue Hosts sofort beim richtigen Kunden. + + )} - -
- - - - - - - - - - - - - {liste.map((host) => ( - - - - - - - - - ))} - -
- - HostProduktServer-IDKeyErste Meldung
- - {host.hostname}{PRODUKT_NAME[host.produkt]} - - - - {formatDatumZeit(host.letzterReport)}
-
-
+ {kunden.length === 0 ? ( + + Zum Zuweisen wird mindestens ein Kunde benötigt.{" "} + Jetzt einen Kunden anlegen. + + ) : null} - -
-
- - -
-
- - -
-
- -
-
-
+ {hosts.length > 0 ? ( + + ({ + id: host.id, + hostname: host.hostname, + produkt: host.produkt, + serverId: host.serverId, + subKey: host.subKey, + erstmalsGesehenAm: host.erstmalsGesehenAm.toISOString(), + }))} + kunden={kunden} + /> + + ) : null}
diff --git a/src/app/(dashboard)/kunden/[id]/bearbeiten/page.tsx b/src/app/(dashboard)/kunden/[id]/bearbeiten/page.tsx new file mode 100644 index 0000000..27e1380 --- /dev/null +++ b/src/app/(dashboard)/kunden/[id]/bearbeiten/page.tsx @@ -0,0 +1,65 @@ +import { notFound } from "next/navigation"; +import { ContentHeader } from "@/components/layout/ContentHeader"; +import { KundeFormular } from "@/components/kunden/KundeFormular"; +import { Card } from "@/components/ui/Card"; +import { prisma } from "@/lib/db"; +import { kundeLoeschen } from "@/server/actions/kunden"; + +export const metadata = { title: "Kunde bearbeiten" }; + +export default async function KundeBearbeitenSeite({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + const kunde = await prisma.kunde.findUnique({ + where: { id }, + include: { _count: { select: { hosts: true } } }, + }); + if (!kunde) notFound(); + + return ( + <> + +
+
+
+
+ + + + + +

+ Der Kunde wird mit allen Standorten und Kundenrechnungen entfernt. Seine{" "} + {kunde._count.hosts} Host(s) bleiben erhalten und erscheinen danach unter „Nicht + zugeordnet“. +

+
+ + +
+
+
+
+
+
+ + ); +} diff --git a/src/app/(dashboard)/kunden/[id]/page.tsx b/src/app/(dashboard)/kunden/[id]/page.tsx index fb08738..c0759a0 100644 --- a/src/app/(dashboard)/kunden/[id]/page.tsx +++ b/src/app/(dashboard)/kunden/[id]/page.tsx @@ -1,6 +1,7 @@ import Link from "next/link"; import { notFound } from "next/navigation"; import { ContentHeader } from "@/components/layout/ContentHeader"; +import { StandortVerwaltung } from "@/components/kunden/StandortVerwaltung"; import { AblaufBadge, EinkaufBadge, @@ -12,55 +13,51 @@ import { Card } from "@/components/ui/Card"; import { CopyableCode } from "@/components/ui/CopyableCode"; import { InfoBox } from "@/components/ui/SmallBox"; import { formatDatum, formatEuro } from "@/lib/format"; -import { HEUTE, kunden } from "@/mock/data"; -import { - PRODUKT_NAME, - alertsVonKunde, - getKunde, - hostMeldetNicht, - hostsVonKunde, - laufzeitenVonKunde, - summenProJahr, - summenVonKunde, -} from "@/mock/queries"; - -export function generateStaticParams() { - return kunden.map((kunde) => ({ id: kunde.id })); -} +import { hostZuordnungAufheben } from "@/server/actions/hosts"; +import { kundeMitDetails, laufzeitenVonKunde, summen, summenProJahr } from "@/server/queries/kunden"; +import { meldungsGrenze } from "@/server/queries/hosts"; +import { PRODUKT_TEXT, statusAusDatenbank } from "@/lib/anzeige"; export async function generateMetadata({ params }: { params: Promise<{ id: string }> }) { const { id } = await params; - return { title: getKunde(id)?.name ?? "Kunde" }; + const kunde = await kundeMitDetails(id); + return { title: kunde?.name ?? "Kunde" }; } export default async function KundenDetailSeite({ params }: { params: Promise<{ id: string }> }) { const { id } = await params; - const kunde = getKunde(id); + const kunde = await kundeMitDetails(id); if (!kunde) notFound(); - const eigeneHosts = hostsVonKunde(kunde.id); - const laufzeiten = laufzeitenVonKunde(kunde.id); - const summen = summenVonKunde(kunde.id); + const laufzeiten = await laufzeitenVonKunde(kunde.id); + const gesamt = summen(laufzeiten); const proJahr = summenProJahr(laufzeiten); - const alerts = alertsVonKunde(kunde.id); + const heute = new Date(); + const grenze = meldungsGrenze(heute); return ( <> +