diff --git a/src/app/(app)/fahrzeuge/[id]/not-found.tsx b/src/app/(app)/fahrzeuge/[id]/not-found.tsx
new file mode 100644
index 0000000..cc98a9d
--- /dev/null
+++ b/src/app/(app)/fahrzeuge/[id]/not-found.tsx
@@ -0,0 +1,14 @@
+import Link from "next/link";
+import { Button } from "@/components/ui/button";
+import { t } from "@/lib/i18n/de";
+
+export default function FahrzeugNotFound() {
+ return (
+
+
{t("detail.nichtGefunden")}
+
+
+ );
+}
diff --git a/src/app/(app)/fahrzeuge/[id]/page.tsx b/src/app/(app)/fahrzeuge/[id]/page.tsx
new file mode 100644
index 0000000..7659f4e
--- /dev/null
+++ b/src/app/(app)/fahrzeuge/[id]/page.tsx
@@ -0,0 +1,43 @@
+import { notFound } from "next/navigation";
+import { requireSession } from "@/lib/auth/guards";
+import { getFahrzeugDetail } from "@/lib/detail/queries";
+import { DetailHeader } from "@/components/detail/DetailHeader";
+import { EckdatenGrid } from "@/components/detail/EckdatenGrid";
+import { BeladungListe } from "@/components/detail/BeladungListe";
+import { WehrCard } from "@/components/kontakt/WehrCard";
+import { t } from "@/lib/i18n/de";
+
+export default async function FahrzeugDetailPage({
+ params,
+}: {
+ params: Promise<{ id: string }>;
+}) {
+ // Default-deny in der Tiefe (Querschnittsstandard 1): Guard als erste Zeile.
+ await requireSession();
+ const { id } = await params;
+ const v = await getFahrzeugDetail(id);
+ if (!v) notFound();
+
+ return (
+
+
+
+
+
+
+ {v.notiz ? (
+
+ {v.notiz}
+
+ ) : null}
+
+ {v.wehr ?
: null}
+
+
+ );
+}
diff --git a/src/app/(app)/geraete/[id]/page.tsx b/src/app/(app)/geraete/[id]/page.tsx
new file mode 100644
index 0000000..0fc153f
--- /dev/null
+++ b/src/app/(app)/geraete/[id]/page.tsx
@@ -0,0 +1,53 @@
+import Link from "next/link";
+import { notFound } from "next/navigation";
+import { requireSession } from "@/lib/auth/guards";
+import { getGeraetDetail } from "@/lib/detail/queries";
+import { DetailHeader } from "@/components/detail/DetailHeader";
+import { EckdatenGrid } from "@/components/detail/EckdatenGrid";
+import { WehrCard } from "@/components/kontakt/WehrCard";
+import { t } from "@/lib/i18n/de";
+
+export default async function GeraetDetailPage({
+ params,
+}: {
+ params: Promise<{ id: string }>;
+}) {
+ // Default-deny in der Tiefe (Querschnittsstandard 1): Guard als erste Zeile.
+ await requireSession();
+ const { id } = await params;
+ const g = await getGeraetDetail(id);
+ if (!g) notFound();
+
+ return (
+
+
+
+
+
+
+
+ {t("detail.zugeordnetesFahrzeug")}
+
+ {g.fahrzeug ? (
+
+ {g.fahrzeug.name}
+
+ ) : (
+
+ {t("detail.imGeraetehaus")}
+
+ )}
+
+
+ {g.wehr ?
: null}
+
+
+ );
+}
diff --git a/src/app/(app)/wehren/[id]/page.tsx b/src/app/(app)/wehren/[id]/page.tsx
new file mode 100644
index 0000000..c59bf4c
--- /dev/null
+++ b/src/app/(app)/wehren/[id]/page.tsx
@@ -0,0 +1,101 @@
+import Link from "next/link";
+import { notFound } from "next/navigation";
+import { requireSession } from "@/lib/auth/guards";
+import { getWehrDetail } from "@/lib/detail/queries";
+import { DetailHeader } from "@/components/detail/DetailHeader";
+import { KontaktButton } from "@/components/kontakt/KontaktButton";
+import { StatusBadge } from "@/components/ui/badge";
+import { t } from "@/lib/i18n/de";
+
+export default async function WehrDetailPage({
+ params,
+}: {
+ params: Promise<{ id: string }>;
+}) {
+ // Default-deny in der Tiefe (Querschnittsstandard 1): Guard als erste Zeile.
+ await requireSession();
+ const { id } = await params;
+ const w = await getWehrDetail(id);
+ if (!w) notFound();
+
+ const adresse = [w.strasse, [w.plz, w.ort].filter(Boolean).join(" ")]
+ .filter((s) => s && s.trim() !== "")
+ .join(", ");
+
+ return (
+
+
+
+
+ {t("kontakt.titel")}
+ {w.wehrfuehrer ? (
+
+ {t("wehr.wehrfuehrer")}: {w.wehrfuehrer}
+
+ ) : null}
+
+
+
+
+
+
+ {t("detail.fahrzeuge")}
+ {w.fahrzeuge.length === 0 ? (
+
+ {t("detail.keineFahrzeuge")}
+
+ ) : (
+
+ {w.fahrzeuge.map((f) => (
+ -
+
+
+ {f.name}
+
+ {f.funkrufname ? (
+
+ {f.funkrufname}
+
+ ) : null}
+
+
+
+ ))}
+
+ )}
+
+
+
+
+ {t("detail.geraeteImHaus")}
+
+ {w.geraeteImHaus.length === 0 ? (
+
+ {t("detail.keineGeraeteImHaus")}
+
+ ) : (
+
+ {w.geraeteImHaus.map((g) => (
+ -
+
+ {g.name}
+
+
+
+ ))}
+
+ )}
+
+
+ );
+}
diff --git a/src/components/detail/BeladungListe.tsx b/src/components/detail/BeladungListe.tsx
new file mode 100644
index 0000000..b4dc7f0
--- /dev/null
+++ b/src/components/detail/BeladungListe.tsx
@@ -0,0 +1,39 @@
+import Link from "next/link";
+import { StatusBadge } from "@/components/ui/badge";
+import { t } from "@/lib/i18n/de";
+import type { BeladungItem } from "@/lib/detail/queries";
+
+/**
+ * Beladung eines Fahrzeugs: jedes Gerät verlinkt auf seine Detailseite
+ * (`/geraete/`). Leere Liste => deutscher Empty-State.
+ */
+export function BeladungListe({ items }: { items: BeladungItem[] }) {
+ return (
+
+ {t("detail.beladung")}
+ {items.length === 0 ? (
+ {t("detail.keineBeladung")}
+ ) : (
+
+ {items.map((it) => (
+ -
+
+
+ {it.name}
+
+
{it.kategorie}
+
+
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/src/components/detail/DetailHeader.tsx b/src/components/detail/DetailHeader.tsx
new file mode 100644
index 0000000..b19feef
--- /dev/null
+++ b/src/components/detail/DetailHeader.tsx
@@ -0,0 +1,38 @@
+import { StatusBadge, type StatusKey } from "@/components/ui/badge";
+
+/**
+ * Kopfzeile einer Detailseite: Kicker (Typ/Vorlage), Titel, optionaler
+ * Funkrufname/Untertitel und Status-Badge. Reiner Präsentations-Baustein.
+ */
+export function DetailHeader({
+ kicker,
+ titel,
+ untertitel,
+ status,
+}: {
+ kicker?: string | null;
+ titel: string;
+ untertitel?: string | null;
+ status?: StatusKey;
+}) {
+ return (
+
+
+ {kicker ? (
+
+ {kicker}
+
+ ) : null}
+
{titel}
+ {untertitel ? (
+
{untertitel}
+ ) : null}
+
+ {status ? (
+
+
+
+ ) : null}
+
+ );
+}
diff --git a/src/components/detail/EckdatenGrid.tsx b/src/components/detail/EckdatenGrid.tsx
new file mode 100644
index 0000000..7cfad0c
--- /dev/null
+++ b/src/components/detail/EckdatenGrid.tsx
@@ -0,0 +1,39 @@
+import { t } from "@/lib/i18n/de";
+import { toEckdaten, type MerkmalRow } from "@/lib/detail/merkmale";
+
+/**
+ * Zeigt die typisierten Eckdaten als Definitionsliste. Leere Liste => deutscher
+ * Empty-State (Querschnittsstandard 10). Die Formatierung (de-AT, NBSP, Ja/Nein,
+ * enum-Label, „–") übernimmt `toEckdaten`/`formatMerkmal`.
+ */
+export function EckdatenGrid({ rows }: { rows: MerkmalRow[] }) {
+ const eckdaten = toEckdaten(rows);
+
+ if (eckdaten.length === 0) {
+ return (
+
+ {t("detail.eckdaten")}
+ {t("detail.keineEckdaten")}
+
+ );
+ }
+
+ return (
+
+ {t("detail.eckdaten")}
+
+ {eckdaten.map((e) => (
+
+
- {e.label}
+ -
+ {e.wert}
+
+
+ ))}
+
+
+ );
+}
diff --git a/src/components/detail/StatusBadge.tsx b/src/components/detail/StatusBadge.tsx
new file mode 100644
index 0000000..c7badb9
--- /dev/null
+++ b/src/components/detail/StatusBadge.tsx
@@ -0,0 +1,6 @@
+/**
+ * Re-Export des kanonischen StatusBadge (Workstream 1, `@/components/ui/badge`).
+ * Workstream 8 listet `components/detail/StatusBadge`; um KEINE zweite Quelle
+ * für Status-Styling zu schaffen, re-exportieren wir den bestehenden Badge.
+ */
+export { StatusBadge, type StatusKey } from "@/components/ui/badge";
diff --git a/src/components/kontakt/KontaktButton.tsx b/src/components/kontakt/KontaktButton.tsx
new file mode 100644
index 0000000..86641b0
--- /dev/null
+++ b/src/components/kontakt/KontaktButton.tsx
@@ -0,0 +1,49 @@
+import { t } from "@/lib/i18n/de";
+
+/**
+ * Out-of-band Kontakt (kein Borrow-Workflow in v1). Rendert je nach
+ * vorhandenen Daten einen `tel:`- und/oder `mailto:`-Link. Telefonnummer wird
+ * für das `tel:`-Schema von Leerzeichen befreit; `mailto:` kann einen `subject`
+ * tragen. Sind beide leer, erscheint der deutsche Hinweistext.
+ */
+export function KontaktButton({
+ telefon,
+ email,
+ subject,
+}: {
+ telefon?: string | null;
+ email?: string | null;
+ subject?: string;
+}) {
+ const tel = telefon?.trim() ? telefon.replace(/\s+/g, "") : null;
+ const mail = email?.trim() ? email.trim() : null;
+
+ if (!tel && !mail) {
+ return {t("kontakt.keine")}
;
+ }
+
+ const mailHref = mail
+ ? `mailto:${mail}${subject ? `?subject=${encodeURIComponent(subject)}` : ""}`
+ : null;
+
+ return (
+
+ );
+}
diff --git a/src/components/kontakt/WehrCard.tsx b/src/components/kontakt/WehrCard.tsx
new file mode 100644
index 0000000..21cfb1e
--- /dev/null
+++ b/src/components/kontakt/WehrCard.tsx
@@ -0,0 +1,54 @@
+import Link from "next/link";
+import { t } from "@/lib/i18n/de";
+import { KontaktButton } from "./KontaktButton";
+import type { BrigadeCard } from "@/lib/detail/queries";
+
+/**
+ * Verlinktes Wehr-Kärtchen für Fahrzeug-/Gerät-Detailseiten: Name, Adresse,
+ * Wehrführer und out-of-band Kontakt. `verlinkt` steuert, ob der Name auf die
+ * Wehr-Detailseite zeigt (auf der Wehr-Seite selbst nicht sinnvoll).
+ */
+export function WehrCard({
+ wehr,
+ verlinkt = true,
+}: {
+ wehr: BrigadeCard;
+ verlinkt?: boolean;
+}) {
+ const adresse = [wehr.strasse, [wehr.plz, wehr.ort].filter(Boolean).join(" ")]
+ .filter((s) => s && s.trim() !== "")
+ .join(", ");
+
+ return (
+
+ {t("kontakt.titel")}
+
+ {verlinkt ? (
+
+ {wehr.name}
+
+ ) : (
+
{wehr.name}
+ )}
+ {adresse ? (
+
{adresse}
+ ) : null}
+ {wehr.wehrfuehrer ? (
+
+ {t("wehr.wehrfuehrer")}: {wehr.wehrfuehrer}
+
+ ) : null}
+
+
+
+
+
+ );
+}
diff --git a/src/lib/detail/merkmale.test.ts b/src/lib/detail/merkmale.test.ts
new file mode 100644
index 0000000..f1c3598
--- /dev/null
+++ b/src/lib/detail/merkmale.test.ts
@@ -0,0 +1,81 @@
+import { describe, it, expect } from "vitest";
+import { formatMerkmal, toEckdaten, type MerkmalRow } from "./merkmale";
+
+function row(p: Partial): MerkmalRow {
+ return {
+ merkmalId: "00000000-0000-0000-0000-000000000000",
+ name: "Merkmal",
+ typ: "text",
+ einheit: null,
+ reihenfolge: 0,
+ valueNum: null,
+ valueText: null,
+ valueBool: null,
+ enumLabel: null,
+ ...p,
+ };
+}
+
+describe("formatMerkmal", () => {
+ it("number mit Einheit -> Tausenderpunkt + NBSP (de-AT)", () => {
+ const out = formatMerkmal(
+ row({ typ: "number", einheit: "l", valueNum: 14000 }),
+ );
+ expect(out).toBe("14.000 l");
+ });
+
+ it("number ohne Einheit -> nur Zahl", () => {
+ expect(formatMerkmal(row({ typ: "number", valueNum: 1500 }))).toBe("1.500");
+ });
+
+ it("boolean false -> Nein", () => {
+ expect(formatMerkmal(row({ typ: "boolean", valueBool: false }))).toBe("Nein");
+ });
+
+ it("boolean true -> Ja", () => {
+ expect(formatMerkmal(row({ typ: "boolean", valueBool: true }))).toBe("Ja");
+ });
+
+ it("enum mit enumLabel -> Label", () => {
+ expect(
+ formatMerkmal(
+ row({ typ: "enum", valueText: "fpn_10_2000", enumLabel: "FPN 10-2000" }),
+ ),
+ ).toBe("FPN 10-2000");
+ });
+
+ it("enum ohne Label -> roher Wert", () => {
+ expect(formatMerkmal(row({ typ: "enum", valueText: "sonst" }))).toBe("sonst");
+ });
+
+ it("text -> roher Wert", () => {
+ expect(formatMerkmal(row({ typ: "text", valueText: "Hallo" }))).toBe("Hallo");
+ });
+
+ it("alle null -> Leerwert (–)", () => {
+ expect(formatMerkmal(row({ typ: "number" }))).toBe("–");
+ expect(formatMerkmal(row({ typ: "boolean" }))).toBe("–");
+ expect(formatMerkmal(row({ typ: "enum" }))).toBe("–");
+ expect(formatMerkmal(row({ typ: "text" }))).toBe("–");
+ });
+
+ it("text mit nur Whitespace -> Leerwert", () => {
+ expect(formatMerkmal(row({ typ: "text", valueText: " " }))).toBe("–");
+ });
+});
+
+describe("toEckdaten", () => {
+ it("sortiert nach reihenfolge und baut Label mit Einheit", () => {
+ const rows: MerkmalRow[] = [
+ row({ name: "B", typ: "number", einheit: "l", valueNum: 2000, reihenfolge: 2 }),
+ row({ name: "A", typ: "boolean", valueBool: true, reihenfolge: 1 }),
+ ];
+ const out = toEckdaten(rows);
+ expect(out.map((e) => e.label)).toEqual(["A", "B (l)"]);
+ expect(out.map((e) => e.wert)).toEqual(["Ja", "2.000 l"]);
+ });
+
+ it("leeres Array -> leeres Ergebnis", () => {
+ expect(toEckdaten([])).toEqual([]);
+ });
+});
diff --git a/src/lib/detail/merkmale.ts b/src/lib/detail/merkmale.ts
new file mode 100644
index 0000000..27c1e3f
--- /dev/null
+++ b/src/lib/detail/merkmale.ts
@@ -0,0 +1,99 @@
+import type { MerkmalTyp } from "@/lib/merkmale/types";
+import { t } from "@/lib/i18n/de";
+
+/**
+ * Eine rohe Merkmal-Wert-Zeile aus der DB (siehe `loadMerkmalRows`). Genau eine
+ * der drei `value*`-Spalten ist typabhängig gesetzt (oder alle null => leer).
+ * `enumLabel` ist das aufgelöste Anzeige-Label aus `merkmal_optionen` (falls
+ * vorhanden), `valueText` der gespeicherte enum-Wert.
+ */
+export interface MerkmalRow {
+ merkmalId: string;
+ name: string;
+ typ: MerkmalTyp;
+ einheit: string | null;
+ reihenfolge: number;
+ valueNum: number | null;
+ valueText: string | null;
+ valueBool: boolean | null;
+ enumLabel: string | null;
+}
+
+/** Eine fertig formatierte Eckdaten-Zeile fürs UI. */
+export interface Eckdatum {
+ merkmalId: string;
+ label: string;
+ wert: string;
+}
+
+/** Schmales geschütztes Leerzeichen (NBSP) zwischen Zahl und Einheit. */
+const NBSP = " ";
+
+/**
+ * Formatiert eine Zahl im de-AT/de-Stil mit Tausenderpunkt und Dezimalkomma.
+ *
+ * Bewusst NICHT direkt `Intl.NumberFormat("de-AT")`: je nach ICU-Build liefert
+ * de-AT als Gruppentrenner ein schmales geschütztes Leerzeichen (U+202F) statt
+ * des fachlich geforderten Tausenderpunkts. Wir normalisieren den Gruppen-
+ * trenner deshalb deterministisch auf „.", damit die Ausgabe ICU-unabhängig
+ * „14.000" ergibt.
+ */
+function formatZahl(n: number): string {
+ const parts = new Intl.NumberFormat("de-AT").formatToParts(n);
+ return parts
+ .map((p) => {
+ if (p.type === "group") return ".";
+ if (p.type === "decimal") return ",";
+ return p.value;
+ })
+ .join("");
+}
+
+/**
+ * Formatiert EINEN typisierten Merkmalwert als deutschen Anzeige-String.
+ *
+ * - number: `Intl.NumberFormat("de-AT")` (Tausenderpunkt), Einheit mit NBSP.
+ * - boolean: „Ja" / „Nein".
+ * - enum: bevorzugt `enumLabel`, sonst der rohe `valueText`.
+ * - text: der rohe `valueText`.
+ * - leerer/fehlender Wert (für den Typ) => „–".
+ *
+ * REIN: keine DB-/IO-Abhängigkeit, damit ohne laufendes Postgres testbar.
+ */
+export function formatMerkmal(row: MerkmalRow): string {
+ const leer = t("detail.leerWert");
+ switch (row.typ) {
+ case "number": {
+ if (row.valueNum === null || row.valueNum === undefined) return leer;
+ const zahl = formatZahl(row.valueNum);
+ return row.einheit ? `${zahl}${NBSP}${row.einheit}` : zahl;
+ }
+ case "boolean": {
+ if (row.valueBool === null || row.valueBool === undefined) return leer;
+ return row.valueBool ? t("detail.ja") : t("detail.nein");
+ }
+ case "enum": {
+ const v = row.enumLabel ?? row.valueText;
+ return v && v.trim() !== "" ? v : leer;
+ }
+ case "text": {
+ return row.valueText && row.valueText.trim() !== "" ? row.valueText : leer;
+ }
+ default:
+ return leer;
+ }
+}
+
+/**
+ * Wandelt rohe Merkmal-Zeilen in fertige Eckdaten um (sortiert nach
+ * `reihenfolge`, dann `name`). Reine Transformation.
+ */
+export function toEckdaten(rows: MerkmalRow[]): Eckdatum[] {
+ return [...rows]
+ .sort((a, b) => a.reihenfolge - b.reihenfolge || a.name.localeCompare(b.name, "de"))
+ .map((r) => ({
+ merkmalId: r.merkmalId,
+ label: r.einheit ? `${r.name} (${r.einheit})` : r.name,
+ wert: formatMerkmal(r),
+ }));
+}
diff --git a/src/lib/detail/queries.test.ts b/src/lib/detail/queries.test.ts
new file mode 100644
index 0000000..2ebdd62
--- /dev/null
+++ b/src/lib/detail/queries.test.ts
@@ -0,0 +1,112 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+
+/**
+ * Offline-Tests für die Mapping-Logik der Detail-Queries. Die Drizzle-DB wird
+ * gemockt (kein Postgres in der Sandbox), sodass wir das reine Verhalten
+ * verifizieren: „im Gerätehaus" (vehicleId null) -> `fahrzeug: null`, sonst
+ * verlinktes Fahrzeug; sowie not-found (-> null) bei fehlendem Datensatz.
+ *
+ * Wir mocken `@/db` als verkettbaren Query-Builder, dessen letzte Stufe
+ * (`limit`/`orderBy`/`where`) ein vorab gesetztes Ergebnis-Array liefert.
+ */
+
+type Rows = unknown[];
+let queue: Rows[];
+
+function nextRows(): Rows {
+ return queue.length > 0 ? (queue.shift() as Rows) : [];
+}
+
+// Ein Thenable-Builder: jede Methode gibt sich selbst zurück; `await` löst die
+// nächste Ergebnismenge auf. So funktionieren sowohl `await db.select()....limit()`
+// als auch `await db.select()....orderBy()`.
+function makeBuilder(): Record {
+ const builder: Record = {};
+ const chain = () => builder;
+ for (const m of [
+ "select",
+ "from",
+ "innerJoin",
+ "leftJoin",
+ "where",
+ "limit",
+ "orderBy",
+ ]) {
+ builder[m] = vi.fn(chain);
+ }
+ builder.then = (resolve: (v: Rows) => unknown) => resolve(nextRows());
+ return builder;
+}
+
+vi.mock("@/db", () => ({
+ db: {
+ select: () => makeBuilder(),
+ },
+}));
+
+const { getGeraetDetail } = await import("@/lib/detail/queries");
+
+const WEHR = {
+ id: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
+ name: "FF Test",
+ art: "FF",
+ strasse: null,
+ plz: null,
+ ort: null,
+ wehrfuehrer: null,
+ telefon: null,
+ email: null,
+};
+
+beforeEach(() => {
+ queue = [];
+});
+
+describe("getGeraetDetail", () => {
+ it("liefert null, wenn das Gerät nicht existiert", async () => {
+ queue = [[]]; // erste Query (Gerät) leer
+ const out = await getGeraetDetail("00000000-0000-0000-0000-000000000000");
+ expect(out).toBeNull();
+ });
+
+ it("ohne Fahrzeug-Zuordnung -> fahrzeug = null (im Gerätehaus)", async () => {
+ queue = [
+ [
+ {
+ id: "g1",
+ brigadeId: WEHR.id,
+ name: "Strahlrohr",
+ status: "einsatzbereit",
+ kategorie: "Armaturen",
+ vehicleId: null,
+ vehicleName: null,
+ },
+ ],
+ [], // loadMerkmalRows
+ [WEHR], // getBrigadeCard
+ ];
+ const out = await getGeraetDetail("g1");
+ expect(out?.fahrzeug).toBeNull();
+ expect(out?.kategorie).toBe("Armaturen");
+ });
+
+ it("mit Fahrzeug-Zuordnung -> verlinktes Fahrzeug", async () => {
+ queue = [
+ [
+ {
+ id: "g1",
+ brigadeId: WEHR.id,
+ name: "Strahlrohr",
+ status: "einsatzbereit",
+ kategorie: "Armaturen",
+ vehicleId: "v1",
+ vehicleName: "TLFA 4000",
+ },
+ ],
+ [],
+ [WEHR],
+ ];
+ const out = await getGeraetDetail("g1");
+ expect(out?.fahrzeug).toEqual({ id: "v1", name: "TLFA 4000" });
+ });
+});
diff --git a/src/lib/detail/queries.ts b/src/lib/detail/queries.ts
new file mode 100644
index 0000000..5a3ff24
--- /dev/null
+++ b/src/lib/detail/queries.ts
@@ -0,0 +1,252 @@
+import { and, eq, asc, isNull } from "drizzle-orm";
+import { db } from "@/db";
+import { vehicles, equipment } from "@/db/schema/assets";
+import { brigades } from "@/db/schema/brigades";
+import { vehicleTemplates } from "@/db/schema/templates";
+import { equipmentCategories } from "@/db/schema/equipment-categories";
+import { merkmale, merkmalOptionen } from "@/db/schema/merkmale";
+import { merkmalValues } from "@/db/schema/merkmal-values";
+import type { StatusKey } from "@/components/ui/badge";
+import type { EntityTyp } from "@/lib/search/types";
+import type { MerkmalRow } from "./merkmale";
+
+/**
+ * Lese-Queries für die drei Detailseiten (Workstream 8). READ-ONLY und
+ * wehrübergreifend (das `(app)`-Gruppen-Gate aus Phase 2 schützt; die Seiten
+ * rufen zusätzlich `requireSession()`). Alle IDs sind UUIDs (`string`).
+ */
+
+/** Verlinktes Wehr-Kärtchen (Kontakt out-of-band, kein Borrow-Workflow). */
+export interface BrigadeCard {
+ id: string;
+ name: string;
+ art: string;
+ strasse: string | null;
+ plz: string | null;
+ ort: string | null;
+ wehrfuehrer: string | null;
+ telefon: string | null;
+ email: string | null;
+}
+
+/** Ein verlinktes Beladungs-Gerät eines Fahrzeugs. */
+export interface BeladungItem {
+ id: string;
+ name: string;
+ status: StatusKey;
+ kategorie: string;
+}
+
+export interface FahrzeugDetail {
+ id: string;
+ name: string;
+ funkrufname: string | null;
+ status: StatusKey;
+ notiz: string | null;
+ templateName: string | null;
+ merkmale: MerkmalRow[];
+ beladung: BeladungItem[];
+ wehr: BrigadeCard | null;
+}
+
+export interface GeraetDetail {
+ id: string;
+ name: string;
+ status: StatusKey;
+ kategorie: string;
+ merkmale: MerkmalRow[];
+ /** Zugeordnetes Fahrzeug oder `null` => „im Gerätehaus". */
+ fahrzeug: { id: string; name: string } | null;
+ wehr: BrigadeCard | null;
+}
+
+export interface WehrDetail extends BrigadeCard {
+ fahrzeuge: { id: string; name: string; funkrufname: string | null; status: StatusKey }[];
+ geraeteImHaus: { id: string; name: string; status: StatusKey }[];
+}
+
+/**
+ * Lädt die typisierten Merkmalwerte einer Entität (joint
+ * `merkmal_values` ↔ `merkmale` ↔ `merkmal_optionen` über
+ * `merkmal_optionen.wert = merkmal_values.value_text`, damit enum-Werte ihr
+ * Anzeige-Label erhalten). Sortiert nach `merkmale.name`.
+ */
+export async function loadMerkmalRows(
+ entityTyp: EntityTyp,
+ entityId: string,
+): Promise {
+ const rows = await db
+ .select({
+ merkmalId: merkmale.id,
+ name: merkmale.name,
+ typ: merkmale.typ,
+ einheit: merkmale.einheit,
+ valueNum: merkmalValues.valueNum,
+ valueText: merkmalValues.valueText,
+ valueBool: merkmalValues.valueBool,
+ enumLabel: merkmalOptionen.label,
+ })
+ .from(merkmalValues)
+ .innerJoin(merkmale, eq(merkmale.id, merkmalValues.merkmalId))
+ .leftJoin(
+ merkmalOptionen,
+ and(
+ eq(merkmalOptionen.merkmalId, merkmale.id),
+ eq(merkmalOptionen.wert, merkmalValues.valueText),
+ ),
+ )
+ .where(
+ and(
+ eq(merkmalValues.entityTyp, entityTyp),
+ eq(merkmalValues.entityId, entityId),
+ ),
+ )
+ .orderBy(asc(merkmale.name));
+
+ return rows.map((r, i) => ({
+ merkmalId: r.merkmalId,
+ name: r.name,
+ typ: r.typ,
+ einheit: r.einheit,
+ reihenfolge: i,
+ valueNum: r.valueNum,
+ valueText: r.valueText,
+ valueBool: r.valueBool,
+ enumLabel: r.enumLabel,
+ }));
+}
+
+/** Wehr-Kärtchen (Stammdaten + Kontakt). `null`, wenn nicht gefunden. */
+export async function getBrigadeCard(id: string): Promise {
+ const [b] = await db
+ .select({
+ id: brigades.id,
+ name: brigades.name,
+ art: brigades.art,
+ strasse: brigades.strasse,
+ plz: brigades.plz,
+ ort: brigades.ort,
+ wehrfuehrer: brigades.wehrfuehrer,
+ telefon: brigades.telefon,
+ email: brigades.email,
+ })
+ .from(brigades)
+ .where(eq(brigades.id, id))
+ .limit(1);
+ return b ?? null;
+}
+
+export async function getFahrzeugDetail(id: string): Promise {
+ const [v] = await db
+ .select({
+ id: vehicles.id,
+ brigadeId: vehicles.brigadeId,
+ name: vehicles.name,
+ funkrufname: vehicles.funkrufname,
+ status: vehicles.status,
+ notiz: vehicles.notiz,
+ templateName: vehicleTemplates.name,
+ })
+ .from(vehicles)
+ .leftJoin(vehicleTemplates, eq(vehicleTemplates.id, vehicles.templateId))
+ .where(eq(vehicles.id, id))
+ .limit(1);
+ if (!v) return null;
+
+ const [rows, beladung, wehr] = await Promise.all([
+ loadMerkmalRows("vehicle", id),
+ db
+ .select({
+ id: equipment.id,
+ name: equipment.name,
+ status: equipment.status,
+ kategorie: equipmentCategories.name,
+ })
+ .from(equipment)
+ .innerJoin(
+ equipmentCategories,
+ eq(equipmentCategories.id, equipment.categoryId),
+ )
+ .where(eq(equipment.vehicleId, id))
+ .orderBy(asc(equipment.name)),
+ getBrigadeCard(v.brigadeId),
+ ]);
+
+ return {
+ id: v.id,
+ name: v.name,
+ funkrufname: v.funkrufname,
+ status: v.status,
+ notiz: v.notiz,
+ templateName: v.templateName,
+ merkmale: rows,
+ beladung,
+ wehr,
+ };
+}
+
+export async function getGeraetDetail(id: string): Promise {
+ const [g] = await db
+ .select({
+ id: equipment.id,
+ brigadeId: equipment.brigadeId,
+ name: equipment.name,
+ status: equipment.status,
+ kategorie: 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.id, id))
+ .limit(1);
+ if (!g) return null;
+
+ const [rows, wehr] = await Promise.all([
+ loadMerkmalRows("equipment", id),
+ getBrigadeCard(g.brigadeId),
+ ]);
+
+ return {
+ id: g.id,
+ name: g.name,
+ status: g.status,
+ kategorie: g.kategorie,
+ merkmale: rows,
+ fahrzeug:
+ g.vehicleId && g.vehicleName
+ ? { id: g.vehicleId, name: g.vehicleName }
+ : null,
+ wehr,
+ };
+}
+
+export async function getWehrDetail(id: string): Promise {
+ const card = await getBrigadeCard(id);
+ if (!card) return null;
+
+ const [fahrzeuge, geraeteImHaus] = await Promise.all([
+ db
+ .select({
+ id: vehicles.id,
+ name: vehicles.name,
+ funkrufname: vehicles.funkrufname,
+ status: vehicles.status,
+ })
+ .from(vehicles)
+ .where(eq(vehicles.brigadeId, id))
+ .orderBy(asc(vehicles.name)),
+ db
+ .select({
+ id: equipment.id,
+ name: equipment.name,
+ status: equipment.status,
+ })
+ .from(equipment)
+ .where(and(eq(equipment.brigadeId, id), isNull(equipment.vehicleId)))
+ .orderBy(asc(equipment.name)),
+ ]);
+
+ return { ...card, fahrzeuge, geraeteImHaus };
+}
diff --git a/src/lib/i18n/de.ts b/src/lib/i18n/de.ts
index fe5a0b5..d42b02f 100644
--- a/src/lib/i18n/de.ts
+++ b/src/lib/i18n/de.ts
@@ -55,7 +55,29 @@ export const de = {
eckdaten: "Eckdaten",
beladung: "Beladung",
keineEckdaten: "Keine Eckdaten erfasst.",
+ keineBeladung: "Keine Beladung zugeordnet.",
imGeraetehaus: "im Gerätehaus",
+ leerWert: "–",
+ ja: "Ja",
+ nein: "Nein",
+ zugeordnetesFahrzeug: "Zugeordnetes Fahrzeug",
+ kategorie: "Kategorie",
+ fahrzeuge: "Fahrzeuge",
+ keineFahrzeuge: "Keine Fahrzeuge erfasst.",
+ geraeteImHaus: "Geräte im Gerätehaus",
+ keineGeraeteImHaus: "Keine Geräte im Gerätehaus.",
+ nichtGefunden: "Nicht gefunden.",
+ },
+ kontakt: {
+ titel: "Kontakt",
+ anrufen: "Anrufen",
+ email: "E-Mail schreiben",
+ keine: "Keine Kontaktdaten hinterlegt.",
+ betreff: "FlorianNetz – Anfrage",
+ },
+ wehr: {
+ wehrfuehrer: "Wehrführer",
+ adresse: "Adresse",
},
fehler: {
allgemein: "Es ist ein Fehler aufgetreten.",
diff --git a/tests/e2e/auth-gating.spec.ts b/tests/e2e/auth-gating.spec.ts
index 1039add..b9f68ad 100644
--- a/tests/e2e/auth-gating.spec.ts
+++ b/tests/e2e/auth-gating.spec.ts
@@ -20,6 +20,9 @@ const PROTECTED_PAGES = [
"/",
"/start",
"/fahrzeuge",
+ "/fahrzeuge/00000000-0000-0000-0000-000000000001",
+ "/geraete/00000000-0000-0000-0000-000000000002",
+ "/wehren/00000000-0000-0000-0000-000000000003",
"/geraete",
"/wehren",
"/verwaltung",
diff --git a/tests/e2e/detail-auth.spec.ts b/tests/e2e/detail-auth.spec.ts
new file mode 100644
index 0000000..b089f54
--- /dev/null
+++ b/tests/e2e/detail-auth.spec.ts
@@ -0,0 +1,83 @@
+import { test, expect } from "@playwright/test";
+
+/**
+ * Detailseiten-Auth & -Inhalt (Workstream 8, Phase 5).
+ *
+ * NICHT in der Sandbox ausführbar (kein Server/DB) — deferred. Wird über
+ * `npm run test:e2e` gegen einen laufenden Server mit Seed-Daten ausgeführt.
+ *
+ * Garantien:
+ * - Default-deny (Querschnittsstandard 1): anonyme Aufrufe der Detailseiten
+ * leiten auf /login um (das `(app)`-Layout-Gate + `requireSession()` je Seite).
+ * - Eingeloggt: Eckdaten, Beladung-Links (`/geraete/`), Wehr-Kontakt
+ * (`tel:`/`mailto:`) sind sichtbar.
+ * - Ungültige IDs -> deutsche 404-Seite (not-found).
+ *
+ * Negativ-Probe (manuell/CI): Entfernen von `requireSession()` aus einer der
+ * Detailseiten ODER aus `(app)/layout.tsx` muss die Default-deny-Tests rot
+ * machen.
+ *
+ * Platzhalter-IDs: zur Laufzeit gegen echte Seed-UUIDs ersetzen
+ * (Env `E2E_FAHRZEUG_ID` etc.) oder per Suchseite ermitteln.
+ */
+
+const FAHRZEUG_ID = process.env.E2E_FAHRZEUG_ID ?? "00000000-0000-0000-0000-000000000001";
+const GERAET_ID = process.env.E2E_GERAET_ID ?? "00000000-0000-0000-0000-000000000002";
+const WEHR_ID = process.env.E2E_WEHR_ID ?? "00000000-0000-0000-0000-000000000003";
+const UNGUELTIGE_ID = "ffffffff-ffff-ffff-ffff-ffffffffffff";
+
+const DETAIL_PAGES = [
+ `/fahrzeuge/${FAHRZEUG_ID}`,
+ `/geraete/${GERAET_ID}`,
+ `/wehren/${WEHR_ID}`,
+];
+
+test.describe("Default-deny: Detailseiten (anonym)", () => {
+ for (const path of DETAIL_PAGES) {
+ test(`anonymer Aufruf von ${path} leitet auf /login um`, async ({ page }) => {
+ await page.goto(path);
+ await expect(page).toHaveURL(/\/login/);
+ });
+ }
+});
+
+test.describe("Eingeloggt: Detail-Inhalte", () => {
+ test.use({ storageState: "tests/e2e/.auth/wehr-read.json" });
+
+ test("Fahrzeug-Detail zeigt Eckdaten, Beladung-Links und Wehr-Kontakt", async ({
+ page,
+ }) => {
+ await page.goto(`/fahrzeuge/${FAHRZEUG_ID}`);
+ await expect(page.getByRole("heading", { name: "Eckdaten" })).toBeVisible();
+ // Beladung verlinkt auf Gerät-Detailseiten.
+ const beladungLink = page.locator('a[href^="/geraete/"]').first();
+ await expect(beladungLink).toBeVisible();
+ // Out-of-band Kontakt: tel:/mailto:-Link vorhanden.
+ const kontaktLink = page.locator('a[href^="tel:"], a[href^="mailto:"]').first();
+ await expect(kontaktLink).toBeVisible();
+ });
+
+ test("Gerät-Detail verlinkt Fahrzeug oder zeigt „im Gerätehaus“", async ({
+ page,
+ }) => {
+ await page.goto(`/geraete/${GERAET_ID}`);
+ const hatFahrzeug = await page.locator('a[href^="/fahrzeuge/"]').count();
+ if (hatFahrzeug === 0) {
+ await expect(page.getByText("im Gerätehaus")).toBeVisible();
+ } else {
+ await expect(page.locator('a[href^="/fahrzeuge/"]').first()).toBeVisible();
+ }
+ });
+
+ test("Wehr-Detail listet Fuhrpark und Kontakt", async ({ page }) => {
+ await page.goto(`/wehren/${WEHR_ID}`);
+ await expect(page.getByRole("heading", { name: "Fahrzeuge" })).toBeVisible();
+ const kontaktLink = page.locator('a[href^="tel:"], a[href^="mailto:"]').first();
+ await expect(kontaktLink).toBeVisible();
+ });
+
+ test("ungültige Fahrzeug-ID -> deutsche 404-Seite", async ({ page }) => {
+ await page.goto(`/fahrzeuge/${UNGUELTIGE_ID}`);
+ await expect(page.getByText("Nicht gefunden.")).toBeVisible();
+ });
+});