Workstream 7: Wehr-Bereich — Fuhrpark & Benutzer (Phase 4)

Implementiert den auf die eigene brigadeId beschränkten Wehr-Bereich:
Profil (inkl. Inline-Geocoding via geocodeAddress), Fuhrpark (Fahrzeug per
Vorlage oder frei, typisierter Merkmal-Editor), Geräte (Kategorie, Werte,
Zuordnung Fahrzeug/„im Gerätehaus") und Benutzerkonten (wehr_admin/wehr_read).

- Schema importiert (nicht neu definiert); ASCII-Property wehrfuehrer.
- Default-deny dreifach: Layout-Guard requireWehrAdmin() + jede Server Action
  beginnt mit requireWehrAdmin(); fremde Entities -> notFound() (404).
- Validierung an der Grenze (Zod): buildMerkmalValuesSchema validiert Werte
  typgerecht gegen die serverseitig aufgelösten Definitionen; Rolle auf
  wehr_admin|wehr_read beschränkt (platform_admin abgelehnt).
- upsertMerkmalValues delete-then-insert mit typisierter Drizzle-Tx (kein any);
  boolean false/num 0 gelten als gesetzt.
- argon2id-Einmalpasswort beim Benutzeranlegen; Selbst-Deaktivierung verhindert.
- Audit vollständig: brigade.profile_update, vehicle.create/update/delete/status,
  equipment.create/update/delete/status, user.create/deactivate.
- Vorgabewerte aus drei typisierten Spalten (vorgabewert_num/_text/_bool).
- i18n via zentraler de.ts; loading/empty/error-konforme Listen.

Tests: 22 neue Unit-Tests (vehicle/equipment/brigade-user-Validierung,
upsertMerkmalValues) grün; Playwright-Specs verwaltung-fuhrpark + -scoping
geschrieben (deferred: kein Server/DB in der Sandbox).

Verifikation offline: tsc --noEmit clean, eslint clean, vitest 147 passed,
next build exit 0 (alle /verwaltung/*-Routen), drizzle-kit check ohne Drift.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matthias Hochmeister
2026-06-09 11:06:17 +02:00
parent 628d35bfcd
commit 5cda09c411
39 changed files with 3201 additions and 0 deletions

View File

@@ -0,0 +1,89 @@
"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 { assetStatusEnum } from "@/db/schema";
import {
setEquipmentStatus,
deleteEquipment,
} from "@/server/actions/equipment";
type Status = (typeof assetStatusEnum.enumValues)[number];
const STATUS_LABEL: Record<Status, string> = {
einsatzbereit: de.status.einsatzbereit,
wartung: de.status.wartung,
ausser_dienst: de.status.ausser_dienst,
};
export function EquipmentControls({
equipmentId,
status,
}: {
equipmentId: string;
status: Status;
}) {
const router = useRouter();
const [pending, startTransition] = React.useTransition();
const [error, setError] = React.useState<string | null>(null);
function onStatus(next: Status) {
setError(null);
startTransition(async () => {
const res = await setEquipmentStatus({ id: equipmentId, status: next });
if (!res.ok) {
setError(res.error);
return;
}
router.refresh();
});
}
function onDelete() {
if (!window.confirm(de.verwaltung.loeschenBestaetigen)) return;
setError(null);
startTransition(async () => {
const res = await deleteEquipment({ id: equipmentId });
if (!res.ok) {
setError(res.error);
return;
}
router.push("/verwaltung/geraete");
router.refresh();
});
}
return (
<div className="flex flex-wrap items-center gap-3 rounded border border-rand bg-white p-4">
<label className="text-sm font-medium text-anthrazit" htmlFor="estatus">
{de.verwaltung.status}
</label>
<select
id="estatus"
className="h-9 rounded border border-rand bg-white px-3 text-sm text-anthrazit focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-navy"
value={status}
disabled={pending}
onChange={(e) => onStatus(e.target.value as Status)}
>
{assetStatusEnum.enumValues.map((v) => (
<option key={v} value={v}>
{STATUS_LABEL[v]}
</option>
))}
</select>
<span className="flex-1" />
<Button
type="button"
variant="outline"
size="sm"
disabled={pending}
onClick={onDelete}
>
{de.verwaltung.loeschen}
</Button>
{error ? <span className="w-full text-sm text-signal">{error}</span> : null}
</div>
);
}