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:
77
src/app/(app)/verwaltung/benutzer/page.tsx
Normal file
77
src/app/(app)/verwaltung/benutzer/page.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import { requireWehrAdmin } from "@/lib/auth/guards";
|
||||
import { listUsersForBrigade } from "@/server/data/brigade-users";
|
||||
import { de } from "@/lib/i18n/de";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
BrigadeUserForm,
|
||||
DeactivateUserButton,
|
||||
} from "@/components/verwaltung/BrigadeUserForm";
|
||||
|
||||
const ROLLE_LABEL: Record<string, string> = {
|
||||
wehr_admin: de.verwaltung.rolleAdmin,
|
||||
wehr_read: de.verwaltung.rolleRead,
|
||||
platform_admin: "Plattform-Admin",
|
||||
};
|
||||
|
||||
export default async function BenutzerPage() {
|
||||
const s = await requireWehrAdmin();
|
||||
const users = await listUsersForBrigade(s.user.brigadeId);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="font-display text-2xl font-semibold text-navy">
|
||||
{de.verwaltung.navBenutzer}
|
||||
</h1>
|
||||
|
||||
<BrigadeUserForm />
|
||||
|
||||
{users.length === 0 ? (
|
||||
<p className="rounded border border-rand bg-white p-6 text-sm text-anthrazit/60">
|
||||
{de.verwaltung.keineBenutzer}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-rand rounded border border-rand bg-white">
|
||||
{users.map((u) => (
|
||||
<li
|
||||
key={u.id}
|
||||
className="flex items-center justify-between gap-4 px-4 py-3"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium text-anthrazit">
|
||||
{u.name}
|
||||
{u.id === s.user.id ? (
|
||||
<span className="ml-2 text-xs text-anthrazit/50">(Sie)</span>
|
||||
) : null}
|
||||
</p>
|
||||
<p className="truncate text-sm text-anthrazit/60">{u.email}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge>{ROLLE_LABEL[u.rolle] ?? u.rolle}</Badge>
|
||||
<Badge>
|
||||
{u.authTyp === "local"
|
||||
? de.verwaltung.authLokal
|
||||
: de.verwaltung.authAuthentik}
|
||||
</Badge>
|
||||
<Badge
|
||||
className={
|
||||
u.aktiv
|
||||
? "border-bereit/30 bg-bereit/10 text-bereit"
|
||||
: "border-anthrazit/30 bg-anthrazit/10 text-anthrazit"
|
||||
}
|
||||
>
|
||||
{u.aktiv ? de.verwaltung.aktiv : de.verwaltung.inaktiv}
|
||||
</Badge>
|
||||
{u.aktiv ? (
|
||||
<DeactivateUserButton
|
||||
userId={u.id}
|
||||
disabled={u.id === s.user.id}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
93
src/app/(app)/verwaltung/fahrzeuge/[id]/VehicleControls.tsx
Normal file
93
src/app/(app)/verwaltung/fahrzeuge/[id]/VehicleControls.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
"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 {
|
||||
setVehicleStatus,
|
||||
deleteVehicle,
|
||||
} from "@/server/actions/vehicles";
|
||||
|
||||
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,
|
||||
};
|
||||
|
||||
/**
|
||||
* Status-Umschaltung + Löschen eines Fahrzeugs (eigene Wehr). Beide rufen
|
||||
* geschützte Server-Actions; Scope-Prüfung erfolgt serverseitig.
|
||||
*/
|
||||
export function VehicleControls({
|
||||
vehicleId,
|
||||
status,
|
||||
}: {
|
||||
vehicleId: 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 setVehicleStatus({ id: vehicleId, 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 deleteVehicle({ id: vehicleId });
|
||||
if (!res.ok) {
|
||||
setError(res.error);
|
||||
return;
|
||||
}
|
||||
router.push("/verwaltung/fahrzeuge");
|
||||
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="vstatus">
|
||||
{de.verwaltung.status}
|
||||
</label>
|
||||
<select
|
||||
id="vstatus"
|
||||
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>
|
||||
);
|
||||
}
|
||||
50
src/app/(app)/verwaltung/fahrzeuge/[id]/page.tsx
Normal file
50
src/app/(app)/verwaltung/fahrzeuge/[id]/page.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { requireWehrAdmin } from "@/lib/auth/guards";
|
||||
import { getVehicleForBrigade } from "@/server/data/vehicles";
|
||||
import {
|
||||
getMerkmaleForTemplate,
|
||||
getMerkmalValuesForEntity,
|
||||
} from "@/server/data/merkmale";
|
||||
import { de } from "@/lib/i18n/de";
|
||||
import { VehicleForm } from "@/components/verwaltung/VehicleForm";
|
||||
import { VehicleControls } from "./VehicleControls";
|
||||
|
||||
export default async function FahrzeugBearbeitenPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const s = await requireWehrAdmin();
|
||||
const { id } = await params;
|
||||
|
||||
// Scoping: fremde/nicht existente Fahrzeuge -> 404 (kein Daten-Leak).
|
||||
const vehicle = await getVehicleForBrigade(id, s.user.brigadeId);
|
||||
if (!vehicle) notFound();
|
||||
|
||||
const defs = vehicle.templateId
|
||||
? await getMerkmaleForTemplate(vehicle.templateId)
|
||||
: [];
|
||||
const werte = await getMerkmalValuesForEntity("vehicle", vehicle.id);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="font-display text-2xl font-semibold text-navy">
|
||||
{de.verwaltung.fahrzeugBearbeiten}
|
||||
</h1>
|
||||
|
||||
<VehicleControls vehicleId={vehicle.id} status={vehicle.status} />
|
||||
|
||||
<VehicleForm
|
||||
mode="edit"
|
||||
vehicleId={vehicle.id}
|
||||
initial={{
|
||||
name: vehicle.name,
|
||||
funkrufname: vehicle.funkrufname ?? "",
|
||||
notiz: vehicle.notiz ?? "",
|
||||
}}
|
||||
definitionen={defs}
|
||||
vorhandeneWerte={werte}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
23
src/app/(app)/verwaltung/fahrzeuge/neu/page.tsx
Normal file
23
src/app/(app)/verwaltung/fahrzeuge/neu/page.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { requireWehrAdmin } from "@/lib/auth/guards";
|
||||
import { listTemplates } from "@/server/data/vehicles";
|
||||
import { getTemplateMerkmaleAction } from "@/server/actions/vehicles";
|
||||
import { de } from "@/lib/i18n/de";
|
||||
import { VehicleForm } from "@/components/verwaltung/VehicleForm";
|
||||
|
||||
export default async function FahrzeugNeuPage() {
|
||||
await requireWehrAdmin();
|
||||
const templates = await listTemplates();
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="font-display text-2xl font-semibold text-navy">
|
||||
{de.verwaltung.fahrzeugAnlegen}
|
||||
</h1>
|
||||
<VehicleForm
|
||||
mode="create"
|
||||
templates={templates}
|
||||
loadTemplateMerkmale={getTemplateMerkmaleAction}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
55
src/app/(app)/verwaltung/fahrzeuge/page.tsx
Normal file
55
src/app/(app)/verwaltung/fahrzeuge/page.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import Link from "next/link";
|
||||
import { requireWehrAdmin } from "@/lib/auth/guards";
|
||||
import { listVehiclesForBrigade } from "@/server/data/vehicles";
|
||||
import { de } from "@/lib/i18n/de";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { StatusBadge } from "@/components/ui/badge";
|
||||
|
||||
export default async function FahrzeugeListePage() {
|
||||
const s = await requireWehrAdmin();
|
||||
const items = await listVehiclesForBrigade(s.user.brigadeId);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<header className="flex items-center justify-between">
|
||||
<h1 className="font-display text-2xl font-semibold text-navy">
|
||||
{de.verwaltung.navFahrzeuge}
|
||||
</h1>
|
||||
<Button asChild>
|
||||
<Link href="/verwaltung/fahrzeuge/neu">
|
||||
{de.verwaltung.fahrzeugAnlegen}
|
||||
</Link>
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
{items.length === 0 ? (
|
||||
<p className="rounded border border-rand bg-white p-6 text-sm text-anthrazit/60">
|
||||
{de.verwaltung.keineFahrzeuge}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-rand rounded border border-rand bg-white">
|
||||
{items.map((v) => (
|
||||
<li
|
||||
key={v.id}
|
||||
className="flex items-center justify-between gap-4 px-4 py-3"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<Link
|
||||
href={`/verwaltung/fahrzeuge/${v.id}`}
|
||||
className="font-medium text-navy hover:underline"
|
||||
>
|
||||
{v.name}
|
||||
</Link>
|
||||
<p className="truncate text-sm text-anthrazit/60">
|
||||
{v.funkrufname ?? de.search.keinFunkrufname}
|
||||
{v.templateName ? ` · ${v.templateName}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
<StatusBadge status={v.status} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
89
src/app/(app)/verwaltung/geraete/[id]/EquipmentControls.tsx
Normal file
89
src/app/(app)/verwaltung/geraete/[id]/EquipmentControls.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
60
src/app/(app)/verwaltung/geraete/[id]/page.tsx
Normal file
60
src/app/(app)/verwaltung/geraete/[id]/page.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { requireWehrAdmin } from "@/lib/auth/guards";
|
||||
import {
|
||||
getEquipmentForBrigade,
|
||||
listCategories,
|
||||
} from "@/server/data/equipment";
|
||||
import { listVehiclesForBrigade } from "@/server/data/vehicles";
|
||||
import {
|
||||
getMerkmaleForCategory,
|
||||
getMerkmalValuesForEntity,
|
||||
} from "@/server/data/merkmale";
|
||||
import { getCategoryMerkmaleAction } from "@/server/actions/equipment";
|
||||
import { de } from "@/lib/i18n/de";
|
||||
import { EquipmentForm } from "@/components/verwaltung/EquipmentForm";
|
||||
import { EquipmentControls } from "./EquipmentControls";
|
||||
|
||||
export default async function GeraetBearbeitenPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const s = await requireWehrAdmin();
|
||||
const { id } = await params;
|
||||
|
||||
// Scoping: fremde/nicht existente Geräte -> 404.
|
||||
const item = await getEquipmentForBrigade(id, s.user.brigadeId);
|
||||
if (!item) notFound();
|
||||
|
||||
const [categories, vehicles, defs, werte] = await Promise.all([
|
||||
listCategories(),
|
||||
listVehiclesForBrigade(s.user.brigadeId),
|
||||
getMerkmaleForCategory(item.categoryId),
|
||||
getMerkmalValuesForEntity("equipment", item.id),
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="font-display text-2xl font-semibold text-navy">
|
||||
{de.verwaltung.geraetBearbeiten}
|
||||
</h1>
|
||||
|
||||
<EquipmentControls equipmentId={item.id} status={item.status} />
|
||||
|
||||
<EquipmentForm
|
||||
mode="edit"
|
||||
equipmentId={item.id}
|
||||
categories={categories}
|
||||
vehicles={vehicles.map((v) => ({ id: v.id, name: v.name }))}
|
||||
initial={{
|
||||
name: item.name,
|
||||
categoryId: item.categoryId,
|
||||
vehicleId: item.vehicleId ?? "",
|
||||
}}
|
||||
definitionen={defs}
|
||||
vorhandeneWerte={werte}
|
||||
loadCategoryMerkmale={getCategoryMerkmaleAction}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
28
src/app/(app)/verwaltung/geraete/neu/page.tsx
Normal file
28
src/app/(app)/verwaltung/geraete/neu/page.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { requireWehrAdmin } from "@/lib/auth/guards";
|
||||
import { listCategories } from "@/server/data/equipment";
|
||||
import { listVehiclesForBrigade } from "@/server/data/vehicles";
|
||||
import { getCategoryMerkmaleAction } from "@/server/actions/equipment";
|
||||
import { de } from "@/lib/i18n/de";
|
||||
import { EquipmentForm } from "@/components/verwaltung/EquipmentForm";
|
||||
|
||||
export default async function GeraetNeuPage() {
|
||||
const s = await requireWehrAdmin();
|
||||
const [categories, vehicles] = await Promise.all([
|
||||
listCategories(),
|
||||
listVehiclesForBrigade(s.user.brigadeId),
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="font-display text-2xl font-semibold text-navy">
|
||||
{de.verwaltung.geraetAnlegen}
|
||||
</h1>
|
||||
<EquipmentForm
|
||||
mode="create"
|
||||
categories={categories}
|
||||
vehicles={vehicles.map((v) => ({ id: v.id, name: v.name }))}
|
||||
loadCategoryMerkmale={getCategoryMerkmaleAction}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
60
src/app/(app)/verwaltung/geraete/page.tsx
Normal file
60
src/app/(app)/verwaltung/geraete/page.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import Link from "next/link";
|
||||
import { requireWehrAdmin } from "@/lib/auth/guards";
|
||||
import { listEquipmentForBrigade } from "@/server/data/equipment";
|
||||
import { de } from "@/lib/i18n/de";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { StatusBadge, Badge } from "@/components/ui/badge";
|
||||
|
||||
export default async function GeraeteListePage() {
|
||||
const s = await requireWehrAdmin();
|
||||
const items = await listEquipmentForBrigade(s.user.brigadeId);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<header className="flex items-center justify-between">
|
||||
<h1 className="font-display text-2xl font-semibold text-navy">
|
||||
{de.verwaltung.navGeraete}
|
||||
</h1>
|
||||
<Button asChild>
|
||||
<Link href="/verwaltung/geraete/neu">
|
||||
{de.verwaltung.geraetAnlegen}
|
||||
</Link>
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
{items.length === 0 ? (
|
||||
<p className="rounded border border-rand bg-white p-6 text-sm text-anthrazit/60">
|
||||
{de.verwaltung.keineGeraete}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-rand rounded border border-rand bg-white">
|
||||
{items.map((e) => (
|
||||
<li
|
||||
key={e.id}
|
||||
className="flex items-center justify-between gap-4 px-4 py-3"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<Link
|
||||
href={`/verwaltung/geraete/${e.id}`}
|
||||
className="font-medium text-navy hover:underline"
|
||||
>
|
||||
{e.name}
|
||||
</Link>
|
||||
<p className="truncate text-sm text-anthrazit/60">
|
||||
{e.categoryName} ·{" "}
|
||||
{e.vehicleName ?? de.verwaltung.imGeraetehaus}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{e.vehicleId ? null : (
|
||||
<Badge>{de.verwaltung.imGeraetehaus}</Badge>
|
||||
)}
|
||||
<StatusBadge status={e.status} />
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
25
src/app/(app)/verwaltung/layout.tsx
Normal file
25
src/app/(app)/verwaltung/layout.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { requireWehrAdmin } from "@/lib/auth/guards";
|
||||
import { VerwaltungNav } from "@/components/verwaltung/VerwaltungNav";
|
||||
|
||||
/**
|
||||
* Route-Group-Layout des Wehr-Bereichs.
|
||||
*
|
||||
* GUARD-SLOT (Default-deny, dreifach — Querschnittsstandard 1+2): Der
|
||||
* serverseitige Guard `requireWehrAdmin()` ist die ALLERERSTE Anweisung. Er
|
||||
* leitet anonyme Aufrufe auf /login (redirect) um und verweigert allen außer
|
||||
* `wehr_admin` (auch `wehr_read`) mit forbidden() -> 403. Jede Server Action
|
||||
* wiederholt den Guard zusätzlich (Verteidigung in der Tiefe).
|
||||
*/
|
||||
export default async function VerwaltungLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
await requireWehrAdmin();
|
||||
return (
|
||||
<div>
|
||||
<VerwaltungNav />
|
||||
<main className="mx-auto max-w-5xl px-6 py-8">{children}</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
33
src/app/(app)/verwaltung/profil/page.tsx
Normal file
33
src/app/(app)/verwaltung/profil/page.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { requireWehrAdmin } from "@/lib/auth/guards";
|
||||
import { getBrigade } from "@/server/data/vehicles";
|
||||
import { de } from "@/lib/i18n/de";
|
||||
import { BrigadeProfileForm } from "@/components/verwaltung/BrigadeProfileForm";
|
||||
|
||||
export default async function ProfilPage() {
|
||||
const s = await requireWehrAdmin();
|
||||
const b = await getBrigade(s.user.brigadeId);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<header>
|
||||
<h1 className="font-display text-2xl font-semibold text-navy">
|
||||
{de.verwaltung.profilTitel}
|
||||
</h1>
|
||||
{b ? (
|
||||
<p className="mt-1 text-sm text-anthrazit/70">{b.name}</p>
|
||||
) : null}
|
||||
</header>
|
||||
<BrigadeProfileForm
|
||||
initial={{
|
||||
strasse: b?.strasse ?? "",
|
||||
plz: b?.plz ?? "",
|
||||
ort: b?.ort ?? "",
|
||||
telefon: b?.telefon ?? "",
|
||||
email: b?.email ?? "",
|
||||
wehrfuehrer: b?.wehrfuehrer ?? "",
|
||||
funkrufnameSchema: b?.funkrufnameSchema ?? "",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
124
src/components/verwaltung/BrigadeProfileForm.tsx
Normal file
124
src/components/verwaltung/BrigadeProfileForm.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 { Label } from "@/components/ui/label";
|
||||
import { de } from "@/lib/i18n/de";
|
||||
import { updateBrigadeProfile } from "@/server/actions/brigade";
|
||||
|
||||
export interface BrigadeProfileInitial {
|
||||
strasse: string;
|
||||
plz: string;
|
||||
ort: string;
|
||||
telefon: string;
|
||||
email: string;
|
||||
wehrfuehrer: string;
|
||||
funkrufnameSchema: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Profilformular der eigenen Wehr. Speichert Stamm-/Kontaktdaten; die Server-
|
||||
* Action geokodiert die Adresse inline. Schlägt das Geocoding fehl, werden die
|
||||
* Daten dennoch gespeichert und ein Warnhinweis angezeigt.
|
||||
*/
|
||||
export function BrigadeProfileForm({
|
||||
initial,
|
||||
}: {
|
||||
initial: BrigadeProfileInitial;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [info, setInfo] = React.useState<{ warnung: boolean } | null>(null);
|
||||
const [pending, startTransition] = React.useTransition();
|
||||
|
||||
function onSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setInfo(null);
|
||||
const fd = new FormData(e.currentTarget);
|
||||
const payload = {
|
||||
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") ?? ""),
|
||||
wehrfuehrer: String(fd.get("wehrfuehrer") ?? ""),
|
||||
funkrufnameSchema: String(fd.get("funkrufnameSchema") ?? ""),
|
||||
};
|
||||
startTransition(async () => {
|
||||
const res = await updateBrigadeProfile(payload);
|
||||
if (!res.ok) {
|
||||
setError(res.error);
|
||||
return;
|
||||
}
|
||||
setInfo({ warnung: res.geocodeWarnung });
|
||||
router.refresh();
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={onSubmit} className="max-w-2xl space-y-5">
|
||||
<div className="grid gap-1.5 sm:grid-cols-2 sm:gap-4">
|
||||
<div className="grid gap-1.5 sm:col-span-2">
|
||||
<Label htmlFor="strasse">{de.verwaltung.strasse}</Label>
|
||||
<Input id="strasse" name="strasse" required defaultValue={initial.strasse} />
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="plz">{de.verwaltung.plz}</Label>
|
||||
<Input id="plz" name="plz" required defaultValue={initial.plz} />
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="ort">{de.verwaltung.ort}</Label>
|
||||
<Input id="ort" name="ort" required defaultValue={initial.ort} />
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="telefon">{de.verwaltung.telefon}</Label>
|
||||
<Input id="telefon" name="telefon" defaultValue={initial.telefon} />
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="email">{de.verwaltung.email}</Label>
|
||||
<Input id="email" name="email" type="email" defaultValue={initial.email} />
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="wehrfuehrer">{de.verwaltung.wehrfuehrer}</Label>
|
||||
<Input id="wehrfuehrer" name="wehrfuehrer" defaultValue={initial.wehrfuehrer} />
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="funkrufnameSchema">
|
||||
{de.verwaltung.funkrufnameSchema}
|
||||
</Label>
|
||||
<Input
|
||||
id="funkrufnameSchema"
|
||||
name="funkrufnameSchema"
|
||||
defaultValue={initial.funkrufnameSchema}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<p role="alert" className="text-sm text-signal">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
{info ? (
|
||||
<p
|
||||
className={
|
||||
info.warnung
|
||||
? "rounded border border-wartung/40 bg-wartung/5 px-3 py-2 text-sm text-wartung"
|
||||
: "rounded border border-bereit/40 bg-bereit/5 px-3 py-2 text-sm text-anthrazit"
|
||||
}
|
||||
>
|
||||
{info.warnung
|
||||
? de.verwaltung.geocodeWarnung
|
||||
: `${de.verwaltung.profilGespeichert} ${de.verwaltung.geocodeOk}`}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<Button type="submit" disabled={pending}>
|
||||
{de.verwaltung.speichern}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
138
src/components/verwaltung/BrigadeUserForm.tsx
Normal file
138
src/components/verwaltung/BrigadeUserForm.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
"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 { Label } from "@/components/ui/label";
|
||||
import { de } from "@/lib/i18n/de";
|
||||
import { createBrigadeUser, deactivateBrigadeUser } from "@/server/actions/brigade-users";
|
||||
|
||||
/**
|
||||
* Formular zum Anlegen eines Wehr-Benutzers (lokales Konto). Die Rolle ist auf
|
||||
* Wehr-Admin/Lesend beschränkt. Nach Erfolg wird das Einmal-Passwort genau
|
||||
* einmal angezeigt.
|
||||
*/
|
||||
export function BrigadeUserForm() {
|
||||
const router = useRouter();
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [tempPassword, setTempPassword] = React.useState<string | null>(null);
|
||||
const [pending, startTransition] = React.useTransition();
|
||||
|
||||
function onSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
const form = e.currentTarget;
|
||||
const fd = new FormData(form);
|
||||
const payload = {
|
||||
email: String(fd.get("email") ?? ""),
|
||||
name: String(fd.get("name") ?? ""),
|
||||
rolle: String(fd.get("rolle") ?? "wehr_read"),
|
||||
};
|
||||
startTransition(async () => {
|
||||
const res = await createBrigadeUser(payload);
|
||||
if (!res.ok) {
|
||||
setError(res.error);
|
||||
return;
|
||||
}
|
||||
setTempPassword(res.tempPassword);
|
||||
form.reset();
|
||||
router.refresh();
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={onSubmit}
|
||||
className="space-y-4 rounded border border-rand bg-white p-5"
|
||||
>
|
||||
<div className="grid gap-1.5 sm:grid-cols-2 sm:gap-4">
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="user-name">{de.verwaltung.name}</Label>
|
||||
<Input id="user-name" name="name" required />
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="user-email">{de.verwaltung.email}</Label>
|
||||
<Input id="user-email" name="email" type="email" required />
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="user-rolle">{de.verwaltung.rolle}</Label>
|
||||
<select
|
||||
id="user-rolle"
|
||||
name="rolle"
|
||||
defaultValue="wehr_read"
|
||||
className="h-10 w-full rounded border border-rand bg-white px-3 text-sm text-anthrazit focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-navy"
|
||||
>
|
||||
<option value="wehr_read">{de.verwaltung.rolleRead}</option>
|
||||
<option value="wehr_admin">{de.verwaltung.rolleAdmin}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<p role="alert" className="text-sm text-signal">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{tempPassword ? (
|
||||
<div className="rounded border border-bereit/40 bg-bereit/5 p-4">
|
||||
<p className="text-sm font-medium text-anthrazit/70">
|
||||
{de.verwaltung.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">
|
||||
{tempPassword}
|
||||
</code>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<Button type="submit" disabled={pending}>
|
||||
{de.verwaltung.benutzerAnlegen}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Knopf zum Deaktivieren eines Benutzers. Eigener Client-Knopf (Bestätigung +
|
||||
* Transition). Selbst-Deaktivierung verhindert die Server-Action zusätzlich.
|
||||
*/
|
||||
export function DeactivateUserButton({
|
||||
userId,
|
||||
disabled,
|
||||
}: {
|
||||
userId: string;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [pending, startTransition] = React.useTransition();
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
function onClick() {
|
||||
if (!window.confirm(de.verwaltung.loeschenBestaetigen)) return;
|
||||
setError(null);
|
||||
startTransition(async () => {
|
||||
const res = await deactivateBrigadeUser({ userId });
|
||||
if (!res.ok) {
|
||||
setError(res.error);
|
||||
return;
|
||||
}
|
||||
router.refresh();
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="inline-flex flex-col items-end gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={disabled || pending}
|
||||
onClick={onClick}
|
||||
>
|
||||
{de.verwaltung.deaktivieren}
|
||||
</Button>
|
||||
{error ? <span className="text-xs text-signal">{error}</span> : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
184
src/components/verwaltung/EquipmentForm.tsx
Normal file
184
src/components/verwaltung/EquipmentForm.tsx
Normal file
@@ -0,0 +1,184 @@
|
||||
"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 { Label } from "@/components/ui/label";
|
||||
import { de } from "@/lib/i18n/de";
|
||||
import {
|
||||
MerkmalValueEditor,
|
||||
initMerkmalWerte,
|
||||
werteToList,
|
||||
type MerkmalWerteState,
|
||||
} from "./MerkmalValueEditor";
|
||||
import type {
|
||||
MerkmalDefinition,
|
||||
MerkmalValueInput,
|
||||
} from "@/lib/merkmale/types";
|
||||
import { createEquipment, updateEquipment } from "@/server/actions/equipment";
|
||||
|
||||
export interface CategoryOption {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
export interface VehicleOption {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface CreateProps {
|
||||
mode: "create";
|
||||
categories: CategoryOption[];
|
||||
vehicles: VehicleOption[];
|
||||
loadCategoryMerkmale: (categoryId: string) => Promise<MerkmalDefinition[]>;
|
||||
}
|
||||
|
||||
interface EditProps {
|
||||
mode: "edit";
|
||||
equipmentId: string;
|
||||
categories: CategoryOption[];
|
||||
vehicles: VehicleOption[];
|
||||
initial: { name: string; categoryId: string; vehicleId: string };
|
||||
definitionen: MerkmalDefinition[];
|
||||
vorhandeneWerte: MerkmalValueInput[];
|
||||
loadCategoryMerkmale: (categoryId: string) => Promise<MerkmalDefinition[]>;
|
||||
}
|
||||
|
||||
type Props = CreateProps | EditProps;
|
||||
|
||||
export function EquipmentForm(props: Props) {
|
||||
const router = useRouter();
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [pending, startTransition] = React.useTransition();
|
||||
|
||||
const [categoryId, setCategoryId] = React.useState<string>(
|
||||
props.mode === "edit" ? props.initial.categoryId : "",
|
||||
);
|
||||
const [vehicleId, setVehicleId] = React.useState<string>(
|
||||
props.mode === "edit" ? props.initial.vehicleId : "",
|
||||
);
|
||||
const [defs, setDefs] = React.useState<MerkmalDefinition[]>(
|
||||
props.mode === "edit" ? props.definitionen : [],
|
||||
);
|
||||
const [werte, setWerte] = React.useState<MerkmalWerteState>(
|
||||
props.mode === "edit"
|
||||
? initMerkmalWerte(props.definitionen, props.vorhandeneWerte)
|
||||
: {},
|
||||
);
|
||||
|
||||
async function onCategoryChange(id: string) {
|
||||
setCategoryId(id);
|
||||
if (!id) {
|
||||
setDefs([]);
|
||||
setWerte({});
|
||||
return;
|
||||
}
|
||||
const loaded = await props.loadCategoryMerkmale(id);
|
||||
setDefs(loaded);
|
||||
setWerte(initMerkmalWerte(loaded));
|
||||
}
|
||||
|
||||
function onSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
const fd = new FormData(e.currentTarget);
|
||||
const base = {
|
||||
name: String(fd.get("name") ?? ""),
|
||||
categoryId,
|
||||
vehicleId: vehicleId || undefined,
|
||||
};
|
||||
const liste = werteToList(werte);
|
||||
|
||||
startTransition(async () => {
|
||||
const res =
|
||||
props.mode === "create"
|
||||
? await createEquipment(base, liste)
|
||||
: await updateEquipment({ ...base, id: props.equipmentId }, liste);
|
||||
if (!res.ok) {
|
||||
setError(res.error);
|
||||
return;
|
||||
}
|
||||
router.push("/verwaltung/geraete");
|
||||
router.refresh();
|
||||
});
|
||||
}
|
||||
|
||||
const initialName = props.mode === "edit" ? props.initial.name : "";
|
||||
|
||||
return (
|
||||
<form onSubmit={onSubmit} className="max-w-2xl space-y-6">
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="name">{de.verwaltung.name}</Label>
|
||||
<Input id="name" name="name" required defaultValue={initialName} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="categoryId">{de.verwaltung.kategorie}</Label>
|
||||
<select
|
||||
id="categoryId"
|
||||
name="categoryId"
|
||||
required
|
||||
className="h-10 w-full 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={categoryId}
|
||||
onChange={(e) => void onCategoryChange(e.target.value)}
|
||||
>
|
||||
<option value="">—</option>
|
||||
{props.categories.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="vehicleId">{de.verwaltung.zuordnung}</Label>
|
||||
<select
|
||||
id="vehicleId"
|
||||
name="vehicleId"
|
||||
className="h-10 w-full 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={vehicleId}
|
||||
onChange={(e) => setVehicleId(e.target.value)}
|
||||
>
|
||||
<option value="">{de.verwaltung.imGeraetehaus}</option>
|
||||
{props.vehicles.map((v) => (
|
||||
<option key={v.id} value={v.id}>
|
||||
{v.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<fieldset className="rounded border border-rand bg-white p-5">
|
||||
<legend className="px-1 text-sm font-semibold text-navy">
|
||||
{de.verwaltung.merkmale}
|
||||
</legend>
|
||||
<MerkmalValueEditor
|
||||
definitionen={defs}
|
||||
werte={werte}
|
||||
onChange={setWerte}
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
{error ? (
|
||||
<p role="alert" className="text-sm text-signal">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button type="submit" disabled={pending}>
|
||||
{de.verwaltung.speichern}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => router.push("/verwaltung/geraete")}
|
||||
>
|
||||
{de.verwaltung.abbrechen}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
160
src/components/verwaltung/MerkmalValueEditor.tsx
Normal file
160
src/components/verwaltung/MerkmalValueEditor.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { de } from "@/lib/i18n/de";
|
||||
import type {
|
||||
MerkmalDefinition,
|
||||
MerkmalValueInput,
|
||||
} from "@/lib/merkmale/types";
|
||||
|
||||
export type MerkmalWerteState = Record<string, MerkmalValueInput>;
|
||||
|
||||
/**
|
||||
* Leitet den initialen Editor-Zustand aus Definitionen + bereits gespeicherten
|
||||
* Werten ab. Vorgabewerte werden typgerecht aus den drei Spalten gelesen, falls
|
||||
* kein gespeicherter Wert existiert.
|
||||
*/
|
||||
export function initMerkmalWerte(
|
||||
defs: MerkmalDefinition[],
|
||||
vorhanden: MerkmalValueInput[] = [],
|
||||
): MerkmalWerteState {
|
||||
const byId = new Map(vorhanden.map((v) => [v.merkmalId, v]));
|
||||
const state: MerkmalWerteState = {};
|
||||
for (const d of defs) {
|
||||
const existing = byId.get(d.merkmalId);
|
||||
state[d.merkmalId] = existing ?? {
|
||||
merkmalId: d.merkmalId,
|
||||
num: d.vorgabeNum,
|
||||
text: d.vorgabeText,
|
||||
bool: d.vorgabeBool,
|
||||
};
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
definitionen: MerkmalDefinition[];
|
||||
werte: MerkmalWerteState;
|
||||
onChange: (next: MerkmalWerteState) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Typisierter Merkmal-Editor: rendert je Merkmal das passende Eingabefeld
|
||||
* (Zahl/Auswahl/Schalter/Text). Pflichtmerkmale sind markiert. Der Editor ist
|
||||
* kontrolliert; die Server-Action validiert die Werte erneut (Default-deny).
|
||||
*/
|
||||
export function MerkmalValueEditor({ definitionen, werte, onChange }: Props) {
|
||||
if (definitionen.length === 0) {
|
||||
return (
|
||||
<p className="text-sm text-anthrazit/60">{de.verwaltung.keineMerkmale}</p>
|
||||
);
|
||||
}
|
||||
|
||||
function update(merkmalId: string, patch: Partial<MerkmalValueInput>) {
|
||||
onChange({
|
||||
...werte,
|
||||
[merkmalId]: { ...werte[merkmalId], merkmalId, ...patch },
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{definitionen.map((d) => {
|
||||
const v = werte[d.merkmalId] ?? { merkmalId: d.merkmalId };
|
||||
const fieldId = `merkmal-${d.merkmalId}`;
|
||||
return (
|
||||
<div key={d.merkmalId} className="grid gap-1.5">
|
||||
<Label htmlFor={fieldId}>
|
||||
{d.name}
|
||||
{d.einheit ? (
|
||||
<span className="text-anthrazit/50"> ({d.einheit})</span>
|
||||
) : null}
|
||||
{d.pflicht ? (
|
||||
<span className="ml-1 text-signal" aria-hidden>
|
||||
*
|
||||
</span>
|
||||
) : null}
|
||||
</Label>
|
||||
|
||||
{d.typ === "number" ? (
|
||||
<Input
|
||||
id={fieldId}
|
||||
name={fieldId}
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
step="any"
|
||||
required={d.pflicht}
|
||||
value={v.num ?? ""}
|
||||
onChange={(e) =>
|
||||
update(d.merkmalId, {
|
||||
num: e.target.value === "" ? null : Number(e.target.value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
) : d.typ === "boolean" ? (
|
||||
<select
|
||||
id={fieldId}
|
||||
name={fieldId}
|
||||
required={d.pflicht}
|
||||
className="h-10 w-full 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={v.bool == null ? "" : v.bool ? "true" : "false"}
|
||||
onChange={(e) =>
|
||||
update(d.merkmalId, {
|
||||
bool:
|
||||
e.target.value === ""
|
||||
? null
|
||||
: e.target.value === "true",
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="">{de.search.egal}</option>
|
||||
<option value="true">{de.search.ja}</option>
|
||||
<option value="false">{de.search.nein}</option>
|
||||
</select>
|
||||
) : d.typ === "enum" ? (
|
||||
<select
|
||||
id={fieldId}
|
||||
name={fieldId}
|
||||
required={d.pflicht}
|
||||
className="h-10 w-full 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={v.text ?? ""}
|
||||
onChange={(e) =>
|
||||
update(d.merkmalId, {
|
||||
text: e.target.value === "" ? null : e.target.value,
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="">—</option>
|
||||
{d.optionen.map((o) => (
|
||||
<option key={o.wert} value={o.wert}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<Input
|
||||
id={fieldId}
|
||||
name={fieldId}
|
||||
type="text"
|
||||
required={d.pflicht}
|
||||
value={v.text ?? ""}
|
||||
onChange={(e) =>
|
||||
update(d.merkmalId, {
|
||||
text: e.target.value === "" ? null : e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Wandelt den Editor-Zustand in die Liste der Server-Eingaben um. */
|
||||
export function werteToList(state: MerkmalWerteState): MerkmalValueInput[] {
|
||||
return Object.values(state);
|
||||
}
|
||||
44
src/components/verwaltung/TemplatePicker.tsx
Normal file
44
src/components/verwaltung/TemplatePicker.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
"use client";
|
||||
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { de } from "@/lib/i18n/de";
|
||||
|
||||
export interface TemplateOption {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
templates: TemplateOption[];
|
||||
value: string;
|
||||
onChange: (id: string) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Auswahl einer Fahrzeug-Vorlage (oder „frei"). Reine Präsentation; das
|
||||
* Nachladen der Merkmale übernimmt das Formular.
|
||||
*/
|
||||
export function TemplatePicker({ templates, value, onChange, disabled }: Props) {
|
||||
return (
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="templateId">{de.verwaltung.vorlage}</Label>
|
||||
<select
|
||||
id="templateId"
|
||||
name="templateId"
|
||||
disabled={disabled}
|
||||
className="h-10 w-full rounded border border-rand bg-white px-3 text-sm text-anthrazit focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-navy disabled:opacity-50"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
>
|
||||
<option value="">{de.verwaltung.keineVorlage}</option>
|
||||
{templates.map((t) => (
|
||||
<option key={t.id} value={t.id}>
|
||||
{t.code} — {t.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
169
src/components/verwaltung/VehicleForm.tsx
Normal file
169
src/components/verwaltung/VehicleForm.tsx
Normal file
@@ -0,0 +1,169 @@
|
||||
"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 { Label } from "@/components/ui/label";
|
||||
import { de } from "@/lib/i18n/de";
|
||||
import {
|
||||
MerkmalValueEditor,
|
||||
initMerkmalWerte,
|
||||
werteToList,
|
||||
type MerkmalWerteState,
|
||||
} from "./MerkmalValueEditor";
|
||||
import { TemplatePicker } from "./TemplatePicker";
|
||||
import type {
|
||||
MerkmalDefinition,
|
||||
MerkmalValueInput,
|
||||
} from "@/lib/merkmale/types";
|
||||
import { createVehicle, updateVehicle } from "@/server/actions/vehicles";
|
||||
|
||||
export interface VehicleFormTemplate {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface CreateProps {
|
||||
mode: "create";
|
||||
templates: VehicleFormTemplate[];
|
||||
/** Lädt die Merkmal-Definitionen einer Vorlage (geschützte Server-Action). */
|
||||
loadTemplateMerkmale: (templateId: string) => Promise<MerkmalDefinition[]>;
|
||||
}
|
||||
|
||||
interface EditProps {
|
||||
mode: "edit";
|
||||
vehicleId: string;
|
||||
initial: {
|
||||
name: string;
|
||||
funkrufname: string;
|
||||
notiz: string;
|
||||
};
|
||||
definitionen: MerkmalDefinition[];
|
||||
vorhandeneWerte: MerkmalValueInput[];
|
||||
}
|
||||
|
||||
type Props = CreateProps | EditProps;
|
||||
|
||||
export function VehicleForm(props: Props) {
|
||||
const router = useRouter();
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [pending, startTransition] = React.useTransition();
|
||||
|
||||
const [templateId, setTemplateId] = React.useState<string>("");
|
||||
const [defs, setDefs] = React.useState<MerkmalDefinition[]>(
|
||||
props.mode === "edit" ? props.definitionen : [],
|
||||
);
|
||||
const [werte, setWerte] = React.useState<MerkmalWerteState>(
|
||||
props.mode === "edit"
|
||||
? initMerkmalWerte(props.definitionen, props.vorhandeneWerte)
|
||||
: {},
|
||||
);
|
||||
|
||||
async function onTemplateChange(id: string) {
|
||||
setTemplateId(id);
|
||||
if (props.mode !== "create") return;
|
||||
if (!id) {
|
||||
setDefs([]);
|
||||
setWerte({});
|
||||
return;
|
||||
}
|
||||
const loaded = await props.loadTemplateMerkmale(id);
|
||||
setDefs(loaded);
|
||||
setWerte(initMerkmalWerte(loaded));
|
||||
}
|
||||
|
||||
function onSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
const fd = new FormData(e.currentTarget);
|
||||
const base = {
|
||||
name: String(fd.get("name") ?? ""),
|
||||
funkrufname: String(fd.get("funkrufname") ?? ""),
|
||||
notiz: String(fd.get("notiz") ?? ""),
|
||||
};
|
||||
const liste = werteToList(werte);
|
||||
|
||||
startTransition(async () => {
|
||||
const res =
|
||||
props.mode === "create"
|
||||
? await createVehicle({ ...base, templateId: templateId || undefined }, liste)
|
||||
: await updateVehicle({ ...base, id: props.vehicleId }, liste);
|
||||
if (!res.ok) {
|
||||
setError(res.error);
|
||||
return;
|
||||
}
|
||||
router.push("/verwaltung/fahrzeuge");
|
||||
router.refresh();
|
||||
});
|
||||
}
|
||||
|
||||
const initial = props.mode === "edit" ? props.initial : undefined;
|
||||
|
||||
return (
|
||||
<form onSubmit={onSubmit} className="max-w-2xl space-y-6">
|
||||
{props.mode === "create" ? (
|
||||
<TemplatePicker
|
||||
templates={props.templates}
|
||||
value={templateId}
|
||||
onChange={(id) => void onTemplateChange(id)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="name">{de.verwaltung.name}</Label>
|
||||
<Input
|
||||
id="name"
|
||||
name="name"
|
||||
required
|
||||
defaultValue={initial?.name ?? ""}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="funkrufname">{de.verwaltung.funkrufname}</Label>
|
||||
<Input
|
||||
id="funkrufname"
|
||||
name="funkrufname"
|
||||
defaultValue={initial?.funkrufname ?? ""}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="notiz">{de.verwaltung.notiz}</Label>
|
||||
<Input id="notiz" name="notiz" defaultValue={initial?.notiz ?? ""} />
|
||||
</div>
|
||||
|
||||
<fieldset className="rounded border border-rand bg-white p-5">
|
||||
<legend className="px-1 text-sm font-semibold text-navy">
|
||||
{de.verwaltung.merkmale}
|
||||
</legend>
|
||||
<MerkmalValueEditor
|
||||
definitionen={defs}
|
||||
werte={werte}
|
||||
onChange={setWerte}
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
{error ? (
|
||||
<p role="alert" className="text-sm text-signal">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button type="submit" disabled={pending}>
|
||||
{de.verwaltung.speichern}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => router.push("/verwaltung/fahrzeuge")}
|
||||
>
|
||||
{de.verwaltung.abbrechen}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
52
src/components/verwaltung/VerwaltungNav.tsx
Normal file
52
src/components/verwaltung/VerwaltungNav.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { de } from "@/lib/i18n/de";
|
||||
|
||||
/**
|
||||
* Sub-Navigation des Wehr-Bereichs. Client-Komponente nur wegen `usePathname`
|
||||
* (aktiver Zustand). Keine Geschäftslogik. Spiegelt die Admin-Navigation für
|
||||
* ein konsistentes Erscheinungsbild.
|
||||
*/
|
||||
const ITEMS = [
|
||||
{ href: "/verwaltung/profil", label: de.verwaltung.navProfil },
|
||||
{ href: "/verwaltung/fahrzeuge", label: de.verwaltung.navFahrzeuge },
|
||||
{ href: "/verwaltung/geraete", label: de.verwaltung.navGeraete },
|
||||
{ href: "/verwaltung/benutzer", label: de.verwaltung.navBenutzer },
|
||||
] as const;
|
||||
|
||||
export function VerwaltungNav() {
|
||||
const pathname = usePathname();
|
||||
return (
|
||||
<nav
|
||||
aria-label="Wehr-Verwaltungsnavigation"
|
||||
className="border-b border-rand bg-white"
|
||||
>
|
||||
<div className="mx-auto flex max-w-5xl flex-wrap items-center gap-1 px-6 py-2">
|
||||
<span className="mr-4 font-display text-sm font-semibold text-navy">
|
||||
{de.verwaltung.titel}
|
||||
</span>
|
||||
{ITEMS.map((item) => {
|
||||
const active = pathname.startsWith(item.href);
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -125,6 +125,63 @@ export const de = {
|
||||
zurueck: "Zurück",
|
||||
weiter: "Weiter",
|
||||
},
|
||||
verwaltung: {
|
||||
titel: "Verwaltung",
|
||||
navProfil: "Profil",
|
||||
navFahrzeuge: "Fahrzeuge",
|
||||
navGeraete: "Geräte",
|
||||
navBenutzer: "Benutzer",
|
||||
speichern: "Speichern",
|
||||
abbrechen: "Abbrechen",
|
||||
loeschen: "Löschen",
|
||||
anlegen: "Anlegen",
|
||||
bearbeiten: "Bearbeiten",
|
||||
neu: "Neu",
|
||||
name: "Name",
|
||||
funkrufname: "Funkrufname",
|
||||
notiz: "Notiz",
|
||||
status: "Status",
|
||||
kategorie: "Kategorie",
|
||||
zuordnung: "Zuordnung",
|
||||
imGeraetehaus: "im Gerätehaus",
|
||||
vorlage: "Fahrzeug-Vorlage",
|
||||
keineVorlage: "Ohne Vorlage (frei)",
|
||||
vorlageWaehlen: "Vorlage wählen",
|
||||
merkmale: "Merkmale",
|
||||
keineMerkmale: "Für diese Auswahl sind keine Merkmale hinterlegt.",
|
||||
fahrzeugAnlegen: "Fahrzeug anlegen",
|
||||
fahrzeugBearbeiten: "Fahrzeug bearbeiten",
|
||||
geraetAnlegen: "Gerät anlegen",
|
||||
geraetBearbeiten: "Gerät bearbeiten",
|
||||
keineFahrzeuge: "Noch keine Fahrzeuge erfasst.",
|
||||
keineGeraete: "Noch keine Geräte erfasst.",
|
||||
keineBenutzer: "Noch keine Benutzer erfasst.",
|
||||
profilTitel: "Wehr-Profil",
|
||||
profilGespeichert: "Profil gespeichert.",
|
||||
geocodeOk: "Adresse geokodiert.",
|
||||
geocodeWarnung:
|
||||
"Adresse konnte nicht geokodiert werden. Daten wurden dennoch gespeichert.",
|
||||
strasse: "Straße",
|
||||
plz: "PLZ",
|
||||
ort: "Ort",
|
||||
email: "E-Mail",
|
||||
telefon: "Telefon",
|
||||
wehrfuehrer: "Wehrführer",
|
||||
funkrufnameSchema: "Funkrufname-Schema",
|
||||
rolle: "Rolle",
|
||||
rolleAdmin: "Wehr-Admin",
|
||||
rolleRead: "Lesend",
|
||||
benutzerAnlegen: "Benutzer anlegen",
|
||||
deaktivieren: "Deaktivieren",
|
||||
aktiv: "aktiv",
|
||||
inaktiv: "inaktiv",
|
||||
authLokal: "lokal",
|
||||
authAuthentik: "Authentik",
|
||||
tempPasswort:
|
||||
"Einmal-Passwort (nur jetzt sichtbar, bitte sicher übergeben):",
|
||||
loeschenBestaetigen: "Wirklich löschen?",
|
||||
pflichtfeld: "Pflichtfeld",
|
||||
},
|
||||
} as const;
|
||||
|
||||
type Leaf = string;
|
||||
|
||||
49
src/lib/merkmale/types.ts
Normal file
49
src/lib/merkmale/types.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Geteilte Typen für den typisierten Merkmal-Wert-Editor (Workstream 7).
|
||||
*
|
||||
* REIN: keine DB-/IO-Abhängigkeit, damit Validierung und Editor ohne laufendes
|
||||
* Postgres testbar sind. Die DB-Schreibseite (`upsertMerkmalValues`) konsumiert
|
||||
* `MerkmalValueInput`.
|
||||
*/
|
||||
|
||||
import type { merkmalTypEnum } from "@/db/schema";
|
||||
|
||||
/** Fachlicher Merkmal-Typ (Single Source of Truth: DB-Enum `merkmal_typ`). */
|
||||
export type MerkmalTyp = (typeof merkmalTypEnum.enumValues)[number];
|
||||
|
||||
/** Eine Auswahloption eines `enum`-Merkmals. */
|
||||
export interface MerkmalOption {
|
||||
wert: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ein zur Bearbeitung angebotenes Merkmal (aus Vorlage oder Kategorie
|
||||
* aufgelöst). `pflicht` markiert Vorlagen-Pflichtfelder. `vorgabe*` sind die
|
||||
* typgerecht vorbefüllten Standardwerte (drei Spalten, exakt einer passt zum
|
||||
* `typ`).
|
||||
*/
|
||||
export interface MerkmalDefinition {
|
||||
merkmalId: string;
|
||||
name: string;
|
||||
typ: MerkmalTyp;
|
||||
einheit: string | null;
|
||||
pflicht: boolean;
|
||||
reihenfolge: number;
|
||||
optionen: MerkmalOption[];
|
||||
vorgabeNum: number | null;
|
||||
vorgabeText: string | null;
|
||||
vorgabeBool: boolean | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ein einzelner, an der Server-Grenze validierter Merkmal-Wert. Genau eine der
|
||||
* Wertspalten ist (typabhängig) gesetzt; alle `null`/leer => der Wert wird
|
||||
* beim Upsert gelöscht (kein Eintrag).
|
||||
*/
|
||||
export interface MerkmalValueInput {
|
||||
merkmalId: string;
|
||||
num?: number | null;
|
||||
text?: string | null;
|
||||
bool?: boolean | null;
|
||||
}
|
||||
37
src/lib/validation/__tests__/brigade-user.test.ts
Normal file
37
src/lib/validation/__tests__/brigade-user.test.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { brigadeUserCreateSchema, brigadeUserDeactivateSchema } from "../brigade-user";
|
||||
|
||||
describe("brigadeUserCreateSchema", () => {
|
||||
const valid = {
|
||||
email: "Neu@FF.at",
|
||||
name: "Neue Person",
|
||||
rolle: "wehr_read",
|
||||
};
|
||||
|
||||
it("akzeptiert wehr_admin und wehr_read und normalisiert die E-Mail", () => {
|
||||
const r = brigadeUserCreateSchema.safeParse(valid);
|
||||
expect(r.success).toBe(true);
|
||||
if (r.success) expect(r.data.email).toBe("neu@ff.at");
|
||||
expect(brigadeUserCreateSchema.safeParse({ ...valid, rolle: "wehr_admin" }).success).toBe(true);
|
||||
});
|
||||
|
||||
it("lehnt platform_admin ab (nicht zuweisbar durch Wehr-Admin)", () => {
|
||||
expect(
|
||||
brigadeUserCreateSchema.safeParse({ ...valid, rolle: "platform_admin" }).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("verlangt Name und gültige E-Mail", () => {
|
||||
expect(brigadeUserCreateSchema.safeParse({ ...valid, name: "" }).success).toBe(false);
|
||||
expect(brigadeUserCreateSchema.safeParse({ ...valid, email: "keine-mail" }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("brigadeUserDeactivateSchema", () => {
|
||||
it("verlangt eine gültige UUID", () => {
|
||||
expect(
|
||||
brigadeUserDeactivateSchema.safeParse({ userId: "11111111-1111-1111-1111-111111111111" }).success,
|
||||
).toBe(true);
|
||||
expect(brigadeUserDeactivateSchema.safeParse({ userId: "x" }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
34
src/lib/validation/__tests__/equipment.test.ts
Normal file
34
src/lib/validation/__tests__/equipment.test.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
equipmentBaseSchema,
|
||||
equipmentStatusSchema,
|
||||
} from "../equipment";
|
||||
|
||||
const CAT = "11111111-1111-1111-1111-111111111111";
|
||||
const VEH = "22222222-2222-2222-2222-222222222222";
|
||||
|
||||
describe("equipmentBaseSchema", () => {
|
||||
it("verlangt Name und Kategorie", () => {
|
||||
expect(equipmentBaseSchema.safeParse({ name: "", categoryId: CAT }).success).toBe(false);
|
||||
expect(equipmentBaseSchema.safeParse({ name: "Pumpe", categoryId: "x" }).success).toBe(false);
|
||||
});
|
||||
|
||||
it("erlaubt leere vehicleId (im Gerätehaus) -> undefined", () => {
|
||||
const r = equipmentBaseSchema.safeParse({ name: "Pumpe", categoryId: CAT, vehicleId: "" });
|
||||
expect(r.success).toBe(true);
|
||||
if (r.success) expect(r.data.vehicleId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("akzeptiert eine gültige vehicleId (Zuordnung)", () => {
|
||||
const r = equipmentBaseSchema.safeParse({ name: "Pumpe", categoryId: CAT, vehicleId: VEH });
|
||||
expect(r.success).toBe(true);
|
||||
if (r.success) expect(r.data.vehicleId).toBe(VEH);
|
||||
});
|
||||
});
|
||||
|
||||
describe("equipmentStatusSchema", () => {
|
||||
it("akzeptiert nur asset_status-Werte", () => {
|
||||
expect(equipmentStatusSchema.safeParse({ id: CAT, status: "ausser_dienst" }).success).toBe(true);
|
||||
expect(equipmentStatusSchema.safeParse({ id: CAT, status: "weg" }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
111
src/lib/validation/__tests__/vehicle.test.ts
Normal file
111
src/lib/validation/__tests__/vehicle.test.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
vehicleBaseSchema,
|
||||
vehicleStatusSchema,
|
||||
buildMerkmalValuesSchema,
|
||||
} from "../vehicle";
|
||||
import type { MerkmalDefinition } from "@/lib/merkmale/types";
|
||||
|
||||
const def = (over: Partial<MerkmalDefinition>): MerkmalDefinition => ({
|
||||
merkmalId: "11111111-1111-1111-1111-111111111111",
|
||||
name: "Merkmal",
|
||||
typ: "text",
|
||||
einheit: null,
|
||||
pflicht: false,
|
||||
reihenfolge: 0,
|
||||
optionen: [],
|
||||
vorgabeNum: null,
|
||||
vorgabeText: null,
|
||||
vorgabeBool: null,
|
||||
...over,
|
||||
});
|
||||
|
||||
describe("vehicleBaseSchema", () => {
|
||||
it("verlangt einen Namen", () => {
|
||||
const r = vehicleBaseSchema.safeParse({ name: "", funkrufname: "", notiz: "" });
|
||||
expect(r.success).toBe(false);
|
||||
});
|
||||
|
||||
it("akzeptiert Name mit leeren Optionalfeldern (-> undefined)", () => {
|
||||
const r = vehicleBaseSchema.safeParse({
|
||||
name: "HLF 2 Musterdorf",
|
||||
funkrufname: "",
|
||||
notiz: "",
|
||||
templateId: "",
|
||||
});
|
||||
expect(r.success).toBe(true);
|
||||
if (r.success) {
|
||||
expect(r.data.funkrufname).toBeUndefined();
|
||||
expect(r.data.templateId).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("akzeptiert eine gültige templateId (uuid)", () => {
|
||||
const r = vehicleBaseSchema.safeParse({
|
||||
name: "HLF 2",
|
||||
templateId: "22222222-2222-2222-2222-222222222222",
|
||||
});
|
||||
expect(r.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("vehicleStatusSchema", () => {
|
||||
it("akzeptiert nur asset_status-Werte", () => {
|
||||
expect(vehicleStatusSchema.safeParse({ id: "33333333-3333-3333-3333-333333333333", status: "wartung" }).success).toBe(true);
|
||||
expect(vehicleStatusSchema.safeParse({ id: "33333333-3333-3333-3333-333333333333", status: "kaputt" }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildMerkmalValuesSchema", () => {
|
||||
it("validiert number-Merkmale typgerecht und coerced Strings", () => {
|
||||
const schema = buildMerkmalValuesSchema([
|
||||
def({ typ: "number", name: "Löschwassertank", einheit: "l" }),
|
||||
]);
|
||||
const r = schema.safeParse([
|
||||
{ merkmalId: def({}).merkmalId, num: "2000" },
|
||||
]);
|
||||
expect(r.success).toBe(true);
|
||||
if (r.success) expect(r.data[0]?.num).toBe(2000);
|
||||
});
|
||||
|
||||
it("lehnt nicht-numerische Eingabe für number ab", () => {
|
||||
const schema = buildMerkmalValuesSchema([def({ typ: "number" })]);
|
||||
const r = schema.safeParse([{ merkmalId: def({}).merkmalId, num: "abc" }]);
|
||||
expect(r.success).toBe(false);
|
||||
});
|
||||
|
||||
it("erzwingt Pflichtmerkmale (number ohne Wert -> Fehler)", () => {
|
||||
const schema = buildMerkmalValuesSchema([def({ typ: "number", pflicht: true })]);
|
||||
const r = schema.safeParse([{ merkmalId: def({}).merkmalId, num: null }]);
|
||||
expect(r.success).toBe(false);
|
||||
});
|
||||
|
||||
it("erlaubt optionale Merkmale ohne Wert", () => {
|
||||
const schema = buildMerkmalValuesSchema([def({ typ: "number", pflicht: false })]);
|
||||
const r = schema.safeParse([{ merkmalId: def({}).merkmalId, num: null }]);
|
||||
expect(r.success).toBe(true);
|
||||
});
|
||||
|
||||
it("beschränkt enum-Werte auf erlaubte Optionen", () => {
|
||||
const schema = buildMerkmalValuesSchema([
|
||||
def({ typ: "enum", optionen: [{ wert: "TS", label: "Tragkraftspritze" }] }),
|
||||
]);
|
||||
expect(schema.safeParse([{ merkmalId: def({}).merkmalId, text: "TS" }]).success).toBe(true);
|
||||
expect(schema.safeParse([{ merkmalId: def({}).merkmalId, text: "XX" }]).success).toBe(false);
|
||||
});
|
||||
|
||||
it("coerced boolean-Merkmale", () => {
|
||||
const schema = buildMerkmalValuesSchema([def({ typ: "boolean", name: "Allradantrieb" })]);
|
||||
const r = schema.safeParse([{ merkmalId: def({}).merkmalId, bool: true }]);
|
||||
expect(r.success).toBe(true);
|
||||
if (r.success) expect(r.data[0]?.bool).toBe(true);
|
||||
});
|
||||
|
||||
it("ignoriert Werte zu unbekannten Merkmalen (nur erlaubte merkmalIds)", () => {
|
||||
const schema = buildMerkmalValuesSchema([def({ typ: "text" })]);
|
||||
const r = schema.safeParse([
|
||||
{ merkmalId: "99999999-9999-9999-9999-999999999999", text: "hallo" },
|
||||
]);
|
||||
expect(r.success).toBe(false);
|
||||
});
|
||||
});
|
||||
35
src/lib/validation/brigade-user.ts
Normal file
35
src/lib/validation/brigade-user.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { z } from "zod";
|
||||
import { uuidSchema } from "./common";
|
||||
|
||||
/**
|
||||
* Zod-Schemas für die Benutzerverwaltung im Wehr-Bereich (Workstream 7).
|
||||
*
|
||||
* WICHTIG (Sicherheit): Die Rolle ist auf `wehr_admin | wehr_read` beschränkt.
|
||||
* Ein Wehr-Admin darf NIEMALS `platform_admin` vergeben — Zod lehnt das an der
|
||||
* Grenze ab (Querschnittsstandard 4, Verteidigung in der Tiefe zusätzlich zum
|
||||
* serverseitigen Scope-Guard).
|
||||
*/
|
||||
|
||||
export const brigadeUserRoleSchema = z.enum(["wehr_admin", "wehr_read"], {
|
||||
errorMap: () => ({ message: "Unzulässige Rolle." }),
|
||||
});
|
||||
|
||||
export const brigadeUserCreateSchema = z.object({
|
||||
email: z
|
||||
.string()
|
||||
.trim()
|
||||
.email({ message: "Ungültige E-Mail." })
|
||||
.transform((v) => v.toLowerCase()),
|
||||
name: z.string().trim().min(1, { message: "Name ist Pflicht." }),
|
||||
rolle: brigadeUserRoleSchema,
|
||||
});
|
||||
|
||||
export type BrigadeUserCreateInput = z.infer<typeof brigadeUserCreateSchema>;
|
||||
|
||||
export const brigadeUserDeactivateSchema = z.object({
|
||||
userId: uuidSchema,
|
||||
});
|
||||
|
||||
export type BrigadeUserDeactivateInput = z.infer<
|
||||
typeof brigadeUserDeactivateSchema
|
||||
>;
|
||||
@@ -38,3 +38,37 @@ export const userResetSchema = z.object({
|
||||
});
|
||||
|
||||
export type UserResetInput = z.infer<typeof userResetSchema>;
|
||||
|
||||
/**
|
||||
* Schema für die Profil-Bearbeitung durch den Wehr-Admin der EIGENEN Wehr
|
||||
* (Workstream 7). Kein `name`/keine Anlage — nur Stamm-/Kontaktdaten. Die
|
||||
* `brigadeId` kommt IMMER serverseitig aus der Session, nie aus dem Input.
|
||||
*/
|
||||
export const brigadeProfileSchema = z.object({
|
||||
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()
|
||||
.optional()
|
||||
.transform((v) => (v === "" ? undefined : v)),
|
||||
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)),
|
||||
funkrufnameSchema: z
|
||||
.string()
|
||||
.trim()
|
||||
.optional()
|
||||
.transform((v) => (v === "" ? undefined : v)),
|
||||
});
|
||||
|
||||
export type BrigadeProfileInput = z.infer<typeof brigadeProfileSchema>;
|
||||
|
||||
45
src/lib/validation/equipment.ts
Normal file
45
src/lib/validation/equipment.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { z } from "zod";
|
||||
import { assetStatusEnum } from "@/db/schema";
|
||||
import { uuidSchema, optionalText } from "./common";
|
||||
|
||||
/**
|
||||
* Zod-Schemas für Geräte/Beladung (Workstream 7). `vehicleId = undefined`
|
||||
* bedeutet „im Gerätehaus" (DB-Spalte NULL). Die Zuordnung darf serverseitig
|
||||
* nur auf ein Fahrzeug DERSELBEN Wehr zeigen (Scope-Prüfung in der Action).
|
||||
* `status` über das DB-Enum `asset_status` (Single Source of Truth).
|
||||
*/
|
||||
|
||||
export const assetStatusSchema = z.enum(assetStatusEnum.enumValues);
|
||||
|
||||
const optionalUuid = z
|
||||
.string()
|
||||
.trim()
|
||||
.optional()
|
||||
.transform((v) => (v === undefined || v === "" ? undefined : v))
|
||||
.refine((v) => v === undefined || uuidSchema.safeParse(v).success, {
|
||||
message: "Ungültige ID.",
|
||||
});
|
||||
|
||||
export const equipmentBaseSchema = z.object({
|
||||
name: z.string().trim().min(1, { message: "Gerätename ist Pflicht." }),
|
||||
categoryId: uuidSchema,
|
||||
vehicleId: optionalUuid,
|
||||
notiz: optionalText,
|
||||
});
|
||||
|
||||
export type EquipmentBaseInput = z.infer<typeof equipmentBaseSchema>;
|
||||
|
||||
export const equipmentCreateSchema = equipmentBaseSchema;
|
||||
export const equipmentUpdateSchema = z.object({
|
||||
id: uuidSchema,
|
||||
...equipmentBaseSchema.shape,
|
||||
});
|
||||
export type EquipmentUpdateInput = z.infer<typeof equipmentUpdateSchema>;
|
||||
|
||||
export const equipmentStatusSchema = z.object({
|
||||
id: uuidSchema,
|
||||
status: assetStatusSchema,
|
||||
});
|
||||
export type EquipmentStatusInput = z.infer<typeof equipmentStatusSchema>;
|
||||
|
||||
export const equipmentIdSchema = z.object({ id: uuidSchema });
|
||||
162
src/lib/validation/vehicle.ts
Normal file
162
src/lib/validation/vehicle.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import { z } from "zod";
|
||||
import { assetStatusEnum } from "@/db/schema";
|
||||
import { uuidSchema, optionalText } from "./common";
|
||||
import type { MerkmalDefinition } from "@/lib/merkmale/types";
|
||||
|
||||
/**
|
||||
* Zod-Schemas für Fahrzeuge (Workstream 7). `funkrufname` ist eine SPALTE
|
||||
* (kein Merkmal). `status` über das DB-Enum `asset_status` (Single Source of
|
||||
* Truth). Leere Optionalfelder werden zu `undefined` normalisiert.
|
||||
*/
|
||||
|
||||
export const assetStatusSchema = z.enum(assetStatusEnum.enumValues);
|
||||
|
||||
const optionalUuid = z
|
||||
.string()
|
||||
.trim()
|
||||
.optional()
|
||||
.transform((v) => (v === undefined || v === "" ? undefined : v))
|
||||
.refine((v) => v === undefined || uuidSchema.safeParse(v).success, {
|
||||
message: "Ungültige ID.",
|
||||
});
|
||||
|
||||
export const vehicleBaseSchema = z.object({
|
||||
name: z.string().trim().min(1, { message: "Fahrzeugname ist Pflicht." }),
|
||||
funkrufname: optionalText,
|
||||
notiz: optionalText,
|
||||
templateId: optionalUuid,
|
||||
});
|
||||
|
||||
export type VehicleBaseInput = z.infer<typeof vehicleBaseSchema>;
|
||||
|
||||
export const vehicleCreateSchema = vehicleBaseSchema;
|
||||
export const vehicleUpdateSchema = z.object({
|
||||
id: uuidSchema,
|
||||
...vehicleBaseSchema.shape,
|
||||
});
|
||||
export type VehicleUpdateInput = z.infer<typeof vehicleUpdateSchema>;
|
||||
|
||||
export const vehicleStatusSchema = z.object({
|
||||
id: uuidSchema,
|
||||
status: assetStatusSchema,
|
||||
});
|
||||
export type VehicleStatusInput = z.infer<typeof vehicleStatusSchema>;
|
||||
|
||||
export const vehicleIdSchema = z.object({ id: uuidSchema });
|
||||
|
||||
/**
|
||||
* Baut ein typgerechtes Zod-Schema für eine Liste von Merkmal-Werten, abgeleitet
|
||||
* aus den angebotenen `MerkmalDefinition`s (Vorlage/Kategorie). Pflichtmerkmale
|
||||
* müssen einen Wert haben; `enum`-Werte sind auf die Optionen beschränkt;
|
||||
* Zahlen werden gecoerced. Werte zu nicht angebotenen `merkmalId`s sind
|
||||
* unzulässig (kein Schmuggeln fremder Merkmale).
|
||||
*
|
||||
* REIN: nimmt Definitionen als Parameter, damit es ohne DB testbar ist.
|
||||
*/
|
||||
export function buildMerkmalValuesSchema(definitionen: MerkmalDefinition[]) {
|
||||
const byId = new Map(definitionen.map((d) => [d.merkmalId, d]));
|
||||
|
||||
const single = z
|
||||
.object({
|
||||
merkmalId: uuidSchema,
|
||||
num: z.unknown().optional(),
|
||||
text: z.unknown().optional(),
|
||||
bool: z.unknown().optional(),
|
||||
})
|
||||
.superRefine((raw, ctx) => {
|
||||
const def = byId.get(raw.merkmalId);
|
||||
if (!def) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["merkmalId"],
|
||||
message: "Unbekanntes Merkmal.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (def.typ === "number") {
|
||||
const v = parseNumber(raw.num);
|
||||
if (v === undefined) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["num"],
|
||||
message: `„${def.name}“ muss eine Zahl sein.`,
|
||||
});
|
||||
} else if (v === null && def.pflicht) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["num"],
|
||||
message: `„${def.name}“ ist Pflicht.`,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (def.typ === "boolean") {
|
||||
const v = parseBool(raw.bool);
|
||||
if (def.pflicht && v == null) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["bool"],
|
||||
message: `„${def.name}“ ist Pflicht.`,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// text | enum
|
||||
const text = parseText(raw.text);
|
||||
if (def.pflicht && (text == null || text === "")) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["text"],
|
||||
message: `„${def.name}“ ist Pflicht.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (def.typ === "enum" && text != null && text !== "") {
|
||||
const erlaubt = def.optionen.map((o) => o.wert);
|
||||
if (!erlaubt.includes(text)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["text"],
|
||||
message: `Ungültige Auswahl für „${def.name}“.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
})
|
||||
.transform((raw) => {
|
||||
const def = byId.get(raw.merkmalId)!;
|
||||
if (def.typ === "number") {
|
||||
const v = parseNumber(raw.num);
|
||||
return { merkmalId: raw.merkmalId, num: v ?? null };
|
||||
}
|
||||
if (def.typ === "boolean") {
|
||||
return { merkmalId: raw.merkmalId, bool: parseBool(raw.bool) };
|
||||
}
|
||||
const text = parseText(raw.text);
|
||||
return { merkmalId: raw.merkmalId, text: text ?? null };
|
||||
});
|
||||
|
||||
return z.array(single);
|
||||
}
|
||||
|
||||
/** `undefined` = ungültig (NaN), `null` = leer, sonst die Zahl. */
|
||||
function parseNumber(v: unknown): number | null | undefined {
|
||||
if (v == null || v === "") return null;
|
||||
const n = typeof v === "number" ? v : Number(v);
|
||||
return Number.isFinite(n) ? n : undefined;
|
||||
}
|
||||
|
||||
function parseBool(v: unknown): boolean | null {
|
||||
if (v == null || v === "") return null;
|
||||
if (typeof v === "boolean") return v;
|
||||
if (v === "true") return true;
|
||||
if (v === "false") return false;
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseText(v: unknown): string | null {
|
||||
if (v == null) return null;
|
||||
return typeof v === "string" ? v : String(v);
|
||||
}
|
||||
131
src/server/actions/brigade-users.ts
Normal file
131
src/server/actions/brigade-users.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
"use server";
|
||||
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { db } from "@/db";
|
||||
import { users } from "@/db/schema";
|
||||
import { requireWehrAdmin } from "@/lib/auth/guards";
|
||||
import { hashPassword } from "@/lib/auth/password";
|
||||
import { generateTempPassword } from "@/lib/admin/provisioning";
|
||||
import { writeAudit } from "@/lib/audit";
|
||||
import {
|
||||
brigadeUserCreateSchema,
|
||||
brigadeUserDeactivateSchema,
|
||||
} from "@/lib/validation/brigade-user";
|
||||
|
||||
export type CreateUserResult =
|
||||
| { ok: true; tempPassword: string }
|
||||
| { ok: false; error: string };
|
||||
|
||||
export type DeactivateResult =
|
||||
| { ok: true }
|
||||
| { ok: false; error: string };
|
||||
|
||||
/**
|
||||
* Legt einen Benutzer (lokales Konto) der EIGENEN Wehr an. Rolle ist per Zod auf
|
||||
* `wehr_admin|wehr_read` beschränkt (platform_admin wird abgelehnt). Passwort
|
||||
* via argon2id (OWASP-Minima). `brigadeId`/`erstelltVon` kommen IMMER aus der
|
||||
* Session. Audit `user.create`. Liefert das Einmal-Passwort genau einmal.
|
||||
*/
|
||||
export async function createBrigadeUser(
|
||||
input: unknown,
|
||||
): Promise<CreateUserResult> {
|
||||
const s = await requireWehrAdmin();
|
||||
const parsed = brigadeUserCreateSchema.safeParse(input);
|
||||
if (!parsed.success) {
|
||||
return {
|
||||
ok: false,
|
||||
error: parsed.error.issues[0]?.message ?? "Ungültige Eingabe.",
|
||||
};
|
||||
}
|
||||
const d = parsed.data;
|
||||
const temp = generateTempPassword();
|
||||
const hash = await hashPassword(temp);
|
||||
|
||||
try {
|
||||
await db.transaction(async (tx) => {
|
||||
const [u] = await tx
|
||||
.insert(users)
|
||||
.values({
|
||||
brigadeId: s.user.brigadeId,
|
||||
rolle: d.rolle,
|
||||
authTyp: "local",
|
||||
email: d.email,
|
||||
name: d.name,
|
||||
passwortHash: hash,
|
||||
aktiv: true,
|
||||
erstelltVon: s.user.id,
|
||||
})
|
||||
.returning({ id: users.id });
|
||||
if (!u) throw new Error("Benutzer konnte nicht angelegt werden.");
|
||||
await writeAudit(
|
||||
s.user.id,
|
||||
"user.create",
|
||||
"user",
|
||||
u.id,
|
||||
{ rolle: d.rolle, authTyp: "local" },
|
||||
tx,
|
||||
);
|
||||
});
|
||||
} catch (e) {
|
||||
// Eindeutigkeitsverletzung (E-Mail bereits vergeben) o. Ä.
|
||||
const msg =
|
||||
e instanceof Error && /unique|duplicate|users_email/i.test(e.message)
|
||||
? "Diese E-Mail ist bereits vergeben."
|
||||
: "Benutzer konnte nicht angelegt werden.";
|
||||
return { ok: false, error: msg };
|
||||
}
|
||||
|
||||
revalidatePath("/verwaltung/benutzer");
|
||||
return { ok: true, tempPassword: temp };
|
||||
}
|
||||
|
||||
/**
|
||||
* Deaktiviert einen Benutzer der EIGENEN Wehr. Selbst-Deaktivierung ist
|
||||
* verboten (sonst sperrt sich ein Admin aus). Scope: nur Benutzer derselben
|
||||
* Wehr. Audit `user.deactivate`.
|
||||
*/
|
||||
export async function deactivateBrigadeUser(
|
||||
input: unknown,
|
||||
): Promise<DeactivateResult> {
|
||||
const s = await requireWehrAdmin();
|
||||
const parsed = brigadeUserDeactivateSchema.safeParse(input);
|
||||
if (!parsed.success) return { ok: false, error: "Ungültige ID." };
|
||||
if (parsed.data.userId === s.user.id) {
|
||||
return { ok: false, error: "Sie können sich nicht selbst deaktivieren." };
|
||||
}
|
||||
|
||||
const [target] = await db
|
||||
.select({ id: users.id })
|
||||
.from(users)
|
||||
.where(
|
||||
and(
|
||||
eq(users.id, parsed.data.userId),
|
||||
eq(users.brigadeId, s.user.brigadeId),
|
||||
),
|
||||
);
|
||||
if (!target) return { ok: false, error: "Benutzer nicht gefunden." };
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
await tx
|
||||
.update(users)
|
||||
.set({ aktiv: false })
|
||||
.where(
|
||||
and(
|
||||
eq(users.id, parsed.data.userId),
|
||||
eq(users.brigadeId, s.user.brigadeId),
|
||||
),
|
||||
);
|
||||
await writeAudit(
|
||||
s.user.id,
|
||||
"user.deactivate",
|
||||
"user",
|
||||
parsed.data.userId,
|
||||
undefined,
|
||||
tx,
|
||||
);
|
||||
});
|
||||
|
||||
revalidatePath("/verwaltung/benutzer");
|
||||
return { ok: true };
|
||||
}
|
||||
66
src/server/actions/brigade.ts
Normal file
66
src/server/actions/brigade.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
"use server";
|
||||
|
||||
import { eq } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { db } from "@/db";
|
||||
import { brigades } from "@/db/schema";
|
||||
import { requireWehrAdmin } from "@/lib/auth/guards";
|
||||
import { brigadeProfileSchema } from "@/lib/validation/brigade";
|
||||
import { geocodeAddress } from "@/lib/geo/nominatim";
|
||||
import { writeAudit } from "@/lib/audit";
|
||||
|
||||
export type ProfileActionResult =
|
||||
| { ok: true; geocodeWarnung: boolean }
|
||||
| { ok: false; error: string };
|
||||
|
||||
/**
|
||||
* Aktualisiert das Profil der EIGENEN Wehr (Default-deny: Guard zuerst).
|
||||
* `brigadeId` kommt IMMER aus der Session. Geocoding inline via `geocodeAddress`
|
||||
* (lat/lng selbst geschrieben; kein zweiter Geo-Pfad). Audit
|
||||
* `brigade.profile_update`. Nicht geokodierbar => Speichern trotzdem, Warnung
|
||||
* an den Aufrufer (Querschnittsstandard 4/6).
|
||||
*/
|
||||
export async function updateBrigadeProfile(
|
||||
input: unknown,
|
||||
): Promise<ProfileActionResult> {
|
||||
const s = await requireWehrAdmin();
|
||||
const parsed = brigadeProfileSchema.safeParse(input);
|
||||
if (!parsed.success) {
|
||||
return {
|
||||
ok: false,
|
||||
error: parsed.error.issues[0]?.message ?? "Ungültige Eingabe.",
|
||||
};
|
||||
}
|
||||
const d = parsed.data;
|
||||
const query = `${d.strasse}, ${d.plz} ${d.ort}, Österreich`;
|
||||
const geo = await geocodeAddress(query);
|
||||
const geocodeWarnung = geo.status !== "ok";
|
||||
|
||||
await db
|
||||
.update(brigades)
|
||||
.set({
|
||||
strasse: d.strasse,
|
||||
plz: d.plz,
|
||||
ort: d.ort,
|
||||
telefon: d.telefon ?? null,
|
||||
email: d.email ?? null,
|
||||
wehrfuehrer: d.wehrfuehrer ?? null,
|
||||
funkrufnameSchema: d.funkrufnameSchema ?? null,
|
||||
geocodeQuery: query,
|
||||
geocodedAt: new Date(),
|
||||
...(geo.status === "ok"
|
||||
? { lat: geo.coords.lat, lng: geo.coords.lng, geocodeStatus: "ok" }
|
||||
: { geocodeStatus: geo.status }),
|
||||
})
|
||||
.where(eq(brigades.id, s.user.brigadeId));
|
||||
|
||||
await writeAudit(
|
||||
s.user.id,
|
||||
"brigade.profile_update",
|
||||
"brigade",
|
||||
s.user.brigadeId,
|
||||
{ geocodeWarnung },
|
||||
);
|
||||
revalidatePath("/verwaltung/profil");
|
||||
return { ok: true, geocodeWarnung };
|
||||
}
|
||||
197
src/server/actions/equipment.ts
Normal file
197
src/server/actions/equipment.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
"use server";
|
||||
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { notFound } from "next/navigation";
|
||||
import { db } from "@/db";
|
||||
import { equipment } from "@/db/schema";
|
||||
import { requireWehrAdmin } from "@/lib/auth/guards";
|
||||
import { writeAudit } from "@/lib/audit";
|
||||
import {
|
||||
equipmentCreateSchema,
|
||||
equipmentUpdateSchema,
|
||||
equipmentStatusSchema,
|
||||
equipmentIdSchema,
|
||||
} from "@/lib/validation/equipment";
|
||||
import { buildMerkmalValuesSchema } from "@/lib/validation/vehicle";
|
||||
import { getMerkmaleForCategory } from "@/server/data/merkmale";
|
||||
import {
|
||||
getEquipmentForBrigade,
|
||||
} from "@/server/data/equipment";
|
||||
import { vehicleBelongsToBrigade } from "@/server/data/vehicles";
|
||||
import { upsertMerkmalValues } from "@/server/merkmale/upsertValues";
|
||||
import type { MerkmalDefinition } from "@/lib/merkmale/types";
|
||||
|
||||
export type ActionResult =
|
||||
| { ok: true; id: string }
|
||||
| { ok: false; error: string };
|
||||
|
||||
/**
|
||||
* Liefert die Merkmal-Definitionen einer Geräte-Kategorie (für die
|
||||
* Editor-Vorbefüllung). Guard zuerst (default-deny, auch für Lesen).
|
||||
*/
|
||||
export async function getCategoryMerkmaleAction(
|
||||
categoryId: string,
|
||||
): Promise<MerkmalDefinition[]> {
|
||||
await requireWehrAdmin();
|
||||
if (!categoryId) return [];
|
||||
return getMerkmaleForCategory(categoryId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft, dass eine optional gewählte `vehicleId` zu EINEM Fahrzeug DERSELBEN
|
||||
* Wehr gehört. `undefined` => „im Gerätehaus" (zulässig). Verhindert die
|
||||
* Zuordnung zu fremden Fahrzeugen (Scoping).
|
||||
*/
|
||||
async function assertVehicleScope(
|
||||
vehicleId: string | undefined,
|
||||
brigadeId: string,
|
||||
): Promise<boolean> {
|
||||
if (!vehicleId) return true;
|
||||
return vehicleBelongsToBrigade(vehicleId, brigadeId);
|
||||
}
|
||||
|
||||
/** Legt ein Gerät der eigenen Wehr an (Guard zuerst, Audit equipment.create). */
|
||||
export async function createEquipment(
|
||||
input: unknown,
|
||||
rawWerte: unknown,
|
||||
): Promise<ActionResult> {
|
||||
const s = await requireWehrAdmin();
|
||||
const parsed = equipmentCreateSchema.safeParse(input);
|
||||
if (!parsed.success) {
|
||||
return { ok: false, error: parsed.error.issues[0]?.message ?? "Ungültige Eingabe." };
|
||||
}
|
||||
const d = parsed.data;
|
||||
if (!(await assertVehicleScope(d.vehicleId, s.user.brigadeId))) {
|
||||
return { ok: false, error: "Fahrzeug nicht gefunden." };
|
||||
}
|
||||
const defs = await getMerkmaleForCategory(d.categoryId);
|
||||
const werteParsed = buildMerkmalValuesSchema(defs).safeParse(rawWerte ?? []);
|
||||
if (!werteParsed.success) {
|
||||
return { ok: false, error: werteParsed.error.issues[0]?.message ?? "Ungültige Merkmal-Werte." };
|
||||
}
|
||||
|
||||
const id = await db.transaction(async (tx) => {
|
||||
const [e] = await tx
|
||||
.insert(equipment)
|
||||
.values({
|
||||
brigadeId: s.user.brigadeId,
|
||||
categoryId: d.categoryId,
|
||||
vehicleId: d.vehicleId ?? null,
|
||||
name: d.name,
|
||||
})
|
||||
.returning({ id: equipment.id });
|
||||
if (!e) throw new Error("Gerät konnte nicht angelegt werden.");
|
||||
await upsertMerkmalValues(tx, "equipment", e.id, werteParsed.data);
|
||||
await writeAudit(
|
||||
s.user.id,
|
||||
"equipment.create",
|
||||
"equipment",
|
||||
e.id,
|
||||
{ categoryId: d.categoryId, zugeordnet: d.vehicleId != null },
|
||||
tx,
|
||||
);
|
||||
return e.id;
|
||||
});
|
||||
|
||||
revalidatePath("/verwaltung/geraete");
|
||||
return { ok: true, id };
|
||||
}
|
||||
|
||||
/** Bearbeitet ein Gerät, NUR wenn es der eigenen Wehr gehört. */
|
||||
export async function updateEquipment(
|
||||
input: unknown,
|
||||
rawWerte: unknown,
|
||||
): Promise<ActionResult> {
|
||||
const s = await requireWehrAdmin();
|
||||
const parsed = equipmentUpdateSchema.safeParse(input);
|
||||
if (!parsed.success) {
|
||||
return { ok: false, error: parsed.error.issues[0]?.message ?? "Ungültige Eingabe." };
|
||||
}
|
||||
const d = parsed.data;
|
||||
const existing = await getEquipmentForBrigade(d.id, s.user.brigadeId);
|
||||
if (!existing) return { ok: false, error: "Gerät nicht gefunden." };
|
||||
if (!(await assertVehicleScope(d.vehicleId, s.user.brigadeId))) {
|
||||
return { ok: false, error: "Fahrzeug nicht gefunden." };
|
||||
}
|
||||
const defs = await getMerkmaleForCategory(d.categoryId);
|
||||
const werteParsed = buildMerkmalValuesSchema(defs).safeParse(rawWerte ?? []);
|
||||
if (!werteParsed.success) {
|
||||
return { ok: false, error: werteParsed.error.issues[0]?.message ?? "Ungültige Merkmal-Werte." };
|
||||
}
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
await tx
|
||||
.update(equipment)
|
||||
.set({
|
||||
name: d.name,
|
||||
categoryId: d.categoryId,
|
||||
vehicleId: d.vehicleId ?? null,
|
||||
})
|
||||
.where(
|
||||
and(eq(equipment.id, d.id), eq(equipment.brigadeId, s.user.brigadeId)),
|
||||
);
|
||||
await upsertMerkmalValues(tx, "equipment", d.id, werteParsed.data);
|
||||
await writeAudit(s.user.id, "equipment.update", "equipment", d.id, undefined, tx);
|
||||
});
|
||||
|
||||
revalidatePath("/verwaltung/geraete");
|
||||
revalidatePath(`/verwaltung/geraete/${d.id}`);
|
||||
return { ok: true, id: d.id };
|
||||
}
|
||||
|
||||
/** Setzt den Status eines eigenen Geräts (Audit equipment.status). */
|
||||
export async function setEquipmentStatus(input: unknown): Promise<ActionResult> {
|
||||
const s = await requireWehrAdmin();
|
||||
const parsed = equipmentStatusSchema.safeParse(input);
|
||||
if (!parsed.success) return { ok: false, error: "Ungültige Eingabe." };
|
||||
const existing = await getEquipmentForBrigade(parsed.data.id, s.user.brigadeId);
|
||||
if (!existing) return { ok: false, error: "Gerät nicht gefunden." };
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
await tx
|
||||
.update(equipment)
|
||||
.set({ status: parsed.data.status })
|
||||
.where(
|
||||
and(
|
||||
eq(equipment.id, parsed.data.id),
|
||||
eq(equipment.brigadeId, s.user.brigadeId),
|
||||
),
|
||||
);
|
||||
await writeAudit(
|
||||
s.user.id,
|
||||
"equipment.status",
|
||||
"equipment",
|
||||
parsed.data.id,
|
||||
{ status: parsed.data.status },
|
||||
tx,
|
||||
);
|
||||
});
|
||||
revalidatePath("/verwaltung/geraete");
|
||||
return { ok: true, id: parsed.data.id };
|
||||
}
|
||||
|
||||
/** Löscht ein eigenes Gerät (Audit equipment.delete). */
|
||||
export async function deleteEquipment(input: unknown): Promise<ActionResult> {
|
||||
const s = await requireWehrAdmin();
|
||||
const parsed = equipmentIdSchema.safeParse(input);
|
||||
if (!parsed.success) return { ok: false, error: "Ungültige ID." };
|
||||
const existing = await getEquipmentForBrigade(parsed.data.id, s.user.brigadeId);
|
||||
if (!existing) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
await tx
|
||||
.delete(equipment)
|
||||
.where(
|
||||
and(
|
||||
eq(equipment.id, parsed.data.id),
|
||||
eq(equipment.brigadeId, s.user.brigadeId),
|
||||
),
|
||||
);
|
||||
await writeAudit(s.user.id, "equipment.delete", "equipment", parsed.data.id, undefined, tx);
|
||||
});
|
||||
revalidatePath("/verwaltung/geraete");
|
||||
return { ok: true, id: parsed.data.id };
|
||||
}
|
||||
191
src/server/actions/vehicles.ts
Normal file
191
src/server/actions/vehicles.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
"use server";
|
||||
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { notFound } from "next/navigation";
|
||||
import { db } from "@/db";
|
||||
import { vehicles } from "@/db/schema";
|
||||
import { requireWehrAdmin } from "@/lib/auth/guards";
|
||||
import { writeAudit } from "@/lib/audit";
|
||||
import {
|
||||
vehicleCreateSchema,
|
||||
vehicleUpdateSchema,
|
||||
vehicleStatusSchema,
|
||||
vehicleIdSchema,
|
||||
buildMerkmalValuesSchema,
|
||||
} from "@/lib/validation/vehicle";
|
||||
import {
|
||||
getMerkmaleForTemplate,
|
||||
} from "@/server/data/merkmale";
|
||||
import { getVehicleForBrigade } from "@/server/data/vehicles";
|
||||
import { upsertMerkmalValues } from "@/server/merkmale/upsertValues";
|
||||
import type { MerkmalDefinition } from "@/lib/merkmale/types";
|
||||
|
||||
export type ActionResult =
|
||||
| { ok: true; id: string }
|
||||
| { ok: false; error: string };
|
||||
|
||||
/**
|
||||
* Löst die für ein Fahrzeug erlaubten Merkmal-Definitionen serverseitig auf
|
||||
* (NUR aus der Vorlage; ohne Vorlage keine Merkmale). Damit kann der Client
|
||||
* keine fremden Merkmale schmuggeln — die Validierung baut ihr Schema NUR aus
|
||||
* diesen Definitionen.
|
||||
*/
|
||||
async function vehicleMerkmalDefs(
|
||||
templateId: string | undefined,
|
||||
): Promise<MerkmalDefinition[]> {
|
||||
if (!templateId) return [];
|
||||
return getMerkmaleForTemplate(templateId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Liefert die Merkmal-Definitionen einer Vorlage (für die Vorbefüllung des
|
||||
* Editors im Anlage-Formular). Guard zuerst (default-deny), auch für Lesen.
|
||||
*/
|
||||
export async function getTemplateMerkmaleAction(
|
||||
templateId: string,
|
||||
): Promise<MerkmalDefinition[]> {
|
||||
await requireWehrAdmin();
|
||||
if (!templateId) return [];
|
||||
return getMerkmaleForTemplate(templateId);
|
||||
}
|
||||
|
||||
/** Legt ein Fahrzeug der EIGENEN Wehr an (Guard zuerst, Audit vehicle.create). */
|
||||
export async function createVehicle(
|
||||
input: unknown,
|
||||
rawWerte: unknown,
|
||||
): Promise<ActionResult> {
|
||||
const s = await requireWehrAdmin();
|
||||
const parsed = vehicleCreateSchema.safeParse(input);
|
||||
if (!parsed.success) {
|
||||
return { ok: false, error: parsed.error.issues[0]?.message ?? "Ungültige Eingabe." };
|
||||
}
|
||||
const d = parsed.data;
|
||||
const defs = await vehicleMerkmalDefs(d.templateId);
|
||||
const werteParsed = buildMerkmalValuesSchema(defs).safeParse(rawWerte ?? []);
|
||||
if (!werteParsed.success) {
|
||||
return { ok: false, error: werteParsed.error.issues[0]?.message ?? "Ungültige Merkmal-Werte." };
|
||||
}
|
||||
|
||||
const id = await db.transaction(async (tx) => {
|
||||
const [v] = await tx
|
||||
.insert(vehicles)
|
||||
.values({
|
||||
brigadeId: s.user.brigadeId,
|
||||
templateId: d.templateId ?? null,
|
||||
name: d.name,
|
||||
funkrufname: d.funkrufname ?? null,
|
||||
notiz: d.notiz ?? null,
|
||||
})
|
||||
.returning({ id: vehicles.id });
|
||||
if (!v) throw new Error("Fahrzeug konnte nicht angelegt werden.");
|
||||
await upsertMerkmalValues(tx, "vehicle", v.id, werteParsed.data);
|
||||
await writeAudit(
|
||||
s.user.id,
|
||||
"vehicle.create",
|
||||
"vehicle",
|
||||
v.id,
|
||||
{ templateId: d.templateId ?? null },
|
||||
tx,
|
||||
);
|
||||
return v.id;
|
||||
});
|
||||
|
||||
revalidatePath("/verwaltung/fahrzeuge");
|
||||
return { ok: true, id };
|
||||
}
|
||||
|
||||
/** Bearbeitet ein Fahrzeug, NUR wenn es der eigenen Wehr gehört. */
|
||||
export async function updateVehicle(
|
||||
input: unknown,
|
||||
rawWerte: unknown,
|
||||
): Promise<ActionResult> {
|
||||
const s = await requireWehrAdmin();
|
||||
const parsed = vehicleUpdateSchema.safeParse(input);
|
||||
if (!parsed.success) {
|
||||
return { ok: false, error: parsed.error.issues[0]?.message ?? "Ungültige Eingabe." };
|
||||
}
|
||||
const d = parsed.data;
|
||||
const existing = await getVehicleForBrigade(d.id, s.user.brigadeId);
|
||||
if (!existing) return { ok: false, error: "Fahrzeug nicht gefunden." };
|
||||
|
||||
// Vorlage ist nach Anlage fix: erlaubte Merkmale aus der GESPEICHERTEN Vorlage.
|
||||
const defs = await vehicleMerkmalDefs(existing.templateId ?? undefined);
|
||||
const werteParsed = buildMerkmalValuesSchema(defs).safeParse(rawWerte ?? []);
|
||||
if (!werteParsed.success) {
|
||||
return { ok: false, error: werteParsed.error.issues[0]?.message ?? "Ungültige Merkmal-Werte." };
|
||||
}
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
await tx
|
||||
.update(vehicles)
|
||||
.set({
|
||||
name: d.name,
|
||||
funkrufname: d.funkrufname ?? null,
|
||||
notiz: d.notiz ?? null,
|
||||
})
|
||||
.where(and(eq(vehicles.id, d.id), eq(vehicles.brigadeId, s.user.brigadeId)));
|
||||
await upsertMerkmalValues(tx, "vehicle", d.id, werteParsed.data);
|
||||
await writeAudit(s.user.id, "vehicle.update", "vehicle", d.id, undefined, tx);
|
||||
});
|
||||
|
||||
revalidatePath("/verwaltung/fahrzeuge");
|
||||
revalidatePath(`/verwaltung/fahrzeuge/${d.id}`);
|
||||
return { ok: true, id: d.id };
|
||||
}
|
||||
|
||||
/** Setzt den Status eines eigenen Fahrzeugs (Audit vehicle.status). */
|
||||
export async function setVehicleStatus(input: unknown): Promise<ActionResult> {
|
||||
const s = await requireWehrAdmin();
|
||||
const parsed = vehicleStatusSchema.safeParse(input);
|
||||
if (!parsed.success) return { ok: false, error: "Ungültige Eingabe." };
|
||||
const existing = await getVehicleForBrigade(parsed.data.id, s.user.brigadeId);
|
||||
if (!existing) return { ok: false, error: "Fahrzeug nicht gefunden." };
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
await tx
|
||||
.update(vehicles)
|
||||
.set({ status: parsed.data.status })
|
||||
.where(
|
||||
and(
|
||||
eq(vehicles.id, parsed.data.id),
|
||||
eq(vehicles.brigadeId, s.user.brigadeId),
|
||||
),
|
||||
);
|
||||
await writeAudit(
|
||||
s.user.id,
|
||||
"vehicle.status",
|
||||
"vehicle",
|
||||
parsed.data.id,
|
||||
{ status: parsed.data.status },
|
||||
tx,
|
||||
);
|
||||
});
|
||||
revalidatePath("/verwaltung/fahrzeuge");
|
||||
return { ok: true, id: parsed.data.id };
|
||||
}
|
||||
|
||||
/** Löscht ein eigenes Fahrzeug (Audit vehicle.delete). */
|
||||
export async function deleteVehicle(input: unknown): Promise<ActionResult> {
|
||||
const s = await requireWehrAdmin();
|
||||
const parsed = vehicleIdSchema.safeParse(input);
|
||||
if (!parsed.success) return { ok: false, error: "Ungültige ID." };
|
||||
const existing = await getVehicleForBrigade(parsed.data.id, s.user.brigadeId);
|
||||
if (!existing) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
await tx
|
||||
.delete(vehicles)
|
||||
.where(
|
||||
and(
|
||||
eq(vehicles.id, parsed.data.id),
|
||||
eq(vehicles.brigadeId, s.user.brigadeId),
|
||||
),
|
||||
);
|
||||
await writeAudit(s.user.id, "vehicle.delete", "vehicle", parsed.data.id, undefined, tx);
|
||||
});
|
||||
revalidatePath("/verwaltung/fahrzeuge");
|
||||
return { ok: true, id: parsed.data.id };
|
||||
}
|
||||
34
src/server/data/brigade-users.ts
Normal file
34
src/server/data/brigade-users.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { asc, eq } from "drizzle-orm";
|
||||
import { db } from "@/db";
|
||||
import { users } from "@/db/schema";
|
||||
|
||||
/**
|
||||
* Lesehelfer für die Benutzer EINER Wehr (Workstream 7). Scope kommt aus der
|
||||
* Session. Passwort-Hashes werden NIE selektiert (kein Daten-Leak).
|
||||
*/
|
||||
|
||||
export interface BrigadeUserListItem {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
rolle: (typeof users.$inferSelect)["rolle"];
|
||||
authTyp: (typeof users.$inferSelect)["authTyp"];
|
||||
aktiv: boolean;
|
||||
}
|
||||
|
||||
export async function listUsersForBrigade(
|
||||
brigadeId: string,
|
||||
): Promise<BrigadeUserListItem[]> {
|
||||
return db
|
||||
.select({
|
||||
id: users.id,
|
||||
name: users.name,
|
||||
email: users.email,
|
||||
rolle: users.rolle,
|
||||
authTyp: users.authTyp,
|
||||
aktiv: users.aktiv,
|
||||
})
|
||||
.from(users)
|
||||
.where(eq(users.brigadeId, brigadeId))
|
||||
.orderBy(asc(users.name));
|
||||
}
|
||||
69
src/server/data/equipment.ts
Normal file
69
src/server/data/equipment.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { and, asc, desc, eq } from "drizzle-orm";
|
||||
import { db } from "@/db";
|
||||
import {
|
||||
equipment,
|
||||
equipmentCategories,
|
||||
vehicles,
|
||||
} from "@/db/schema";
|
||||
|
||||
/**
|
||||
* Lesehelfer für Geräte des Wehr-Bereichs (Workstream 7). Alle Helfer sind auf
|
||||
* eine `brigadeId` beschränkt (Scope aus Session). `vehicleId IS NULL` =
|
||||
* „im Gerätehaus".
|
||||
*/
|
||||
|
||||
export interface EquipmentListItem {
|
||||
id: string;
|
||||
name: string;
|
||||
status: (typeof equipment.$inferSelect)["status"];
|
||||
categoryName: string;
|
||||
vehicleId: string | null;
|
||||
vehicleName: string | null;
|
||||
}
|
||||
|
||||
/** Liste der Geräte EINER Wehr mit Kategorie- und Zuordnungsnamen. */
|
||||
export async function listEquipmentForBrigade(
|
||||
brigadeId: string,
|
||||
): Promise<EquipmentListItem[]> {
|
||||
return db
|
||||
.select({
|
||||
id: equipment.id,
|
||||
name: equipment.name,
|
||||
status: equipment.status,
|
||||
categoryName: equipmentCategories.name,
|
||||
vehicleId: equipment.vehicleId,
|
||||
vehicleName: vehicles.name,
|
||||
})
|
||||
.from(equipment)
|
||||
.innerJoin(
|
||||
equipmentCategories,
|
||||
eq(equipmentCategories.id, equipment.categoryId),
|
||||
)
|
||||
.leftJoin(vehicles, eq(vehicles.id, equipment.vehicleId))
|
||||
.where(eq(equipment.brigadeId, brigadeId))
|
||||
.orderBy(desc(equipment.erstelltAm));
|
||||
}
|
||||
|
||||
/** Lädt EIN Gerät scoped auf die Wehr (sonst `null` -> notFound). */
|
||||
export async function getEquipmentForBrigade(
|
||||
equipmentId: string,
|
||||
brigadeId: string,
|
||||
): Promise<typeof equipment.$inferSelect | null> {
|
||||
const [e] = await db
|
||||
.select()
|
||||
.from(equipment)
|
||||
.where(
|
||||
and(eq(equipment.id, equipmentId), eq(equipment.brigadeId, brigadeId)),
|
||||
);
|
||||
return e ?? null;
|
||||
}
|
||||
|
||||
/** Alle Geräte-Kategorien, geordnet — für die Auswahl im Formular. */
|
||||
export async function listCategories(): Promise<
|
||||
{ id: string; name: string }[]
|
||||
> {
|
||||
return db
|
||||
.select({ id: equipmentCategories.id, name: equipmentCategories.name })
|
||||
.from(equipmentCategories)
|
||||
.orderBy(asc(equipmentCategories.reihenfolge), asc(equipmentCategories.name));
|
||||
}
|
||||
149
src/server/data/merkmale.ts
Normal file
149
src/server/data/merkmale.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import { and, asc, eq } from "drizzle-orm";
|
||||
import { db } from "@/db";
|
||||
import {
|
||||
merkmale,
|
||||
merkmalOptionen,
|
||||
merkmalValues,
|
||||
vehicleTemplateMerkmale,
|
||||
equipmentCategoryMerkmale,
|
||||
} from "@/db/schema";
|
||||
import type {
|
||||
MerkmalDefinition,
|
||||
MerkmalOption,
|
||||
MerkmalValueInput,
|
||||
} from "@/lib/merkmale/types";
|
||||
|
||||
/**
|
||||
* Lesehelfer für Merkmal-Definitionen (Workstream 7). Lösen die Vorgabewerte aus
|
||||
* den DREI typisierten Spalten (`vorgabewert_num/_text/_bool`) und laden je
|
||||
* enum-Merkmal die Optionen. Genutzt von Fahrzeug-/Geräteformularen, um den
|
||||
* typisierten Editor vorzubefüllen.
|
||||
*/
|
||||
|
||||
async function optionenFor(
|
||||
merkmalIds: string[],
|
||||
): Promise<Map<string, MerkmalOption[]>> {
|
||||
const map = new Map<string, MerkmalOption[]>();
|
||||
if (merkmalIds.length === 0) return map;
|
||||
const rows = await db
|
||||
.select({
|
||||
merkmalId: merkmalOptionen.merkmalId,
|
||||
wert: merkmalOptionen.wert,
|
||||
label: merkmalOptionen.label,
|
||||
reihenfolge: merkmalOptionen.reihenfolge,
|
||||
})
|
||||
.from(merkmalOptionen)
|
||||
.orderBy(asc(merkmalOptionen.reihenfolge), asc(merkmalOptionen.label));
|
||||
for (const r of rows) {
|
||||
if (!merkmalIds.includes(r.merkmalId)) continue;
|
||||
const list = map.get(r.merkmalId) ?? [];
|
||||
list.push({ wert: r.wert, label: r.label });
|
||||
map.set(r.merkmalId, list);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Liefert die Pflicht-/Vorgabemerkmale einer Fahrzeug-Vorlage, geordnet.
|
||||
* Vorgabewerte werden typgerecht aus drei Spalten gelesen.
|
||||
*/
|
||||
export async function getMerkmaleForTemplate(
|
||||
templateId: string,
|
||||
): Promise<MerkmalDefinition[]> {
|
||||
const rows = await db
|
||||
.select({
|
||||
merkmalId: merkmale.id,
|
||||
name: merkmale.name,
|
||||
typ: merkmale.typ,
|
||||
einheit: merkmale.einheit,
|
||||
pflicht: vehicleTemplateMerkmale.pflicht,
|
||||
reihenfolge: vehicleTemplateMerkmale.reihenfolge,
|
||||
vorgabeNum: vehicleTemplateMerkmale.vorgabewertNum,
|
||||
vorgabeText: vehicleTemplateMerkmale.vorgabewertText,
|
||||
vorgabeBool: vehicleTemplateMerkmale.vorgabewertBool,
|
||||
})
|
||||
.from(vehicleTemplateMerkmale)
|
||||
.innerJoin(merkmale, eq(merkmale.id, vehicleTemplateMerkmale.merkmalId))
|
||||
.where(eq(vehicleTemplateMerkmale.templateId, templateId))
|
||||
.orderBy(asc(vehicleTemplateMerkmale.reihenfolge), asc(merkmale.name));
|
||||
|
||||
const opts = await optionenFor(rows.map((r) => r.merkmalId));
|
||||
return rows.map((r) => ({
|
||||
merkmalId: r.merkmalId,
|
||||
name: r.name,
|
||||
typ: r.typ,
|
||||
einheit: r.einheit,
|
||||
pflicht: r.pflicht,
|
||||
reihenfolge: r.reihenfolge,
|
||||
optionen: opts.get(r.merkmalId) ?? [],
|
||||
vorgabeNum: r.vorgabeNum,
|
||||
vorgabeText: r.vorgabeText,
|
||||
vorgabeBool: r.vorgabeBool,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Liefert die Merkmale einer Geräte-Kategorie, geordnet. Kategorien tragen
|
||||
* keine Vorgabewerte/Pflicht — beide bleiben neutral (`pflicht=false`).
|
||||
*/
|
||||
export async function getMerkmaleForCategory(
|
||||
categoryId: string,
|
||||
): Promise<MerkmalDefinition[]> {
|
||||
const rows = await db
|
||||
.select({
|
||||
merkmalId: merkmale.id,
|
||||
name: merkmale.name,
|
||||
typ: merkmale.typ,
|
||||
einheit: merkmale.einheit,
|
||||
reihenfolge: equipmentCategoryMerkmale.reihenfolge,
|
||||
})
|
||||
.from(equipmentCategoryMerkmale)
|
||||
.innerJoin(merkmale, eq(merkmale.id, equipmentCategoryMerkmale.merkmalId))
|
||||
.where(eq(equipmentCategoryMerkmale.categoryId, categoryId))
|
||||
.orderBy(asc(equipmentCategoryMerkmale.reihenfolge), asc(merkmale.name));
|
||||
|
||||
const opts = await optionenFor(rows.map((r) => r.merkmalId));
|
||||
return rows.map((r) => ({
|
||||
merkmalId: r.merkmalId,
|
||||
name: r.name,
|
||||
typ: r.typ,
|
||||
einheit: r.einheit,
|
||||
pflicht: false,
|
||||
reihenfolge: r.reihenfolge,
|
||||
optionen: opts.get(r.merkmalId) ?? [],
|
||||
vorgabeNum: null,
|
||||
vorgabeText: null,
|
||||
vorgabeBool: null,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Liest die aktuell gespeicherten Merkmal-Werte einer Entity (für die
|
||||
* Bearbeiten-Ansicht). Liefert `MerkmalValueInput[]` (genau eine Wertspalte je
|
||||
* Eintrag gesetzt).
|
||||
*/
|
||||
export async function getMerkmalValuesForEntity(
|
||||
entityTyp: "vehicle" | "equipment",
|
||||
entityId: string,
|
||||
): Promise<MerkmalValueInput[]> {
|
||||
const rows = await db
|
||||
.select({
|
||||
merkmalId: merkmalValues.merkmalId,
|
||||
num: merkmalValues.valueNum,
|
||||
text: merkmalValues.valueText,
|
||||
bool: merkmalValues.valueBool,
|
||||
})
|
||||
.from(merkmalValues)
|
||||
.where(
|
||||
and(
|
||||
eq(merkmalValues.entityTyp, entityTyp),
|
||||
eq(merkmalValues.entityId, entityId),
|
||||
),
|
||||
);
|
||||
return rows.map((r) => ({
|
||||
merkmalId: r.merkmalId,
|
||||
num: r.num,
|
||||
text: r.text,
|
||||
bool: r.bool,
|
||||
}));
|
||||
}
|
||||
88
src/server/data/vehicles.ts
Normal file
88
src/server/data/vehicles.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { and, asc, desc, eq } from "drizzle-orm";
|
||||
import { db } from "@/db";
|
||||
import {
|
||||
vehicles,
|
||||
vehicleTemplates,
|
||||
brigades,
|
||||
} from "@/db/schema";
|
||||
|
||||
/**
|
||||
* Lesehelfer für Fahrzeuge des Wehr-Bereichs (Workstream 7). ALLE Helfer sind
|
||||
* auf eine `brigadeId` beschränkt (Scope kommt aus der Session, nie aus Input).
|
||||
*/
|
||||
|
||||
export interface VehicleListItem {
|
||||
id: string;
|
||||
name: string;
|
||||
funkrufname: string | null;
|
||||
status: (typeof vehicles.$inferSelect)["status"];
|
||||
templateName: string | null;
|
||||
}
|
||||
|
||||
/** Liste der Fahrzeuge EINER Wehr, neueste zuerst, mit Vorlagenname. */
|
||||
export async function listVehiclesForBrigade(
|
||||
brigadeId: string,
|
||||
): Promise<VehicleListItem[]> {
|
||||
return db
|
||||
.select({
|
||||
id: vehicles.id,
|
||||
name: vehicles.name,
|
||||
funkrufname: vehicles.funkrufname,
|
||||
status: vehicles.status,
|
||||
templateName: vehicleTemplates.name,
|
||||
})
|
||||
.from(vehicles)
|
||||
.leftJoin(vehicleTemplates, eq(vehicleTemplates.id, vehicles.templateId))
|
||||
.where(eq(vehicles.brigadeId, brigadeId))
|
||||
.orderBy(desc(vehicles.erstelltAm));
|
||||
}
|
||||
|
||||
/**
|
||||
* Lädt EIN Fahrzeug, aber nur wenn es zur angegebenen Wehr gehört. Liefert
|
||||
* `null`, wenn es nicht existiert ODER einer fremden Wehr gehört (Scoping:
|
||||
* der Aufrufer reagiert mit `notFound()`).
|
||||
*/
|
||||
export async function getVehicleForBrigade(
|
||||
vehicleId: string,
|
||||
brigadeId: string,
|
||||
): Promise<typeof vehicles.$inferSelect | null> {
|
||||
const [v] = await db
|
||||
.select()
|
||||
.from(vehicles)
|
||||
.where(and(eq(vehicles.id, vehicleId), eq(vehicles.brigadeId, brigadeId)));
|
||||
return v ?? null;
|
||||
}
|
||||
|
||||
/** Prüft (scoped), ob ein Fahrzeug zur Wehr gehört — für Geräte-Zuordnung. */
|
||||
export async function vehicleBelongsToBrigade(
|
||||
vehicleId: string,
|
||||
brigadeId: string,
|
||||
): Promise<boolean> {
|
||||
const v = await getVehicleForBrigade(vehicleId, brigadeId);
|
||||
return v !== null;
|
||||
}
|
||||
|
||||
/** Alle Fahrzeug-Vorlagen, geordnet — für den Vorlagen-Picker. */
|
||||
export async function listTemplates(): Promise<
|
||||
{ id: string; code: string; name: string }[]
|
||||
> {
|
||||
return db
|
||||
.select({
|
||||
id: vehicleTemplates.id,
|
||||
code: vehicleTemplates.code,
|
||||
name: vehicleTemplates.name,
|
||||
})
|
||||
.from(vehicleTemplates)
|
||||
.orderBy(asc(vehicleTemplates.reihenfolge), asc(vehicleTemplates.name));
|
||||
}
|
||||
|
||||
/** Stammdaten der eigenen Wehr (für die Profilseite). */
|
||||
export async function getBrigade(
|
||||
brigadeId: string,
|
||||
): Promise<typeof brigades.$inferSelect | null> {
|
||||
const [b] = await db
|
||||
.select()
|
||||
.from(brigades)
|
||||
.where(eq(brigades.id, brigadeId));
|
||||
return b ?? null;
|
||||
}
|
||||
68
src/server/merkmale/__tests__/upsertValues.test.ts
Normal file
68
src/server/merkmale/__tests__/upsertValues.test.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { upsertMerkmalValues } from "../upsertValues";
|
||||
import type { MerkmalValueInput } from "@/lib/merkmale/types";
|
||||
|
||||
/**
|
||||
* Test-Double für die Drizzle-Transaktion. Es zeichnet nur auf, wie oft
|
||||
* delete/insert aufgerufen wurden — kein echtes Postgres. `upsertMerkmalValues`
|
||||
* ruft pro Wert genau ein `delete(...).where(...)` und (bei nicht-leerem Wert)
|
||||
* ein `insert(...).values(...)`.
|
||||
*/
|
||||
function makeFakeTx() {
|
||||
const deletes: unknown[] = [];
|
||||
const inserts: Record<string, unknown>[] = [];
|
||||
const tx = {
|
||||
delete: vi.fn(() => ({
|
||||
where: vi.fn(async (cond: unknown) => {
|
||||
deletes.push(cond);
|
||||
}),
|
||||
})),
|
||||
insert: vi.fn(() => ({
|
||||
values: vi.fn(async (v: Record<string, unknown>) => {
|
||||
inserts.push(v);
|
||||
}),
|
||||
})),
|
||||
};
|
||||
return { tx, deletes, inserts };
|
||||
}
|
||||
|
||||
const MID = "11111111-1111-1111-1111-111111111111";
|
||||
|
||||
describe("upsertMerkmalValues", () => {
|
||||
it("löscht alten Wert und fügt neuen ein (delete-then-insert)", async () => {
|
||||
const { tx, deletes, inserts } = makeFakeTx();
|
||||
const werte: MerkmalValueInput[] = [{ merkmalId: MID, num: 2000 }];
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await upsertMerkmalValues(tx as any, "vehicle", "veh-1", werte);
|
||||
expect(deletes).toHaveLength(1);
|
||||
expect(inserts).toHaveLength(1);
|
||||
expect(inserts[0]).toMatchObject({
|
||||
merkmalId: MID,
|
||||
entityTyp: "vehicle",
|
||||
entityId: "veh-1",
|
||||
valueNum: 2000,
|
||||
valueText: null,
|
||||
valueBool: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("schreibt bei leerem Wert nur delete, kein insert", async () => {
|
||||
const { tx, deletes, inserts } = makeFakeTx();
|
||||
const werte: MerkmalValueInput[] = [
|
||||
{ merkmalId: MID, num: null, text: "", bool: null },
|
||||
];
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await upsertMerkmalValues(tx as any, "vehicle", "veh-1", werte);
|
||||
expect(deletes).toHaveLength(1);
|
||||
expect(inserts).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("schreibt boolean false als Wert (nicht als leer behandelt)", async () => {
|
||||
const { tx, inserts } = makeFakeTx();
|
||||
const werte: MerkmalValueInput[] = [{ merkmalId: MID, bool: false }];
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await upsertMerkmalValues(tx as any, "equipment", "eq-1", werte);
|
||||
expect(inserts).toHaveLength(1);
|
||||
expect(inserts[0]).toMatchObject({ valueBool: false, valueNum: null, valueText: null });
|
||||
});
|
||||
});
|
||||
50
src/server/merkmale/upsertValues.ts
Normal file
50
src/server/merkmale/upsertValues.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { merkmalValues } from "@/db/schema";
|
||||
import type { Tx } from "@/lib/audit";
|
||||
import type { MerkmalValueInput } from "@/lib/merkmale/types";
|
||||
|
||||
/**
|
||||
* Schreibt die Merkmal-Werte einer Entity (Fahrzeug/Gerät) in `merkmal_values`
|
||||
* per delete-then-insert: pro Merkmal wird der bestehende Wert gelöscht und —
|
||||
* sofern nicht leer — neu eingefügt. So bleibt pro (entity, merkmal) genau eine
|
||||
* Zeile; ein geleerter Wert hinterlässt KEINE Zeile.
|
||||
*
|
||||
* Läuft IMMER innerhalb der aufrufenden Transaktion (`Tx` aus @/lib/audit,
|
||||
* kein `any` — Querschnittsstandard 12), damit Insert/Audit atomar sind.
|
||||
*
|
||||
* `boolean false` und `num 0` gelten als gesetzt; nur `null`/leerer String
|
||||
* zählen als leer.
|
||||
*/
|
||||
export async function upsertMerkmalValues(
|
||||
tx: Tx,
|
||||
entityTyp: "vehicle" | "equipment",
|
||||
entityId: string,
|
||||
werte: MerkmalValueInput[],
|
||||
): Promise<void> {
|
||||
for (const w of werte) {
|
||||
await tx
|
||||
.delete(merkmalValues)
|
||||
.where(
|
||||
and(
|
||||
eq(merkmalValues.entityTyp, entityTyp),
|
||||
eq(merkmalValues.entityId, entityId),
|
||||
eq(merkmalValues.merkmalId, w.merkmalId),
|
||||
),
|
||||
);
|
||||
|
||||
const empty =
|
||||
w.num == null &&
|
||||
(w.text == null || w.text === "") &&
|
||||
w.bool == null;
|
||||
if (empty) continue;
|
||||
|
||||
await tx.insert(merkmalValues).values({
|
||||
merkmalId: w.merkmalId,
|
||||
entityTyp,
|
||||
entityId,
|
||||
valueNum: w.num ?? null,
|
||||
valueText: w.text ?? null,
|
||||
valueBool: w.bool ?? null,
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user