feat(kunden): Kundenverwaltung, Hostzuweisung und Token-Verwaltung (#006)
Kunden und Standorte pflegen, nicht zugeordnete Hosts in einem Schritt zuweisen, Enrollment- und Host-Tokens verwalten, Audit-Log. Dashboard, Kundenseiten und globale Suche laufen jetzt auf der Datenbank. 45 Tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user