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,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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user