TEN-05: backend-api + dev-server + next.js tenant-verwaltungsoberflaeche
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
export const metadata = {
|
||||
title: "NEXARCH Mandantenverwaltung",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang="de">
|
||||
<body style={{ fontFamily: "system-ui, sans-serif", margin: 0, background: "#f5f6f8" }}>
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
fetchTenants,
|
||||
fetchTenantDetail,
|
||||
updateSettings,
|
||||
performLifecycleAction,
|
||||
type TenantListItem,
|
||||
type TenantDetail,
|
||||
type LifecycleAction,
|
||||
} from "@/lib/api";
|
||||
|
||||
const ACTION_LABEL: Record<LifecycleAction, string> = {
|
||||
suspend: "Suspendieren",
|
||||
reactivate: "Reaktivieren",
|
||||
schedule_deletion: "Löschung vormerken",
|
||||
cancel_deletion: "Löschung abbrechen",
|
||||
};
|
||||
|
||||
const ACTION_CONFIRM: Record<LifecycleAction, string> = {
|
||||
suspend: "Mandant wirklich suspendieren? Benutzer können sich danach nicht mehr anmelden.",
|
||||
reactivate: "Mandant wirklich reaktivieren?",
|
||||
schedule_deletion:
|
||||
"Mandant wirklich zur Löschung vormerken? Nach der Karenzzeit wird er unwiderruflich gelöscht.",
|
||||
cancel_deletion: "Vorgemerkte Löschung wirklich abbrechen?",
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
const [superadminId, setSuperadminId] = useState("");
|
||||
const [search, setSearch] = useState("");
|
||||
const [status, setStatus] = useState("");
|
||||
const [tenants, setTenants] = useState<TenantListItem[] | null>(null);
|
||||
const [detail, setDetail] = useState<TenantDetail | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
|
||||
async function loadList() {
|
||||
setError(null);
|
||||
try {
|
||||
const items = await fetchTenants(superadminId.trim(), search.trim(), status);
|
||||
setTenants(items);
|
||||
} catch (e: any) {
|
||||
setError(e.message ?? "Unbekannter Fehler");
|
||||
setTenants(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function openDetail(slug: string) {
|
||||
setError(null);
|
||||
try {
|
||||
const d = await fetchTenantDetail(superadminId.trim(), slug);
|
||||
setDetail(d);
|
||||
} catch (e: any) {
|
||||
setError(e.message ?? "Unbekannter Fehler");
|
||||
}
|
||||
}
|
||||
|
||||
async function onSubmitSettings(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
if (!detail) return;
|
||||
setFormError(null);
|
||||
|
||||
const form = new FormData(e.currentTarget);
|
||||
const displayName = String(form.get("displayName") ?? "").trim();
|
||||
if (!displayName) {
|
||||
setFormError("Anzeigename ist ein Pflichtfeld.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await updateSettings(
|
||||
superadminId.trim(),
|
||||
detail.slug,
|
||||
displayName,
|
||||
String(form.get("colorScheme") ?? ""),
|
||||
String(form.get("timezone") ?? ""),
|
||||
String(form.get("language") ?? "")
|
||||
);
|
||||
await openDetail(detail.slug);
|
||||
} catch (e: any) {
|
||||
setFormError(e.message ?? "Speichern fehlgeschlagen");
|
||||
}
|
||||
}
|
||||
|
||||
async function onLifecycleAction(action: LifecycleAction) {
|
||||
if (!detail) return;
|
||||
if (!window.confirm(ACTION_CONFIRM[action])) return;
|
||||
setError(null);
|
||||
try {
|
||||
await performLifecycleAction(superadminId.trim(), detail.slug, action);
|
||||
await openDetail(detail.slug);
|
||||
await loadList();
|
||||
} catch (e: any) {
|
||||
setError(e.message ?? "Aktion fehlgeschlagen");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main style={{ maxWidth: 900, margin: "0 auto", padding: "2rem 1rem" }}>
|
||||
<h1>Mandantenverwaltung</h1>
|
||||
|
||||
<div style={{ display: "flex", gap: "0.5rem", marginBottom: "1rem", flexWrap: "wrap" }}>
|
||||
<input
|
||||
value={superadminId}
|
||||
onChange={(e) => setSuperadminId(e.target.value)}
|
||||
placeholder="Superadmin-ID"
|
||||
style={{ padding: "0.5rem" }}
|
||||
/>
|
||||
<input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Suche (Slug/Name)"
|
||||
style={{ padding: "0.5rem" }}
|
||||
/>
|
||||
<select value={status} onChange={(e) => setStatus(e.target.value)} style={{ padding: "0.5rem" }}>
|
||||
<option value="">Alle Status</option>
|
||||
<option value="active">Aktiv</option>
|
||||
<option value="suspended">Suspendiert</option>
|
||||
<option value="pending_deletion">Löschung vorgemerkt</option>
|
||||
<option value="deleted">Gelöscht</option>
|
||||
</select>
|
||||
<button onClick={loadList} style={{ padding: "0.5rem 1rem" }}>
|
||||
Anzeigen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p style={{ color: "#c62828" }} role="alert">
|
||||
Fehler: {error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{tenants && (
|
||||
<table style={{ width: "100%", borderCollapse: "collapse", background: "white" }}>
|
||||
<thead>
|
||||
<tr style={{ textAlign: "left", borderBottom: "2px solid #ddd" }}>
|
||||
<th style={{ padding: "0.5rem" }}>Slug</th>
|
||||
<th style={{ padding: "0.5rem" }}>Name</th>
|
||||
<th style={{ padding: "0.5rem" }}>Status</th>
|
||||
<th style={{ padding: "0.5rem" }}></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tenants.map((t) => (
|
||||
<tr key={t.id} style={{ borderBottom: "1px solid #eee" }}>
|
||||
<td style={{ padding: "0.5rem" }}>{t.slug}</td>
|
||||
<td style={{ padding: "0.5rem" }}>{t.name}</td>
|
||||
<td style={{ padding: "0.5rem" }}>{t.status}</td>
|
||||
<td style={{ padding: "0.5rem" }}>
|
||||
<button onClick={() => openDetail(t.slug)}>Details</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{tenants.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={4} style={{ padding: "0.5rem" }}>
|
||||
Keine Mandanten gefunden.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
||||
{detail && (
|
||||
<section style={{ background: "white", padding: "1rem", borderRadius: 8, marginTop: "1.5rem" }}>
|
||||
<h2>
|
||||
{detail.name} ({detail.slug}) — Status: {detail.status}
|
||||
</h2>
|
||||
|
||||
<form onSubmit={onSubmitSettings} style={{ display: "grid", gap: "0.75rem", maxWidth: 400 }}>
|
||||
{formError && (
|
||||
<p style={{ color: "#c62828" }} role="alert">
|
||||
{formError}
|
||||
</p>
|
||||
)}
|
||||
<label>
|
||||
Anzeigename (Pflichtfeld)
|
||||
<input
|
||||
name="displayName"
|
||||
required
|
||||
defaultValue={detail.settings.DisplayName}
|
||||
style={{ width: "100%", padding: "0.4rem" }}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Farbschema
|
||||
<input
|
||||
name="colorScheme"
|
||||
defaultValue={detail.settings.ColorScheme}
|
||||
style={{ width: "100%", padding: "0.4rem" }}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Zeitzone
|
||||
<input
|
||||
name="timezone"
|
||||
defaultValue={detail.settings.Timezone}
|
||||
style={{ width: "100%", padding: "0.4rem" }}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Sprache
|
||||
<input
|
||||
name="language"
|
||||
defaultValue={detail.settings.Language}
|
||||
style={{ width: "100%", padding: "0.4rem" }}
|
||||
/>
|
||||
</label>
|
||||
<button type="submit">Einstellungen speichern</button>
|
||||
</form>
|
||||
|
||||
<div style={{ display: "flex", gap: "0.5rem", marginTop: "1.5rem", flexWrap: "wrap" }}>
|
||||
{(Object.keys(ACTION_LABEL) as LifecycleAction[]).map((action) => (
|
||||
<button key={action} onClick={() => onLifecycleAction(action)}>
|
||||
{ACTION_LABEL[action]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user