Workstream 8: Detailseiten & Kontakt (Phase 5)

Drei serverseitige Lese-Detailseiten (Fahrzeug, Gerät, Wehr), default-deny:
- src/lib/detail/merkmale.ts: formatMerkmal (de-AT Tausenderpunkt + NBSP,
  Ja/Nein, enum-Label, „–"), toEckdaten. ICU-unabhängige Zahl-Formatierung
  (formatToParts -> Punkt/Komma), da de-AT je nach ICU-Build U+202F gruppiert.
- src/lib/detail/queries.ts: read-only, wehrübergreifend; loadMerkmalRows
  (Join merkmal_values↔merkmale↔merkmal_optionen via wert=value_text),
  getFahrzeugDetail (+Beladung, +WehrCard), getGeraetDetail (Fahrzeug-Link
  oder „im Gerätehaus"), getWehrDetail (Fuhrpark + Geräte im Haus),
  getBrigadeCard. UUID-IDs.
- Komponenten: detail/{DetailHeader,EckdatenGrid,BeladungListe,StatusBadge},
  kontakt/{KontaktButton (tel:/mailto:, Telefon ohne Leerzeichen, subject;
  Empty-State),WehrCard}.
- Seiten (app)/{fahrzeuge,geraete,wehren}/[id]/page.tsx mit requireSession()
  als erster Zeile (Default-deny in der Tiefe) + fahrzeuge/[id]/not-found.tsx.
- i18n-Keys (detail/kontakt/wehr) ergänzt; keine hartkodierten Strings.

Tests: merkmale.test.ts (11), queries.test.ts (3, gemockte DB für
„im Gerätehaus" + not-found). Playwright detail-auth.spec.ts geschrieben
(deferred: kein Server/DB in Sandbox); Detailrouten ins Gating-Manifest
aufgenommen.

Offline verifiziert: vitest src/lib/detail grün; tsc --noEmit ok; eslint
ok; next build erfolgreich (alle drei [id]-Routen vorhanden).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matthias Hochmeister
2026-06-09 11:35:34 +02:00
parent 632ba2b081
commit 44050c7278
17 changed files with 1088 additions and 0 deletions

View File

@@ -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 (
<div className="mx-auto max-w-md py-16 text-center">
<p className="text-anthrazit">{t("detail.nichtGefunden")}</p>
<Button asChild className="mt-4">
<Link href="/fahrzeuge">{t("nav.fahrzeuge")}</Link>
</Button>
</div>
);
}

View File

@@ -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 (
<div className="mx-auto flex max-w-3xl flex-col gap-6">
<DetailHeader
kicker={v.templateName ?? t("nav.fahrzeuge")}
titel={v.name}
untertitel={v.funkrufname}
status={v.status}
/>
<div className="grid grid-cols-1 gap-6 md:grid-cols-[1fr_18rem]">
<div className="flex flex-col gap-6">
<EckdatenGrid rows={v.merkmale} />
<BeladungListe items={v.beladung} />
{v.notiz ? (
<p className="whitespace-pre-line text-sm text-anthrazit/70">
{v.notiz}
</p>
) : null}
</div>
{v.wehr ? <WehrCard wehr={v.wehr} /> : null}
</div>
</div>
);
}

View File

@@ -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 (
<div className="mx-auto flex max-w-3xl flex-col gap-6">
<DetailHeader
kicker={g.kategorie}
titel={g.name}
status={g.status}
/>
<div className="grid grid-cols-1 gap-6 md:grid-cols-[1fr_18rem]">
<div className="flex flex-col gap-6">
<EckdatenGrid rows={g.merkmale} />
<section>
<h2 className="text-sm font-semibold text-navy">
{t("detail.zugeordnetesFahrzeug")}
</h2>
{g.fahrzeug ? (
<Link
href={`/fahrzeuge/${g.fahrzeug.id}`}
className="mt-2 inline-block font-medium text-navy hover:underline"
>
{g.fahrzeug.name}
</Link>
) : (
<p className="mt-2 text-sm text-anthrazit/60">
{t("detail.imGeraetehaus")}
</p>
)}
</section>
</div>
{g.wehr ? <WehrCard wehr={g.wehr} /> : null}
</div>
</div>
);
}

View File

@@ -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 (
<div className="mx-auto flex max-w-3xl flex-col gap-6">
<DetailHeader kicker={w.art} titel={w.name} untertitel={adresse || null} />
<section className="rounded-md border border-rand bg-nebel/50 p-4">
<h2 className="text-sm font-semibold text-navy">{t("kontakt.titel")}</h2>
{w.wehrfuehrer ? (
<p className="mt-1 text-sm text-anthrazit/70">
{t("wehr.wehrfuehrer")}: {w.wehrfuehrer}
</p>
) : null}
<div className="mt-3">
<KontaktButton
telefon={w.telefon}
email={w.email}
subject={t("kontakt.betreff")}
/>
</div>
</section>
<section>
<h2 className="text-sm font-semibold text-navy">{t("detail.fahrzeuge")}</h2>
{w.fahrzeuge.length === 0 ? (
<p className="mt-2 text-sm text-anthrazit/60">
{t("detail.keineFahrzeuge")}
</p>
) : (
<ul className="mt-3 divide-y divide-rand/60">
{w.fahrzeuge.map((f) => (
<li key={f.id} className="flex items-center justify-between gap-3 py-2">
<div className="min-w-0">
<Link
href={`/fahrzeuge/${f.id}`}
className="font-medium text-navy hover:underline"
>
{f.name}
</Link>
{f.funkrufname ? (
<p className="truncate text-xs text-anthrazit/60">
{f.funkrufname}
</p>
) : null}
</div>
<StatusBadge status={f.status} />
</li>
))}
</ul>
)}
</section>
<section>
<h2 className="text-sm font-semibold text-navy">
{t("detail.geraeteImHaus")}
</h2>
{w.geraeteImHaus.length === 0 ? (
<p className="mt-2 text-sm text-anthrazit/60">
{t("detail.keineGeraeteImHaus")}
</p>
) : (
<ul className="mt-3 divide-y divide-rand/60">
{w.geraeteImHaus.map((g) => (
<li key={g.id} className="flex items-center justify-between gap-3 py-2">
<Link
href={`/geraete/${g.id}`}
className="font-medium text-navy hover:underline"
>
{g.name}
</Link>
<StatusBadge status={g.status} />
</li>
))}
</ul>
)}
</section>
</div>
);
}

View File

@@ -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/<id>`). Leere Liste => deutscher Empty-State.
*/
export function BeladungListe({ items }: { items: BeladungItem[] }) {
return (
<section>
<h2 className="text-sm font-semibold text-navy">{t("detail.beladung")}</h2>
{items.length === 0 ? (
<p className="mt-2 text-sm text-anthrazit/60">{t("detail.keineBeladung")}</p>
) : (
<ul className="mt-3 divide-y divide-rand/60">
{items.map((it) => (
<li
key={it.id}
className="flex items-center justify-between gap-3 py-2"
>
<div className="min-w-0">
<Link
href={`/geraete/${it.id}`}
className="font-medium text-navy hover:underline"
>
{it.name}
</Link>
<p className="truncate text-xs text-anthrazit/60">{it.kategorie}</p>
</div>
<StatusBadge status={it.status} />
</li>
))}
</ul>
)}
</section>
);
}

View File

@@ -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 (
<header className="flex flex-wrap items-start justify-between gap-3 border-b border-rand pb-4">
<div className="min-w-0">
{kicker ? (
<p className="text-xs font-medium uppercase tracking-wide text-anthrazit/60">
{kicker}
</p>
) : null}
<h1 className="text-2xl font-semibold text-navy">{titel}</h1>
{untertitel ? (
<p className="mt-0.5 text-sm text-anthrazit/70">{untertitel}</p>
) : null}
</div>
{status ? (
<div className="shrink-0">
<StatusBadge status={status} />
</div>
) : null}
</header>
);
}

View File

@@ -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 (
<section>
<h2 className="text-sm font-semibold text-navy">{t("detail.eckdaten")}</h2>
<p className="mt-2 text-sm text-anthrazit/60">{t("detail.keineEckdaten")}</p>
</section>
);
}
return (
<section>
<h2 className="text-sm font-semibold text-navy">{t("detail.eckdaten")}</h2>
<dl className="mt-3 grid grid-cols-1 gap-x-6 gap-y-2 sm:grid-cols-2">
{eckdaten.map((e) => (
<div
key={e.merkmalId}
className="flex items-baseline justify-between gap-3 border-b border-rand/60 py-1.5"
>
<dt className="text-sm text-anthrazit/70">{e.label}</dt>
<dd className="text-sm font-medium tabular-nums text-anthrazit">
{e.wert}
</dd>
</div>
))}
</dl>
</section>
);
}

View File

@@ -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";

View File

@@ -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 <p className="text-sm text-anthrazit/60">{t("kontakt.keine")}</p>;
}
const mailHref = mail
? `mailto:${mail}${subject ? `?subject=${encodeURIComponent(subject)}` : ""}`
: null;
return (
<div className="flex flex-wrap gap-2">
{tel ? (
<a
href={`tel:${tel}`}
className="inline-flex items-center rounded-sm border border-rand bg-nebel px-3 py-1.5 text-sm font-medium text-navy hover:bg-rand/40"
>
{t("kontakt.anrufen")}
</a>
) : null}
{mailHref ? (
<a
href={mailHref}
className="inline-flex items-center rounded-sm border border-rand bg-nebel px-3 py-1.5 text-sm font-medium text-navy hover:bg-rand/40"
>
{t("kontakt.email")}
</a>
) : null}
</div>
);
}

View File

@@ -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 (
<section className="rounded-md border border-rand bg-nebel/50 p-4">
<h2 className="text-sm font-semibold text-navy">{t("kontakt.titel")}</h2>
<div className="mt-2">
{verlinkt ? (
<Link
href={`/wehren/${wehr.id}`}
className="font-medium text-navy hover:underline"
>
{wehr.name}
</Link>
) : (
<p className="font-medium text-navy">{wehr.name}</p>
)}
{adresse ? (
<p className="text-sm text-anthrazit/70">{adresse}</p>
) : null}
{wehr.wehrfuehrer ? (
<p className="mt-1 text-sm text-anthrazit/70">
{t("wehr.wehrfuehrer")}: {wehr.wehrfuehrer}
</p>
) : null}
</div>
<div className="mt-3">
<KontaktButton
telefon={wehr.telefon}
email={wehr.email}
subject={t("kontakt.betreff")}
/>
</div>
</section>
);
}

View File

@@ -0,0 +1,81 @@
import { describe, it, expect } from "vitest";
import { formatMerkmal, toEckdaten, type MerkmalRow } from "./merkmale";
function row(p: Partial<MerkmalRow>): 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([]);
});
});

View File

@@ -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),
}));
}

View File

@@ -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<string, unknown> {
const builder: Record<string, unknown> = {};
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" });
});
});

252
src/lib/detail/queries.ts Normal file
View File

@@ -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<MerkmalRow[]> {
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<BrigadeCard | null> {
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<FahrzeugDetail | null> {
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<GeraetDetail | null> {
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<WehrDetail | null> {
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 };
}

View File

@@ -55,7 +55,29 @@ export const de = {
eckdaten: "Eckdaten", eckdaten: "Eckdaten",
beladung: "Beladung", beladung: "Beladung",
keineEckdaten: "Keine Eckdaten erfasst.", keineEckdaten: "Keine Eckdaten erfasst.",
keineBeladung: "Keine Beladung zugeordnet.",
imGeraetehaus: "im Gerätehaus", 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: { fehler: {
allgemein: "Es ist ein Fehler aufgetreten.", allgemein: "Es ist ein Fehler aufgetreten.",

View File

@@ -20,6 +20,9 @@ const PROTECTED_PAGES = [
"/", "/",
"/start", "/start",
"/fahrzeuge", "/fahrzeuge",
"/fahrzeuge/00000000-0000-0000-0000-000000000001",
"/geraete/00000000-0000-0000-0000-000000000002",
"/wehren/00000000-0000-0000-0000-000000000003",
"/geraete", "/geraete",
"/wehren", "/wehren",
"/verwaltung", "/verwaltung",

View File

@@ -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/<id>`), 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();
});
});