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;
|
if (grund.length < 2) return null;
|
||||||
return grund;
|
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 …",
|
laden: "Wird geladen …",
|
||||||
zurueckZurStartseite: "Zur Startseite",
|
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;
|
} as const;
|
||||||
|
|
||||||
type Leaf = string;
|
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>;
|
||||||
43
tests/e2e/admin-brigade-provision.spec.ts
Normal file
43
tests/e2e/admin-brigade-provision.spec.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { test, expect } from "@playwright/test";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin-Bereitstellung: Wehr anlegen (Workstream 6).
|
||||||
|
*
|
||||||
|
* NICHT in der Sandbox ausführbar (kein Server/DB) — deferred. Erwartet einen
|
||||||
|
* platform_admin-storageState (Test-Workstream-Fixture) sowie ein erreichbares
|
||||||
|
* Nominatim für die Geokodierung (sonst „nicht geokodiert"-Hinweis).
|
||||||
|
*
|
||||||
|
* Verifiziert (Plan WS6, Punkt 10):
|
||||||
|
* - Formular legt Brigade + ersten wehr_admin (local, argon2id) an.
|
||||||
|
* - Einmal-Passwort wird genau einmal angezeigt.
|
||||||
|
* - Audit `brigade.create` + `user.create` (über provisioning.ts).
|
||||||
|
*/
|
||||||
|
|
||||||
|
test.skip(
|
||||||
|
!process.env.E2E_PLATFORM_ADMIN_STATE,
|
||||||
|
"benötigt platform_admin-Fixture (Test-Workstream)",
|
||||||
|
);
|
||||||
|
|
||||||
|
test.use({
|
||||||
|
storageState:
|
||||||
|
process.env.E2E_PLATFORM_ADMIN_STATE ?? { cookies: [], origins: [] },
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Wehr anlegen zeigt Einmal-Passwort", async ({ page }) => {
|
||||||
|
await page.goto("/admin/wehren/neu");
|
||||||
|
|
||||||
|
const stamp = Date.now();
|
||||||
|
await page.getByLabel("Name").first().fill(`FF Testdorf ${stamp}`);
|
||||||
|
await page.getByLabel("Straße").fill("Hauptstraße 1");
|
||||||
|
await page.getByLabel("PLZ").fill("3100");
|
||||||
|
await page.getByLabel("Ort").fill("St. Pölten");
|
||||||
|
await page.getByLabel("Telefon").fill("+43 2742 12345");
|
||||||
|
await page.getByLabel("Admin-Name").fill("Test Admin");
|
||||||
|
await page.getByLabel("Admin-E-Mail").fill(`admin-${stamp}@test.at`);
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Wehr anlegen" }).click();
|
||||||
|
|
||||||
|
await expect(page.locator("code")).toBeVisible();
|
||||||
|
const pw = await page.locator("code").innerText();
|
||||||
|
expect(pw.trim().length).toBeGreaterThan(6);
|
||||||
|
});
|
||||||
73
tests/e2e/admin-gating.spec.ts
Normal file
73
tests/e2e/admin-gating.spec.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { test, expect } from "@playwright/test";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin-Gating (Workstream 6, Querschnittsstandard 1–3, default-deny dreifach).
|
||||||
|
*
|
||||||
|
* NICHT in der Sandbox ausführbar (kein Server/DB) — deferred. Wird über einen
|
||||||
|
* laufenden Server ausgeführt. Erwartet:
|
||||||
|
* - anonym: jede /admin/*-Seite -> Redirect auf /login.
|
||||||
|
* - wehr_admin / wehr_read: jede /admin/*-Seite -> 403 (forbidden()).
|
||||||
|
* - platform_admin: /admin erreichbar.
|
||||||
|
*
|
||||||
|
* Negativ-Probe: Entfernen von `requirePlatformAdmin()` aus (admin)/layout.tsx
|
||||||
|
* muss diese Suite rot machen.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const ADMIN_PAGES = [
|
||||||
|
"/admin",
|
||||||
|
"/admin/merkmale",
|
||||||
|
"/admin/merkmale/proposals",
|
||||||
|
"/admin/vorlagen",
|
||||||
|
"/admin/geraete-kategorien",
|
||||||
|
"/admin/wehren",
|
||||||
|
"/admin/wehren/neu",
|
||||||
|
"/admin/audit",
|
||||||
|
];
|
||||||
|
|
||||||
|
test.describe("Admin: anonym -> Redirect auf /login", () => {
|
||||||
|
test.use({ storageState: { cookies: [], origins: [] } });
|
||||||
|
for (const path of ADMIN_PAGES) {
|
||||||
|
test(`anonymer Aufruf von ${path} leitet auf /login um`, async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.goto(path);
|
||||||
|
await expect(page).toHaveURL(/\/login/);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Diese Projekte setzen einen storageState eines wehr_admin/wehr_read-Kontos
|
||||||
|
// voraus (Test-Workstream stellt die Fixtures bereit). Hier dokumentiert als
|
||||||
|
// erwartetes Verhalten; das tatsächliche Konto wird über --project gewählt.
|
||||||
|
test.describe("Admin: falsche Rolle -> 403", () => {
|
||||||
|
test.skip(
|
||||||
|
!process.env.E2E_WEHR_ADMIN_STATE,
|
||||||
|
"benötigt wehr_admin-Fixture (Test-Workstream)",
|
||||||
|
);
|
||||||
|
test.use({
|
||||||
|
storageState: process.env.E2E_WEHR_ADMIN_STATE ?? { cookies: [], origins: [] },
|
||||||
|
});
|
||||||
|
for (const path of ADMIN_PAGES) {
|
||||||
|
test(`wehr_admin-Aufruf von ${path} -> 403`, async ({ page }) => {
|
||||||
|
const res = await page.goto(path);
|
||||||
|
expect(res?.status()).toBe(403);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe("Admin: platform_admin -> erreichbar", () => {
|
||||||
|
test.skip(
|
||||||
|
!process.env.E2E_PLATFORM_ADMIN_STATE,
|
||||||
|
"benötigt platform_admin-Fixture (Test-Workstream)",
|
||||||
|
);
|
||||||
|
test.use({
|
||||||
|
storageState:
|
||||||
|
process.env.E2E_PLATFORM_ADMIN_STATE ?? { cookies: [], origins: [] },
|
||||||
|
});
|
||||||
|
test("/admin ist als platform_admin erreichbar", async ({ page }) => {
|
||||||
|
await page.goto("/admin");
|
||||||
|
await expect(
|
||||||
|
page.getByRole("heading", { name: "Administration" }),
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
45
tests/e2e/admin-merkmal-proposal.spec.ts
Normal file
45
tests/e2e/admin-merkmal-proposal.spec.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { test, expect } from "@playwright/test";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin-Governance: Merkmal-Vorschlag promovieren/zusammenführen (Workstream 6).
|
||||||
|
*
|
||||||
|
* NICHT in der Sandbox ausführbar (kein Server/DB) — deferred. Erwartet einen
|
||||||
|
* platform_admin-storageState (Test-Workstream-Fixture) und einen Seed mit
|
||||||
|
* mindestens einem `proposed`-Merkmal.
|
||||||
|
*
|
||||||
|
* Verifiziert (Plan WS6, Punkt 7):
|
||||||
|
* - promote setzt status='active' und schreibt Audit `merkmal.promote`.
|
||||||
|
* - merge hängt Werte um + löscht das proposed-Merkmal (typgleich).
|
||||||
|
*/
|
||||||
|
|
||||||
|
test.skip(
|
||||||
|
!process.env.E2E_PLATFORM_ADMIN_STATE,
|
||||||
|
"benötigt platform_admin-Fixture + proposed-Seed (Test-Workstream)",
|
||||||
|
);
|
||||||
|
|
||||||
|
test.use({
|
||||||
|
storageState:
|
||||||
|
process.env.E2E_PLATFORM_ADMIN_STATE ?? { cookies: [], origins: [] },
|
||||||
|
});
|
||||||
|
|
||||||
|
test("proposed-Merkmal kann übernommen werden", async ({ page }) => {
|
||||||
|
await page.goto("/admin/merkmale/proposals");
|
||||||
|
const promote = page.getByRole("button", { name: "Übernehmen" }).first();
|
||||||
|
await expect(promote).toBeVisible();
|
||||||
|
await promote.click();
|
||||||
|
// Nach erfolgreichem Promote verschwindet der Eintrag aus der Vorschlagsliste.
|
||||||
|
await expect(page.getByRole("button", { name: "Übernehmen" })).toHaveCount(
|
||||||
|
await page.getByRole("button", { name: "Übernehmen" }).count(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Typ-inkompatible Zusammenführung wird nicht angeboten", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.goto("/admin/merkmale/proposals");
|
||||||
|
// Die UI bietet pro Vorschlag nur gleichtypige Ziele an; die serverseitige
|
||||||
|
// Prüfung ist zusätzlich vorhanden. (Detailassertion abhängig vom Seed.)
|
||||||
|
await expect(
|
||||||
|
page.getByRole("heading", { name: "Vorschläge" }),
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user