Workstream 6: Admin-Panel — Taxonomie & Bereitstellung (Phase 4)
Platform-Admin-only Oberflächen und Domänenlogik: - codes.ts erweitert um allradCode/normalizeCode/codesMatch (Allrad-Infix kanonisch; Suche importiert weiterhin expandNameQuery). Pure-Unit-Tests. - slug.ts (Idempotenz-Key-Erzeugung) + Tests. - audit.ts: writeAudit mit EINER Signatur und optionalem typisierten tx. - provisioning.ts: createBrigadeWithFirstAdmin (Geocoding inline, argon2id, Audit brigade.create/user.create) + resetUserPassword (Audit user.reset). - Zod-Validierung: merkmal/template/equipment-category/brigade (+ Tests). - Server Actions (jede mit Guard als erster Anweisung, default-deny): merkmale (CRUD, Delete blockiert bei Referenz), proposals (promote/merge mit Typ-Kompatibilität), templates (Merkmale/Vorgabewerte/Aliasse), equipment- categories, brigades (Bereitstellung/Reset). Audit in allen Schreib-Actions. - (admin)-Route-Group: Layout mit requirePlatformAdmin als erster Zeile, AdminNav, DataTable, loading/error; Seiten für Merkmale (+Editor), Vorschläge (Merge), Vorlagen (+Detail mit Merkmal-/Alias-Editor und Allrad-Hinweis), Geräte-Kategorien (+Detail), Wehren (Liste/neu/Detail mit Passwort-Reset), paginierter Audit-Viewer mit Filter. Jede Seite ruft zusätzlich den Guard. - i18n: admin-Strings in zentraler de.ts. - Playwright-Specs (deferred, nicht ausgeführt): admin-gating, admin-merkmal-proposal, admin-brigade-provision. Schema NICHT neu definiert — nur importiert. codes.ts ist hier Eigentümer. Offline-Verifikation: tsc --noEmit grün; eslint grün; vitest run grün (119 passed, 7 DB-roundtrip skipped); next build Exit 0; drizzle-kit check ok. DB-/Server-/Browser-abhängige Schritte deferred (kein Postgres/Server im Sandbox). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
124
src/app/(admin)/admin/wehren/neu/BrigadeProvisionForm.tsx
Normal file
124
src/app/(admin)/admin/wehren/neu/BrigadeProvisionForm.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { de } from "@/lib/i18n/de";
|
||||
import { provisionBrigade } from "../../../_actions/brigades";
|
||||
|
||||
/**
|
||||
* Bereitstellungsformular: legt Wehr + ersten wehr_admin an. Zeigt das
|
||||
* Einmal-Passwort nach Erfolg genau einmal an. Warnt, wenn die Adresse nicht
|
||||
* geokodiert werden konnte (Wehr wird dennoch angelegt).
|
||||
*/
|
||||
export function BrigadeProvisionForm() {
|
||||
const router = useRouter();
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [result, setResult] = React.useState<{
|
||||
tempPassword: string;
|
||||
geocoded: boolean;
|
||||
} | null>(null);
|
||||
const [pending, startTransition] = React.useTransition();
|
||||
|
||||
function onSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
const fd = new FormData(e.currentTarget);
|
||||
const payload = {
|
||||
name: String(fd.get("name") ?? ""),
|
||||
strasse: String(fd.get("strasse") ?? ""),
|
||||
plz: String(fd.get("plz") ?? ""),
|
||||
ort: String(fd.get("ort") ?? ""),
|
||||
telefon: String(fd.get("telefon") ?? ""),
|
||||
email: String(fd.get("email") ?? "") || undefined,
|
||||
wehrfuehrer: String(fd.get("wehrfuehrer") ?? "") || undefined,
|
||||
adminEmail: String(fd.get("adminEmail") ?? ""),
|
||||
adminName: String(fd.get("adminName") ?? ""),
|
||||
};
|
||||
startTransition(async () => {
|
||||
const res = await provisionBrigade(payload);
|
||||
if (!res.ok) {
|
||||
setError(res.error);
|
||||
return;
|
||||
}
|
||||
setResult({ tempPassword: res.tempPassword, geocoded: res.geocoded });
|
||||
router.refresh();
|
||||
});
|
||||
}
|
||||
|
||||
if (result) {
|
||||
return (
|
||||
<div className="space-y-4 rounded border border-bereit/40 bg-bereit/5 p-5">
|
||||
<p className="text-sm text-anthrazit">
|
||||
{result.geocoded ? de.admin.geocodeOk : de.admin.geocodeFehler}
|
||||
</p>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-anthrazit/70">
|
||||
{de.admin.tempPasswort}
|
||||
</p>
|
||||
<code className="mt-1 block rounded border border-rand bg-white px-3 py-2 font-mono text-lg tracking-wide text-navy">
|
||||
{result.tempPassword}
|
||||
</code>
|
||||
</div>
|
||||
<Button onClick={() => router.push("/admin/wehren")}>
|
||||
{de.admin.navWehren}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={onSubmit} className="space-y-4">
|
||||
<fieldset className="grid gap-3 rounded border border-rand bg-white p-4 sm:grid-cols-2">
|
||||
<legend className="px-1 text-sm font-semibold text-navy">
|
||||
{de.admin.navWehren}
|
||||
</legend>
|
||||
<Field name="name" label={de.admin.name} required />
|
||||
<Field name="strasse" label={de.admin.strasse} required />
|
||||
<Field name="plz" label={de.admin.plz} required />
|
||||
<Field name="ort" label={de.admin.ort} required />
|
||||
<Field name="telefon" label={de.admin.telefon} required />
|
||||
<Field name="email" label={de.auth.email} type="email" />
|
||||
<Field name="wehrfuehrer" label={de.admin.wehrfuehrer} />
|
||||
</fieldset>
|
||||
|
||||
<fieldset className="grid gap-3 rounded border border-rand bg-white p-4 sm:grid-cols-2">
|
||||
<legend className="px-1 text-sm font-semibold text-navy">
|
||||
Erster Wehr-Admin
|
||||
</legend>
|
||||
<Field name="adminName" label={de.admin.adminName} required />
|
||||
<Field
|
||||
name="adminEmail"
|
||||
label={de.admin.adminEmail}
|
||||
type="email"
|
||||
required
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
{error && <p className="text-sm text-signal">{error}</p>}
|
||||
<Button type="submit" disabled={pending}>
|
||||
{de.admin.wehrAnlegen}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({
|
||||
name,
|
||||
label,
|
||||
type = "text",
|
||||
required = false,
|
||||
}: {
|
||||
name: string;
|
||||
label: string;
|
||||
type?: string;
|
||||
required?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<label className="text-sm">
|
||||
<span className="mb-1 block font-medium text-anthrazit/70">{label}</span>
|
||||
<Input name={name} type={type} required={required} />
|
||||
</label>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user