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:
14
src/app/(app)/fahrzeuge/[id]/not-found.tsx
Normal file
14
src/app/(app)/fahrzeuge/[id]/not-found.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
43
src/app/(app)/fahrzeuge/[id]/page.tsx
Normal file
43
src/app/(app)/fahrzeuge/[id]/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
53
src/app/(app)/geraete/[id]/page.tsx
Normal file
53
src/app/(app)/geraete/[id]/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
101
src/app/(app)/wehren/[id]/page.tsx
Normal file
101
src/app/(app)/wehren/[id]/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
39
src/components/detail/BeladungListe.tsx
Normal file
39
src/components/detail/BeladungListe.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
38
src/components/detail/DetailHeader.tsx
Normal file
38
src/components/detail/DetailHeader.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
39
src/components/detail/EckdatenGrid.tsx
Normal file
39
src/components/detail/EckdatenGrid.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
6
src/components/detail/StatusBadge.tsx
Normal file
6
src/components/detail/StatusBadge.tsx
Normal 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";
|
||||||
49
src/components/kontakt/KontaktButton.tsx
Normal file
49
src/components/kontakt/KontaktButton.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
54
src/components/kontakt/WehrCard.tsx
Normal file
54
src/components/kontakt/WehrCard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
81
src/lib/detail/merkmale.test.ts
Normal file
81
src/lib/detail/merkmale.test.ts
Normal 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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
99
src/lib/detail/merkmale.ts
Normal file
99
src/lib/detail/merkmale.ts
Normal 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),
|
||||||
|
}));
|
||||||
|
}
|
||||||
112
src/lib/detail/queries.test.ts
Normal file
112
src/lib/detail/queries.test.ts
Normal 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
252
src/lib/detail/queries.ts
Normal 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 };
|
||||||
|
}
|
||||||
@@ -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.",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
83
tests/e2e/detail-auth.spec.ts
Normal file
83
tests/e2e/detail-auth.spec.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user