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")}

+ ) : ( + + )} +
+ ); +} 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 ( +
+ {tel ? ( + + {t("kontakt.anrufen")} + + ) : null} + {mailHref ? ( + + {t("kontakt.email")} + + ) : null} +
+ ); +} 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(); + }); +});