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:
56
src/app/(admin)/_actions/brigades.ts
Normal file
56
src/app/(admin)/_actions/brigades.ts
Normal 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 };
|
||||
}
|
||||
120
src/app/(admin)/_actions/equipment-categories.ts
Normal file
120
src/app/(admin)/_actions/equipment-categories.ts
Normal 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 };
|
||||
}
|
||||
159
src/app/(admin)/_actions/merkmale.ts
Normal file
159
src/app/(admin)/_actions/merkmale.ts
Normal 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 };
|
||||
}
|
||||
105
src/app/(admin)/_actions/proposals.ts
Normal file
105
src/app/(admin)/_actions/proposals.ts
Normal 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 };
|
||||
}
|
||||
200
src/app/(admin)/_actions/templates.ts
Normal file
200
src/app/(admin)/_actions/templates.ts
Normal 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 };
|
||||
}
|
||||
150
src/app/(admin)/admin/audit/page.tsx
Normal file
150
src/app/(admin)/admin/audit/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
72
src/app/(admin)/admin/geraete-kategorien/[id]/page.tsx
Normal file
72
src/app/(admin)/admin/geraete-kategorien/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
54
src/app/(admin)/admin/geraete-kategorien/page.tsx
Normal file
54
src/app/(admin)/admin/geraete-kategorien/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
209
src/app/(admin)/admin/merkmale/MerkmalEditor.tsx
Normal file
209
src/app/(admin)/admin/merkmale/MerkmalEditor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
112
src/app/(admin)/admin/merkmale/MerkmalListClient.tsx
Normal file
112
src/app/(admin)/admin/merkmale/MerkmalListClient.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
40
src/app/(admin)/admin/merkmale/page.tsx
Normal file
40
src/app/(admin)/admin/merkmale/page.tsx
Normal 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} />;
|
||||
}
|
||||
143
src/app/(admin)/admin/merkmale/proposals/MergeDialog.tsx
Normal file
143
src/app/(admin)/admin/merkmale/proposals/MergeDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
26
src/app/(admin)/admin/merkmale/proposals/page.tsx
Normal file
26
src/app/(admin)/admin/merkmale/proposals/page.tsx
Normal 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} />;
|
||||
}
|
||||
32
src/app/(admin)/admin/page.tsx
Normal file
32
src/app/(admin)/admin/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
65
src/app/(admin)/admin/vorlagen/TemplateCreateForm.tsx
Normal file
65
src/app/(admin)/admin/vorlagen/TemplateCreateForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
122
src/app/(admin)/admin/vorlagen/[id]/AliasEditor.tsx
Normal file
122
src/app/(admin)/admin/vorlagen/[id]/AliasEditor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
197
src/app/(admin)/admin/vorlagen/[id]/TemplateMerkmaleEditor.tsx
Normal file
197
src/app/(admin)/admin/vorlagen/[id]/TemplateMerkmaleEditor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
110
src/app/(admin)/admin/vorlagen/[id]/page.tsx
Normal file
110
src/app/(admin)/admin/vorlagen/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
63
src/app/(admin)/admin/vorlagen/page.tsx
Normal file
63
src/app/(admin)/admin/vorlagen/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
58
src/app/(admin)/admin/wehren/[id]/UserResetButton.tsx
Normal file
58
src/app/(admin)/admin/wehren/[id]/UserResetButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
75
src/app/(admin)/admin/wehren/[id]/page.tsx
Normal file
75
src/app/(admin)/admin/wehren/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
124
src/app/(admin)/admin/wehren/neu/BrigadeProvisionForm.tsx
Normal file
124
src/app/(admin)/admin/wehren/neu/BrigadeProvisionForm.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { de } from "@/lib/i18n/de";
|
||||
import { provisionBrigade } from "../../../_actions/brigades";
|
||||
|
||||
/**
|
||||
* Bereitstellungsformular: legt Wehr + ersten wehr_admin an. Zeigt das
|
||||
* Einmal-Passwort nach Erfolg genau einmal an. Warnt, wenn die Adresse nicht
|
||||
* geokodiert werden konnte (Wehr wird dennoch angelegt).
|
||||
*/
|
||||
export function BrigadeProvisionForm() {
|
||||
const router = useRouter();
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [result, setResult] = React.useState<{
|
||||
tempPassword: string;
|
||||
geocoded: boolean;
|
||||
} | null>(null);
|
||||
const [pending, startTransition] = React.useTransition();
|
||||
|
||||
function onSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
const fd = new FormData(e.currentTarget);
|
||||
const payload = {
|
||||
name: String(fd.get("name") ?? ""),
|
||||
strasse: String(fd.get("strasse") ?? ""),
|
||||
plz: String(fd.get("plz") ?? ""),
|
||||
ort: String(fd.get("ort") ?? ""),
|
||||
telefon: String(fd.get("telefon") ?? ""),
|
||||
email: String(fd.get("email") ?? "") || undefined,
|
||||
wehrfuehrer: String(fd.get("wehrfuehrer") ?? "") || undefined,
|
||||
adminEmail: String(fd.get("adminEmail") ?? ""),
|
||||
adminName: String(fd.get("adminName") ?? ""),
|
||||
};
|
||||
startTransition(async () => {
|
||||
const res = await provisionBrigade(payload);
|
||||
if (!res.ok) {
|
||||
setError(res.error);
|
||||
return;
|
||||
}
|
||||
setResult({ tempPassword: res.tempPassword, geocoded: res.geocoded });
|
||||
router.refresh();
|
||||
});
|
||||
}
|
||||
|
||||
if (result) {
|
||||
return (
|
||||
<div className="space-y-4 rounded border border-bereit/40 bg-bereit/5 p-5">
|
||||
<p className="text-sm text-anthrazit">
|
||||
{result.geocoded ? de.admin.geocodeOk : de.admin.geocodeFehler}
|
||||
</p>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-anthrazit/70">
|
||||
{de.admin.tempPasswort}
|
||||
</p>
|
||||
<code className="mt-1 block rounded border border-rand bg-white px-3 py-2 font-mono text-lg tracking-wide text-navy">
|
||||
{result.tempPassword}
|
||||
</code>
|
||||
</div>
|
||||
<Button onClick={() => router.push("/admin/wehren")}>
|
||||
{de.admin.navWehren}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={onSubmit} className="space-y-4">
|
||||
<fieldset className="grid gap-3 rounded border border-rand bg-white p-4 sm:grid-cols-2">
|
||||
<legend className="px-1 text-sm font-semibold text-navy">
|
||||
{de.admin.navWehren}
|
||||
</legend>
|
||||
<Field name="name" label={de.admin.name} required />
|
||||
<Field name="strasse" label={de.admin.strasse} required />
|
||||
<Field name="plz" label={de.admin.plz} required />
|
||||
<Field name="ort" label={de.admin.ort} required />
|
||||
<Field name="telefon" label={de.admin.telefon} required />
|
||||
<Field name="email" label={de.auth.email} type="email" />
|
||||
<Field name="wehrfuehrer" label={de.admin.wehrfuehrer} />
|
||||
</fieldset>
|
||||
|
||||
<fieldset className="grid gap-3 rounded border border-rand bg-white p-4 sm:grid-cols-2">
|
||||
<legend className="px-1 text-sm font-semibold text-navy">
|
||||
Erster Wehr-Admin
|
||||
</legend>
|
||||
<Field name="adminName" label={de.admin.adminName} required />
|
||||
<Field
|
||||
name="adminEmail"
|
||||
label={de.admin.adminEmail}
|
||||
type="email"
|
||||
required
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
{error && <p className="text-sm text-signal">{error}</p>}
|
||||
<Button type="submit" disabled={pending}>
|
||||
{de.admin.wehrAnlegen}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({
|
||||
name,
|
||||
label,
|
||||
type = "text",
|
||||
required = false,
|
||||
}: {
|
||||
name: string;
|
||||
label: string;
|
||||
type?: string;
|
||||
required?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<label className="text-sm">
|
||||
<span className="mb-1 block font-medium text-anthrazit/70">{label}</span>
|
||||
<Input name={name} type={type} required={required} />
|
||||
</label>
|
||||
);
|
||||
}
|
||||
23
src/app/(admin)/admin/wehren/neu/page.tsx
Normal file
23
src/app/(admin)/admin/wehren/neu/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
66
src/app/(admin)/admin/wehren/page.tsx
Normal file
66
src/app/(admin)/admin/wehren/page.tsx
Normal 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
15
src/app/(admin)/error.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
25
src/app/(admin)/layout.tsx
Normal file
25
src/app/(admin)/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
9
src/app/(admin)/loading.tsx
Normal file
9
src/app/(admin)/loading.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
58
src/components/admin/AdminNav.tsx
Normal file
58
src/components/admin/AdminNav.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
74
src/components/admin/DataTable.tsx
Normal file
74
src/components/admin/DataTable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
59
src/lib/admin/codes.test.ts
Normal file
59
src/lib/admin/codes.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
126
src/lib/admin/provisioning.ts
Normal file
126
src/lib/admin/provisioning.ts
Normal 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 };
|
||||
}
|
||||
20
src/lib/admin/slug.test.ts
Normal file
20
src/lib/admin/slug.test.ts
Normal 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
18
src/lib/admin/slug.ts
Normal 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
53
src/lib/audit.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
50
src/lib/validation/brigade.test.ts
Normal file
50
src/lib/validation/brigade.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
40
src/lib/validation/brigade.ts
Normal file
40
src/lib/validation/brigade.ts
Normal 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>;
|
||||
36
src/lib/validation/equipment-category.test.ts
Normal file
36
src/lib/validation/equipment-category.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
26
src/lib/validation/equipment-category.ts
Normal file
26
src/lib/validation/equipment-category.ts
Normal 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>;
|
||||
67
src/lib/validation/merkmal.test.ts
Normal file
67
src/lib/validation/merkmal.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
57
src/lib/validation/merkmal.ts
Normal file
57
src/lib/validation/merkmal.ts
Normal 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>;
|
||||
65
src/lib/validation/template.test.ts
Normal file
65
src/lib/validation/template.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
46
src/lib/validation/template.ts
Normal file
46
src/lib/validation/template.ts
Normal 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>;
|
||||
Reference in New Issue
Block a user