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>
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
Before Width: | Height: | Size: 262 KiB After Width: | Height: | Size: 264 KiB |
|
Before Width: | Height: | Size: 220 KiB After Width: | Height: | Size: 235 KiB |
|
After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 107 KiB After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 118 KiB |
@@ -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).
|
||||
|
||||
@@ -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** |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "proxledger",
|
||||
"version": "0.2.0",
|
||||
"version": "0.3.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<ContentHeader
|
||||
titel="Agent-Tokens"
|
||||
brotkrumen={[{ titel: "Einstellungen" }, { titel: "Agent-Tokens" }]}
|
||||
/>
|
||||
|
||||
<div className="app-content">
|
||||
<div className="container-fluid">
|
||||
<Card titel="Agent installieren" rahmen="primary">
|
||||
<p>
|
||||
Auf dem Proxmox-Host als root ausführen. Der Enrollment-Token gilt bis zum angegebenen Datum und
|
||||
kann mehrfach verwendet werden.
|
||||
<Card titel="Neuen Enrollment-Token erzeugen" rahmen="primary">
|
||||
<p className="text-body-secondary">
|
||||
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{" "}
|
||||
<Link href="/hosts/nicht-zugeordnet">Nicht zugeordnet</Link> auf.
|
||||
</p>
|
||||
<pre className="bg-body-secondary p-3 rounded">
|
||||
<code className="pl-mono">
|
||||
curl -fsSL https://proxledger.example.de/install.sh | bash -s -- --token plx_e_BEISPIEL
|
||||
</code>
|
||||
</pre>
|
||||
<CopyableCode wert="plx_e_BEISPIEL" />
|
||||
<TokenFormular kunden={kunden} dashboardUrl={dashboardUrl} />
|
||||
</Card>
|
||||
<Card titel="Vorhandene Tokens" rahmen="info">
|
||||
<p className="text-body-secondary mb-0">
|
||||
Ausgabe, Ablauf und Widerruf von Enrollment- und Host-Tokens folgen in #006.
|
||||
</p>
|
||||
|
||||
<Card titel={`Enrollment-Tokens (${enrollmentTokens.length})`} rahmen="info" tabelle>
|
||||
<div className="table-responsive">
|
||||
<table className="table table-striped align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Bezeichnung</th>
|
||||
<th scope="col">Anfang</th>
|
||||
<th scope="col">Kunde</th>
|
||||
<th scope="col">Gültig bis</th>
|
||||
<th scope="col">Nutzungen</th>
|
||||
<th scope="col">Zustand</th>
|
||||
<th scope="col" className="text-end">
|
||||
Aktion
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{enrollmentTokens.map((token) => {
|
||||
const status = zustand(token);
|
||||
return (
|
||||
<tr key={token.id}>
|
||||
<td>{token.bezeichnung ?? "–"}</td>
|
||||
<td className="pl-mono">{token.praefix}…</td>
|
||||
<td>{token.kunde?.name ?? "–"}</td>
|
||||
<td>{formatDatum(token.gueltigBis)}</td>
|
||||
<td>
|
||||
{token.nutzungen}
|
||||
{token.maxNutzungen !== null ? ` / ${token.maxNutzungen}` : ""}
|
||||
</td>
|
||||
<td>
|
||||
<Badge farbe={status.farbe}>{status.text}</Badge>
|
||||
</td>
|
||||
<td className="text-end">
|
||||
{token.widerrufenAm ? null : (
|
||||
<form action={enrollmentTokenWiderrufen}>
|
||||
<input type="hidden" name="id" value={token.id} />
|
||||
<button type="submit" className="btn btn-sm btn-outline-danger">
|
||||
Widerrufen
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
{enrollmentTokens.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="text-center text-body-secondary py-4">
|
||||
Noch kein Enrollment-Token erzeugt.
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card titel={`Host-Tokens (${hostTokens.length})`} rahmen="info" tabelle>
|
||||
<div className="table-responsive">
|
||||
<table className="table table-striped align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Host</th>
|
||||
<th scope="col">Anfang</th>
|
||||
<th scope="col">Erstellt</th>
|
||||
<th scope="col">Zuletzt benutzt</th>
|
||||
<th scope="col">Zustand</th>
|
||||
<th scope="col" className="text-end">
|
||||
Aktion
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{hostTokens.map((token) => (
|
||||
<tr key={token.id}>
|
||||
<td>
|
||||
<Link href={`/hosts/${token.host.id}`} className="text-decoration-none">
|
||||
{token.host.hostname}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="pl-mono">{token.praefix}…</td>
|
||||
<td>{formatDatum(token.erstelltAm)}</td>
|
||||
<td>{formatDatumZeit(token.letzteNutzungAm)}</td>
|
||||
<td>
|
||||
{token.widerrufenAm ? (
|
||||
<Badge farbe="neutral">Widerrufen</Badge>
|
||||
) : (
|
||||
<Badge farbe="success">Aktiv</Badge>
|
||||
)}
|
||||
</td>
|
||||
<td className="text-end">
|
||||
{token.widerrufenAm ? null : (
|
||||
<form action={hostTokenWiderrufen}>
|
||||
<input type="hidden" name="id" value={token.id} />
|
||||
<input type="hidden" name="hostId" value={token.host.id} />
|
||||
<button type="submit" className="btn btn-sm btn-outline-danger">
|
||||
Widerrufen
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{hostTokens.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="text-center text-body-secondary py-4">
|
||||
Noch kein Host registriert.
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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" };
|
||||
|
||||
@@ -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() {
|
||||
|
||||
<div className="app-content">
|
||||
<div className="container-fluid">
|
||||
<Callout titel="Schnellzuweisung" farbe="info">
|
||||
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 ? (
|
||||
<Callout titel="Alles zugeordnet" farbe="info">
|
||||
Jeder gemeldete Host gehört zu einem Kunden. Neu registrierte Hosts erscheinen automatisch hier.
|
||||
</Callout>
|
||||
) : (
|
||||
<Callout titel="Schnellzuweisung" farbe="warning">
|
||||
Hosts auswählen, Kunde wählen und in einem Schritt zuweisen. Wer Hosts direkt vorzuordnen
|
||||
möchte, erzeugt unter <Link href="/einstellungen/tokens">Agent-Tokens</Link> einen
|
||||
Enrollment-Token mit Kundenbindung – damit landen neue Hosts sofort beim richtigen Kunden.
|
||||
</Callout>
|
||||
)}
|
||||
|
||||
<Card titel={`${liste.length} Hosts ohne Kunde`} rahmen="warning" tabelle>
|
||||
<div className="table-responsive">
|
||||
<table className="table table-striped align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">
|
||||
<input type="checkbox" className="form-check-input" aria-label="Alle auswählen" />
|
||||
</th>
|
||||
<th scope="col">Host</th>
|
||||
<th scope="col">Produkt</th>
|
||||
<th scope="col">Server-ID</th>
|
||||
<th scope="col">Key</th>
|
||||
<th scope="col">Erste Meldung</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{liste.map((host) => (
|
||||
<tr key={host.id}>
|
||||
<td>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="form-check-input"
|
||||
aria-label={`${host.hostname} auswählen`}
|
||||
defaultChecked
|
||||
{kunden.length === 0 ? (
|
||||
<Callout titel="Noch kein Kunde vorhanden" farbe="danger">
|
||||
Zum Zuweisen wird mindestens ein Kunde benötigt.{" "}
|
||||
<Link href="/kunden/neu">Jetzt einen Kunden anlegen.</Link>
|
||||
</Callout>
|
||||
) : null}
|
||||
|
||||
{hosts.length > 0 ? (
|
||||
<Card titel={`${hosts.length} Hosts ohne Kunde`} rahmen="warning" tabelle>
|
||||
<ZuweisungsFormular
|
||||
hosts={hosts.map((host) => ({
|
||||
id: host.id,
|
||||
hostname: host.hostname,
|
||||
produkt: host.produkt,
|
||||
serverId: host.serverId,
|
||||
subKey: host.subKey,
|
||||
erstmalsGesehenAm: host.erstmalsGesehenAm.toISOString(),
|
||||
}))}
|
||||
kunden={kunden}
|
||||
/>
|
||||
</td>
|
||||
<td className="fw-semibold">{host.hostname}</td>
|
||||
<td>{PRODUKT_NAME[host.produkt]}</td>
|
||||
<td>
|
||||
<CopyableCode wert={host.serverId} />
|
||||
</td>
|
||||
<td>
|
||||
<CopyableCode wert={host.subscription.key} />
|
||||
</td>
|
||||
<td>{formatDatumZeit(host.letzterReport)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card titel="Ausgewählte Hosts zuweisen">
|
||||
<div className="row g-2 align-items-end">
|
||||
<div className="col-md-5">
|
||||
<label className="form-label" htmlFor="kunde">
|
||||
Kunde
|
||||
</label>
|
||||
<select id="kunde" className="form-select">
|
||||
{kunden.map((kunde) => (
|
||||
<option key={kunde.id}>{kunde.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="col-md-5">
|
||||
<label className="form-label" htmlFor="standort">
|
||||
Standort (optional)
|
||||
</label>
|
||||
<select id="standort" className="form-select">
|
||||
<option>– kein Standort –</option>
|
||||
{kunden[0].standorte.map((standort) => (
|
||||
<option key={standort.id}>{standort.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="col-md-2">
|
||||
<button type="button" className="btn btn-primary w-100" disabled>
|
||||
Zuweisen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<ContentHeader
|
||||
titel={`${kunde.name} bearbeiten`}
|
||||
brotkrumen={[
|
||||
{ titel: "Kunden", href: "/kunden" },
|
||||
{ titel: kunde.name, href: `/kunden/${kunde.id}` },
|
||||
{ titel: "Bearbeiten" },
|
||||
]}
|
||||
/>
|
||||
<div className="app-content">
|
||||
<div className="container-fluid">
|
||||
<div className="row">
|
||||
<div className="col-lg-8">
|
||||
<Card titel="Stammdaten" rahmen="primary">
|
||||
<KundeFormular
|
||||
werte={{
|
||||
id: kunde.id,
|
||||
name: kunde.name,
|
||||
kundennummer: kunde.kundennummer,
|
||||
ansprechpartner: kunde.ansprechpartner,
|
||||
email: kunde.email,
|
||||
telefon: kunde.telefon,
|
||||
notizen: kunde.notizen,
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Card titel="Kunde löschen" rahmen="danger">
|
||||
<p>
|
||||
Der Kunde wird mit allen Standorten und Kundenrechnungen entfernt. Seine{" "}
|
||||
{kunde._count.hosts} Host(s) bleiben erhalten und erscheinen danach unter „Nicht
|
||||
zugeordnet“.
|
||||
</p>
|
||||
<form action={kundeLoeschen}>
|
||||
<input type="hidden" name="id" value={kunde.id} />
|
||||
<button type="submit" className="btn btn-danger pl-touch-target">
|
||||
Endgültig löschen
|
||||
</button>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<>
|
||||
<ContentHeader
|
||||
titel={kunde.name}
|
||||
brotkrumen={[{ titel: "Kunden", href: "/kunden" }, { titel: kunde.name }]}
|
||||
aktion={
|
||||
<Link href={`/kunden/${kunde.id}/bearbeiten`} className="btn btn-sm btn-outline-primary">
|
||||
<i className="bi bi-pencil me-1" aria-hidden="true" />
|
||||
Bearbeiten
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="app-content">
|
||||
<div className="container-fluid">
|
||||
<div className="row">
|
||||
<div className="col-6 col-lg-3">
|
||||
<InfoBox text="Hosts" wert={String(eigeneHosts.length)} icon="bi-hdd-stack" farbe="info" />
|
||||
<InfoBox text="Hosts" wert={String(kunde.hosts.length)} icon="bi-hdd-stack" farbe="info" />
|
||||
</div>
|
||||
<div className="col-6 col-lg-3">
|
||||
<InfoBox
|
||||
text="Einkauf netto"
|
||||
wert={formatEuro(summen.einkauf)}
|
||||
wert={formatEuro(gesamt.einkauf)}
|
||||
icon="bi-cart"
|
||||
farbe="neutral"
|
||||
/>
|
||||
@@ -68,7 +65,7 @@ export default async function KundenDetailSeite({ params }: { params: Promise<{
|
||||
<div className="col-6 col-lg-3">
|
||||
<InfoBox
|
||||
text="Verkauf netto"
|
||||
wert={formatEuro(summen.verkauf)}
|
||||
wert={formatEuro(gesamt.verkauf)}
|
||||
icon="bi-receipt"
|
||||
farbe="primary"
|
||||
/>
|
||||
@@ -76,7 +73,7 @@ export default async function KundenDetailSeite({ params }: { params: Promise<{
|
||||
<div className="col-6 col-lg-3">
|
||||
<InfoBox
|
||||
text="Marge"
|
||||
wert={formatEuro(summen.marge)}
|
||||
wert={formatEuro(gesamt.marge)}
|
||||
icon="bi-graph-up-arrow"
|
||||
farbe="success"
|
||||
/>
|
||||
@@ -97,15 +94,6 @@ export default async function KundenDetailSeite({ params }: { params: Promise<{
|
||||
</dd>
|
||||
<dt className="col-5 fw-normal text-body-secondary">Telefon</dt>
|
||||
<dd className="col-7">{kunde.telefon ?? "–"}</dd>
|
||||
<dt className="col-5 fw-normal text-body-secondary">Standorte</dt>
|
||||
<dd className="col-7">
|
||||
{kunde.standorte.map((s) => (
|
||||
<div key={s.id}>
|
||||
{s.name}
|
||||
{s.ort ? <span className="text-body-secondary"> · {s.ort}</span> : null}
|
||||
</div>
|
||||
))}
|
||||
</dd>
|
||||
</dl>
|
||||
<hr />
|
||||
<h4 className="h6">Notizen</h4>
|
||||
@@ -114,12 +102,26 @@ export default async function KundenDetailSeite({ params }: { params: Promise<{
|
||||
</p>
|
||||
</Card>
|
||||
|
||||
<Card titel="Offene Warnungen" rahmen={alerts.length > 0 ? "danger" : "info"}>
|
||||
{alerts.length === 0 ? (
|
||||
<Card titel="Standorte" rahmen="info">
|
||||
<StandortVerwaltung
|
||||
kundeId={kunde.id}
|
||||
standorte={kunde.standorte.map((standort) => ({
|
||||
id: standort.id,
|
||||
name: standort.name,
|
||||
strasse: standort.strasse,
|
||||
plz: standort.plz,
|
||||
ort: standort.ort,
|
||||
hosts: kunde.hosts.filter((host) => host.standortId === standort.id).length,
|
||||
}))}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Card titel="Offene Warnungen" rahmen={kunde.alerts.length > 0 ? "danger" : "info"}>
|
||||
{kunde.alerts.length === 0 ? (
|
||||
<p className="mb-0 text-body-secondary">Keine offenen Warnungen.</p>
|
||||
) : (
|
||||
<ul className="list-unstyled mb-0 small">
|
||||
{alerts.map((alert) => (
|
||||
{kunde.alerts.map((alert) => (
|
||||
<li key={alert.id} className="mb-2">
|
||||
<strong>{alert.titel}</strong>
|
||||
<div className="text-body-secondary">{alert.beschreibung}</div>
|
||||
@@ -131,7 +133,7 @@ export default async function KundenDetailSeite({ params }: { params: Promise<{
|
||||
</div>
|
||||
|
||||
<div className="col-lg-8">
|
||||
<Card titel="Hosts" rahmen="primary" tabelle>
|
||||
<Card titel={`Hosts (${kunde.hosts.length})`} rahmen="primary" tabelle>
|
||||
<div className="table-responsive">
|
||||
<table className="table table-striped align-middle">
|
||||
<thead>
|
||||
@@ -141,29 +143,54 @@ export default async function KundenDetailSeite({ params }: { params: Promise<{
|
||||
<th scope="col">Status</th>
|
||||
<th scope="col">Key</th>
|
||||
<th scope="col">Ablauf</th>
|
||||
<th scope="col" className="text-end">
|
||||
Zuordnung
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{eigeneHosts.map((host) => (
|
||||
{kunde.hosts.map((host) => (
|
||||
<tr key={host.id}>
|
||||
<td>
|
||||
<Link href={`/hosts/${host.id}`} className="fw-semibold text-decoration-none">
|
||||
{host.hostname}
|
||||
</Link>
|
||||
{hostMeldetNicht(host) ? <KeineMeldungBadge /> : null}
|
||||
{!host.letzterReportAm || host.letzterReportAm < grenze ? (
|
||||
<KeineMeldungBadge />
|
||||
) : null}
|
||||
</td>
|
||||
<td>{PRODUKT_NAME[host.produkt]}</td>
|
||||
<td>{PRODUKT_TEXT[host.produkt]}</td>
|
||||
<td>
|
||||
<StatusBadge status={host.subscription.status} />
|
||||
<StatusBadge status={statusAusDatenbank(host.subStatus)} />
|
||||
</td>
|
||||
<td>
|
||||
<CopyableCode wert={host.subscription.key} />
|
||||
<CopyableCode wert={host.subKey} />
|
||||
</td>
|
||||
<td>
|
||||
<AblaufBadge datum={host.subscription.nextduedate} heute={HEUTE} />
|
||||
<AblaufBadge datum={host.subNextduedate?.toISOString() ?? null} heute={heute} />
|
||||
</td>
|
||||
<td className="text-end">
|
||||
<form action={hostZuordnungAufheben}>
|
||||
<input type="hidden" name="hostId" value={host.id} />
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-sm btn-outline-secondary"
|
||||
title="Zuordnung zu diesem Kunden aufheben"
|
||||
>
|
||||
Lösen
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{kunde.hosts.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="text-center text-body-secondary py-4">
|
||||
Diesem Kunden ist noch kein Host zugewiesen.{" "}
|
||||
<Link href="/hosts/nicht-zugeordnet">Jetzt zuweisen</Link>
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -183,9 +210,7 @@ export default async function KundenDetailSeite({ params }: { params: Promise<{
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{laufzeiten.map((laufzeit) => {
|
||||
const host = eigeneHosts.find((h) => h.id === laufzeit.hostId);
|
||||
return (
|
||||
{laufzeiten.map((laufzeit) => (
|
||||
<tr key={laufzeit.id}>
|
||||
<td>
|
||||
{formatDatum(laufzeit.start)} – {formatDatum(laufzeit.ende)}
|
||||
@@ -193,7 +218,7 @@ export default async function KundenDetailSeite({ params }: { params: Promise<{
|
||||
<span className="badge pl-bg-info ms-2">Vorschlag</span>
|
||||
) : null}
|
||||
</td>
|
||||
<td>{host?.hostname ?? "–"}</td>
|
||||
<td>{laufzeit.hostname}</td>
|
||||
<td>
|
||||
<CopyableCode wert={laufzeit.einkauf?.nummer ?? null} />
|
||||
<div>
|
||||
@@ -212,13 +237,20 @@ export default async function KundenDetailSeite({ params }: { params: Promise<{
|
||||
</td>
|
||||
<td>{formatEuro(laufzeit.verkauf?.nettoBetrag)}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
))}
|
||||
{laufzeiten.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="text-center text-body-secondary py-4">
|
||||
Noch keine Laufzeiten erfasst.
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{proJahr.length > 0 ? (
|
||||
<Card titel="Summen pro Jahr" rahmen="info" tabelle>
|
||||
<table className="table table-striped align-middle mb-0">
|
||||
<thead>
|
||||
@@ -243,6 +275,7 @@ export default async function KundenDetailSeite({ params }: { params: Promise<{
|
||||
</tbody>
|
||||
</table>
|
||||
</Card>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { ContentHeader } from "@/components/layout/ContentHeader";
|
||||
import { KundeFormular } from "@/components/kunden/KundeFormular";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
|
||||
export const metadata = { title: "Kunde anlegen" };
|
||||
|
||||
export default function KundeNeuSeite() {
|
||||
return (
|
||||
<>
|
||||
<ContentHeader
|
||||
titel="Kunde anlegen"
|
||||
brotkrumen={[{ titel: "Kunden", href: "/kunden" }, { titel: "Neu" }]}
|
||||
/>
|
||||
<div className="app-content">
|
||||
<div className="container-fluid">
|
||||
<div className="row">
|
||||
<div className="col-lg-8">
|
||||
<Card titel="Stammdaten" rahmen="primary">
|
||||
<KundeFormular />
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -2,14 +2,13 @@ import Link from "next/link";
|
||||
import { ContentHeader } from "@/components/layout/ContentHeader";
|
||||
import { AblaufBadge, Badge } from "@/components/ui/Badges";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { formatEuro } from "@/lib/format";
|
||||
import { HEUTE } from "@/mock/data";
|
||||
import { kundenUebersicht, summenVonKunde } from "@/mock/queries";
|
||||
import { kundenListe, offeneRechnungspunkteProKunde } from "@/server/queries/kunden";
|
||||
|
||||
export const metadata = { title: "Kunden" };
|
||||
|
||||
export default function KundenSeite() {
|
||||
const uebersicht = kundenUebersicht();
|
||||
export default async function KundenSeite() {
|
||||
const [kunden, rechnungspunkte] = await Promise.all([kundenListe(), offeneRechnungspunkteProKunde()]);
|
||||
const heute = new Date();
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -18,10 +17,15 @@ export default function KundenSeite() {
|
||||
<div className="app-content">
|
||||
<div className="container-fluid">
|
||||
<Card
|
||||
titel={`${uebersicht.length} Kunden`}
|
||||
titel={`${kunden.length} Kunden`}
|
||||
rahmen="primary"
|
||||
tabelle
|
||||
werkzeuge={<span className="btn btn-sm btn-primary disabled">Kunde anlegen (ab #006)</span>}
|
||||
werkzeuge={
|
||||
<Link href="/kunden/neu" className="btn btn-sm btn-primary">
|
||||
<i className="bi bi-plus-lg me-1" aria-hidden="true" />
|
||||
Kunde anlegen
|
||||
</Link>
|
||||
}
|
||||
>
|
||||
<div className="table-responsive">
|
||||
<table className="table table-striped align-middle">
|
||||
@@ -34,49 +38,50 @@ export default function KundenSeite() {
|
||||
<th scope="col">Nächster Ablauf</th>
|
||||
<th scope="col">Warnungen</th>
|
||||
<th scope="col">Rechnungen</th>
|
||||
<th scope="col">Marge (netto)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{uebersicht.map((eintrag) => {
|
||||
const summen = summenVonKunde(eintrag.kunde.id);
|
||||
{kunden.map((kunde) => {
|
||||
const punkte = rechnungspunkte.get(kunde.id);
|
||||
return (
|
||||
<tr key={eintrag.kunde.id}>
|
||||
<tr key={kunde.id}>
|
||||
<td>
|
||||
<Link
|
||||
href={`/kunden/${eintrag.kunde.id}`}
|
||||
className="fw-semibold text-decoration-none"
|
||||
>
|
||||
{eintrag.kunde.name}
|
||||
<Link href={`/kunden/${kunde.id}`} className="fw-semibold text-decoration-none">
|
||||
{kunde.name}
|
||||
</Link>
|
||||
<div className="small text-body-secondary">
|
||||
{eintrag.kunde.ansprechpartner ?? "–"}
|
||||
</div>
|
||||
<div className="small text-body-secondary">{kunde.ansprechpartner ?? "–"}</div>
|
||||
</td>
|
||||
<td className="pl-mono">{eintrag.kunde.kundennummer}</td>
|
||||
<td>{eintrag.hosts}</td>
|
||||
<td>{eintrag.aktiv}</td>
|
||||
<td className="pl-mono">{kunde.kundennummer}</td>
|
||||
<td>{kunde.hosts}</td>
|
||||
<td>{kunde.aktiv}</td>
|
||||
<td>
|
||||
<AblaufBadge datum={eintrag.naechsterAblauf} heute={HEUTE} />
|
||||
<AblaufBadge datum={kunde.naechsterAblauf?.toISOString() ?? null} heute={heute} />
|
||||
</td>
|
||||
<td>
|
||||
{eintrag.offeneAlerts > 0 ? (
|
||||
<Badge farbe="warning">{eintrag.offeneAlerts} offen</Badge>
|
||||
{kunde.offeneAlerts > 0 ? (
|
||||
<Badge farbe="warning">{kunde.offeneAlerts} offen</Badge>
|
||||
) : (
|
||||
<Badge farbe="success">keine</Badge>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
{eintrag.offeneRechnungspunkte > 0 ? (
|
||||
<Badge farbe="danger">{eintrag.offeneRechnungspunkte} offen</Badge>
|
||||
{punkte && punkte.offen > 0 ? (
|
||||
<Badge farbe="danger">{punkte.offen} offen</Badge>
|
||||
) : (
|
||||
<Badge farbe="success">erledigt</Badge>
|
||||
)}
|
||||
</td>
|
||||
<td>{formatEuro(summen.marge)}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
{kunden.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="text-center text-body-secondary py-4">
|
||||
Noch keine Kunden angelegt.{" "}
|
||||
<Link href="/kunden/neu">Jetzt den ersten Kunden anlegen.</Link>
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@@ -1,21 +1,35 @@
|
||||
import { Footer } from "@/components/layout/Footer";
|
||||
import { Header } from "@/components/layout/Header";
|
||||
import { Sidebar } from "@/components/layout/Sidebar";
|
||||
import { hosts } from "@/mock/data";
|
||||
import { dashboardKennzahlen, offeneAlerts } from "@/mock/queries";
|
||||
import { suchIndex } from "@/mock/suchindex";
|
||||
import { kennzahlen, offeneAlerts, suchIndex } from "@/server/queries/hosts";
|
||||
|
||||
/**
|
||||
* Das Dashboard zeigt immer den aktuellen Datenbankstand; beim Bauen darf nichts
|
||||
* vorgerendert werden (sonst öffnet jeder Build-Worker eigene Datenbankverbindungen).
|
||||
*/
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const BENUTZER = "Florian";
|
||||
|
||||
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
|
||||
const kennzahlen = dashboardKennzahlen();
|
||||
export default async function DashboardLayout({ children }: { children: React.ReactNode }) {
|
||||
const [zahlen, alerts, suche] = await Promise.all([kennzahlen(), offeneAlerts(), suchIndex()]);
|
||||
|
||||
return (
|
||||
<div className="app-wrapper">
|
||||
<Header alerts={offeneAlerts()} suchEintraege={suchIndex()} benutzer={BENUTZER} />
|
||||
<Header
|
||||
alerts={alerts.map((alert) => ({
|
||||
id: alert.id,
|
||||
titel: alert.titel,
|
||||
beschreibung: alert.beschreibung,
|
||||
schwere: alert.schwere,
|
||||
hostId: alert.hostId,
|
||||
}))}
|
||||
suchEintraege={suche}
|
||||
benutzer={BENUTZER}
|
||||
/>
|
||||
<Sidebar
|
||||
hostsAnzahl={hosts.length}
|
||||
offeneRechnungspunkte={kennzahlen.offeneRechnungspunkte}
|
||||
hostsAnzahl={zahlen.hostsGesamt}
|
||||
offeneRechnungspunkte={zahlen.offeneRechnungspunkte}
|
||||
benutzer={BENUTZER}
|
||||
/>
|
||||
<main className="app-main">{children}</main>
|
||||
|
||||
@@ -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) => ({
|
||||
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} · seit ${formatDatum(host.letzterReport)} gemeldet`,
|
||||
dringlichkeit: "info",
|
||||
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 (
|
||||
{ablaufListe.map((host) => (
|
||||
<tr key={host.id}>
|
||||
<td>
|
||||
<Link href={`/hosts/${host.id}`} className="fw-semibold text-decoration-none">
|
||||
{host.hostname}
|
||||
</Link>
|
||||
{hostMeldetNicht(host) ? <KeineMeldungBadge /> : null}
|
||||
{host.meldetNicht ? <KeineMeldungBadge /> : null}
|
||||
</td>
|
||||
<td>
|
||||
{kunde ? (
|
||||
<Link href={`/kunden/${kunde.id}`} className="text-decoration-none">
|
||||
{kunde.name}
|
||||
{host.kunde ? (
|
||||
<Link href={`/kunden/${host.kunde.id}`} className="text-decoration-none">
|
||||
{host.kunde.name}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="badge pl-bg-neutral">Nicht zugeordnet</span>
|
||||
<Link
|
||||
href="/hosts/nicht-zugeordnet"
|
||||
className="badge pl-bg-neutral text-decoration-none"
|
||||
>
|
||||
Nicht zugeordnet
|
||||
</Link>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<CopyableCode wert={host.subscription.key} />
|
||||
<CopyableCode wert={host.subKey} />
|
||||
</td>
|
||||
<td>
|
||||
<AblaufBadge datum={host.subscription.nextduedate} heute={HEUTE} />
|
||||
<AblaufBadge datum={host.nextduedate?.toISOString() ?? null} heute={heute} />
|
||||
</td>
|
||||
<td>
|
||||
<EinkaufBadge bezahlt={laufzeit?.einkauf ? laufzeit.einkauf.bezahlt : null} />
|
||||
<EinkaufBadge bezahlt={host.einkaufBezahlt} />
|
||||
</td>
|
||||
<td>
|
||||
<VerkaufBadge
|
||||
erstellt={laufzeit?.verkauf?.erstellt ?? false}
|
||||
bezahlt={laufzeit?.verkauf?.bezahlt ?? false}
|
||||
/>
|
||||
<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}>
|
||||
{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/${eintrag.kunde.id}`} className="text-decoration-none">
|
||||
{eintrag.kunde.name}
|
||||
<Link href={`/kunden/${kunde.id}`} className="text-decoration-none">
|
||||
{kunde.name}
|
||||
</Link>
|
||||
<span className="text-body-secondary">
|
||||
{eintrag.erledigt} von {eintrag.gesamt} Punkten erledigt
|
||||
{erledigt} von {punkte.gesamt} Punkten erledigt
|
||||
</span>
|
||||
</div>
|
||||
<ProgressBar
|
||||
prozent={eintrag.prozent}
|
||||
beschriftung={`Rechnungsstatus ${eintrag.kunde.name}`}
|
||||
/>
|
||||
<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>
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
"use client";
|
||||
|
||||
import { useActionState, useState } from "react";
|
||||
import { CopyableCode } from "@/components/ui/CopyableCode";
|
||||
import { formatDatumZeit } from "@/lib/format";
|
||||
import { PRODUKT_TEXT } from "@/lib/anzeige";
|
||||
import { hostsZuweisen } from "@/server/actions/hosts";
|
||||
import type { AktionsErgebnis } from "@/server/actions/kunden";
|
||||
|
||||
export interface ZuweisbarerHost {
|
||||
id: string;
|
||||
hostname: string;
|
||||
produkt: string;
|
||||
serverId: string | null;
|
||||
subKey: string | null;
|
||||
erstmalsGesehenAm: string;
|
||||
}
|
||||
|
||||
export interface KundeMitStandorten {
|
||||
id: string;
|
||||
name: string;
|
||||
standorte: { id: string; name: string }[];
|
||||
}
|
||||
|
||||
const LEER: AktionsErgebnis = {};
|
||||
|
||||
/** Liste der nicht zugeordneten Hosts mit Mehrfachauswahl und gemeinsamer Zuweisung. */
|
||||
export function ZuweisungsFormular({
|
||||
hosts,
|
||||
kunden,
|
||||
}: {
|
||||
hosts: ZuweisbarerHost[];
|
||||
kunden: KundeMitStandorten[];
|
||||
}) {
|
||||
const [ergebnis, aktion, laeuft] = useActionState(hostsZuweisen, LEER);
|
||||
const [ausgewaehlt, setAusgewaehlt] = useState<string[]>(hosts.map((host) => host.id));
|
||||
const [kundeId, setKundeId] = useState(kunden[0]?.id ?? "");
|
||||
|
||||
const standorte = kunden.find((kunde) => kunde.id === kundeId)?.standorte ?? [];
|
||||
const alleAusgewaehlt = ausgewaehlt.length === hosts.length && hosts.length > 0;
|
||||
|
||||
function umschalten(id: string) {
|
||||
setAusgewaehlt((alt) => (alt.includes(id) ? alt.filter((x) => x !== id) : [...alt, id]));
|
||||
}
|
||||
|
||||
return (
|
||||
<form action={aktion}>
|
||||
{ergebnis.fehler ? <div className="alert alert-danger">{ergebnis.fehler}</div> : null}
|
||||
{ergebnis.erfolg ? <div className="alert alert-success">{ergebnis.erfolg}</div> : null}
|
||||
|
||||
<div className="table-responsive">
|
||||
<table className="table table-striped align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="form-check-input"
|
||||
aria-label="Alle auswählen"
|
||||
checked={alleAusgewaehlt}
|
||||
onChange={() => setAusgewaehlt(alleAusgewaehlt ? [] : hosts.map((host) => host.id))}
|
||||
/>
|
||||
</th>
|
||||
<th scope="col">Host</th>
|
||||
<th scope="col">Produkt</th>
|
||||
<th scope="col">Server-ID</th>
|
||||
<th scope="col">Key</th>
|
||||
<th scope="col">Erstmals gesehen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{hosts.map((host) => (
|
||||
<tr key={host.id}>
|
||||
<td>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="form-check-input pl-touch-target"
|
||||
name="hostId"
|
||||
value={host.id}
|
||||
checked={ausgewaehlt.includes(host.id)}
|
||||
onChange={() => umschalten(host.id)}
|
||||
aria-label={`${host.hostname} auswählen`}
|
||||
/>
|
||||
</td>
|
||||
<td className="fw-semibold">{host.hostname}</td>
|
||||
<td>{PRODUKT_TEXT[host.produkt] ?? host.produkt}</td>
|
||||
<td>
|
||||
<CopyableCode wert={host.serverId} />
|
||||
</td>
|
||||
<td>
|
||||
<CopyableCode wert={host.subKey} />
|
||||
</td>
|
||||
<td>{formatDatumZeit(host.erstmalsGesehenAm)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="row g-2 align-items-end p-3 border-top">
|
||||
<div className="col-md-5">
|
||||
<label className="form-label" htmlFor="kundeId">
|
||||
Kunde
|
||||
</label>
|
||||
<select
|
||||
id="kundeId"
|
||||
name="kundeId"
|
||||
className="form-select"
|
||||
value={kundeId}
|
||||
onChange={(e) => setKundeId(e.target.value)}
|
||||
required
|
||||
>
|
||||
{kunden.length === 0 ? <option value="">Bitte zuerst einen Kunden anlegen</option> : null}
|
||||
{kunden.map((kunde) => (
|
||||
<option key={kunde.id} value={kunde.id}>
|
||||
{kunde.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="col-md-5">
|
||||
<label className="form-label" htmlFor="standortId">
|
||||
Standort (optional)
|
||||
</label>
|
||||
<select id="standortId" name="standortId" className="form-select">
|
||||
<option value="">– kein Standort –</option>
|
||||
{standorte.map((standort) => (
|
||||
<option key={standort.id} value={standort.id}>
|
||||
{standort.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="col-md-2">
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary w-100 pl-touch-target"
|
||||
disabled={laeuft || ausgewaehlt.length === 0 || kunden.length === 0}
|
||||
>
|
||||
{laeuft ? "Weist zu …" : `${ausgewaehlt.length} zuweisen`}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useActionState } from "react";
|
||||
import { kundeSpeichern, type AktionsErgebnis } from "@/server/actions/kunden";
|
||||
|
||||
export interface KundeFormularWerte {
|
||||
id?: string;
|
||||
name: string;
|
||||
kundennummer: string;
|
||||
ansprechpartner: string | null;
|
||||
email: string | null;
|
||||
telefon: string | null;
|
||||
notizen: string | null;
|
||||
}
|
||||
|
||||
const LEER: AktionsErgebnis = {};
|
||||
|
||||
export function KundeFormular({ werte }: { werte?: KundeFormularWerte }) {
|
||||
const [ergebnis, aktion, laeuft] = useActionState(kundeSpeichern, LEER);
|
||||
|
||||
function feldKlasse(feld: string) {
|
||||
return `form-control ${ergebnis.felder?.[feld] ? "is-invalid" : ""}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<form action={aktion}>
|
||||
{werte?.id ? <input type="hidden" name="id" value={werte.id} /> : null}
|
||||
|
||||
{ergebnis.fehler ? (
|
||||
<div className="alert alert-danger" role="alert">
|
||||
{ergebnis.fehler}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="row g-3">
|
||||
<div className="col-md-6">
|
||||
<label className="form-label" htmlFor="name">
|
||||
Name *
|
||||
</label>
|
||||
<input
|
||||
id="name"
|
||||
name="name"
|
||||
className={feldKlasse("name")}
|
||||
defaultValue={werte?.name ?? ""}
|
||||
required
|
||||
/>
|
||||
{ergebnis.felder?.name ? <div className="invalid-feedback">{ergebnis.felder.name}</div> : null}
|
||||
</div>
|
||||
|
||||
<div className="col-md-6">
|
||||
<label className="form-label" htmlFor="kundennummer">
|
||||
Kundennummer *
|
||||
</label>
|
||||
<input
|
||||
id="kundennummer"
|
||||
name="kundennummer"
|
||||
className={`${feldKlasse("kundennummer")} pl-mono`}
|
||||
defaultValue={werte?.kundennummer ?? ""}
|
||||
placeholder="K-1001"
|
||||
required
|
||||
/>
|
||||
{ergebnis.felder?.kundennummer ? (
|
||||
<div className="invalid-feedback">{ergebnis.felder.kundennummer}</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="col-md-6">
|
||||
<label className="form-label" htmlFor="ansprechpartner">
|
||||
Ansprechpartner
|
||||
</label>
|
||||
<input
|
||||
id="ansprechpartner"
|
||||
name="ansprechpartner"
|
||||
className="form-control"
|
||||
defaultValue={werte?.ansprechpartner ?? ""}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="col-md-6">
|
||||
<label className="form-label" htmlFor="email">
|
||||
E-Mail
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
className={feldKlasse("email")}
|
||||
defaultValue={werte?.email ?? ""}
|
||||
/>
|
||||
{ergebnis.felder?.email ? <div className="invalid-feedback">{ergebnis.felder.email}</div> : null}
|
||||
</div>
|
||||
|
||||
<div className="col-md-6">
|
||||
<label className="form-label" htmlFor="telefon">
|
||||
Telefon
|
||||
</label>
|
||||
<input id="telefon" name="telefon" className="form-control" defaultValue={werte?.telefon ?? ""} />
|
||||
</div>
|
||||
|
||||
<div className="col-12">
|
||||
<label className="form-label" htmlFor="notizen">
|
||||
Notizen
|
||||
</label>
|
||||
<textarea
|
||||
id="notizen"
|
||||
name="notizen"
|
||||
rows={3}
|
||||
className="form-control"
|
||||
defaultValue={werte?.notizen ?? ""}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="d-flex gap-2 mt-4">
|
||||
<button type="submit" className="btn btn-primary pl-touch-target" disabled={laeuft}>
|
||||
{laeuft ? "Speichert …" : "Speichern"}
|
||||
</button>
|
||||
<Link href={werte?.id ? `/kunden/${werte.id}` : "/kunden"} className="btn btn-outline-secondary">
|
||||
Abbrechen
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
"use client";
|
||||
|
||||
import { useActionState } from "react";
|
||||
import { standortLoeschen, standortSpeichern, type AktionsErgebnis } from "@/server/actions/kunden";
|
||||
|
||||
export interface StandortAnzeige {
|
||||
id: string;
|
||||
name: string;
|
||||
strasse: string | null;
|
||||
plz: string | null;
|
||||
ort: string | null;
|
||||
hosts: number;
|
||||
}
|
||||
|
||||
const LEER: AktionsErgebnis = {};
|
||||
|
||||
export function StandortVerwaltung({
|
||||
kundeId,
|
||||
standorte,
|
||||
}: {
|
||||
kundeId: string;
|
||||
standorte: StandortAnzeige[];
|
||||
}) {
|
||||
const [ergebnis, aktion, laeuft] = useActionState(standortSpeichern, LEER);
|
||||
|
||||
return (
|
||||
<>
|
||||
{standorte.length > 0 ? (
|
||||
<ul className="list-group list-group-flush mb-3">
|
||||
{standorte.map((standort) => (
|
||||
<li
|
||||
key={standort.id}
|
||||
className="list-group-item d-flex justify-content-between align-items-center px-0"
|
||||
>
|
||||
<span>
|
||||
<span className="fw-semibold">{standort.name}</span>
|
||||
<span className="d-block small text-body-secondary">
|
||||
{[standort.strasse, [standort.plz, standort.ort].filter(Boolean).join(" ")]
|
||||
.filter(Boolean)
|
||||
.join(" · ") || "Keine Adresse hinterlegt"}
|
||||
{standort.hosts > 0 ? ` · ${standort.hosts} Host(s)` : ""}
|
||||
</span>
|
||||
</span>
|
||||
<form action={standortLoeschen}>
|
||||
<input type="hidden" name="id" value={standort.id} />
|
||||
<input type="hidden" name="kundeId" value={kundeId} />
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-sm btn-outline-danger"
|
||||
title="Standort löschen"
|
||||
aria-label={`Standort ${standort.name} löschen`}
|
||||
>
|
||||
<i className="bi bi-trash" aria-hidden="true" />
|
||||
</button>
|
||||
</form>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-body-secondary">Noch kein Standort hinterlegt.</p>
|
||||
)}
|
||||
|
||||
{ergebnis.fehler ? <div className="alert alert-danger py-2">{ergebnis.fehler}</div> : null}
|
||||
{ergebnis.erfolg ? <div className="alert alert-success py-2">{ergebnis.erfolg}</div> : null}
|
||||
|
||||
<form action={aktion} className="row g-2">
|
||||
<input type="hidden" name="kundeId" value={kundeId} />
|
||||
<div className="col-12">
|
||||
<label className="form-label small" htmlFor="standort-name">
|
||||
Neuer Standort
|
||||
</label>
|
||||
<input
|
||||
id="standort-name"
|
||||
name="name"
|
||||
className="form-control form-control-sm"
|
||||
placeholder="z. B. Zentrale Landshut"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="col-6">
|
||||
<input name="plz" className="form-control form-control-sm" placeholder="PLZ" />
|
||||
</div>
|
||||
<div className="col-6">
|
||||
<input name="ort" className="form-control form-control-sm" placeholder="Ort" />
|
||||
</div>
|
||||
<div className="col-12">
|
||||
<button type="submit" className="btn btn-sm btn-outline-primary" disabled={laeuft}>
|
||||
{laeuft ? "Speichert …" : "Standort hinzufügen"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { APP_VERSION } from "@/lib/version";
|
||||
export function Footer() {
|
||||
return (
|
||||
<footer className="app-footer bg-body">
|
||||
<div className="float-end d-none d-sm-inline">Prototyp mit Beispieldaten (#002)</div>
|
||||
<div className="float-end d-none d-sm-inline">Proxmox Subscription Dashboard</div>
|
||||
<strong>ProxLedger</strong> · Version {APP_VERSION}
|
||||
</footer>
|
||||
);
|
||||
|
||||
@@ -2,14 +2,20 @@
|
||||
|
||||
import Link from "next/link";
|
||||
import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
|
||||
import type { SuchEintrag } from "@/mock/suchindex";
|
||||
import type { Alert } from "@/mock/types";
|
||||
import { SearchDialog } from "./SearchDialog";
|
||||
import { SearchDialog, type SuchEintrag } from "./SearchDialog";
|
||||
|
||||
const SCHWERE_ICON: Record<Alert["schwere"], string> = {
|
||||
gefahr: "bi-exclamation-octagon-fill pl-text-danger",
|
||||
warnung: "bi-exclamation-triangle-fill pl-text-warning",
|
||||
info: "bi-info-circle-fill text-body-secondary",
|
||||
export interface KopfleistenAlert {
|
||||
id: string;
|
||||
titel: string;
|
||||
beschreibung: string;
|
||||
schwere: "INFO" | "WARNUNG" | "GEFAHR";
|
||||
hostId: string | null;
|
||||
}
|
||||
|
||||
const SCHWERE_ICON: Record<KopfleistenAlert["schwere"], string> = {
|
||||
GEFAHR: "bi-exclamation-octagon-fill pl-text-danger",
|
||||
WARNUNG: "bi-exclamation-triangle-fill pl-text-warning",
|
||||
INFO: "bi-info-circle-fill text-body-secondary",
|
||||
};
|
||||
|
||||
/** Farbschema aus dem <html>-Attribut lesen, damit Server und Client übereinstimmen. */
|
||||
@@ -28,7 +34,7 @@ export function Header({
|
||||
suchEintraege,
|
||||
benutzer,
|
||||
}: {
|
||||
alerts: Alert[];
|
||||
alerts: KopfleistenAlert[];
|
||||
suchEintraege: SuchEintrag[];
|
||||
benutzer: string;
|
||||
}) {
|
||||
|
||||
@@ -2,7 +2,15 @@
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useMemo, useState } from "react";
|
||||
import type { SuchEintrag } from "@/mock/suchindex";
|
||||
|
||||
export interface SuchEintrag {
|
||||
typ: "Host" | "Kunde" | "Rechnung";
|
||||
titel: string;
|
||||
untertitel: string;
|
||||
href: string;
|
||||
/** Alle durchsuchbaren Werte in Kleinschreibung. */
|
||||
begriffe: string[];
|
||||
}
|
||||
|
||||
const TYP_FARBE: Record<SuchEintrag["typ"], string> = {
|
||||
Host: "pl-bg-info",
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
"use client";
|
||||
|
||||
import { useActionState } from "react";
|
||||
import { CopyableCode } from "@/components/ui/CopyableCode";
|
||||
import { enrollmentTokenErzeugen, type TokenErgebnis } from "@/server/actions/tokens";
|
||||
|
||||
const LEER: TokenErgebnis = {};
|
||||
|
||||
/** Erzeugt Enrollment-Tokens und zeigt den Klartext genau einmal an. */
|
||||
export function TokenFormular({
|
||||
kunden,
|
||||
dashboardUrl,
|
||||
}: {
|
||||
kunden: { id: string; name: string }[];
|
||||
dashboardUrl: string;
|
||||
}) {
|
||||
const [ergebnis, aktion, laeuft] = useActionState(enrollmentTokenErzeugen, LEER);
|
||||
|
||||
return (
|
||||
<>
|
||||
{ergebnis.fehler ? <div className="alert alert-danger">{ergebnis.fehler}</div> : null}
|
||||
|
||||
{ergebnis.klartext ? (
|
||||
<div className="alert alert-success">
|
||||
<p className="fw-semibold mb-2">
|
||||
<i className="bi bi-check-circle me-1" aria-hidden="true" />
|
||||
Token erzeugt – er wird nur dieses eine Mal angezeigt.
|
||||
</p>
|
||||
<div className="mb-2">
|
||||
<CopyableCode wert={ergebnis.klartext} />
|
||||
</div>
|
||||
<p className="mb-1 small">Auf dem Proxmox-Host als root ausführen:</p>
|
||||
<pre className="bg-body p-2 rounded mb-0">
|
||||
<code className="pl-mono">
|
||||
curl -fsSL {dashboardUrl}/install.sh | bash -s -- --token {ergebnis.klartext}
|
||||
</code>
|
||||
</pre>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<form action={aktion} className="row g-3">
|
||||
<div className="col-md-4">
|
||||
<label className="form-label" htmlFor="bezeichnung">
|
||||
Bezeichnung
|
||||
</label>
|
||||
<input
|
||||
id="bezeichnung"
|
||||
name="bezeichnung"
|
||||
className="form-control"
|
||||
placeholder="z. B. Rollout Brandl"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-md-4">
|
||||
<label className="form-label" htmlFor="token-kundeId">
|
||||
Hosts direkt zuordnen zu
|
||||
</label>
|
||||
<select id="token-kundeId" name="kundeId" className="form-select">
|
||||
<option value="">– keinem Kunden –</option>
|
||||
{kunden.map((kunde) => (
|
||||
<option key={kunde.id} value={kunde.id}>
|
||||
{kunde.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="col-md-2">
|
||||
<label className="form-label" htmlFor="gueltigTage">
|
||||
Gültig (Tage)
|
||||
</label>
|
||||
<input
|
||||
id="gueltigTage"
|
||||
name="gueltigTage"
|
||||
type="number"
|
||||
min={1}
|
||||
max={365}
|
||||
defaultValue={30}
|
||||
className="form-control"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-md-2">
|
||||
<label className="form-label" htmlFor="maxNutzungen">
|
||||
Max. Nutzungen
|
||||
</label>
|
||||
<input
|
||||
id="maxNutzungen"
|
||||
name="maxNutzungen"
|
||||
type="number"
|
||||
min={1}
|
||||
placeholder="unbegrenzt"
|
||||
className="form-control"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-12">
|
||||
<button type="submit" className="btn btn-primary pl-touch-target" disabled={laeuft}>
|
||||
{laeuft ? "Erzeugt …" : "Token erzeugen"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,22 +1,23 @@
|
||||
import { formatDatum, formatTageBis, tageBis } from "@/lib/format";
|
||||
import { STATUS_TEXT } from "@/mock/queries";
|
||||
import type { SubscriptionStatus } from "@/mock/types";
|
||||
import { STATUS_TEXT, statusAusDatenbank, type AnzeigeStatus } from "@/lib/anzeige";
|
||||
import { FARB_KLASSE, type Farbton } from "./SmallBox";
|
||||
|
||||
export function Badge({ farbe, children }: { farbe: Farbton; children: React.ReactNode }) {
|
||||
return <span className={`badge ${FARB_KLASSE[farbe]}`}>{children}</span>;
|
||||
}
|
||||
|
||||
const STATUS_FARBE: Record<SubscriptionStatus, Farbton> = {
|
||||
const STATUS_FARBE: Record<AnzeigeStatus, Farbton> = {
|
||||
active: "success",
|
||||
notfound: "neutral",
|
||||
invalid: "danger",
|
||||
expired: "danger",
|
||||
suspended: "warning",
|
||||
unknown: "neutral",
|
||||
};
|
||||
|
||||
export function StatusBadge({ status }: { status: SubscriptionStatus }) {
|
||||
return <Badge farbe={STATUS_FARBE[status]}>{STATUS_TEXT[status]}</Badge>;
|
||||
export function StatusBadge({ status }: { status: string }) {
|
||||
const anzeige = statusAusDatenbank(status);
|
||||
return <Badge farbe={STATUS_FARBE[anzeige]}>{STATUS_TEXT[anzeige]}</Badge>;
|
||||
}
|
||||
|
||||
/** Ablaufdatum mit Ampel: rot unter 14 Tagen, gelb unter 30 Tagen, sonst Info. */
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
/** Einheitliche Beschriftungen für Produkt und Subscription-Status (Datenbank und Prototyp-Daten). */
|
||||
|
||||
export type AnzeigeStatus = "active" | "notfound" | "invalid" | "expired" | "suspended" | "unknown";
|
||||
|
||||
export const PRODUKT_TEXT: Record<string, string> = {
|
||||
PVE: "Proxmox VE",
|
||||
PBS: "Proxmox Backup Server",
|
||||
PMG: "Proxmox Mail Gateway",
|
||||
pve: "Proxmox VE",
|
||||
pbs: "Proxmox Backup Server",
|
||||
pmg: "Proxmox Mail Gateway",
|
||||
};
|
||||
|
||||
export const STATUS_TEXT: Record<AnzeigeStatus, string> = {
|
||||
active: "Aktiv",
|
||||
notfound: "Keine Subscription",
|
||||
invalid: "Ungültig",
|
||||
expired: "Abgelaufen",
|
||||
suspended: "Ausgesetzt",
|
||||
unknown: "Unbekannt",
|
||||
};
|
||||
|
||||
/** Nimmt sowohl die Datenbank-Schreibweise (ACTIVE) als auch die von Proxmox (active) entgegen. */
|
||||
export function statusAusDatenbank(wert: string): AnzeigeStatus {
|
||||
const klein = wert.toLowerCase();
|
||||
return (["active", "notfound", "invalid", "expired", "suspended"] as const).includes(
|
||||
klein as AnzeigeStatus & ("active" | "notfound" | "invalid" | "expired" | "suspended"),
|
||||
)
|
||||
? (klein as AnzeigeStatus)
|
||||
: "unknown";
|
||||
}
|
||||
@@ -3,28 +3,33 @@ import { PrismaClient } from "@/generated/prisma/client";
|
||||
|
||||
/**
|
||||
* Prisma-Client als Singleton, der erst beim ersten Zugriff verbindet.
|
||||
* Das ist wichtig, weil Next.js beim Bauen Module lädt, ohne dass `DATABASE_URL` gesetzt sein muss.
|
||||
* Im Entwicklungsmodus verhindert der globale Zwischenspeicher, dass jeder Hot-Reload
|
||||
* einen neuen Verbindungspool öffnet.
|
||||
* Verzögert, weil Next.js beim Bauen Module lädt, ohne dass `DATABASE_URL` gesetzt sein muss.
|
||||
* Der Client wird im Modul festgehalten – sonst entstünde bei jedem Zugriff ein neuer
|
||||
* Verbindungspool – und im Entwicklungsmodus zusätzlich global, damit Hot-Reloads ihn wiederverwenden.
|
||||
*/
|
||||
declare global {
|
||||
var proxledgerPrisma: PrismaClient | undefined;
|
||||
}
|
||||
|
||||
function erzeugeClient(): PrismaClient {
|
||||
let client: PrismaClient | undefined;
|
||||
|
||||
export function getPrisma(): PrismaClient {
|
||||
if (client) return client;
|
||||
if (globalThis.proxledgerPrisma) {
|
||||
client = globalThis.proxledgerPrisma;
|
||||
return client;
|
||||
}
|
||||
|
||||
const connectionString = process.env.DATABASE_URL;
|
||||
if (!connectionString) {
|
||||
throw new Error("DATABASE_URL ist nicht gesetzt. Beispielwerte stehen in .env.example.");
|
||||
}
|
||||
const client = new PrismaClient({ adapter: new PrismaPg({ connectionString }) });
|
||||
|
||||
client = new PrismaClient({ adapter: new PrismaPg({ connectionString, max: 10 }) });
|
||||
if (process.env.NODE_ENV !== "production") globalThis.proxledgerPrisma = client;
|
||||
return client;
|
||||
}
|
||||
|
||||
export function getPrisma(): PrismaClient {
|
||||
return globalThis.proxledgerPrisma ?? erzeugeClient();
|
||||
}
|
||||
|
||||
export const prisma: PrismaClient = new Proxy({} as PrismaClient, {
|
||||
get(_ziel, eigenschaft, empfaenger) {
|
||||
return Reflect.get(getPrisma(), eigenschaft, empfaenger);
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { protokolliere } from "@/server/audit";
|
||||
import { hostsZuweisenService, ZuweisungsFehler } from "@/server/services/zuweisung";
|
||||
import type { AktionsErgebnis } from "./kunden";
|
||||
|
||||
/** Weist mehrere Hosts in einem Schritt einem Kunden zu. */
|
||||
export async function hostsZuweisen(_vorher: AktionsErgebnis, formular: FormData): Promise<AktionsErgebnis> {
|
||||
const hostIds = formular.getAll("hostId").map(String).filter(Boolean);
|
||||
const kundeId = formular.get("kundeId")?.toString() || null;
|
||||
const standortId = formular.get("standortId")?.toString() || null;
|
||||
|
||||
try {
|
||||
const { anzahl, kundenName } = await hostsZuweisenService(hostIds, kundeId, standortId);
|
||||
|
||||
revalidatePath("/hosts/nicht-zugeordnet");
|
||||
revalidatePath("/hosts");
|
||||
revalidatePath("/kunden");
|
||||
revalidatePath(`/kunden/${kundeId}`);
|
||||
revalidatePath("/");
|
||||
|
||||
return { erfolg: `${anzahl} Host${anzahl === 1 ? "" : "s"} ${kundenName} zugewiesen.` };
|
||||
} catch (fehler) {
|
||||
if (fehler instanceof ZuweisungsFehler) return { fehler: fehler.message };
|
||||
throw fehler;
|
||||
}
|
||||
}
|
||||
|
||||
/** Hebt die Zuordnung eines einzelnen Hosts auf. */
|
||||
export async function hostZuordnungAufheben(formular: FormData) {
|
||||
const hostId = formular.get("hostId")?.toString();
|
||||
if (!hostId) return;
|
||||
|
||||
await prisma.host.update({ where: { id: hostId }, data: { kundeId: null, standortId: null } });
|
||||
await protokolliere("host.zuordnung_aufgehoben", "Host", hostId, {});
|
||||
|
||||
revalidatePath("/hosts");
|
||||
revalidatePath("/hosts/nicht-zugeordnet");
|
||||
revalidatePath(`/hosts/${hostId}`);
|
||||
revalidatePath("/kunden");
|
||||
revalidatePath("/");
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { protokolliere } from "@/server/audit";
|
||||
|
||||
export interface AktionsErgebnis {
|
||||
fehler?: string;
|
||||
felder?: Record<string, string>;
|
||||
erfolg?: string;
|
||||
}
|
||||
|
||||
const leerZuNull = (wert: FormDataEntryValue | null) => {
|
||||
const text = typeof wert === "string" ? wert.trim() : "";
|
||||
return text.length > 0 ? text : null;
|
||||
};
|
||||
|
||||
const kundeSchema = z.object({
|
||||
name: z.string().min(2, "Bitte einen Namen angeben."),
|
||||
kundennummer: z.string().min(1, "Bitte eine Kundennummer angeben."),
|
||||
ansprechpartner: z.string().nullable(),
|
||||
email: z.union([z.string().email("Keine gültige E-Mail-Adresse."), z.null()]),
|
||||
telefon: z.string().nullable(),
|
||||
notizen: z.string().nullable(),
|
||||
});
|
||||
|
||||
/** Legt einen Kunden an oder ändert ihn; `id` im Formular entscheidet darüber. */
|
||||
export async function kundeSpeichern(_vorher: AktionsErgebnis, formular: FormData): Promise<AktionsErgebnis> {
|
||||
const id = leerZuNull(formular.get("id"));
|
||||
const eingabe = kundeSchema.safeParse({
|
||||
name: formular.get("name")?.toString().trim() ?? "",
|
||||
kundennummer: formular.get("kundennummer")?.toString().trim() ?? "",
|
||||
ansprechpartner: leerZuNull(formular.get("ansprechpartner")),
|
||||
email: leerZuNull(formular.get("email")),
|
||||
telefon: leerZuNull(formular.get("telefon")),
|
||||
notizen: leerZuNull(formular.get("notizen")),
|
||||
});
|
||||
|
||||
if (!eingabe.success) {
|
||||
const felder: Record<string, string> = {};
|
||||
for (const problem of eingabe.error.issues) felder[String(problem.path[0])] = problem.message;
|
||||
return { fehler: "Bitte die markierten Felder prüfen.", felder };
|
||||
}
|
||||
|
||||
const vorhandeneNummer = await prisma.kunde.findUnique({
|
||||
where: { kundennummer: eingabe.data.kundennummer },
|
||||
});
|
||||
if (vorhandeneNummer && vorhandeneNummer.id !== id) {
|
||||
return {
|
||||
fehler: "Diese Kundennummer ist bereits vergeben.",
|
||||
felder: { kundennummer: "Bereits vergeben." },
|
||||
};
|
||||
}
|
||||
|
||||
const kunde = id
|
||||
? await prisma.kunde.update({ where: { id }, data: eingabe.data })
|
||||
: await prisma.kunde.create({ data: eingabe.data });
|
||||
|
||||
await protokolliere(id ? "kunde.geaendert" : "kunde.angelegt", "Kunde", kunde.id, eingabe.data);
|
||||
|
||||
revalidatePath("/kunden");
|
||||
revalidatePath(`/kunden/${kunde.id}`);
|
||||
redirect(`/kunden/${kunde.id}`);
|
||||
}
|
||||
|
||||
export async function kundeLoeschen(formular: FormData) {
|
||||
const id = formular.get("id")?.toString();
|
||||
if (!id) return;
|
||||
|
||||
const kunde = await prisma.kunde.findUnique({ where: { id }, select: { name: true } });
|
||||
// Hosts bleiben erhalten und gelten danach als nicht zugeordnet (onDelete: SetNull).
|
||||
await prisma.kunde.delete({ where: { id } });
|
||||
await protokolliere("kunde.geloescht", "Kunde", id, { name: kunde?.name });
|
||||
|
||||
revalidatePath("/kunden");
|
||||
revalidatePath("/hosts/nicht-zugeordnet");
|
||||
redirect("/kunden");
|
||||
}
|
||||
|
||||
const standortSchema = z.object({
|
||||
kundeId: z.string().min(1),
|
||||
name: z.string().min(2, "Bitte einen Namen angeben."),
|
||||
strasse: z.string().nullable(),
|
||||
plz: z.string().nullable(),
|
||||
ort: z.string().nullable(),
|
||||
});
|
||||
|
||||
export async function standortSpeichern(
|
||||
_vorher: AktionsErgebnis,
|
||||
formular: FormData,
|
||||
): Promise<AktionsErgebnis> {
|
||||
const id = leerZuNull(formular.get("id"));
|
||||
const eingabe = standortSchema.safeParse({
|
||||
kundeId: formular.get("kundeId")?.toString() ?? "",
|
||||
name: formular.get("name")?.toString().trim() ?? "",
|
||||
strasse: leerZuNull(formular.get("strasse")),
|
||||
plz: leerZuNull(formular.get("plz")),
|
||||
ort: leerZuNull(formular.get("ort")),
|
||||
});
|
||||
|
||||
if (!eingabe.success) {
|
||||
const felder: Record<string, string> = {};
|
||||
for (const problem of eingabe.error.issues) felder[String(problem.path[0])] = problem.message;
|
||||
return { fehler: "Bitte die markierten Felder prüfen.", felder };
|
||||
}
|
||||
|
||||
const standort = id
|
||||
? await prisma.standort.update({ where: { id }, data: eingabe.data })
|
||||
: await prisma.standort.create({ data: eingabe.data });
|
||||
|
||||
await protokolliere(id ? "standort.geaendert" : "standort.angelegt", "Standort", standort.id, eingabe.data);
|
||||
revalidatePath(`/kunden/${eingabe.data.kundeId}`);
|
||||
return { erfolg: "Standort gespeichert." };
|
||||
}
|
||||
|
||||
export async function standortLoeschen(formular: FormData) {
|
||||
const id = formular.get("id")?.toString();
|
||||
const kundeId = formular.get("kundeId")?.toString();
|
||||
if (!id) return;
|
||||
|
||||
await prisma.standort.delete({ where: { id } });
|
||||
await protokolliere("standort.geloescht", "Standort", id, {});
|
||||
if (kundeId) revalidatePath(`/kunden/${kundeId}`);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { erzeugeToken } from "@/lib/tokens";
|
||||
import { protokolliere } from "@/server/audit";
|
||||
import type { AktionsErgebnis } from "./kunden";
|
||||
|
||||
export interface TokenErgebnis extends AktionsErgebnis {
|
||||
/** Nur direkt nach dem Erzeugen gesetzt – danach ist der Token nicht mehr lesbar. */
|
||||
klartext?: string;
|
||||
}
|
||||
|
||||
/** Erzeugt einen Enrollment-Token; der Klartext wird genau einmal zurückgegeben. */
|
||||
export async function enrollmentTokenErzeugen(
|
||||
_vorher: TokenErgebnis,
|
||||
formular: FormData,
|
||||
): Promise<TokenErgebnis> {
|
||||
const bezeichnung = formular.get("bezeichnung")?.toString().trim() || null;
|
||||
const kundeId = formular.get("kundeId")?.toString() || null;
|
||||
const standortId = formular.get("standortId")?.toString() || null;
|
||||
const gueltigTage = Number(formular.get("gueltigTage") ?? 30);
|
||||
const maxNutzungenRoh = formular.get("maxNutzungen")?.toString().trim();
|
||||
const maxNutzungen = maxNutzungenRoh ? Number(maxNutzungenRoh) : null;
|
||||
|
||||
if (!Number.isFinite(gueltigTage) || gueltigTage < 1 || gueltigTage > 365) {
|
||||
return { fehler: "Gültigkeit muss zwischen 1 und 365 Tagen liegen." };
|
||||
}
|
||||
if (maxNutzungen !== null && (!Number.isInteger(maxNutzungen) || maxNutzungen < 1)) {
|
||||
return { fehler: "Die Anzahl der Nutzungen muss mindestens 1 sein." };
|
||||
}
|
||||
|
||||
const token = erzeugeToken("enrollment");
|
||||
const eintrag = await prisma.enrollmentToken.create({
|
||||
data: {
|
||||
tokenHash: token.hash,
|
||||
praefix: token.praefix,
|
||||
bezeichnung,
|
||||
kundeId,
|
||||
standortId,
|
||||
maxNutzungen,
|
||||
gueltigBis: new Date(Date.now() + gueltigTage * 86_400_000),
|
||||
},
|
||||
});
|
||||
|
||||
await protokolliere("enrollment_token.angelegt", "EnrollmentToken", eintrag.id, {
|
||||
bezeichnung,
|
||||
kundeId,
|
||||
gueltigTage,
|
||||
maxNutzungen,
|
||||
});
|
||||
|
||||
revalidatePath("/einstellungen/tokens");
|
||||
return {
|
||||
klartext: token.klartext,
|
||||
erfolg: "Token erzeugt. Er wird nur dieses eine Mal angezeigt.",
|
||||
};
|
||||
}
|
||||
|
||||
export async function enrollmentTokenWiderrufen(formular: FormData) {
|
||||
const id = formular.get("id")?.toString();
|
||||
if (!id) return;
|
||||
|
||||
await prisma.enrollmentToken.update({ where: { id }, data: { widerrufenAm: new Date() } });
|
||||
await protokolliere("enrollment_token.widerrufen", "EnrollmentToken", id, {});
|
||||
revalidatePath("/einstellungen/tokens");
|
||||
}
|
||||
|
||||
export async function hostTokenWiderrufen(formular: FormData) {
|
||||
const id = formular.get("id")?.toString();
|
||||
const hostId = formular.get("hostId")?.toString();
|
||||
if (!id) return;
|
||||
|
||||
await prisma.agentToken.update({ where: { id }, data: { widerrufenAm: new Date() } });
|
||||
await protokolliere("agent_token.widerrufen", "AgentToken", id, { hostId });
|
||||
|
||||
revalidatePath("/einstellungen/tokens");
|
||||
if (hostId) revalidatePath(`/hosts/${hostId}`);
|
||||
}
|
||||
@@ -20,11 +20,7 @@ export function fehlerBehandeln(fehler: unknown) {
|
||||
}
|
||||
|
||||
/** Prüft das Rate-Limit für IP und (falls vorhanden) Token. */
|
||||
export function rateLimitPruefen(
|
||||
limit: RateLimit,
|
||||
headers: Headers,
|
||||
token: string | null,
|
||||
): Response | null {
|
||||
export function rateLimitPruefen(limit: RateLimit, headers: Headers, token: string | null): Response | null {
|
||||
const schluessel = token ? `token:${token.slice(0, 16)}` : `ip:${clientIp(headers)}`;
|
||||
const ergebnis = limit.pruefe(schluessel);
|
||||
if (ergebnis.erlaubt) return null;
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
/**
|
||||
* Schreibt einen Eintrag ins Audit-Log. Solange es keine Anmeldung gibt (#013),
|
||||
* bleibt `benutzerId` leer und die Änderung gilt als vom System ausgelöst.
|
||||
*/
|
||||
export async function protokolliere(
|
||||
aktion: string,
|
||||
entitaet: string,
|
||||
entitaetId: string | null,
|
||||
aenderung?: Record<string, unknown>,
|
||||
) {
|
||||
await prisma.auditEintrag.create({
|
||||
data: {
|
||||
aktion,
|
||||
entitaet,
|
||||
entitaetId,
|
||||
aenderung: aenderung ? JSON.parse(JSON.stringify(aenderung)) : undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { meldungsGrenze } from "./hosts";
|
||||
|
||||
/**
|
||||
* Hosts nach Ablaufdatum mit der Laufzeit, die zu diesem Ablauf gehört.
|
||||
* Gezeigt wird bewusst die Verlängerung (die Laufzeit, die am `nextduedate` beginnt),
|
||||
* denn deren Rechnungen sind offen – nicht die der laufenden Periode.
|
||||
*/
|
||||
export async function hostsNachAblauf(jetzt: Date = new Date()) {
|
||||
const hosts = await prisma.host.findMany({
|
||||
where: { subNextduedate: { not: null } },
|
||||
orderBy: { subNextduedate: "asc" },
|
||||
include: {
|
||||
kunde: { select: { id: true, name: true } },
|
||||
laufzeiten: {
|
||||
where: { status: "BESTAETIGT" },
|
||||
orderBy: { start: "desc" },
|
||||
include: {
|
||||
proxmoxPosition: { include: { rechnung: { select: { nummer: true, bezahlt: true } } } },
|
||||
kundenPosition: { include: { rechnung: { select: { erstellt: true, bezahlt: true } } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const grenze = meldungsGrenze(jetzt);
|
||||
|
||||
return hosts.map((host) => {
|
||||
const ablauf = host.subNextduedate;
|
||||
const verlaengerung = host.laufzeiten.find(
|
||||
(laufzeit) => ablauf && laufzeit.start.getTime() === ablauf.getTime(),
|
||||
);
|
||||
const laufend = host.laufzeiten[0];
|
||||
const laufzeit = verlaengerung ?? laufend;
|
||||
|
||||
return {
|
||||
id: host.id,
|
||||
hostname: host.hostname,
|
||||
kunde: host.kunde,
|
||||
subKey: host.subKey,
|
||||
nextduedate: ablauf,
|
||||
meldetNicht: !host.letzterReportAm || host.letzterReportAm < grenze,
|
||||
einkaufBezahlt: laufzeit?.proxmoxPosition ? laufzeit.proxmoxPosition.rechnung.bezahlt : null,
|
||||
verkaufErstellt: laufzeit?.kundenPosition?.rechnung.erstellt ?? false,
|
||||
verkaufBezahlt: laufzeit?.kundenPosition?.rechnung.bezahlt ?? false,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Offene Rechnungspunkte als Aufgabenliste fürs Dashboard. */
|
||||
export async function offeneAufgaben() {
|
||||
const laufzeiten = await prisma.laufzeit.findMany({
|
||||
where: {
|
||||
status: "BESTAETIGT",
|
||||
OR: [
|
||||
{ proxmoxPosition: { rechnung: { bezahlt: false } } },
|
||||
{ kundenPosition: null },
|
||||
{ kundenPosition: { rechnung: { erstellt: false } } },
|
||||
],
|
||||
},
|
||||
orderBy: { start: "asc" },
|
||||
include: {
|
||||
host: { select: { id: true, hostname: true, kunde: { select: { name: true } } } },
|
||||
proxmoxPosition: { include: { rechnung: true } },
|
||||
kundenPosition: { include: { rechnung: true } },
|
||||
},
|
||||
});
|
||||
|
||||
const aufgaben = [];
|
||||
for (const laufzeit of laufzeiten) {
|
||||
const basis = `${laufzeit.host.hostname} · ${laufzeit.host.kunde?.name ?? "Nicht zugeordnet"}`;
|
||||
const einkauf = laufzeit.proxmoxPosition?.rechnung;
|
||||
|
||||
if (einkauf && !einkauf.bezahlt) {
|
||||
aufgaben.push({
|
||||
id: `${laufzeit.id}-einkauf`,
|
||||
titel: `Proxmox-Rechnung ${einkauf.nummer} bezahlen`,
|
||||
detail: basis,
|
||||
betrag: Number(laufzeit.proxmoxPosition?.nettoBetrag ?? 0),
|
||||
faelligAm: einkauf.faelligAm,
|
||||
dringlichkeit: "gefahr" as const,
|
||||
badgeText: "Einkauf",
|
||||
href: `/hosts/${laufzeit.hostId}`,
|
||||
start: laufzeit.start,
|
||||
ende: laufzeit.ende,
|
||||
});
|
||||
}
|
||||
|
||||
if (einkauf && !laufzeit.kundenPosition?.rechnung.erstellt) {
|
||||
aufgaben.push({
|
||||
id: `${laufzeit.id}-verkauf`,
|
||||
titel: "Kundenrechnung erstellen",
|
||||
detail: basis,
|
||||
betrag: null,
|
||||
faelligAm: null,
|
||||
dringlichkeit: "warnung" as const,
|
||||
badgeText: "Verkauf",
|
||||
href: "/rechnungen",
|
||||
start: laufzeit.start,
|
||||
ende: laufzeit.ende,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return aufgaben;
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
/** Frist, nach der ein Host als „meldet sich nicht“ gilt (konfigurierbar ab #011). */
|
||||
export const MELDUNG_FRIST_STUNDEN = 48;
|
||||
|
||||
export function meldungsGrenze(jetzt: Date = new Date()): Date {
|
||||
return new Date(jetzt.getTime() - MELDUNG_FRIST_STUNDEN * 3_600_000);
|
||||
}
|
||||
|
||||
export async function hostsOhneKunde() {
|
||||
return prisma.host.findMany({
|
||||
where: { kundeId: null },
|
||||
orderBy: { erstmalsGesehenAm: "desc" },
|
||||
});
|
||||
}
|
||||
|
||||
export async function hostAnzahl() {
|
||||
return prisma.host.count();
|
||||
}
|
||||
|
||||
/** Kennzahlen für Dashboard-Kacheln und Sidebar-Badges. */
|
||||
export async function kennzahlen(jetzt: Date = new Date()) {
|
||||
const in30Tagen = new Date(jetzt.getTime() + 30 * 86_400_000);
|
||||
|
||||
const [gesamt, aktiv, ohneKunde, baldAblaufend, ohneMeldung, offeneAlerts] = await Promise.all([
|
||||
prisma.host.count(),
|
||||
prisma.host.count({ where: { subStatus: "ACTIVE" } }),
|
||||
prisma.host.count({ where: { kundeId: null } }),
|
||||
prisma.host.count({ where: { subNextduedate: { gte: jetzt, lte: in30Tagen } } }),
|
||||
prisma.host.count({
|
||||
where: { OR: [{ letzterReportAm: null }, { letzterReportAm: { lt: meldungsGrenze(jetzt) } }] },
|
||||
}),
|
||||
prisma.alert.count({ where: { erledigtAm: null } }),
|
||||
]);
|
||||
|
||||
const [offeneEinkaufsrechnungen, fehlendeKundenrechnungen] = await Promise.all([
|
||||
prisma.laufzeit.count({
|
||||
where: { status: "BESTAETIGT", proxmoxPosition: { rechnung: { bezahlt: false } } },
|
||||
}),
|
||||
prisma.laufzeit.count({
|
||||
where: {
|
||||
status: "BESTAETIGT",
|
||||
proxmoxPosition: { isNot: null },
|
||||
OR: [{ kundenPosition: null }, { kundenPosition: { rechnung: { erstellt: false } } }],
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
hostsGesamt: gesamt,
|
||||
aktivLizenziert: aktiv,
|
||||
ohneSubscription: gesamt - aktiv,
|
||||
ohneKunde,
|
||||
baldAblaufend,
|
||||
ohneMeldung,
|
||||
offeneAlerts,
|
||||
offeneEinkaufsrechnungen,
|
||||
fehlendeKundenrechnungen,
|
||||
offeneRechnungspunkte: offeneEinkaufsrechnungen + fehlendeKundenrechnungen,
|
||||
};
|
||||
}
|
||||
|
||||
export async function offeneAlerts(anzahl = 20) {
|
||||
return prisma.alert.findMany({
|
||||
where: { erledigtAm: null },
|
||||
orderBy: [{ schwere: "asc" }, { geoeffnetAm: "desc" }],
|
||||
take: anzahl,
|
||||
});
|
||||
}
|
||||
|
||||
/** Einträge für die globale Suche (Strg+K). */
|
||||
export async function suchIndex() {
|
||||
const [hosts, kunden, proxmoxRechnungen, kundenrechnungen] = await Promise.all([
|
||||
prisma.host.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
hostname: true,
|
||||
fqdn: true,
|
||||
serverId: true,
|
||||
subKey: true,
|
||||
ips: true,
|
||||
produkt: true,
|
||||
kunde: { select: { name: true } },
|
||||
},
|
||||
}),
|
||||
prisma.kunde.findMany({
|
||||
select: { id: true, name: true, kundennummer: true, ansprechpartner: true, email: true },
|
||||
}),
|
||||
prisma.proxmoxRechnung.findMany({
|
||||
select: { id: true, nummer: true, positionen: { select: { laufzeit: { select: { hostId: true } } } } },
|
||||
}),
|
||||
prisma.kundenrechnung.findMany({
|
||||
where: { nummer: { not: null } },
|
||||
select: { id: true, nummer: true, kunde: { select: { id: true, name: true } } },
|
||||
}),
|
||||
]);
|
||||
|
||||
const eintraege = [
|
||||
...hosts.map((host) => ({
|
||||
typ: "Host" as const,
|
||||
titel: host.hostname,
|
||||
untertitel: `${host.produkt} · ${host.kunde?.name ?? "Nicht zugeordnet"}${
|
||||
host.subKey ? ` · ${host.subKey}` : ""
|
||||
}`,
|
||||
href: `/hosts/${host.id}`,
|
||||
begriffe: [host.hostname, host.fqdn, host.serverId, host.subKey, ...host.ips]
|
||||
.filter((w): w is string => Boolean(w))
|
||||
.map((w) => w.toLowerCase()),
|
||||
})),
|
||||
...kunden.map((kunde) => ({
|
||||
typ: "Kunde" as const,
|
||||
titel: kunde.name,
|
||||
untertitel: `${kunde.kundennummer}${kunde.ansprechpartner ? ` · ${kunde.ansprechpartner}` : ""}`,
|
||||
href: `/kunden/${kunde.id}`,
|
||||
begriffe: [kunde.name, kunde.kundennummer, kunde.ansprechpartner, kunde.email]
|
||||
.filter((w): w is string => Boolean(w))
|
||||
.map((w) => w.toLowerCase()),
|
||||
})),
|
||||
...proxmoxRechnungen.map((rechnung) => ({
|
||||
typ: "Rechnung" as const,
|
||||
titel: rechnung.nummer,
|
||||
untertitel: `Proxmox-Rechnung · ${rechnung.positionen.length} Position(en)`,
|
||||
href: `/rechnungen?q=${encodeURIComponent(rechnung.nummer)}`,
|
||||
begriffe: [rechnung.nummer.toLowerCase()],
|
||||
})),
|
||||
...kundenrechnungen.map((rechnung) => ({
|
||||
typ: "Rechnung" as const,
|
||||
titel: rechnung.nummer ?? "",
|
||||
untertitel: `Kundenrechnung · ${rechnung.kunde.name}`,
|
||||
href: `/kunden/${rechnung.kunde.id}`,
|
||||
begriffe: [(rechnung.nummer ?? "").toLowerCase()],
|
||||
})),
|
||||
];
|
||||
|
||||
return eintraege;
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
/** Abfragen rund um Kunden. Beträge kommen als Decimal aus der Datenbank und werden hier zu Zahlen. */
|
||||
|
||||
export async function kundenListe() {
|
||||
const kunden = await prisma.kunde.findMany({
|
||||
orderBy: { name: "asc" },
|
||||
include: {
|
||||
standorte: true,
|
||||
hosts: {
|
||||
select: { id: true, subStatus: true, subNextduedate: true },
|
||||
},
|
||||
_count: { select: { hosts: true } },
|
||||
},
|
||||
});
|
||||
|
||||
const offeneAlerts = await prisma.alert.groupBy({
|
||||
by: ["kundeId"],
|
||||
where: { erledigtAm: null, kundeId: { not: null } },
|
||||
_count: { _all: true },
|
||||
});
|
||||
|
||||
const alertsProKunde = new Map(offeneAlerts.map((e) => [e.kundeId, e._count._all]));
|
||||
|
||||
return kunden.map((kunde) => {
|
||||
const ablaufDaten = kunde.hosts
|
||||
.map((h) => h.subNextduedate)
|
||||
.filter((d): d is Date => d !== null)
|
||||
.sort((a, b) => a.getTime() - b.getTime());
|
||||
|
||||
return {
|
||||
id: kunde.id,
|
||||
name: kunde.name,
|
||||
kundennummer: kunde.kundennummer,
|
||||
ansprechpartner: kunde.ansprechpartner,
|
||||
hosts: kunde._count.hosts,
|
||||
aktiv: kunde.hosts.filter((h) => h.subStatus === "ACTIVE").length,
|
||||
naechsterAblauf: ablaufDaten[0] ?? null,
|
||||
offeneAlerts: alertsProKunde.get(kunde.id) ?? 0,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function kundeMitDetails(id: string) {
|
||||
return prisma.kunde.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
standorte: { orderBy: { name: "asc" } },
|
||||
hosts: { orderBy: { hostname: "asc" } },
|
||||
alerts: { where: { erledigtAm: null }, orderBy: { geoeffnetAm: "desc" } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Laufzeiten eines Kunden samt Rechnungsangaben, aufbereitet für Tabellen und Summen. */
|
||||
export async function laufzeitenVonKunde(kundeId: string) {
|
||||
const laufzeiten = await prisma.laufzeit.findMany({
|
||||
where: { host: { kundeId } },
|
||||
orderBy: { start: "desc" },
|
||||
include: {
|
||||
host: { select: { id: true, hostname: true } },
|
||||
proxmoxPosition: { include: { rechnung: true } },
|
||||
kundenPosition: { include: { rechnung: true } },
|
||||
},
|
||||
});
|
||||
|
||||
return laufzeiten.map((laufzeit) => ({
|
||||
id: laufzeit.id,
|
||||
hostId: laufzeit.hostId,
|
||||
hostname: laufzeit.host.hostname,
|
||||
key: laufzeit.key,
|
||||
start: laufzeit.start,
|
||||
ende: laufzeit.ende,
|
||||
vorschlag: laufzeit.status === "VORSCHLAG",
|
||||
einkauf: laufzeit.proxmoxPosition
|
||||
? {
|
||||
nummer: laufzeit.proxmoxPosition.rechnung.nummer,
|
||||
nettoBetrag: Number(laufzeit.proxmoxPosition.nettoBetrag),
|
||||
bezahlt: laufzeit.proxmoxPosition.rechnung.bezahlt,
|
||||
faelligAm: laufzeit.proxmoxPosition.rechnung.faelligAm,
|
||||
}
|
||||
: null,
|
||||
verkauf: laufzeit.kundenPosition
|
||||
? {
|
||||
nummer: laufzeit.kundenPosition.rechnung.nummer,
|
||||
nettoBetrag: Number(laufzeit.kundenPosition.nettoBetrag),
|
||||
erstellt: laufzeit.kundenPosition.rechnung.erstellt,
|
||||
bezahlt: laufzeit.kundenPosition.rechnung.bezahlt,
|
||||
}
|
||||
: null,
|
||||
}));
|
||||
}
|
||||
|
||||
export type KundenLaufzeit = Awaited<ReturnType<typeof laufzeitenVonKunde>>[number];
|
||||
|
||||
export function summen(laufzeiten: KundenLaufzeit[]) {
|
||||
const einkauf = laufzeiten.reduce((s, l) => s + (l.einkauf?.nettoBetrag ?? 0), 0);
|
||||
const verkauf = laufzeiten.reduce((s, l) => s + (l.verkauf?.nettoBetrag ?? 0), 0);
|
||||
return { einkauf, verkauf, marge: verkauf - einkauf };
|
||||
}
|
||||
|
||||
export function summenProJahr(laufzeiten: KundenLaufzeit[]) {
|
||||
const proJahr = new Map<string, { einkauf: number; verkauf: number }>();
|
||||
for (const laufzeit of laufzeiten) {
|
||||
const jahr = String(laufzeit.start.getUTCFullYear());
|
||||
const eintrag = proJahr.get(jahr) ?? { einkauf: 0, verkauf: 0 };
|
||||
eintrag.einkauf += laufzeit.einkauf?.nettoBetrag ?? 0;
|
||||
eintrag.verkauf += laufzeit.verkauf?.nettoBetrag ?? 0;
|
||||
proJahr.set(jahr, eintrag);
|
||||
}
|
||||
return [...proJahr.entries()]
|
||||
.sort((a, b) => b[0].localeCompare(a[0]))
|
||||
.map(([jahr, werte]) => ({ jahr, ...werte, marge: werte.verkauf - werte.einkauf }));
|
||||
}
|
||||
|
||||
/** Offene Rechnungspunkte je Kunde: unbezahlter Einkauf bzw. fehlende Kundenrechnung. */
|
||||
export async function offeneRechnungspunkteProKunde() {
|
||||
const laufzeiten = await prisma.laufzeit.findMany({
|
||||
where: { status: "BESTAETIGT", host: { kundeId: { not: null } } },
|
||||
select: {
|
||||
host: { select: { kundeId: true } },
|
||||
proxmoxPosition: { select: { rechnung: { select: { bezahlt: true } } } },
|
||||
kundenPosition: { select: { rechnung: { select: { erstellt: true } } } },
|
||||
},
|
||||
});
|
||||
|
||||
const proKunde = new Map<string, { offen: number; gesamt: number }>();
|
||||
for (const laufzeit of laufzeiten) {
|
||||
const kundeId = laufzeit.host.kundeId;
|
||||
if (!kundeId) continue;
|
||||
const eintrag = proKunde.get(kundeId) ?? { offen: 0, gesamt: 0 };
|
||||
eintrag.gesamt += 2;
|
||||
if (!laufzeit.proxmoxPosition?.rechnung.bezahlt) eintrag.offen += 1;
|
||||
if (!laufzeit.kundenPosition?.rechnung.erstellt) eintrag.offen += 1;
|
||||
proKunde.set(kundeId, eintrag);
|
||||
}
|
||||
return proKunde;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { protokolliere } from "@/server/audit";
|
||||
|
||||
export class ZuweisungsFehler extends Error {}
|
||||
|
||||
export interface ZuweisungsErgebnis {
|
||||
anzahl: number;
|
||||
kundenName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Weist mehrere Hosts einem Kunden (und optional einem seiner Standorte) zu.
|
||||
* Getrennt von der Server Action, damit die Regeln ohne Next.js testbar sind.
|
||||
*/
|
||||
export async function hostsZuweisenService(
|
||||
hostIds: string[],
|
||||
kundeId: string | null,
|
||||
standortId: string | null,
|
||||
): Promise<ZuweisungsErgebnis> {
|
||||
if (hostIds.length === 0) {
|
||||
throw new ZuweisungsFehler("Bitte mindestens einen Host auswählen.");
|
||||
}
|
||||
if (!kundeId) {
|
||||
throw new ZuweisungsFehler("Bitte einen Kunden auswählen.");
|
||||
}
|
||||
|
||||
const kunde = await prisma.kunde.findUnique({
|
||||
where: { id: kundeId },
|
||||
include: { standorte: { select: { id: true } } },
|
||||
});
|
||||
if (!kunde) {
|
||||
throw new ZuweisungsFehler("Der gewählte Kunde existiert nicht mehr.");
|
||||
}
|
||||
if (standortId && !kunde.standorte.some((standort) => standort.id === standortId)) {
|
||||
throw new ZuweisungsFehler("Der gewählte Standort gehört nicht zu diesem Kunden.");
|
||||
}
|
||||
|
||||
const { count } = await prisma.host.updateMany({
|
||||
where: { id: { in: hostIds } },
|
||||
data: { kundeId, standortId },
|
||||
});
|
||||
|
||||
// Die Warnung „Host ohne Kunde“ ist damit erledigt (die Regel selbst folgt in #011).
|
||||
await prisma.alert.updateMany({
|
||||
where: { regel: "host-ohne-kunde", hostId: { in: hostIds }, erledigtAm: null },
|
||||
data: { erledigtAm: new Date() },
|
||||
});
|
||||
|
||||
await protokolliere("host.zugewiesen", "Host", null, { hostIds, kundeId, standortId, anzahl: count });
|
||||
|
||||
return { anzahl: count, kundenName: kunde.name };
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import { afterAll, beforeEach, describe, expect, it } from "vitest";
|
||||
import { hostsZuweisenService, ZuweisungsFehler } from "@/server/services/zuweisung";
|
||||
import { kennzahlen, hostsOhneKunde } from "@/server/queries/hosts";
|
||||
import { kundenListe, offeneRechnungspunkteProKunde } from "@/server/queries/kunden";
|
||||
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
|
||||
|
||||
async function kundeMitStandort(name = "Testkunde GmbH", nummer = "K-7001") {
|
||||
return testPrisma.kunde.create({
|
||||
data: {
|
||||
name,
|
||||
kundennummer: nummer,
|
||||
standorte: { create: [{ name: "Zentrale" }] },
|
||||
},
|
||||
include: { standorte: true },
|
||||
});
|
||||
}
|
||||
|
||||
async function host(agentId: string, hostname: string, daten: Record<string, unknown> = {}) {
|
||||
return testPrisma.host.create({
|
||||
data: { agentId, hostname, produkt: "PVE", ...daten },
|
||||
});
|
||||
}
|
||||
|
||||
describe.runIf(datenbankVerfuegbar)("Hostzuweisung", () => {
|
||||
beforeEach(async () => {
|
||||
await datenbankLeeren();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await testPrisma.$disconnect();
|
||||
});
|
||||
|
||||
it("weist mehrere Hosts gleichzeitig einem Kunden und Standort zu", async () => {
|
||||
const kunde = await kundeMitStandort();
|
||||
const eins = await host("agent-1", "pve-01");
|
||||
const zwei = await host("agent-2", "pve-02");
|
||||
await host("agent-3", "pve-03");
|
||||
|
||||
const ergebnis = await hostsZuweisenService([eins.id, zwei.id], kunde.id, kunde.standorte[0].id);
|
||||
|
||||
expect(ergebnis.anzahl).toBe(2);
|
||||
expect(ergebnis.kundenName).toBe("Testkunde GmbH");
|
||||
|
||||
const zugewiesen = await testPrisma.host.findMany({ where: { kundeId: kunde.id } });
|
||||
expect(zugewiesen.map((h) => h.hostname).sort()).toEqual(["pve-01", "pve-02"]);
|
||||
expect(zugewiesen.every((h) => h.standortId === kunde.standorte[0].id)).toBe(true);
|
||||
|
||||
const offen = await hostsOhneKunde();
|
||||
expect(offen.map((h) => h.hostname)).toEqual(["pve-03"]);
|
||||
});
|
||||
|
||||
it("schreibt einen Audit-Eintrag", async () => {
|
||||
const kunde = await kundeMitStandort();
|
||||
const eins = await host("agent-1", "pve-01");
|
||||
|
||||
await hostsZuweisenService([eins.id], kunde.id, null);
|
||||
|
||||
const eintrag = await testPrisma.auditEintrag.findFirst({ where: { aktion: "host.zugewiesen" } });
|
||||
expect(eintrag).not.toBeNull();
|
||||
expect(eintrag?.aenderung).toMatchObject({ anzahl: 1, kundeId: kunde.id });
|
||||
});
|
||||
|
||||
it("schließt die Warnung „Host ohne Kunde“ mit der Zuweisung", async () => {
|
||||
const kunde = await kundeMitStandort();
|
||||
const eins = await host("agent-1", "pve-01");
|
||||
await testPrisma.alert.create({
|
||||
data: {
|
||||
regel: "host-ohne-kunde",
|
||||
fingerprint: `host-ohne-kunde:${eins.id}`,
|
||||
schwere: "INFO",
|
||||
titel: "Host ist keinem Kunden zugewiesen",
|
||||
beschreibung: "pve-01",
|
||||
hostId: eins.id,
|
||||
},
|
||||
});
|
||||
|
||||
await hostsZuweisenService([eins.id], kunde.id, null);
|
||||
|
||||
const alert = await testPrisma.alert.findFirstOrThrow({ where: { hostId: eins.id } });
|
||||
expect(alert.erledigtAm).not.toBeNull();
|
||||
});
|
||||
|
||||
it("lehnt einen Standort ab, der zu einem anderen Kunden gehört", async () => {
|
||||
const kundeA = await kundeMitStandort("Kunde A", "K-7001");
|
||||
const kundeB = await kundeMitStandort("Kunde B", "K-7002");
|
||||
const eins = await host("agent-1", "pve-01");
|
||||
|
||||
await expect(hostsZuweisenService([eins.id], kundeA.id, kundeB.standorte[0].id)).rejects.toThrow(
|
||||
ZuweisungsFehler,
|
||||
);
|
||||
|
||||
const unveraendert = await testPrisma.host.findUniqueOrThrow({ where: { id: eins.id } });
|
||||
expect(unveraendert.kundeId).toBeNull();
|
||||
});
|
||||
|
||||
it("lehnt eine Zuweisung ohne Auswahl oder ohne Kunde ab", async () => {
|
||||
const kunde = await kundeMitStandort();
|
||||
await expect(hostsZuweisenService([], kunde.id, null)).rejects.toThrow(ZuweisungsFehler);
|
||||
await expect(hostsZuweisenService(["irgendwas"], null, null)).rejects.toThrow(ZuweisungsFehler);
|
||||
});
|
||||
|
||||
it("zählt Kennzahlen für Dashboard und Sidebar korrekt", async () => {
|
||||
const jetzt = new Date("2026-09-17T12:00:00Z");
|
||||
const kunde = await kundeMitStandort();
|
||||
|
||||
await host("agent-1", "pve-aktiv", {
|
||||
kundeId: kunde.id,
|
||||
subStatus: "ACTIVE",
|
||||
subNextduedate: new Date("2026-10-01"),
|
||||
letzterReportAm: new Date("2026-09-17T03:00:00Z"),
|
||||
});
|
||||
await host("agent-2", "pve-abgelaufen", {
|
||||
kundeId: kunde.id,
|
||||
subStatus: "EXPIRED",
|
||||
letzterReportAm: new Date("2026-09-17T03:00:00Z"),
|
||||
});
|
||||
await host("agent-3", "pve-stumm", {
|
||||
subStatus: "ACTIVE",
|
||||
letzterReportAm: new Date("2026-09-10T03:00:00Z"),
|
||||
});
|
||||
|
||||
const zahlen = await kennzahlen(jetzt);
|
||||
|
||||
expect(zahlen.hostsGesamt).toBe(3);
|
||||
expect(zahlen.aktivLizenziert).toBe(2);
|
||||
expect(zahlen.ohneSubscription).toBe(1);
|
||||
expect(zahlen.baldAblaufend).toBe(1);
|
||||
expect(zahlen.ohneKunde).toBe(1);
|
||||
expect(zahlen.ohneMeldung).toBe(1);
|
||||
});
|
||||
|
||||
it("zählt offene Rechnungspunkte je Kunde", async () => {
|
||||
const kunde = await kundeMitStandort();
|
||||
const eins = await host("agent-1", "pve-01", { kundeId: kunde.id });
|
||||
|
||||
const rechnung = await testPrisma.proxmoxRechnung.create({
|
||||
data: {
|
||||
nummer: "PX-TEST-9",
|
||||
datum: new Date("2026-09-01"),
|
||||
nettoBetrag: 115,
|
||||
bezahlt: false,
|
||||
positionen: { create: [{ bezeichnung: "Key", nettoBetrag: 115 }] },
|
||||
},
|
||||
include: { positionen: true },
|
||||
});
|
||||
|
||||
await testPrisma.laufzeit.create({
|
||||
data: {
|
||||
hostId: eins.id,
|
||||
key: "pve1c-test",
|
||||
start: new Date("2026-09-24"),
|
||||
ende: new Date("2027-09-23"),
|
||||
status: "BESTAETIGT",
|
||||
proxmoxPositionId: rechnung.positionen[0].id,
|
||||
},
|
||||
});
|
||||
|
||||
const punkte = await offeneRechnungspunkteProKunde();
|
||||
// Einkauf unbezahlt und Kundenrechnung fehlt = zwei offene Punkte von zwei möglichen.
|
||||
expect(punkte.get(kunde.id)).toEqual({ offen: 2, gesamt: 2 });
|
||||
|
||||
const liste = await kundenListe();
|
||||
expect(liste[0].hosts).toBe(1);
|
||||
});
|
||||
});
|
||||