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",
|
||||
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.",
|
||||
|
||||
Reference in New Issue
Block a user