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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-17 21:02:44 +02:00
co-authored by Claude Opus 5
parent f591b45aac
commit f9a5ad2daa
40 changed files with 2060 additions and 415 deletions
+125
View File
@@ -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>
</>
);
}