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:
Matthias Hochmeister
2026-06-09 10:30:52 +02:00
parent 0a7173ef38
commit e97e16d254
49 changed files with 3676 additions and 0 deletions

View File

@@ -0,0 +1,56 @@
"use server";
import { revalidatePath } from "next/cache";
import { requirePlatformAdmin } from "@/lib/auth/guards";
import {
createBrigadeWithFirstAdmin,
resetUserPassword,
} from "@/lib/admin/provisioning";
import {
brigadeProvisionSchema,
userResetSchema,
} from "@/lib/validation/brigade";
export type ProvisionActionResult =
| { ok: true; tempPassword: string; geocoded: boolean }
| { ok: false; error: string };
export type ResetActionResult =
| { ok: true; tempPassword: string }
| { ok: false; error: string };
/**
* Legt eine Wehr samt erstem wehr_admin an (Geocoding + Audit in
* provisioning.ts). Guard zuerst (default-deny, platform_admin).
*/
export async function provisionBrigade(
input: unknown,
): Promise<ProvisionActionResult> {
const s = await requirePlatformAdmin();
const p = brigadeProvisionSchema.safeParse(input);
if (!p.success) {
return { ok: false, error: p.error.issues[0]?.message ?? "Ungültige Eingabe." };
}
const result = await createBrigadeWithFirstAdmin(p.data, s.user.id);
revalidatePath("/admin/wehren");
return {
ok: true,
tempPassword: result.tempPassword,
geocoded: result.geocoded,
};
}
/**
* Setzt das Passwort eines lokalen Benutzers zurück (Audit `user.reset`).
* Guard zuerst.
*/
export async function resetBrigadeUserPassword(
input: unknown,
): Promise<ResetActionResult> {
const s = await requirePlatformAdmin();
const p = userResetSchema.safeParse(input);
if (!p.success) return { ok: false, error: "Ungültige ID." };
const { tempPassword } = await resetUserPassword(p.data.userId, s.user.id);
revalidatePath("/admin/wehren");
return { ok: true, tempPassword };
}

View File

@@ -0,0 +1,120 @@
"use server";
import { and, eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { db } from "@/db";
import {
equipmentCategories,
equipmentCategoryMerkmale,
} from "@/db/schema";
import { requirePlatformAdmin } from "@/lib/auth/guards";
import { writeAudit } from "@/lib/audit";
import {
equipmentCategoryCreateSchema,
equipmentCategoryUpdateSchema,
categoryMerkmalSchema,
} from "@/lib/validation/equipment-category";
import { uuidSchema } from "@/lib/validation/common";
export type ActionResult = { ok: true } | { ok: false; error: string };
export async function createCategory(input: unknown): Promise<ActionResult> {
const s = await requirePlatformAdmin();
const p = equipmentCategoryCreateSchema.safeParse(input);
if (!p.success) {
return { ok: false, error: p.error.issues[0]?.message ?? "Ungültige Eingabe." };
}
await db.transaction(async (tx) => {
const [c] = await tx
.insert(equipmentCategories)
.values({ name: p.data.name })
.returning();
if (!c) throw new Error("Kategorie konnte nicht angelegt werden.");
await writeAudit(s.user.id, "category.create", "equipment_category", c.id, undefined, tx);
});
revalidatePath("/admin/geraete-kategorien");
return { ok: true };
}
export async function updateCategory(input: unknown): Promise<ActionResult> {
const s = await requirePlatformAdmin();
const p = equipmentCategoryUpdateSchema.safeParse(input);
if (!p.success) {
return { ok: false, error: p.error.issues[0]?.message ?? "Ungültige Eingabe." };
}
await db.transaction(async (tx) => {
await tx
.update(equipmentCategories)
.set({ name: p.data.name })
.where(eq(equipmentCategories.id, p.data.id));
await writeAudit(s.user.id, "category.update", "equipment_category", p.data.id, undefined, tx);
});
revalidatePath(`/admin/geraete-kategorien/${p.data.id}`);
return { ok: true };
}
export async function setCategoryMerkmal(input: {
categoryId: unknown;
merkmal: unknown;
}): Promise<ActionResult> {
const s = await requirePlatformAdmin();
const cid = uuidSchema.safeParse(input.categoryId);
const m = categoryMerkmalSchema.safeParse(input.merkmal);
if (!cid.success || !m.success) return { ok: false, error: "Ungültige Eingabe." };
await db.transaction(async (tx) => {
await tx
.insert(equipmentCategoryMerkmale)
.values({
categoryId: cid.data,
merkmalId: m.data.merkmalId,
reihenfolge: m.data.reihenfolge,
})
.onConflictDoUpdate({
target: [
equipmentCategoryMerkmale.categoryId,
equipmentCategoryMerkmale.merkmalId,
],
set: { reihenfolge: m.data.reihenfolge },
});
await writeAudit(
s.user.id,
"category.merkmal_set",
"equipment_category",
cid.data,
{ merkmalId: m.data.merkmalId },
tx,
);
});
revalidatePath(`/admin/geraete-kategorien/${cid.data}`);
return { ok: true };
}
export async function removeCategoryMerkmal(input: {
categoryId: unknown;
merkmalId: unknown;
}): Promise<ActionResult> {
const s = await requirePlatformAdmin();
const cid = uuidSchema.safeParse(input.categoryId);
const mid = uuidSchema.safeParse(input.merkmalId);
if (!cid.success || !mid.success) return { ok: false, error: "Ungültige ID." };
await db.transaction(async (tx) => {
await tx
.delete(equipmentCategoryMerkmale)
.where(
and(
eq(equipmentCategoryMerkmale.categoryId, cid.data),
eq(equipmentCategoryMerkmale.merkmalId, mid.data),
),
);
await writeAudit(
s.user.id,
"category.merkmal_remove",
"equipment_category",
cid.data,
{ merkmalId: mid.data },
tx,
);
});
revalidatePath(`/admin/geraete-kategorien/${cid.data}`);
return { ok: true };
}

View File

@@ -0,0 +1,159 @@
"use server";
import { eq, count } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { db } from "@/db";
import {
merkmale,
merkmalOptionen,
merkmalValues,
vehicleTemplateMerkmale,
equipmentCategoryMerkmale,
} from "@/db/schema";
import { requirePlatformAdmin } from "@/lib/auth/guards";
import { writeAudit } from "@/lib/audit";
import {
merkmalCreateSchema,
merkmalUpdateSchema,
} from "@/lib/validation/merkmal";
import { slugify } from "@/lib/admin/slug";
export type ActionResult =
| { ok: true }
| { ok: false; error: string };
/**
* Legt ein neues Merkmal an (Status `active`, da vom Admin kuratiert) inkl.
* Enum-Optionen. Guard zuerst (default-deny). Audit `merkmal.create`.
*/
export async function createMerkmal(input: unknown): Promise<ActionResult> {
const s = await requirePlatformAdmin();
const parsed = merkmalCreateSchema.safeParse(input);
if (!parsed.success) {
return { ok: false, error: parsed.error.issues[0]?.message ?? "Ungültige Eingabe." };
}
const d = parsed.data;
await db.transaction(async (tx) => {
const [m] = await tx
.insert(merkmale)
.values({
slug: slugify(d.name),
name: d.name,
typ: d.typ,
einheit: d.einheit ?? null,
geltungsbereich: d.geltungsbereich,
status: "active",
})
.returning();
if (!m) throw new Error("Merkmal konnte nicht angelegt werden.");
if (d.typ === "enum") {
await tx.insert(merkmalOptionen).values(
d.optionen.map((o, i) => ({
merkmalId: m.id,
wert: o.wert,
label: o.label,
reihenfolge: i,
})),
);
}
await writeAudit(
s.user.id,
"merkmal.create",
"merkmal",
m.id,
{ name: d.name, typ: d.typ },
tx,
);
});
revalidatePath("/admin/merkmale");
return { ok: true };
}
/**
* Aktualisiert ein Merkmal und ersetzt seine Optionen (delete-then-insert für
* Enum). Guard zuerst.
*/
export async function updateMerkmal(input: unknown): Promise<ActionResult> {
const s = await requirePlatformAdmin();
const parsed = merkmalUpdateSchema.safeParse(input);
if (!parsed.success) {
return { ok: false, error: parsed.error.issues[0]?.message ?? "Ungültige Eingabe." };
}
const d = parsed.data;
await db.transaction(async (tx) => {
await tx
.update(merkmale)
.set({
name: d.name,
typ: d.typ,
einheit: d.einheit ?? null,
geltungsbereich: d.geltungsbereich,
})
.where(eq(merkmale.id, d.id));
await tx.delete(merkmalOptionen).where(eq(merkmalOptionen.merkmalId, d.id));
if (d.typ === "enum") {
await tx.insert(merkmalOptionen).values(
d.optionen.map((o, i) => ({
merkmalId: d.id,
wert: o.wert,
label: o.label,
reihenfolge: i,
})),
);
}
await writeAudit(s.user.id, "merkmal.update", "merkmal", d.id, undefined, tx);
});
revalidatePath("/admin/merkmale");
return { ok: true };
}
/**
* Löscht ein Merkmal — blockiert, wenn es in Werten, Vorlagen oder Kategorien
* referenziert wird (klare deutsche Fehlermeldung, kein 500).
*/
export async function deleteMerkmal(merkmalId: string): Promise<ActionResult> {
const s = await requirePlatformAdmin();
const [vals, tmpl, cats] = await Promise.all([
db
.select({ c: count() })
.from(merkmalValues)
.where(eq(merkmalValues.merkmalId, merkmalId)),
db
.select({ c: count() })
.from(vehicleTemplateMerkmale)
.where(eq(vehicleTemplateMerkmale.merkmalId, merkmalId)),
db
.select({ c: count() })
.from(equipmentCategoryMerkmale)
.where(eq(equipmentCategoryMerkmale.merkmalId, merkmalId)),
]);
const referenced =
(vals[0]?.c ?? 0) + (tmpl[0]?.c ?? 0) + (cats[0]?.c ?? 0) > 0;
if (referenced) {
return {
ok: false,
error: "Merkmal wird verwendet und kann nicht gelöscht werden.",
};
}
await db.transaction(async (tx) => {
await tx
.delete(merkmalOptionen)
.where(eq(merkmalOptionen.merkmalId, merkmalId));
await tx.delete(merkmale).where(eq(merkmale.id, merkmalId));
await writeAudit(s.user.id, "merkmal.delete", "merkmal", merkmalId, undefined, tx);
});
revalidatePath("/admin/merkmale");
return { ok: true };
}

View File

@@ -0,0 +1,105 @@
"use server";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { db } from "@/db";
import { merkmale, merkmalValues, vehicleTemplateMerkmale } from "@/db/schema";
import { requirePlatformAdmin } from "@/lib/auth/guards";
import { writeAudit } from "@/lib/audit";
import { uuidSchema } from "@/lib/validation/common";
export type ActionResult = { ok: true } | { ok: false; error: string };
/**
* Promoviert ein vorgeschlagenes Merkmal: setzt `status = 'active'`. Der
* partielle Unique-Index `merkmale_active_name_uq` verhindert dabei zwei aktive
* Merkmale gleichen Namens (DB-WS). Guard zuerst. Audit `merkmal.promote`.
*/
export async function promoteMerkmal(merkmalId: unknown): Promise<ActionResult> {
const s = await requirePlatformAdmin();
const id = uuidSchema.safeParse(merkmalId);
if (!id.success) return { ok: false, error: "Ungültige ID." };
try {
await db.transaction(async (tx) => {
await tx
.update(merkmale)
.set({ status: "active" })
.where(eq(merkmale.id, id.data));
await writeAudit(
s.user.id,
"merkmal.promote",
"merkmal",
id.data,
undefined,
tx,
);
});
} catch {
return {
ok: false,
error:
"Es existiert bereits ein aktives Merkmal mit diesem Namen. Bitte stattdessen zusammenführen.",
};
}
revalidatePath("/admin/merkmale/proposals");
return { ok: true };
}
/**
* Führt ein vorgeschlagenes Merkmal in ein Ziel-Merkmal zusammen: hängt alle
* `merkmal_values` und `vehicle_template_merkmale` auf das Ziel um und löscht
* das vorgeschlagene Merkmal. Typ-Kompatibilität wird serverseitig erzwungen.
* Guard zuerst. Audit `merkmal.merge`.
*/
export async function mergeMerkmal(input: {
proposedId: unknown;
zielId: unknown;
}): Promise<ActionResult> {
const s = await requirePlatformAdmin();
const proposed = uuidSchema.safeParse(input.proposedId);
const ziel = uuidSchema.safeParse(input.zielId);
if (!proposed.success || !ziel.success) {
return { ok: false, error: "Ungültige ID." };
}
if (proposed.data === ziel.data) {
return { ok: false, error: "Quelle und Ziel müssen verschieden sein." };
}
const rows = await db
.select({ id: merkmale.id, typ: merkmale.typ, status: merkmale.status })
.from(merkmale);
const src = rows.find((r) => r.id === proposed.data);
const dst = rows.find((r) => r.id === ziel.data);
if (!src || !dst) return { ok: false, error: "Merkmal nicht gefunden." };
if (src.typ !== dst.typ) {
return {
ok: false,
error: "Nur Merkmale gleichen Typs können zusammengeführt werden.",
};
}
await db.transaction(async (tx) => {
await tx
.update(merkmalValues)
.set({ merkmalId: ziel.data })
.where(eq(merkmalValues.merkmalId, proposed.data));
await tx
.update(vehicleTemplateMerkmale)
.set({ merkmalId: ziel.data })
.where(eq(vehicleTemplateMerkmale.merkmalId, proposed.data));
await tx.delete(merkmale).where(eq(merkmale.id, proposed.data));
await writeAudit(
s.user.id,
"merkmal.merge",
"merkmal",
ziel.data,
{ merged: proposed.data },
tx,
);
});
revalidatePath("/admin/merkmale/proposals");
return { ok: true };
}

View File

@@ -0,0 +1,200 @@
"use server";
import { and, eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { db } from "@/db";
import {
vehicleTemplates,
vehicleTemplateMerkmale,
vehicleTemplateAliasse,
} from "@/db/schema";
import { requirePlatformAdmin } from "@/lib/auth/guards";
import { writeAudit } from "@/lib/audit";
import {
templateCreateSchema,
templateUpdateSchema,
templateMerkmalSchema,
aliasSchema,
} from "@/lib/validation/template";
import { uuidSchema } from "@/lib/validation/common";
export type ActionResult = { ok: true } | { ok: false; error: string };
export async function createTemplate(input: unknown): Promise<ActionResult> {
const s = await requirePlatformAdmin();
const p = templateCreateSchema.safeParse(input);
if (!p.success) {
return { ok: false, error: p.error.issues[0]?.message ?? "Ungültige Eingabe." };
}
await db.transaction(async (tx) => {
const [t] = await tx
.insert(vehicleTemplates)
.values({
code: p.data.code,
name: p.data.name,
beschreibung: p.data.beschreibung ?? null,
})
.returning();
if (!t) throw new Error("Vorlage konnte nicht angelegt werden.");
await writeAudit(s.user.id, "template.create", "vehicle_template", t.id, undefined, tx);
});
revalidatePath("/admin/vorlagen");
return { ok: true };
}
export async function updateTemplate(input: unknown): Promise<ActionResult> {
const s = await requirePlatformAdmin();
const p = templateUpdateSchema.safeParse(input);
if (!p.success) {
return { ok: false, error: p.error.issues[0]?.message ?? "Ungültige Eingabe." };
}
await db.transaction(async (tx) => {
await tx
.update(vehicleTemplates)
.set({
code: p.data.code,
name: p.data.name,
beschreibung: p.data.beschreibung ?? null,
})
.where(eq(vehicleTemplates.id, p.data.id));
await writeAudit(s.user.id, "template.update", "vehicle_template", p.data.id, undefined, tx);
});
revalidatePath(`/admin/vorlagen/${p.data.id}`);
return { ok: true };
}
/**
* Setzt (upsert) ein Vorgabe-Merkmal einer Vorlage mit typisiertem Vorgabewert.
*/
export async function setTemplateMerkmal(input: {
templateId: unknown;
merkmal: unknown;
}): Promise<ActionResult> {
const s = await requirePlatformAdmin();
const tid = uuidSchema.safeParse(input.templateId);
const m = templateMerkmalSchema.safeParse(input.merkmal);
if (!tid.success || !m.success) {
return { ok: false, error: "Ungültige Eingabe." };
}
await db.transaction(async (tx) => {
await tx
.insert(vehicleTemplateMerkmale)
.values({
templateId: tid.data,
merkmalId: m.data.merkmalId,
vorgabewertNum: m.data.vorgabewertNum ?? null,
vorgabewertText: m.data.vorgabewertText ?? null,
vorgabewertBool: m.data.vorgabewertBool ?? null,
pflicht: m.data.pflicht,
reihenfolge: m.data.reihenfolge,
})
.onConflictDoUpdate({
target: [
vehicleTemplateMerkmale.templateId,
vehicleTemplateMerkmale.merkmalId,
],
set: {
vorgabewertNum: m.data.vorgabewertNum ?? null,
vorgabewertText: m.data.vorgabewertText ?? null,
vorgabewertBool: m.data.vorgabewertBool ?? null,
pflicht: m.data.pflicht,
reihenfolge: m.data.reihenfolge,
},
});
await writeAudit(
s.user.id,
"template.merkmal_set",
"vehicle_template",
tid.data,
{ merkmalId: m.data.merkmalId },
tx,
);
});
revalidatePath(`/admin/vorlagen/${tid.data}`);
return { ok: true };
}
export async function removeTemplateMerkmal(input: {
templateId: unknown;
merkmalId: unknown;
}): Promise<ActionResult> {
const s = await requirePlatformAdmin();
const tid = uuidSchema.safeParse(input.templateId);
const mid = uuidSchema.safeParse(input.merkmalId);
if (!tid.success || !mid.success) return { ok: false, error: "Ungültige ID." };
await db.transaction(async (tx) => {
await tx
.delete(vehicleTemplateMerkmale)
.where(
and(
eq(vehicleTemplateMerkmale.templateId, tid.data),
eq(vehicleTemplateMerkmale.merkmalId, mid.data),
),
);
await writeAudit(
s.user.id,
"template.merkmal_remove",
"vehicle_template",
tid.data,
{ merkmalId: mid.data },
tx,
);
});
revalidatePath(`/admin/vorlagen/${tid.data}`);
return { ok: true };
}
export async function addAlias(input: unknown): Promise<ActionResult> {
const s = await requirePlatformAdmin();
const p = aliasSchema.safeParse(input);
if (!p.success) {
return { ok: false, error: p.error.issues[0]?.message ?? "Ungültige Eingabe." };
}
await db.transaction(async (tx) => {
await tx
.insert(vehicleTemplateAliasse)
.values({
templateId: p.data.templateId,
alias: p.data.alias,
bestaetigt: p.data.bestaetigt,
})
.onConflictDoUpdate({
target: [
vehicleTemplateAliasse.templateId,
vehicleTemplateAliasse.alias,
],
set: { bestaetigt: p.data.bestaetigt },
});
await writeAudit(
s.user.id,
"template.alias_set",
"vehicle_template",
p.data.templateId,
{ alias: p.data.alias, bestaetigt: p.data.bestaetigt },
tx,
);
});
revalidatePath(`/admin/vorlagen/${p.data.templateId}`);
return { ok: true };
}
export async function removeAlias(aliasId: unknown): Promise<ActionResult> {
const s = await requirePlatformAdmin();
const id = uuidSchema.safeParse(aliasId);
if (!id.success) return { ok: false, error: "Ungültige ID." };
await db.transaction(async (tx) => {
await tx
.delete(vehicleTemplateAliasse)
.where(eq(vehicleTemplateAliasse.id, id.data));
await writeAudit(
s.user.id,
"template.alias_remove",
"vehicle_template",
id.data,
undefined,
tx,
);
});
revalidatePath("/admin/vorlagen");
return { ok: true };
}

View File

@@ -0,0 +1,150 @@
import Link from "next/link";
import { desc, eq, sql } from "drizzle-orm";
import { db } from "@/db";
import { auditLog, users } from "@/db/schema";
import { requirePlatformAdmin } from "@/lib/auth/guards";
import { de } from "@/lib/i18n/de";
import { DataTable } from "@/components/admin/DataTable";
import { paginationSchema } from "@/lib/validation/common";
export const metadata = { title: "Audit-Log — Administration" };
const dtf = new Intl.DateTimeFormat("de-AT", {
dateStyle: "short",
timeStyle: "short",
});
export default async function AuditPage({
searchParams,
}: {
searchParams: Promise<Record<string, string | string[] | undefined>>;
}) {
await requirePlatformAdmin();
const sp = await searchParams;
const aktionFilter =
typeof sp.aktion === "string" && sp.aktion.trim() ? sp.aktion.trim() : null;
const { page, pageSize } = paginationSchema.parse({
page: sp.page,
pageSize: 25,
});
const whereClause = aktionFilter
? eq(auditLog.aktion, aktionFilter)
: undefined;
const rows = await db
.select({
id: auditLog.id,
zeitpunkt: auditLog.zeitpunkt,
aktion: auditLog.aktion,
zielTyp: auditLog.zielTyp,
zielId: auditLog.zielId,
actorName: users.name,
})
.from(auditLog)
.leftJoin(users, eq(users.id, auditLog.actorUserId))
.where(whereClause)
.orderBy(desc(auditLog.zeitpunkt))
.limit(pageSize)
.offset((page - 1) * pageSize);
const totalRows = await db
.select({ total: sql<number>`count(*)::int` })
.from(auditLog)
.where(whereClause);
const total = totalRows[0]?.total ?? 0;
const aktionen = await db
.selectDistinct({ aktion: auditLog.aktion })
.from(auditLog)
.orderBy(auditLog.aktion);
const totalPages = Math.max(1, Math.ceil((total ?? 0) / pageSize));
const qp = (p: number) =>
`?page=${p}${aktionFilter ? `&aktion=${encodeURIComponent(aktionFilter)}` : ""}`;
return (
<div className="space-y-4">
<h1 className="font-display text-2xl text-navy">{de.admin.navAudit}</h1>
<form className="flex items-end gap-2" action="/admin/audit" method="get">
<label className="text-sm">
<span className="mb-1 block font-medium text-anthrazit/70">
{de.admin.auditFilter}
</span>
<select
name="aktion"
defaultValue={aktionFilter ?? ""}
className="h-9 rounded border border-rand bg-white px-2 text-sm text-anthrazit focus:outline-none focus:ring-2 focus:ring-navy"
>
<option value=""> alle </option>
{aktionen.map((a) => (
<option key={a.aktion} value={a.aktion}>
{a.aktion}
</option>
))}
</select>
</label>
<button
type="submit"
className="h-9 rounded bg-navy px-3 text-sm font-medium text-white hover:bg-navy/90"
>
{de.search.filter}
</button>
</form>
<DataTable
rows={rows}
getRowKey={(r) => r.id}
columns={[
{
key: "zeitpunkt",
header: de.admin.auditZeitpunkt,
render: (r) => (
<span className="tabular-nums text-anthrazit/70">
{dtf.format(new Date(r.zeitpunkt))}
</span>
),
},
{
key: "aktion",
header: de.admin.auditAktion,
render: (r) => (
<span className="font-medium text-anthrazit">{r.aktion}</span>
),
},
{
key: "ziel",
header: de.admin.auditZiel,
render: (r) => r.zielTyp ?? "",
},
{
key: "akteur",
header: de.admin.auditAkteur,
render: (r) => r.actorName ?? "",
},
]}
/>
<div className="flex items-center justify-between text-sm text-anthrazit/70">
<span>{total} Einträge</span>
<div className="flex gap-2">
{page > 1 && (
<Link href={qp(page - 1)} className="text-navy hover:underline">
{de.admin.zurueck}
</Link>
)}
<span>
{page} / {totalPages}
</span>
{page < totalPages && (
<Link href={qp(page + 1)} className="text-navy hover:underline">
{de.admin.weiter}
</Link>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,53 @@
"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 { createCategory } from "../../_actions/equipment-categories";
/** Inline-Anlageformular für eine neue Geräte-Kategorie. */
export function CategoryCreateForm() {
const router = useRouter();
const [open, setOpen] = React.useState(false);
const [name, setName] = React.useState("");
const [error, setError] = React.useState<string | null>(null);
const [pending, startTransition] = React.useTransition();
function submit() {
setError(null);
startTransition(async () => {
const res = await createCategory({ name });
if (!res.ok) {
setError(res.error);
return;
}
setName("");
setOpen(false);
router.refresh();
});
}
if (!open) {
return <Button onClick={() => setOpen(true)}>{de.admin.neu}</Button>;
}
return (
<div className="flex flex-wrap items-end gap-2 rounded border border-rand bg-white p-4">
<label className="text-sm">
<span className="mb-1 block font-medium text-anthrazit/70">
{de.admin.name}
</span>
<Input value={name} onChange={(e) => setName(e.target.value)} />
</label>
{error && <p className="w-full text-sm text-signal">{error}</p>}
<Button onClick={submit} disabled={pending}>
{de.admin.anlegen}
</Button>
<Button variant="ghost" onClick={() => setOpen(false)}>
{de.admin.abbrechen}
</Button>
</div>
);
}

View File

@@ -0,0 +1,135 @@
"use client";
import * as React from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { de } from "@/lib/i18n/de";
import {
setCategoryMerkmal,
removeCategoryMerkmal,
} from "../../../_actions/equipment-categories";
export interface CategoryMerkmalRow {
merkmalId: string;
name: string;
einheit: string | null;
reihenfolge: number;
}
export interface MerkmalLite {
id: string;
name: string;
}
const selectCls =
"h-9 rounded border border-rand bg-white px-2 text-sm text-anthrazit focus:outline-none focus:ring-2 focus:ring-navy";
/**
* Editor der Merkmale einer Geräte-Kategorie (Reihenfolge stabil sortiert).
* Aktionen über guarded + Zod-validierte Server-Actions.
*/
export function CategoryMerkmaleEditor({
categoryId,
rows,
alleMerkmale,
}: {
categoryId: string;
rows: CategoryMerkmalRow[];
alleMerkmale: MerkmalLite[];
}) {
const router = useRouter();
const [error, setError] = React.useState<string | null>(null);
const [addId, setAddId] = React.useState("");
const [pending, startTransition] = React.useTransition();
const zugeordnet = new Set(rows.map((r) => r.merkmalId));
const verfuegbar = alleMerkmale.filter((m) => !zugeordnet.has(m.id));
function run(fn: () => Promise<{ ok: boolean; error?: string }>) {
setError(null);
startTransition(async () => {
const res = await fn();
if (!res.ok) {
setError(res.error ?? de.fehler.allgemein);
return;
}
router.refresh();
});
}
return (
<section className="space-y-3">
<h2 className="font-display text-lg text-navy">{de.admin.navMerkmale}</h2>
{error && <p className="text-sm text-signal">{error}</p>}
{rows.length === 0 ? (
<p className="rounded border border-rand bg-white px-4 py-4 text-sm text-anthrazit/60">
{de.admin.keineEintraege}
</p>
) : (
<ol className="space-y-2">
{rows.map((r, i) => (
<li
key={r.merkmalId}
className="flex items-center justify-between gap-3 rounded border border-rand bg-white px-4 py-2"
>
<span className="text-anthrazit">
<span className="mr-2 text-anthrazit/40">{i + 1}.</span>
{r.name}
{r.einheit && (
<span className="ml-1 text-anthrazit/60">({r.einheit})</span>
)}
</span>
<Button
variant="ghost"
size="sm"
disabled={pending}
onClick={() =>
run(() =>
removeCategoryMerkmal({
categoryId,
merkmalId: r.merkmalId,
}),
)
}
>
{de.admin.loeschen}
</Button>
</li>
))}
</ol>
)}
{verfuegbar.length > 0 && (
<div className="flex flex-wrap items-end gap-2 rounded border border-rand/70 bg-white p-3">
<select
className={selectCls}
value={addId}
onChange={(e) => setAddId(e.target.value)}
>
<option value="">{de.admin.navMerkmale} </option>
{verfuegbar.map((m) => (
<option key={m.id} value={m.id}>
{m.name}
</option>
))}
</select>
<Button
size="sm"
disabled={pending || !addId}
onClick={() => {
run(() =>
setCategoryMerkmal({
categoryId,
merkmal: { merkmalId: addId, reihenfolge: rows.length },
}),
);
setAddId("");
}}
>
{de.admin.anlegen}
</Button>
</div>
)}
</section>
);
}

View File

@@ -0,0 +1,72 @@
import { notFound } from "next/navigation";
import Link from "next/link";
import { eq } from "drizzle-orm";
import { db } from "@/db";
import {
equipmentCategories,
equipmentCategoryMerkmale,
merkmale,
} from "@/db/schema";
import { requirePlatformAdmin } from "@/lib/auth/guards";
import { de } from "@/lib/i18n/de";
import {
CategoryMerkmaleEditor,
type CategoryMerkmalRow,
type MerkmalLite,
} from "./CategoryMerkmaleEditor";
export default async function KategorieDetailPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
await requirePlatformAdmin();
const { id } = await params;
const [cat] = await db
.select()
.from(equipmentCategories)
.where(eq(equipmentCategories.id, id));
if (!cat) notFound();
const [cmRows, alleMerkmale] = await Promise.all([
db
.select({
merkmalId: equipmentCategoryMerkmale.merkmalId,
name: merkmale.name,
einheit: merkmale.einheit,
reihenfolge: equipmentCategoryMerkmale.reihenfolge,
})
.from(equipmentCategoryMerkmale)
.innerJoin(merkmale, eq(merkmale.id, equipmentCategoryMerkmale.merkmalId))
.where(eq(equipmentCategoryMerkmale.categoryId, id))
.orderBy(equipmentCategoryMerkmale.reihenfolge, merkmale.name),
db
.select({ id: merkmale.id, name: merkmale.name })
.from(merkmale)
.where(eq(merkmale.status, "active"))
.orderBy(merkmale.name),
]);
const rows: CategoryMerkmalRow[] = cmRows;
const merkmaleLite: MerkmalLite[] = alleMerkmale;
return (
<div className="space-y-8">
<div>
<Link
href="/admin/geraete-kategorien"
className="text-sm text-navy hover:underline"
>
{de.admin.navKategorien}
</Link>
<h1 className="mt-2 font-display text-2xl text-navy">{cat.name}</h1>
</div>
<CategoryMerkmaleEditor
categoryId={id}
rows={rows}
alleMerkmale={merkmaleLite}
/>
</div>
);
}

View File

@@ -0,0 +1,54 @@
import Link from "next/link";
import { db } from "@/db";
import { equipmentCategories } from "@/db/schema";
import { requirePlatformAdmin } from "@/lib/auth/guards";
import { de } from "@/lib/i18n/de";
import { DataTable } from "@/components/admin/DataTable";
import { CategoryCreateForm } from "./CategoryCreateForm";
export const metadata = { title: "Geräte-Kategorien — Administration" };
export default async function KategorienPage() {
await requirePlatformAdmin();
const rows = await db
.select()
.from(equipmentCategories)
.orderBy(equipmentCategories.reihenfolge, equipmentCategories.name);
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h1 className="font-display text-2xl text-navy">
{de.admin.navKategorien}
</h1>
<CategoryCreateForm />
</div>
<DataTable
rows={rows}
getRowKey={(r) => r.id}
columns={[
{
key: "name",
header: de.admin.name,
render: (r) => (
<span className="font-medium text-anthrazit">{r.name}</span>
),
},
{
key: "aktion",
header: "",
render: (r) => (
<Link
href={`/admin/geraete-kategorien/${r.id}`}
className="text-navy underline-offset-2 hover:underline"
>
{de.admin.bearbeiten}
</Link>
),
},
]}
/>
</div>
);
}

View File

@@ -0,0 +1,209 @@
"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 { createMerkmal, updateMerkmal } from "../../_actions/merkmale";
export type MerkmalTyp = "number" | "enum" | "boolean" | "text";
export type Geltung = "vehicle" | "equipment" | "both";
export interface MerkmalOption {
wert: string;
label: string;
}
export interface MerkmalEditorValue {
id?: string;
name: string;
typ: MerkmalTyp;
einheit?: string;
geltungsbereich: Geltung;
optionen: MerkmalOption[];
}
const TYP_LABELS: Record<MerkmalTyp, string> = {
number: "Zahl",
enum: "Auswahl",
boolean: "Ja/Nein",
text: "Text",
};
const GELTUNG_LABELS: Record<Geltung, string> = {
vehicle: "Fahrzeug",
equipment: "Gerät",
both: "beide",
};
const selectCls =
"flex h-10 w-full rounded border border-rand bg-white px-3 py-2 text-sm text-anthrazit focus:outline-none focus:ring-2 focus:ring-navy";
/**
* Client-Editor für ein Merkmal. Validiert clientseitig minimal (Enum braucht
* Optionen) und ruft die serverseitige (guarded + Zod-validierte) Action. Die
* autoritative Validierung passiert serverseitig.
*/
export function MerkmalEditor({
initial,
onDone,
}: {
initial?: MerkmalEditorValue;
onDone?: () => void;
}) {
const router = useRouter();
const [name, setName] = React.useState(initial?.name ?? "");
const [typ, setTyp] = React.useState<MerkmalTyp>(initial?.typ ?? "number");
const [einheit, setEinheit] = React.useState(initial?.einheit ?? "");
const [geltung, setGeltung] = React.useState<Geltung>(
initial?.geltungsbereich ?? "vehicle",
);
const [optionen, setOptionen] = React.useState<MerkmalOption[]>(
initial?.optionen ?? [],
);
const [error, setError] = React.useState<string | null>(null);
const [pending, startTransition] = React.useTransition();
function setOption(i: number, patch: Partial<MerkmalOption>) {
setOptionen((prev) =>
prev.map((o, idx) => (idx === i ? { ...o, ...patch } : o)),
);
}
function submit() {
setError(null);
if (typ === "enum" && optionen.filter((o) => o.wert.trim()).length === 0) {
setError(de.admin.optionen + ": " + "mindestens eine erforderlich.");
return;
}
const payload = {
...(initial?.id ? { id: initial.id } : {}),
name,
typ,
einheit: einheit || undefined,
geltungsbereich: geltung,
optionen: typ === "enum" ? optionen.filter((o) => o.wert.trim()) : [],
};
startTransition(async () => {
const res = initial?.id
? await updateMerkmal(payload)
: await createMerkmal(payload);
if (!res.ok) {
setError(res.error);
return;
}
router.refresh();
onDone?.();
});
}
return (
<div className="space-y-3 rounded border border-rand bg-white p-4">
<div className="grid gap-3 sm:grid-cols-2">
<label className="text-sm">
<span className="mb-1 block font-medium text-anthrazit/70">
{de.admin.name}
</span>
<Input value={name} onChange={(e) => setName(e.target.value)} />
</label>
<label className="text-sm">
<span className="mb-1 block font-medium text-anthrazit/70">
{de.admin.typ}
</span>
<select
className={selectCls}
value={typ}
onChange={(e) => setTyp(e.target.value as MerkmalTyp)}
>
{(Object.keys(TYP_LABELS) as MerkmalTyp[]).map((k) => (
<option key={k} value={k}>
{TYP_LABELS[k]}
</option>
))}
</select>
</label>
<label className="text-sm">
<span className="mb-1 block font-medium text-anthrazit/70">
{de.admin.einheit}
</span>
<Input
value={einheit}
onChange={(e) => setEinheit(e.target.value)}
placeholder="z. B. l, kg"
/>
</label>
<label className="text-sm">
<span className="mb-1 block font-medium text-anthrazit/70">
{de.admin.geltungsbereich}
</span>
<select
className={selectCls}
value={geltung}
onChange={(e) => setGeltung(e.target.value as Geltung)}
>
{(Object.keys(GELTUNG_LABELS) as Geltung[]).map((k) => (
<option key={k} value={k}>
{GELTUNG_LABELS[k]}
</option>
))}
</select>
</label>
</div>
{typ === "enum" && (
<fieldset className="space-y-2 rounded border border-rand/70 p-3">
<legend className="px-1 text-sm font-medium text-anthrazit/70">
{de.admin.optionen}
</legend>
{optionen.map((o, i) => (
<div key={i} className="flex gap-2">
<Input
value={o.wert}
onChange={(e) => setOption(i, { wert: e.target.value })}
placeholder="wert"
/>
<Input
value={o.label}
onChange={(e) => setOption(i, { label: e.target.value })}
placeholder="Bezeichnung"
/>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() =>
setOptionen((prev) => prev.filter((_, idx) => idx !== i))
}
>
×
</Button>
</div>
))}
<Button
type="button"
variant="outline"
size="sm"
onClick={() =>
setOptionen((prev) => [...prev, { wert: "", label: "" }])
}
>
{de.admin.optionHinzufuegen}
</Button>
</fieldset>
)}
{error && <p className="text-sm text-signal">{error}</p>}
<div className="flex gap-2">
<Button type="button" onClick={submit} disabled={pending}>
{de.admin.speichern}
</Button>
{onDone && (
<Button type="button" variant="ghost" onClick={onDone}>
{de.admin.abbrechen}
</Button>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,112 @@
"use client";
import * as React from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { de } from "@/lib/i18n/de";
import {
MerkmalEditor,
type MerkmalEditorValue,
} from "./MerkmalEditor";
import { deleteMerkmal } from "../../_actions/merkmale";
export interface MerkmalRow extends MerkmalEditorValue {
id: string;
}
const TYP_LABELS: Record<string, string> = {
number: "Zahl",
enum: "Auswahl",
boolean: "Ja/Nein",
text: "Text",
};
/**
* Client-Hülle der Merkmal-Liste: Anlegen/Bearbeiten via MerkmalEditor, Löschen
* mit klarer Fehlermeldung (kein 500), wenn referenziert. Die Server-Action ist
* guarded + Zod-validiert.
*/
export function MerkmalListClient({ rows }: { rows: MerkmalRow[] }) {
const router = useRouter();
const [creating, setCreating] = React.useState(false);
const [editId, setEditId] = React.useState<string | null>(null);
const [error, setError] = React.useState<string | null>(null);
const [pending, startTransition] = React.useTransition();
function remove(id: string) {
setError(null);
startTransition(async () => {
const res = await deleteMerkmal(id);
if (!res.ok) {
setError(res.error);
return;
}
router.refresh();
});
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h1 className="font-display text-2xl text-navy">
{de.admin.navMerkmale}
</h1>
{!creating && (
<Button onClick={() => setCreating(true)}>{de.admin.neu}</Button>
)}
</div>
{creating && <MerkmalEditor onDone={() => setCreating(false)} />}
{error && <p className="text-sm text-signal">{error}</p>}
{rows.length === 0 ? (
<p className="rounded border border-rand bg-white px-4 py-6 text-sm text-anthrazit/60">
{de.admin.keineEintraege}
</p>
) : (
<ul className="space-y-2">
{rows.map((m) => (
<li
key={m.id}
className="rounded border border-rand bg-white px-4 py-3"
>
{editId === m.id ? (
<MerkmalEditor
initial={m}
onDone={() => setEditId(null)}
/>
) : (
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<span className="font-medium text-anthrazit">{m.name}</span>
<span className="ml-2 text-sm text-anthrazit/60">
{TYP_LABELS[m.typ] ?? m.typ}
{m.einheit ? ` · ${m.einheit}` : ""}
</span>
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setEditId(m.id)}
>
{de.admin.bearbeiten}
</Button>
<Button
variant="ghost"
size="sm"
disabled={pending}
onClick={() => remove(m.id)}
>
{de.admin.loeschen}
</Button>
</div>
</div>
)}
</li>
))}
</ul>
)}
</div>
);
}

View File

@@ -0,0 +1,40 @@
import { eq } from "drizzle-orm";
import { db } from "@/db";
import { merkmale, merkmalOptionen } from "@/db/schema";
import { requirePlatformAdmin } from "@/lib/auth/guards";
import {
MerkmalListClient,
type MerkmalRow,
} from "./MerkmalListClient";
export const metadata = { title: "Merkmale — Administration" };
export default async function MerkmalePage() {
// Default-deny (Verteidigung in der Tiefe — zusätzlich zum Layout-Guard).
await requirePlatformAdmin();
const liste = await db
.select()
.from(merkmale)
.where(eq(merkmale.status, "active"))
.orderBy(merkmale.name);
const optionen = await db.select().from(merkmalOptionen);
const optByMerkmal = new Map<string, { wert: string; label: string }[]>();
for (const o of optionen) {
const arr = optByMerkmal.get(o.merkmalId) ?? [];
arr.push({ wert: o.wert, label: o.label });
optByMerkmal.set(o.merkmalId, arr);
}
const rows: MerkmalRow[] = liste.map((m) => ({
id: m.id,
name: m.name,
typ: m.typ,
einheit: m.einheit ?? undefined,
geltungsbereich: m.geltungsbereich,
optionen: optByMerkmal.get(m.id) ?? [],
}));
return <MerkmalListClient rows={rows} />;
}

View File

@@ -0,0 +1,143 @@
"use client";
import * as React from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { de } from "@/lib/i18n/de";
import { promoteMerkmal, mergeMerkmal } from "../../../_actions/proposals";
export interface ProposalRow {
id: string;
name: string;
typ: string;
}
export interface ActiveTarget {
id: string;
name: string;
typ: string;
}
const TYP_LABELS: Record<string, string> = {
number: "Zahl",
enum: "Auswahl",
boolean: "Ja/Nein",
text: "Text",
};
const selectCls =
"h-9 rounded border border-rand bg-white px-2 text-sm text-anthrazit focus:outline-none focus:ring-2 focus:ring-navy";
/**
* Governance-Flow für vorgeschlagene Merkmale: Übernehmen (promote -> active)
* oder in ein typkompatibles aktives Merkmal Zusammenführen (merge). Die
* Typ-Kompatibilität wird zusätzlich serverseitig erzwungen; die UI bietet nur
* gleichtypige Ziele an.
*/
export function ProposalsClient({
proposals,
targets,
}: {
proposals: ProposalRow[];
targets: ActiveTarget[];
}) {
const router = useRouter();
const [error, setError] = React.useState<string | null>(null);
const [zielByRow, setZielByRow] = React.useState<Record<string, string>>({});
const [pending, startTransition] = React.useTransition();
function run(fn: () => Promise<{ ok: boolean; error?: string }>) {
setError(null);
startTransition(async () => {
const res = await fn();
if (!res.ok) {
setError(res.error ?? de.fehler.allgemein);
return;
}
router.refresh();
});
}
if (proposals.length === 0) {
return (
<div>
<h1 className="mb-6 font-display text-2xl text-navy">
{de.admin.navVorschlaege}
</h1>
<p className="rounded border border-rand bg-white px-4 py-6 text-sm text-anthrazit/60">
{de.admin.keineEintraege}
</p>
</div>
);
}
return (
<div className="space-y-4">
<h1 className="font-display text-2xl text-navy">
{de.admin.navVorschlaege}
</h1>
{error && <p className="text-sm text-signal">{error}</p>}
<ul className="space-y-2">
{proposals.map((p) => {
const kompatibel = targets.filter((t) => t.typ === p.typ);
return (
<li
key={p.id}
className="flex flex-wrap items-center justify-between gap-3 rounded border border-rand bg-white px-4 py-3"
>
<div>
<span className="font-medium text-anthrazit">{p.name}</span>
<span className="ml-2 text-sm text-anthrazit/60">
{TYP_LABELS[p.typ] ?? p.typ}
</span>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button
size="sm"
disabled={pending}
onClick={() => run(() => promoteMerkmal(p.id))}
>
{de.admin.promote}
</Button>
{kompatibel.length > 0 && (
<>
<select
className={selectCls}
value={zielByRow[p.id] ?? ""}
onChange={(e) =>
setZielByRow((prev) => ({
...prev,
[p.id]: e.target.value,
}))
}
>
<option value="">{de.admin.mergeZiel} </option>
{kompatibel.map((t) => (
<option key={t.id} value={t.id}>
{t.name}
</option>
))}
</select>
<Button
size="sm"
variant="outline"
disabled={pending || !zielByRow[p.id]}
onClick={() =>
run(() =>
mergeMerkmal({
proposedId: p.id,
zielId: zielByRow[p.id],
}),
)
}
>
{de.admin.merge}
</Button>
</>
)}
</div>
</li>
);
})}
</ul>
</div>
);
}

View File

@@ -0,0 +1,26 @@
import { eq } from "drizzle-orm";
import { db } from "@/db";
import { merkmale } from "@/db/schema";
import { requirePlatformAdmin } from "@/lib/auth/guards";
import { ProposalsClient } from "./MergeDialog";
export const metadata = { title: "Vorschläge — Administration" };
export default async function ProposalsPage() {
await requirePlatformAdmin();
const [proposals, active] = await Promise.all([
db
.select({ id: merkmale.id, name: merkmale.name, typ: merkmale.typ })
.from(merkmale)
.where(eq(merkmale.status, "proposed"))
.orderBy(merkmale.name),
db
.select({ id: merkmale.id, name: merkmale.name, typ: merkmale.typ })
.from(merkmale)
.where(eq(merkmale.status, "active"))
.orderBy(merkmale.name),
]);
return <ProposalsClient proposals={proposals} targets={active} />;
}

View File

@@ -0,0 +1,32 @@
import Link from "next/link";
import { de } from "@/lib/i18n/de";
export const metadata = { title: "Administration — FlorianNetz" };
const CARDS = [
{ href: "/admin/merkmale", label: de.admin.navMerkmale },
{ href: "/admin/merkmale/proposals", label: de.admin.navVorschlaege },
{ href: "/admin/vorlagen", label: de.admin.navVorlagen },
{ href: "/admin/geraete-kategorien", label: de.admin.navKategorien },
{ href: "/admin/wehren", label: de.admin.navWehren },
{ href: "/admin/audit", label: de.admin.navAudit },
] as const;
export default function AdminHomePage() {
return (
<div>
<h1 className="mb-6 font-display text-2xl text-navy">{de.admin.titel}</h1>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{CARDS.map((c) => (
<Link
key={c.href}
href={c.href}
className="rounded border border-rand bg-white px-5 py-4 text-anthrazit transition-colors hover:border-navy/40 hover:bg-nebel"
>
<span className="font-medium">{c.label}</span>
</Link>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,65 @@
"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 { createTemplate } from "../../_actions/templates";
/** Inline-Anlageformular für eine neue Fahrzeug-Vorlage. */
export function TemplateCreateForm() {
const router = useRouter();
const [open, setOpen] = React.useState(false);
const [code, setCode] = React.useState("");
const [name, setName] = React.useState("");
const [error, setError] = React.useState<string | null>(null);
const [pending, startTransition] = React.useTransition();
function submit() {
setError(null);
startTransition(async () => {
const res = await createTemplate({ code, name });
if (!res.ok) {
setError(res.error);
return;
}
setCode("");
setName("");
setOpen(false);
router.refresh();
});
}
if (!open) {
return <Button onClick={() => setOpen(true)}>{de.admin.neu}</Button>;
}
return (
<div className="space-y-3 rounded border border-rand bg-white p-4">
<div className="grid gap-3 sm:grid-cols-2">
<label className="text-sm">
<span className="mb-1 block font-medium text-anthrazit/70">
{de.admin.code}
</span>
<Input value={code} onChange={(e) => setCode(e.target.value)} />
</label>
<label className="text-sm">
<span className="mb-1 block font-medium text-anthrazit/70">
{de.admin.name}
</span>
<Input value={name} onChange={(e) => setName(e.target.value)} />
</label>
</div>
{error && <p className="text-sm text-signal">{error}</p>}
<div className="flex gap-2">
<Button onClick={submit} disabled={pending}>
{de.admin.anlegen}
</Button>
<Button variant="ghost" onClick={() => setOpen(false)}>
{de.admin.abbrechen}
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,122 @@
"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 { allradCode } from "@/lib/admin/codes";
import { addAlias, removeAlias } from "../../../_actions/templates";
export interface AliasRow {
id: string;
alias: string;
bestaetigt: boolean;
}
/**
* Editor der Such-/Anzeige-Aliasse einer Vorlage. Zeigt zusätzlich den
* kanonischen Allrad-Hinweis (`allradCode(code)`), da Allrad eine Laufzeitregel
* ist und NICHT als Alias gespeichert wird.
*/
export function AliasEditor({
templateId,
code,
aliasse,
}: {
templateId: string;
code: string;
aliasse: AliasRow[];
}) {
const router = useRouter();
const [neu, setNeu] = React.useState("");
const [bestaetigt, setBestaetigt] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const [pending, startTransition] = React.useTransition();
function run(fn: () => Promise<{ ok: boolean; error?: string }>) {
setError(null);
startTransition(async () => {
const res = await fn();
if (!res.ok) {
setError(res.error ?? de.fehler.allgemein);
return;
}
router.refresh();
});
}
return (
<section className="space-y-3">
<h2 className="font-display text-lg text-navy">{de.admin.aliasse}</h2>
<p className="text-sm text-anthrazit/70">
{de.admin.allradHinweis}:{" "}
<span className="font-medium text-anthrazit">{allradCode(code)}</span>{" "}
<span className="text-anthrazit/50">
(Laufzeitregel, kein gespeicherter Alias)
</span>
</p>
{error && <p className="text-sm text-signal">{error}</p>}
{aliasse.length === 0 ? (
<p className="rounded border border-rand bg-white px-4 py-4 text-sm text-anthrazit/60">
{de.admin.keineEintraege}
</p>
) : (
<ul className="space-y-2">
{aliasse.map((a) => (
<li
key={a.id}
className="flex items-center justify-between gap-3 rounded border border-rand bg-white px-4 py-2"
>
<span className="text-anthrazit">
{a.alias}
{a.bestaetigt && (
<span className="ml-2 rounded-sm bg-bereit/10 px-1.5 py-0.5 text-xs text-bereit">
{de.admin.bestaetigt}
</span>
)}
</span>
<Button
variant="ghost"
size="sm"
disabled={pending}
onClick={() => run(() => removeAlias(a.id))}
>
{de.admin.loeschen}
</Button>
</li>
))}
</ul>
)}
<div className="flex flex-wrap items-end gap-2 rounded border border-rand/70 bg-white p-3">
<label className="text-sm">
<span className="mb-1 block font-medium text-anthrazit/70">
{de.admin.alias}
</span>
<Input value={neu} onChange={(e) => setNeu(e.target.value)} />
</label>
<label className="flex items-center gap-2 pb-2 text-sm text-anthrazit/70">
<input
type="checkbox"
checked={bestaetigt}
onChange={(e) => setBestaetigt(e.target.checked)}
/>
{de.admin.bestaetigt}
</label>
<Button
size="sm"
disabled={pending || !neu.trim()}
onClick={() => {
run(() => addAlias({ templateId, alias: neu, bestaetigt }));
setNeu("");
setBestaetigt(false);
}}
>
{de.admin.anlegen}
</Button>
</div>
</section>
);
}

View File

@@ -0,0 +1,197 @@
"use client";
import * as React from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { de } from "@/lib/i18n/de";
import {
setTemplateMerkmal,
removeTemplateMerkmal,
} from "../../../_actions/templates";
export interface MerkmalOptionLite {
wert: string;
label: string;
}
export interface MerkmalLite {
id: string;
name: string;
typ: "number" | "enum" | "boolean" | "text";
einheit: string | null;
optionen: MerkmalOptionLite[];
}
export interface TemplateMerkmalRow {
merkmalId: string;
name: string;
typ: MerkmalLite["typ"];
einheit: string | null;
vorgabewertNum: number | null;
vorgabewertText: string | null;
vorgabewertBool: boolean | null;
pflicht: boolean;
optionen: MerkmalOptionLite[];
}
const selectCls =
"h-9 rounded border border-rand bg-white px-2 text-sm text-anthrazit focus:outline-none focus:ring-2 focus:ring-navy";
function vorgabewertLabel(r: TemplateMerkmalRow): string {
if (r.typ === "number") return r.vorgabewertNum?.toString() ?? "";
if (r.typ === "boolean")
return r.vorgabewertBool == null
? ""
: r.vorgabewertBool
? de.search.ja
: de.search.nein;
if (r.typ === "enum") {
const opt = r.optionen.find((o) => o.wert === r.vorgabewertText);
return opt?.label ?? r.vorgabewertText ?? "";
}
return r.vorgabewertText ?? "";
}
/**
* Editor der Vorgabe-Merkmale einer Vorlage: zuordnen, Vorgabewert je Typ
* (vorgabewert_num/_text/_bool) erfassen, Pflicht-Flag, entfernen. Alle Aktionen
* über guarded + Zod-validierte Server-Actions.
*/
export function TemplateMerkmaleEditor({
templateId,
rows,
alleMerkmale,
}: {
templateId: string;
rows: TemplateMerkmalRow[];
alleMerkmale: MerkmalLite[];
}) {
const router = useRouter();
const [error, setError] = React.useState<string | null>(null);
const [pending, startTransition] = React.useTransition();
const [addId, setAddId] = React.useState("");
const zugeordnet = new Set(rows.map((r) => r.merkmalId));
const verfuegbar = alleMerkmale.filter((m) => !zugeordnet.has(m.id));
function run(fn: () => Promise<{ ok: boolean; error?: string }>) {
setError(null);
startTransition(async () => {
const res = await fn();
if (!res.ok) {
setError(res.error ?? de.fehler.allgemein);
return;
}
router.refresh();
});
}
function saveVorgabe(
m: MerkmalLite,
value: { num?: number | null; text?: string | null; bool?: boolean | null },
pflicht: boolean,
) {
run(() =>
setTemplateMerkmal({
templateId,
merkmal: {
merkmalId: m.id,
vorgabewertNum: value.num ?? null,
vorgabewertText: value.text ?? undefined,
vorgabewertBool: value.bool ?? null,
pflicht,
},
}),
);
}
return (
<section className="space-y-3">
<h2 className="font-display text-lg text-navy">{de.admin.navMerkmale}</h2>
{error && <p className="text-sm text-signal">{error}</p>}
{rows.length === 0 ? (
<p className="rounded border border-rand bg-white px-4 py-4 text-sm text-anthrazit/60">
{de.admin.keineEintraege}
</p>
) : (
<ul className="space-y-2">
{rows.map((r) => (
<li
key={r.merkmalId}
className="flex flex-wrap items-center justify-between gap-3 rounded border border-rand bg-white px-4 py-3"
>
<div>
<span className="font-medium text-anthrazit">{r.name}</span>
{r.einheit && (
<span className="ml-1 text-anthrazit/60">({r.einheit})</span>
)}
<span className="ml-3 text-sm text-anthrazit/60">
{de.admin.vorgabewert}: {vorgabewertLabel(r)}
{r.pflicht ? ` · ${de.admin.pflicht}` : ""}
</span>
</div>
<Button
variant="ghost"
size="sm"
disabled={pending}
onClick={() =>
run(() =>
removeTemplateMerkmal({
templateId,
merkmalId: r.merkmalId,
}),
)
}
>
{de.admin.loeschen}
</Button>
</li>
))}
</ul>
)}
{verfuegbar.length > 0 && (
<div className="flex flex-wrap items-end gap-2 rounded border border-rand/70 bg-white p-3">
<select
className={selectCls}
value={addId}
onChange={(e) => setAddId(e.target.value)}
>
<option value="">{de.admin.navMerkmale} </option>
{verfuegbar.map((m) => (
<option key={m.id} value={m.id}>
{m.name}
</option>
))}
</select>
<Button
size="sm"
disabled={pending || !addId}
onClick={() => {
const m = alleMerkmale.find((x) => x.id === addId);
if (!m) return;
saveVorgabe(m, {}, false);
setAddId("");
}}
>
{de.admin.anlegen}
</Button>
<span className="text-xs text-anthrazit/50">
Vorgabewerte nach dem Hinzufügen über erneutes Speichern setzen.
</span>
</div>
)}
<VorgabewertHinweis />
</section>
);
}
/** Begleittext zu den drei typisierten Vorgabewert-Spalten. */
function VorgabewertHinweis() {
return (
<p className="text-xs text-anthrazit/50">
Vorgabewerte werden je Typ in eine der Spalten vorgabewert_num /
vorgabewert_text / vorgabewert_bool geschrieben.
</p>
);
}

View File

@@ -0,0 +1,110 @@
import { notFound } from "next/navigation";
import Link from "next/link";
import { eq } from "drizzle-orm";
import { db } from "@/db";
import {
vehicleTemplates,
vehicleTemplateMerkmale,
vehicleTemplateAliasse,
merkmale,
merkmalOptionen,
} from "@/db/schema";
import { requirePlatformAdmin } from "@/lib/auth/guards";
import { de } from "@/lib/i18n/de";
import {
TemplateMerkmaleEditor,
type TemplateMerkmalRow,
type MerkmalLite,
} from "./TemplateMerkmaleEditor";
import { AliasEditor, type AliasRow } from "./AliasEditor";
export default async function VorlageDetailPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
await requirePlatformAdmin();
const { id } = await params;
const [tpl] = await db
.select()
.from(vehicleTemplates)
.where(eq(vehicleTemplates.id, id));
if (!tpl) notFound();
const [tmRows, aliasse, alleMerkmale, alleOptionen] = await Promise.all([
db
.select({
merkmalId: vehicleTemplateMerkmale.merkmalId,
name: merkmale.name,
typ: merkmale.typ,
einheit: merkmale.einheit,
vorgabewertNum: vehicleTemplateMerkmale.vorgabewertNum,
vorgabewertText: vehicleTemplateMerkmale.vorgabewertText,
vorgabewertBool: vehicleTemplateMerkmale.vorgabewertBool,
pflicht: vehicleTemplateMerkmale.pflicht,
})
.from(vehicleTemplateMerkmale)
.innerJoin(merkmale, eq(merkmale.id, vehicleTemplateMerkmale.merkmalId))
.where(eq(vehicleTemplateMerkmale.templateId, id))
.orderBy(vehicleTemplateMerkmale.reihenfolge, merkmale.name),
db
.select()
.from(vehicleTemplateAliasse)
.where(eq(vehicleTemplateAliasse.templateId, id))
.orderBy(vehicleTemplateAliasse.alias),
db
.select()
.from(merkmale)
.where(eq(merkmale.status, "active"))
.orderBy(merkmale.name),
db.select().from(merkmalOptionen),
]);
const optByMerkmal = new Map<string, { wert: string; label: string }[]>();
for (const o of alleOptionen) {
const arr = optByMerkmal.get(o.merkmalId) ?? [];
arr.push({ wert: o.wert, label: o.label });
optByMerkmal.set(o.merkmalId, arr);
}
const rows: TemplateMerkmalRow[] = tmRows.map((r) => ({
...r,
optionen: optByMerkmal.get(r.merkmalId) ?? [],
}));
const merkmaleLite: MerkmalLite[] = alleMerkmale.map((m) => ({
id: m.id,
name: m.name,
typ: m.typ,
einheit: m.einheit,
optionen: optByMerkmal.get(m.id) ?? [],
}));
const aliasRows: AliasRow[] = aliasse.map((a) => ({
id: a.id,
alias: a.alias,
bestaetigt: a.bestaetigt,
}));
return (
<div className="space-y-8">
<div>
<Link
href="/admin/vorlagen"
className="text-sm text-navy hover:underline"
>
{de.admin.navVorlagen}
</Link>
<h1 className="mt-2 font-display text-2xl text-navy">
{tpl.code} {tpl.name}
</h1>
</div>
<TemplateMerkmaleEditor
templateId={id}
rows={rows}
alleMerkmale={merkmaleLite}
/>
<AliasEditor templateId={id} code={tpl.code} aliasse={aliasRows} />
</div>
);
}

View File

@@ -0,0 +1,63 @@
import Link from "next/link";
import { db } from "@/db";
import { vehicleTemplates } from "@/db/schema";
import { requirePlatformAdmin } from "@/lib/auth/guards";
import { de } from "@/lib/i18n/de";
import { allradCode } from "@/lib/admin/codes";
import { DataTable } from "@/components/admin/DataTable";
import { TemplateCreateForm } from "./TemplateCreateForm";
export const metadata = { title: "Fahrzeug-Vorlagen — Administration" };
export default async function VorlagenPage() {
await requirePlatformAdmin();
const rows = await db
.select()
.from(vehicleTemplates)
.orderBy(vehicleTemplates.reihenfolge, vehicleTemplates.code);
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h1 className="font-display text-2xl text-navy">
{de.admin.navVorlagen}
</h1>
<TemplateCreateForm />
</div>
<DataTable
rows={rows}
getRowKey={(r) => r.id}
columns={[
{
key: "code",
header: de.admin.code,
render: (r) => (
<span className="font-medium text-anthrazit">{r.code}</span>
),
},
{ key: "name", header: de.admin.name, render: (r) => r.name },
{
key: "allrad",
header: de.admin.allradHinweis,
render: (r) => (
<span className="text-anthrazit/60">{allradCode(r.code)}</span>
),
},
{
key: "aktion",
header: "",
render: (r) => (
<Link
href={`/admin/vorlagen/${r.id}`}
className="text-navy underline-offset-2 hover:underline"
>
{de.admin.bearbeiten}
</Link>
),
},
]}
/>
</div>
);
}

View File

@@ -0,0 +1,58 @@
"use client";
import * as React from "react";
import { Button } from "@/components/ui/button";
import { de } from "@/lib/i18n/de";
import { resetBrigadeUserPassword } from "../../../_actions/brigades";
/**
* Setzt das Passwort eines lokalen Wehr-Benutzers zurück und zeigt das neue
* Einmal-Passwort genau einmal. Authentik-Konten haben keinen Button.
*/
export function UserResetButton({
userId,
authTyp,
}: {
userId: string;
authTyp: string;
}) {
const [error, setError] = React.useState<string | null>(null);
const [pw, setPw] = React.useState<string | null>(null);
const [pending, startTransition] = React.useTransition();
if (authTyp !== "local") {
return <span className="text-xs text-anthrazit/40">Authentik</span>;
}
if (pw) {
return (
<code className="rounded border border-rand bg-white px-2 py-1 font-mono text-xs text-navy">
{pw}
</code>
);
}
return (
<div className="flex flex-col items-end gap-1">
<Button
variant="outline"
size="sm"
disabled={pending}
onClick={() =>
startTransition(async () => {
setError(null);
const res = await resetBrigadeUserPassword({ userId });
if (!res.ok) {
setError(res.error);
return;
}
setPw(res.tempPassword);
})
}
>
{de.admin.passwortReset}
</Button>
{error && <span className="text-xs text-signal">{error}</span>}
</div>
);
}

View File

@@ -0,0 +1,75 @@
import { notFound } from "next/navigation";
import Link from "next/link";
import { eq } from "drizzle-orm";
import { db } from "@/db";
import { brigades, users } from "@/db/schema";
import { requirePlatformAdmin } from "@/lib/auth/guards";
import { de } from "@/lib/i18n/de";
import { DataTable } from "@/components/admin/DataTable";
import { UserResetButton } from "./UserResetButton";
export default async function WehrDetailPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
await requirePlatformAdmin();
const { id } = await params;
const [brigade] = await db.select().from(brigades).where(eq(brigades.id, id));
if (!brigade) notFound();
const benutzer = await db
.select()
.from(users)
.where(eq(users.brigadeId, id))
.orderBy(users.name);
return (
<div className="space-y-6">
<div>
<Link href="/admin/wehren" className="text-sm text-navy hover:underline">
{de.admin.navWehren}
</Link>
<h1 className="mt-2 font-display text-2xl text-navy">{brigade.name}</h1>
<p className="text-sm text-anthrazit/70">
{[brigade.strasse, `${brigade.plz ?? ""} ${brigade.ort ?? ""}`.trim()]
.filter(Boolean)
.join(", ")}
</p>
{brigade.lat == null || brigade.lng == null ? (
<p className="mt-1 text-sm text-wartung">{de.admin.geocodeFehler}</p>
) : (
<p className="mt-1 text-sm text-bereit">{de.admin.geocodeOk}</p>
)}
</div>
<section className="space-y-3">
<h2 className="font-display text-lg text-navy">Benutzer</h2>
<DataTable
rows={benutzer}
getRowKey={(u) => u.id}
columns={[
{
key: "name",
header: de.admin.name,
render: (u) => (
<span className="font-medium text-anthrazit">{u.name}</span>
),
},
{ key: "email", header: de.auth.email, render: (u) => u.email },
{ key: "rolle", header: "Rolle", render: (u) => u.rolle },
{
key: "reset",
header: "",
className: "text-right",
render: (u) => (
<UserResetButton userId={u.id} authTyp={u.authTyp} />
),
},
]}
/>
</section>
</div>
);
}

View 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>
);
}

View File

@@ -0,0 +1,23 @@
import Link from "next/link";
import { requirePlatformAdmin } from "@/lib/auth/guards";
import { de } from "@/lib/i18n/de";
import { BrigadeProvisionForm } from "./BrigadeProvisionForm";
export const metadata = { title: "Wehr anlegen — Administration" };
export default async function WehrNeuPage() {
await requirePlatformAdmin();
return (
<div className="mx-auto max-w-2xl space-y-6">
<div>
<Link href="/admin/wehren" className="text-sm text-navy hover:underline">
{de.admin.navWehren}
</Link>
<h1 className="mt-2 font-display text-2xl text-navy">
{de.admin.wehrAnlegen}
</h1>
</div>
<BrigadeProvisionForm />
</div>
);
}

View File

@@ -0,0 +1,66 @@
import Link from "next/link";
import { db } from "@/db";
import { brigades } from "@/db/schema";
import { requirePlatformAdmin } from "@/lib/auth/guards";
import { de } from "@/lib/i18n/de";
import { Button } from "@/components/ui/button";
import { DataTable } from "@/components/admin/DataTable";
export const metadata = { title: "Wehren — Administration" };
export default async function WehrenPage() {
await requirePlatformAdmin();
const rows = await db.select().from(brigades).orderBy(brigades.name);
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h1 className="font-display text-2xl text-navy">{de.admin.navWehren}</h1>
<Button asChild>
<Link href="/admin/wehren/neu">{de.admin.wehrAnlegen}</Link>
</Button>
</div>
<DataTable
rows={rows}
getRowKey={(r) => r.id}
columns={[
{
key: "name",
header: de.admin.name,
render: (r) => (
<span className="font-medium text-anthrazit">{r.name}</span>
),
},
{
key: "ort",
header: de.admin.ort,
render: (r) => `${r.plz ?? ""} ${r.ort ?? ""}`.trim() || "",
},
{
key: "geo",
header: "Geo",
render: (r) =>
r.lat != null && r.lng != null ? (
<span className="text-bereit"></span>
) : (
<span className="text-wartung">{de.admin.geocodeFehler}</span>
),
},
{
key: "aktion",
header: "",
render: (r) => (
<Link
href={`/admin/wehren/${r.id}`}
className="text-navy underline-offset-2 hover:underline"
>
{de.admin.bearbeiten}
</Link>
),
},
]}
/>
</div>
);
}

15
src/app/(admin)/error.tsx Normal file
View File

@@ -0,0 +1,15 @@
"use client";
import { Button } from "@/components/ui/button";
import { t } from "@/lib/i18n/de";
export default function AdminError({ reset }: { error: Error; reset: () => void }) {
return (
<div className="mx-auto max-w-md py-16 text-center">
<p className="text-anthrazit">{t("fehler.allgemein")}</p>
<Button onClick={reset} className="mt-4">
{t("aktion.erneutVersuchen")}
</Button>
</div>
);
}

View File

@@ -0,0 +1,25 @@
import { requirePlatformAdmin } from "@/lib/auth/guards";
import { AdminNav } from "@/components/admin/AdminNav";
/**
* Admin-Route-Group-Layout.
*
* GUARD-SLOT (Default-deny, dreifach — Querschnittsstandard 1+2): Der
* serverseitige Rollen-Guard ist die ALLERERSTE Anweisung. Er leitet anonyme
* Aufrufe auf /login um (redirect) und verweigert Nicht-platform_admins mit
* forbidden() -> 403. Jede API-Route und jede Server Action wiederholt den
* Guard zusätzlich (Verteidigung in der Tiefe).
*/
export default async function AdminLayout({
children,
}: {
children: React.ReactNode;
}) {
await requirePlatformAdmin();
return (
<div className="min-h-screen bg-nebel">
<AdminNav />
<main className="mx-auto max-w-6xl px-6 py-8">{children}</main>
</div>
);
}

View File

@@ -0,0 +1,9 @@
import { t } from "@/lib/i18n/de";
export default function AdminLoading() {
return (
<div className="py-16 text-center text-sm text-anthrazit/70">
{t("aktion.laden")}
</div>
);
}

View File

@@ -0,0 +1,58 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { cn } from "@/lib/utils";
import { de } from "@/lib/i18n/de";
/**
* Seitennavigation des Admin-Panels. Client-Komponente nur wegen `usePathname`
* (aktiver Zustand). Keine Geschäftslogik, keine Datenabhängigkeit.
*/
const ITEMS = [
{ href: "/admin/merkmale", label: de.admin.navMerkmale },
{ href: "/admin/merkmale/proposals", label: de.admin.navVorschlaege },
{ href: "/admin/vorlagen", label: de.admin.navVorlagen },
{ href: "/admin/geraete-kategorien", label: de.admin.navKategorien },
{ href: "/admin/wehren", label: de.admin.navWehren },
{ href: "/admin/audit", label: de.admin.navAudit },
] as const;
export function AdminNav() {
const pathname = usePathname();
return (
<nav
aria-label="Administrationsnavigation"
className="border-b border-rand bg-white"
>
<div className="mx-auto flex max-w-6xl flex-wrap items-center gap-1 px-6 py-2">
<span className="mr-4 font-display text-sm font-semibold text-navy">
{de.admin.titel}
</span>
{ITEMS.map((item) => {
const active =
pathname === item.href ||
(item.href !== "/admin/merkmale" &&
pathname.startsWith(item.href)) ||
(item.href === "/admin/merkmale" &&
pathname === "/admin/merkmale");
return (
<Link
key={item.href}
href={item.href}
aria-current={active ? "page" : undefined}
className={cn(
"rounded px-3 py-1.5 text-sm font-medium transition-colors",
active
? "bg-navy text-white"
: "text-anthrazit/80 hover:bg-nebel hover:text-anthrazit",
)}
>
{item.label}
</Link>
);
})}
</div>
</nav>
);
}

View File

@@ -0,0 +1,74 @@
import * as React from "react";
import { cn } from "@/lib/utils";
import { de } from "@/lib/i18n/de";
export interface Column<T> {
key: string;
header: string;
render: (row: T) => React.ReactNode;
className?: string;
}
/**
* Schlanke, server-renderbare Tabelle für Admin-Listen. Generisch über den
* Zeilentyp, keine Client-Interaktivität — Aktionen werden als `render`-Zellen
* (eigene Client-Komponenten) eingehängt. Empty-State (Querschnittsstandard 10).
*/
export function DataTable<T>({
columns,
rows,
getRowKey,
emptyText = de.admin.keineEintraege,
}: {
columns: Column<T>[];
rows: T[];
getRowKey: (row: T) => string;
emptyText?: string;
}) {
if (rows.length === 0) {
return (
<p className="rounded border border-rand bg-white px-4 py-6 text-sm text-anthrazit/60">
{emptyText}
</p>
);
}
return (
<div className="overflow-x-auto rounded border border-rand bg-white">
<table className="w-full border-collapse text-sm">
<thead>
<tr className="border-b border-rand text-left">
{columns.map((c) => (
<th
key={c.key}
scope="col"
className={cn(
"px-4 py-2 font-medium text-anthrazit/70",
c.className,
)}
>
{c.header}
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr
key={getRowKey(row)}
className="border-b border-rand/60 last:border-0 hover:bg-nebel/60"
>
{columns.map((c) => (
<td
key={c.key}
className={cn("px-4 py-2 align-top", c.className)}
>
{c.render(row)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
}

View File

@@ -0,0 +1,59 @@
import { describe, it, expect } from "vitest";
import {
allradCode,
normalizeCode,
codesMatch,
expandNameQuery,
} from "./codes";
describe("allradCode — eingeschobenes Allrad-A in die Abkürzung", () => {
it('"HLF 3" -> "HLFA 3"', () => {
expect(allradCode("HLF 3")).toBe("HLFA 3");
});
it('"MTF" -> "MTFA"', () => {
expect(allradCode("MTF")).toBe("MTFA");
});
it('"HLF 1 W" -> "HLFA 1 W"', () => {
expect(allradCode("HLF 1 W")).toBe("HLFA 1 W");
});
it("bereits Allrad bleibt unverändert: HLFA 2 -> HLFA 2", () => {
expect(allradCode("HLFA 2")).toBe("HLFA 2");
});
});
describe("normalizeCode — Grundform für Vergleiche (Allrad-Infix entfernt)", () => {
it('"HLFA 3" -> "HLF 3"', () => {
expect(normalizeCode("HLFA 3")).toBe("HLF 3");
});
it("trimmt und vereinheitlicht Leerzeichen + Großschreibung", () => {
expect(normalizeCode(" hlf 3 ")).toBe("HLF 3");
});
it('"MTFA" -> "MTF"', () => {
expect(normalizeCode("MTFA")).toBe("MTF");
});
});
describe("codesMatch — Allrad-/Grundform sind gleich", () => {
it('codesMatch("HLFA 3", "HLF 3") === true', () => {
expect(codesMatch("HLFA 3", "HLF 3")).toBe(true);
});
it("verschiedene Codes matchen nicht", () => {
expect(codesMatch("HLF 2", "HLF 3")).toBe(false);
});
});
describe("expandNameQuery (bestehende Regel) bleibt intakt", () => {
it('expandNameQuery("HLFA 1 W") enthält "HLF 1 W"', () => {
expect(expandNameQuery("HLFA 1 W").nameLikes).toContain("HLF 1 W");
});
it('expandNameQuery("MTFA") enthält "MTF"', () => {
expect(expandNameQuery("MTFA").nameLikes).toContain("MTF");
});
});

View File

@@ -76,3 +76,47 @@ function entferneAllradInfix(abk: string): string | null {
if (grund.length < 2) return null;
return grund;
}
/**
* Hängt das Allrad-"A" IN die Abkürzung (an das erste Token), sofern noch nicht
* vorhanden. "HLF 3" -> "HLFA 3", "MTF" -> "MTFA", "HLF 1 W" -> "HLFA 1 W".
* Bereits-Allrad-Codes ("HLFA 2") bleiben unverändert. Codes ohne erkennbares
* Abkürzungs-Token (kein reines Großbuchstaben-Token) werden unverändert
* zurückgegeben.
*
* Diese Funktion ist die kanonische Anzeige-Hilfe des Admin-Workstreams für den
* Allrad-Hinweis im Vorlagen-Editor.
*/
export function allradCode(code: string): string {
const trimmed = code.trim().replace(/\s+/g, " ");
if (trimmed === "") return trimmed;
const spaceIdx = trimmed.indexOf(" ");
const abk = spaceIdx === -1 ? trimmed : trimmed.slice(0, spaceIdx);
const rest = spaceIdx === -1 ? "" : trimmed.slice(spaceIdx);
// Nur reine Großbuchstaben-Tokens (ggf. mit Bindestrich) sind Abkürzungen.
if (!/^[A-ZÄÖÜ-]+$/.test(abk)) return trimmed;
// Schon Allrad? (endet auf "FA" mit valider Grundform) -> unverändert.
if (entferneAllradInfix(abk) !== null) return trimmed;
return `${abk}A${rest}`;
}
/**
* Liefert die kanonische Grundform eines Codes für Gleichheitsvergleiche:
* Großschreibung, vereinheitlichte Leerzeichen, getrimmt und das Allrad-Infix
* entfernt. "HLFA 3" und "HLF 3" normalisieren beide auf "HLF 3".
*/
export function normalizeCode(code: string): string {
const trimmed = code.toUpperCase().trim().replace(/\s+/g, " ");
if (trimmed === "") return trimmed;
const spaceIdx = trimmed.indexOf(" ");
const abk = spaceIdx === -1 ? trimmed : trimmed.slice(0, spaceIdx);
const rest = spaceIdx === -1 ? "" : trimmed.slice(spaceIdx);
const grund = entferneAllradInfix(abk);
return grund === null ? trimmed : `${grund}${rest}`;
}
/** true, wenn zwei Codes (nach Normalisierung inkl. Allrad-Infix) gleich sind. */
export function codesMatch(a: string, b: string): boolean {
return normalizeCode(a) === normalizeCode(b);
}

View File

@@ -0,0 +1,126 @@
import { randomBytes } from "node:crypto";
import { eq } from "drizzle-orm";
import { db } from "@/db";
import { brigades, users } from "@/db/schema";
import { hashPassword } from "@/lib/auth/password";
import { geocodeAddress } from "@/lib/geo/nominatim";
import { writeAudit } from "@/lib/audit";
import type { BrigadeProvisionInput } from "@/lib/validation/brigade";
/**
* Erzeugt ein Einmal-Initialpasswort (URL-sicher, 12 Zeichen base64url aus 9
* Zufalls-Bytes). Wird dem Admin nur einmal angezeigt; nur der Hash wird
* gespeichert.
*/
export function generateTempPassword(): string {
return randomBytes(9).toString("base64url");
}
export interface ProvisionResult {
brigadeId: string;
userId: string;
tempPassword: string;
geocoded: boolean;
}
/**
* Legt eine Brigade samt erstem `wehr_admin` (lokales Konto) an. Geocoding
* inline (Adresse -> lat/lng selbst geschrieben). Audit `brigade.create` und
* `user.create` laufen in derselben Transaktion wie die Inserts.
*
* `actorUserId` ist der ausführende platform_admin (aus der Session des
* aufrufenden Server-Action-Guards).
*/
export async function createBrigadeWithFirstAdmin(
input: BrigadeProvisionInput,
actorUserId: string,
): Promise<ProvisionResult> {
const adr = `${input.strasse}, ${input.plz} ${input.ort}, Österreich`;
const geo = await geocodeAddress(adr);
const temp = generateTempPassword();
const hash = await hashPassword(temp);
return db.transaction(async (tx) => {
const [b] = await tx
.insert(brigades)
.values({
name: input.name,
art: "FF",
strasse: input.strasse,
plz: input.plz,
ort: input.ort,
bundesland: "Niederösterreich",
lat: geo.status === "ok" ? geo.coords.lat : null,
lng: geo.status === "ok" ? geo.coords.lng : null,
geocodeQuery: adr,
geocodeStatus: geo.status,
geocodedAt: new Date(),
telefon: input.telefon,
email: input.email ?? null,
wehrfuehrer: input.wehrfuehrer ?? null,
aktiv: true,
})
.returning();
if (!b) throw new Error("Brigade konnte nicht angelegt werden.");
const [u] = await tx
.insert(users)
.values({
brigadeId: b.id,
rolle: "wehr_admin",
authTyp: "local",
email: input.adminEmail,
name: input.adminName,
passwortHash: hash,
aktiv: true,
erstelltVon: actorUserId,
})
.returning();
if (!u) throw new Error("Benutzer konnte nicht angelegt werden.");
await writeAudit(
actorUserId,
"brigade.create",
"brigade",
b.id,
{ geocoded: geo.status === "ok" },
tx,
);
await writeAudit(
actorUserId,
"user.create",
"user",
u.id,
{ rolle: "wehr_admin", authTyp: "local" },
tx,
);
return {
brigadeId: b.id,
userId: u.id,
tempPassword: temp,
geocoded: geo.status === "ok",
};
});
}
/**
* Setzt das Passwort eines lokalen Wehr-Benutzers zurück und liefert das neue
* Einmal-Passwort. Audit `user.reset`. Authentik-Konten haben kein lokales
* Passwort und werden nicht zurückgesetzt.
*/
export async function resetUserPassword(
userId: string,
actorUserId: string,
): Promise<{ tempPassword: string }> {
const temp = generateTempPassword();
const hash = await hashPassword(temp);
await db.transaction(async (tx) => {
await tx
.update(users)
.set({ passwortHash: hash })
.where(eq(users.id, userId));
await writeAudit(actorUserId, "user.reset", "user", userId, undefined, tx);
});
return { tempPassword: temp };
}

View File

@@ -0,0 +1,20 @@
import { describe, it, expect } from "vitest";
import { slugify } from "./slug";
describe("slugify", () => {
it("kleinbuchstaben + Unterstriche", () => {
expect(slugify("Löschwassertank")).toBe("loeschwassertank");
});
it("ersetzt Umlaute/ß", () => {
expect(slugify("Größe Maß")).toBe("groesse_mass");
});
it("entfernt Sonderzeichen und Einheiten-Klammern", () => {
expect(slugify("Feuerlöschpumpe (Typ)")).toBe("feuerloeschpumpe_typ");
});
it("kollabiert Mehrfach-Trenner", () => {
expect(slugify(" A -- B ")).toBe("a_b");
});
});

18
src/lib/admin/slug.ts Normal file
View File

@@ -0,0 +1,18 @@
/**
* Erzeugt einen stabilen `slug` aus einem Merkmal-/Kategorienamen
* (Idempotenz-Key). Deutsche Umlaute/ß werden transliteriert, Sonderzeichen
* entfernt, Wörter mit `_` verbunden.
*
* Rein (keine IO) — testbar ohne DB.
*/
export function slugify(input: string): string {
return input
.trim()
.toLowerCase()
.replace(/ä/g, "ae")
.replace(/ö/g, "oe")
.replace(/ü/g, "ue")
.replace(/ß/g, "ss")
.replace(/[^a-z0-9]+/g, "_")
.replace(/^_+|_+$/g, "");
}

53
src/lib/audit.ts Normal file
View File

@@ -0,0 +1,53 @@
import type { PgTransaction } from "drizzle-orm/pg-core";
import type { ExtractTablesWithRelations } from "drizzle-orm";
import type { NodePgQueryResultHKT } from "drizzle-orm/node-postgres";
import { db } from "@/db";
import * as schema from "@/db/schema";
import { auditLog } from "@/db/schema";
/**
* Transaktionstyp des Drizzle-pg-Clients (kein `any`). Server Actions, die in
* einer Transaktion schreiben, übergeben ihre `tx` an `writeAudit`, damit das
* Audit-Insert Teil derselben atomaren Operation ist.
*/
export type Tx = PgTransaction<
NodePgQueryResultHKT,
typeof schema,
ExtractTablesWithRelations<typeof schema>
>;
/** Audit-fähiges Ziel-Objekt. `zielId` ist eine UUID (Schema-Spalte). */
export type AuditZielTyp =
| "brigade"
| "user"
| "merkmal"
| "vehicle"
| "equipment"
| "vehicle_template"
| "equipment_category";
/**
* EINE Signatur (Querschnittsstandard 6) für alle Schreib-Actions. Mit
* optionalem `tx`: ohne `tx` läuft das Insert auf dem Pool-`db`.
*
* `details` ist serialisierbares JSON (kein PII über das Nötige hinaus). Der
* `actorUserId` referenziert `users.id` (FK set-null), sodass die Historie auch
* nach Benutzerlöschung erhalten bleibt.
*/
export async function writeAudit(
actorUserId: string | null,
aktion: string,
zielTyp: AuditZielTyp | null,
zielId: string | null,
details?: Record<string, unknown>,
tx?: Tx,
): Promise<void> {
const exec = tx ?? db;
await exec.insert(auditLog).values({
actorUserId: actorUserId ?? null,
aktion,
zielTyp,
zielId,
details: details ?? null,
});
}

View File

@@ -67,6 +67,64 @@ export const de = {
laden: "Wird geladen …",
zurueckZurStartseite: "Zur Startseite",
},
admin: {
titel: "Administration",
navMerkmale: "Merkmale",
navVorschlaege: "Vorschläge",
navVorlagen: "Fahrzeug-Vorlagen",
navKategorien: "Geräte-Kategorien",
navWehren: "Wehren",
navAudit: "Audit-Log",
speichern: "Speichern",
abbrechen: "Abbrechen",
loeschen: "Löschen",
anlegen: "Anlegen",
bearbeiten: "Bearbeiten",
neu: "Neu",
name: "Name",
code: "Code",
typ: "Typ",
einheit: "Einheit",
geltungsbereich: "Geltungsbereich",
status: "Status",
optionen: "Optionen",
optionHinzufuegen: "Option hinzufügen",
keineEintraege: "Keine Einträge vorhanden.",
referenziertFehler:
"Merkmal wird verwendet und kann nicht gelöscht werden.",
promote: "Übernehmen",
merge: "Zusammenführen",
mergeZiel: "Ziel-Merkmal",
mergeTypFehler: "Nur Merkmale gleichen Typs können zusammengeführt werden.",
vorgabewert: "Vorgabewert",
pflicht: "Pflicht",
reihenfolge: "Reihenfolge",
alias: "Alias",
aliasse: "Aliasse",
bestaetigt: "bestätigt",
allradHinweis: "Allrad-Schreibweise",
strasse: "Straße",
plz: "PLZ",
ort: "Ort",
telefon: "Telefon",
wehrfuehrer: "Wehrführer",
adminEmail: "Admin-E-Mail",
adminName: "Admin-Name",
wehrAnlegen: "Wehr anlegen",
passwortReset: "Passwort zurücksetzen",
tempPasswort:
"Einmal-Passwort (nur jetzt sichtbar, bitte sicher übergeben):",
geocodeOk: "Adresse geokodiert.",
geocodeFehler:
"Adresse konnte nicht geokodiert werden. Wehr wurde dennoch angelegt.",
auditZeitpunkt: "Zeitpunkt",
auditAktion: "Aktion",
auditZiel: "Ziel",
auditAkteur: "Akteur",
auditFilter: "Aktion filtern",
zurueck: "Zurück",
weiter: "Weiter",
},
} as const;
type Leaf = string;

View File

@@ -0,0 +1,50 @@
import { describe, it, expect } from "vitest";
import { brigadeProvisionSchema } from "./brigade";
const valid = {
name: "FF Musterdorf",
strasse: "Hauptstraße 1",
plz: "3100",
ort: "St. Pölten",
telefon: "+43 2742 12345",
adminEmail: "admin@ff-musterdorf.at",
adminName: "Max Muster",
};
describe("brigadeProvisionSchema", () => {
it("akzeptiert gültige Bereitstellungsdaten", () => {
expect(brigadeProvisionSchema.safeParse(valid).success).toBe(true);
});
it("verlangt einen Wehr-Namen", () => {
expect(
brigadeProvisionSchema.safeParse({ ...valid, name: "" }).success,
).toBe(false);
});
it("normalisiert die Admin-E-Mail auf Kleinbuchstaben", () => {
const r = brigadeProvisionSchema.parse({
...valid,
adminEmail: "ADMIN@FF.AT",
});
expect(r.adminEmail).toBe("admin@ff.at");
});
it("lehnt ungültige E-Mail ab", () => {
expect(
brigadeProvisionSchema.safeParse({ ...valid, adminEmail: "keine-mail" })
.success,
).toBe(false);
});
it("optionale Felder dürfen fehlen", () => {
const { email, wehrfuehrer, ...rest } = {
...valid,
email: undefined,
wehrfuehrer: undefined,
};
void email;
void wehrfuehrer;
expect(brigadeProvisionSchema.safeParse(rest).success).toBe(true);
});
});

View File

@@ -0,0 +1,40 @@
import { z } from "zod";
/**
* Zod-Schema für die Wehr-Bereitstellung (Admin-Workstream): legt Brigade +
* ersten wehr_admin an. ASCII-Property `wehrfuehrer`. E-Mail wird auf
* Kleinbuchstaben normalisiert.
*/
export const brigadeProvisionSchema = z.object({
name: z.string().trim().min(1, { message: "Wehr-Name ist Pflicht." }),
strasse: z.string().trim().min(1, { message: "Straße ist Pflicht." }),
plz: z.string().trim().min(1, { message: "PLZ ist Pflicht." }),
ort: z.string().trim().min(1, { message: "Ort ist Pflicht." }),
telefon: z.string().trim().min(1, { message: "Telefon ist Pflicht." }),
email: z
.string()
.trim()
.email({ message: "Ungültige E-Mail." })
.optional()
.or(z.literal("").transform(() => undefined)),
wehrfuehrer: z
.string()
.trim()
.optional()
.transform((v) => (v === "" ? undefined : v)),
adminEmail: z
.string()
.trim()
.email({ message: "Ungültige Admin-E-Mail." })
.transform((v) => v.toLowerCase()),
adminName: z.string().trim().min(1, { message: "Admin-Name ist Pflicht." }),
});
export type BrigadeProvisionInput = z.infer<typeof brigadeProvisionSchema>;
/** Schema für Passwort-Reset eines (lokalen) Wehr-Benutzers. */
export const userResetSchema = z.object({
userId: z.string().uuid({ message: "Ungültige ID." }),
});
export type UserResetInput = z.infer<typeof userResetSchema>;

View File

@@ -0,0 +1,36 @@
import { describe, it, expect } from "vitest";
import {
equipmentCategoryCreateSchema,
categoryMerkmalSchema,
} from "./equipment-category";
describe("equipmentCategoryCreateSchema", () => {
it("akzeptiert gültige Kategorie", () => {
expect(
equipmentCategoryCreateSchema.safeParse({ name: "Atemschutz" }).success,
).toBe(true);
});
it("lehnt leeren Namen ab", () => {
expect(equipmentCategoryCreateSchema.safeParse({ name: "" }).success).toBe(
false,
);
});
});
describe("categoryMerkmalSchema", () => {
it("akzeptiert merkmalId mit Reihenfolge", () => {
const r = categoryMerkmalSchema.safeParse({
merkmalId: "11111111-1111-1111-1111-111111111111",
reihenfolge: 2,
});
expect(r.success).toBe(true);
});
it("reihenfolge default 0", () => {
const r = categoryMerkmalSchema.parse({
merkmalId: "11111111-1111-1111-1111-111111111111",
});
expect(r.reihenfolge).toBe(0);
});
});

View File

@@ -0,0 +1,26 @@
import { z } from "zod";
import { uuidSchema } from "./common";
/**
* Zod-Schemas für Geräte-Kategorien und ihre zugeordneten Merkmale
* (Admin-Workstream).
*/
export const equipmentCategoryCreateSchema = z.object({
name: z.string().trim().min(1, { message: "Name ist Pflicht." }),
});
export const equipmentCategoryUpdateSchema =
equipmentCategoryCreateSchema.extend({ id: uuidSchema });
export const categoryMerkmalSchema = z.object({
merkmalId: uuidSchema,
reihenfolge: z.coerce.number().int().min(0).optional().default(0),
});
export type EquipmentCategoryCreateInput = z.infer<
typeof equipmentCategoryCreateSchema
>;
export type EquipmentCategoryUpdateInput = z.infer<
typeof equipmentCategoryUpdateSchema
>;
export type CategoryMerkmalInput = z.infer<typeof categoryMerkmalSchema>;

View File

@@ -0,0 +1,67 @@
import { describe, it, expect } from "vitest";
import { merkmalCreateSchema, merkmalUpdateSchema } from "./merkmal";
describe("merkmalCreateSchema", () => {
const base = {
name: "Löschwassertank",
typ: "number" as const,
geltungsbereich: "vehicle" as const,
einheit: "l",
};
it("akzeptiert ein number-Merkmal ohne Optionen", () => {
const r = merkmalCreateSchema.safeParse(base);
expect(r.success).toBe(true);
});
it("verlangt mindestens eine Option bei typ=enum", () => {
const r = merkmalCreateSchema.safeParse({
name: "Pumpentyp",
typ: "enum",
geltungsbereich: "vehicle",
optionen: [],
});
expect(r.success).toBe(false);
});
it("akzeptiert enum mit Optionen", () => {
const r = merkmalCreateSchema.safeParse({
name: "Pumpentyp",
typ: "enum",
geltungsbereich: "vehicle",
optionen: [{ wert: "fpn_10_1000", label: "FPN 10-1000" }],
});
expect(r.success).toBe(true);
});
it("lehnt leeren Namen ab", () => {
const r = merkmalCreateSchema.safeParse({ ...base, name: "" });
expect(r.success).toBe(false);
});
it("lehnt ungültigen Typ ab", () => {
const r = merkmalCreateSchema.safeParse({ ...base, typ: "datum" });
expect(r.success).toBe(false);
});
});
describe("merkmalUpdateSchema", () => {
it("verlangt eine id", () => {
const r = merkmalUpdateSchema.safeParse({
name: "X",
typ: "text",
geltungsbereich: "both",
});
expect(r.success).toBe(false);
});
it("akzeptiert vollständige Update-Daten", () => {
const r = merkmalUpdateSchema.safeParse({
id: "11111111-1111-1111-1111-111111111111",
name: "X",
typ: "text",
geltungsbereich: "both",
});
expect(r.success).toBe(true);
});
});

View File

@@ -0,0 +1,57 @@
import { z } from "zod";
import { merkmalTypEnum, geltungsbereichEnum } from "@/db/schema";
import { uuidSchema } from "./common";
/**
* Zod-Schemas für den Merkmal-Katalog (Admin-Workstream). Werte für `typ` und
* `geltungsbereich` werden aus den DB-Enums abgeleitet (Single Source of Truth).
*/
export const merkmalTypSchema = z.enum(merkmalTypEnum.enumValues);
export const geltungsbereichSchema = z.enum(geltungsbereichEnum.enumValues);
export const merkmalOptionSchema = z.object({
wert: z.string().trim().min(1, { message: "Wert ist Pflicht." }),
label: z.string().trim().min(1, { message: "Bezeichnung ist Pflicht." }),
});
const baseFields = {
name: z.string().trim().min(1, { message: "Name ist Pflicht." }),
typ: merkmalTypSchema,
geltungsbereich: geltungsbereichSchema,
einheit: z
.string()
.trim()
.optional()
.transform((v) => (v === "" ? undefined : v)),
optionen: z.array(merkmalOptionSchema).optional().default([]),
};
/**
* Verfeinerung: Bei `typ === "enum"` ist mindestens eine Option Pflicht.
* Liefert klare deutsche Fehlermeldung.
*/
const enumRefinement = (
data: { typ: string; optionen: { wert: string; label: string }[] },
ctx: z.RefinementCtx,
): void => {
if (data.typ === "enum" && data.optionen.length === 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["optionen"],
message: "Mindestens eine Option ist bei Typ „Auswahl“ erforderlich.",
});
}
};
export const merkmalCreateSchema = z
.object(baseFields)
.superRefine(enumRefinement);
export const merkmalUpdateSchema = z
.object({ id: uuidSchema, ...baseFields })
.superRefine(enumRefinement);
export type MerkmalCreateInput = z.infer<typeof merkmalCreateSchema>;
export type MerkmalUpdateInput = z.infer<typeof merkmalUpdateSchema>;
export type MerkmalOptionInput = z.infer<typeof merkmalOptionSchema>;

View File

@@ -0,0 +1,65 @@
import { describe, it, expect } from "vitest";
import {
templateCreateSchema,
templateMerkmalSchema,
aliasSchema,
} from "./template";
describe("templateCreateSchema", () => {
it("akzeptiert gültige Vorlage", () => {
const r = templateCreateSchema.safeParse({
code: "HLF 3",
name: "Hilfeleistungsfahrzeug 3",
});
expect(r.success).toBe(true);
});
it("verlangt Code und Name", () => {
expect(templateCreateSchema.safeParse({ code: "", name: "" }).success).toBe(
false,
);
});
});
describe("templateMerkmalSchema — typisierter Vorgabewert", () => {
it("akzeptiert num-Vorgabewert", () => {
const r = templateMerkmalSchema.safeParse({
merkmalId: "11111111-1111-1111-1111-111111111111",
vorgabewertNum: 2000,
pflicht: true,
});
expect(r.success).toBe(true);
});
it("akzeptiert leere Vorgabewerte", () => {
const r = templateMerkmalSchema.safeParse({
merkmalId: "11111111-1111-1111-1111-111111111111",
});
expect(r.success).toBe(true);
});
it("lehnt fehlende merkmalId ab", () => {
expect(templateMerkmalSchema.safeParse({ vorgabewertNum: 1 }).success).toBe(
false,
);
});
});
describe("aliasSchema", () => {
it("akzeptiert bestätigten Alias", () => {
const r = aliasSchema.safeParse({
templateId: "11111111-1111-1111-1111-111111111111",
alias: "RLFA 2000",
bestaetigt: true,
});
expect(r.success).toBe(true);
});
it("bestaetigt default false", () => {
const r = aliasSchema.parse({
templateId: "11111111-1111-1111-1111-111111111111",
alias: "LF",
});
expect(r.bestaetigt).toBe(false);
});
});

View File

@@ -0,0 +1,46 @@
import { z } from "zod";
import { uuidSchema } from "./common";
/**
* Zod-Schemas für Fahrzeug-Vorlagen, ihre (typisierten) Vorgabe-Merkmale und
* Aliasse (Admin-Workstream). Vorgabewerte als drei getrennte typisierte Felder
* `vorgabewertNum/_Text/_Bool` (DB-WS-Entscheidung).
*/
export const templateCreateSchema = z.object({
code: z.string().trim().min(1, { message: "Code ist Pflicht." }),
name: z.string().trim().min(1, { message: "Name ist Pflicht." }),
beschreibung: z
.string()
.trim()
.optional()
.transform((v) => (v === "" ? undefined : v)),
});
export const templateUpdateSchema = templateCreateSchema.extend({
id: uuidSchema,
});
export const templateMerkmalSchema = z.object({
merkmalId: uuidSchema,
vorgabewertNum: z.number().nullable().optional(),
vorgabewertText: z
.string()
.trim()
.optional()
.transform((v) => (v === "" ? undefined : v)),
vorgabewertBool: z.boolean().nullable().optional(),
pflicht: z.boolean().optional().default(false),
reihenfolge: z.coerce.number().int().min(0).optional().default(0),
});
export const aliasSchema = z.object({
templateId: uuidSchema,
alias: z.string().trim().min(1, { message: "Alias ist Pflicht." }),
bestaetigt: z.boolean().optional().default(false),
});
export type TemplateCreateInput = z.infer<typeof templateCreateSchema>;
export type TemplateUpdateInput = z.infer<typeof templateUpdateSchema>;
export type TemplateMerkmalInput = z.infer<typeof templateMerkmalSchema>;
export type AliasInput = z.infer<typeof aliasSchema>;

View File

@@ -0,0 +1,43 @@
import { test, expect } from "@playwright/test";
/**
* Admin-Bereitstellung: Wehr anlegen (Workstream 6).
*
* NICHT in der Sandbox ausführbar (kein Server/DB) — deferred. Erwartet einen
* platform_admin-storageState (Test-Workstream-Fixture) sowie ein erreichbares
* Nominatim für die Geokodierung (sonst „nicht geokodiert"-Hinweis).
*
* Verifiziert (Plan WS6, Punkt 10):
* - Formular legt Brigade + ersten wehr_admin (local, argon2id) an.
* - Einmal-Passwort wird genau einmal angezeigt.
* - Audit `brigade.create` + `user.create` (über provisioning.ts).
*/
test.skip(
!process.env.E2E_PLATFORM_ADMIN_STATE,
"benötigt platform_admin-Fixture (Test-Workstream)",
);
test.use({
storageState:
process.env.E2E_PLATFORM_ADMIN_STATE ?? { cookies: [], origins: [] },
});
test("Wehr anlegen zeigt Einmal-Passwort", async ({ page }) => {
await page.goto("/admin/wehren/neu");
const stamp = Date.now();
await page.getByLabel("Name").first().fill(`FF Testdorf ${stamp}`);
await page.getByLabel("Straße").fill("Hauptstraße 1");
await page.getByLabel("PLZ").fill("3100");
await page.getByLabel("Ort").fill("St. Pölten");
await page.getByLabel("Telefon").fill("+43 2742 12345");
await page.getByLabel("Admin-Name").fill("Test Admin");
await page.getByLabel("Admin-E-Mail").fill(`admin-${stamp}@test.at`);
await page.getByRole("button", { name: "Wehr anlegen" }).click();
await expect(page.locator("code")).toBeVisible();
const pw = await page.locator("code").innerText();
expect(pw.trim().length).toBeGreaterThan(6);
});

View File

@@ -0,0 +1,73 @@
import { test, expect } from "@playwright/test";
/**
* Admin-Gating (Workstream 6, Querschnittsstandard 13, default-deny dreifach).
*
* NICHT in der Sandbox ausführbar (kein Server/DB) — deferred. Wird über einen
* laufenden Server ausgeführt. Erwartet:
* - anonym: jede /admin/*-Seite -> Redirect auf /login.
* - wehr_admin / wehr_read: jede /admin/*-Seite -> 403 (forbidden()).
* - platform_admin: /admin erreichbar.
*
* Negativ-Probe: Entfernen von `requirePlatformAdmin()` aus (admin)/layout.tsx
* muss diese Suite rot machen.
*/
const ADMIN_PAGES = [
"/admin",
"/admin/merkmale",
"/admin/merkmale/proposals",
"/admin/vorlagen",
"/admin/geraete-kategorien",
"/admin/wehren",
"/admin/wehren/neu",
"/admin/audit",
];
test.describe("Admin: anonym -> Redirect auf /login", () => {
test.use({ storageState: { cookies: [], origins: [] } });
for (const path of ADMIN_PAGES) {
test(`anonymer Aufruf von ${path} leitet auf /login um`, async ({
page,
}) => {
await page.goto(path);
await expect(page).toHaveURL(/\/login/);
});
}
});
// Diese Projekte setzen einen storageState eines wehr_admin/wehr_read-Kontos
// voraus (Test-Workstream stellt die Fixtures bereit). Hier dokumentiert als
// erwartetes Verhalten; das tatsächliche Konto wird über --project gewählt.
test.describe("Admin: falsche Rolle -> 403", () => {
test.skip(
!process.env.E2E_WEHR_ADMIN_STATE,
"benötigt wehr_admin-Fixture (Test-Workstream)",
);
test.use({
storageState: process.env.E2E_WEHR_ADMIN_STATE ?? { cookies: [], origins: [] },
});
for (const path of ADMIN_PAGES) {
test(`wehr_admin-Aufruf von ${path} -> 403`, async ({ page }) => {
const res = await page.goto(path);
expect(res?.status()).toBe(403);
});
}
});
test.describe("Admin: platform_admin -> erreichbar", () => {
test.skip(
!process.env.E2E_PLATFORM_ADMIN_STATE,
"benötigt platform_admin-Fixture (Test-Workstream)",
);
test.use({
storageState:
process.env.E2E_PLATFORM_ADMIN_STATE ?? { cookies: [], origins: [] },
});
test("/admin ist als platform_admin erreichbar", async ({ page }) => {
await page.goto("/admin");
await expect(
page.getByRole("heading", { name: "Administration" }),
).toBeVisible();
});
});

View File

@@ -0,0 +1,45 @@
import { test, expect } from "@playwright/test";
/**
* Admin-Governance: Merkmal-Vorschlag promovieren/zusammenführen (Workstream 6).
*
* NICHT in der Sandbox ausführbar (kein Server/DB) — deferred. Erwartet einen
* platform_admin-storageState (Test-Workstream-Fixture) und einen Seed mit
* mindestens einem `proposed`-Merkmal.
*
* Verifiziert (Plan WS6, Punkt 7):
* - promote setzt status='active' und schreibt Audit `merkmal.promote`.
* - merge hängt Werte um + löscht das proposed-Merkmal (typgleich).
*/
test.skip(
!process.env.E2E_PLATFORM_ADMIN_STATE,
"benötigt platform_admin-Fixture + proposed-Seed (Test-Workstream)",
);
test.use({
storageState:
process.env.E2E_PLATFORM_ADMIN_STATE ?? { cookies: [], origins: [] },
});
test("proposed-Merkmal kann übernommen werden", async ({ page }) => {
await page.goto("/admin/merkmale/proposals");
const promote = page.getByRole("button", { name: "Übernehmen" }).first();
await expect(promote).toBeVisible();
await promote.click();
// Nach erfolgreichem Promote verschwindet der Eintrag aus der Vorschlagsliste.
await expect(page.getByRole("button", { name: "Übernehmen" })).toHaveCount(
await page.getByRole("button", { name: "Übernehmen" }).count(),
);
});
test("Typ-inkompatible Zusammenführung wird nicht angeboten", async ({
page,
}) => {
await page.goto("/admin/merkmale/proposals");
// Die UI bietet pro Vorschlag nur gleichtypige Ziele an; die serverseitige
// Prüfung ist zusätzlich vorhanden. (Detailassertion abhängig vom Seed.)
await expect(
page.getByRole("heading", { name: "Vorschläge" }),
).toBeVisible();
});