ESLint hatte keine Konfiguration — das Skript "npm run lint" pruefte nichts. Jetzt Flat Config ohne Formatierungsregeln, dafuer mit den Regeln, die die Fehler dieses Projekts gemeldet haetten. Er hat gleich zwei gefunden, die niemand gesucht hatte. Das Badge rendert ein span statt eines div. Damit ist die Meldung "div cannot be a descendant of p" weg, die seit Phase 3 auf der Einsatzseite stand — sie ist der klassische Ausloeser fuer einen Hydration-Fehler. Die E2E-Tests laufen gegen eine eigene Wehr. Nach fuenf Phasen standen in der Beispielwehr 120 Atemschutz-Ueberwachungen statt einer und 115 Geraete statt 45; bei jedem Lauf aenderten sich alle Bildschirmfotos. Der Bestand ist einmal zurueckgesetzt, npm run db:test-reset baut die Testwehr neu auf. Behoben nebenbei: - Die Fehlerflaeche der Karte konnte nie etwas anzeigen. - Zwei useMemo der Lagekarte rechneten bei jedem Rendern neu. - tests/e2e war vom Typecheck ausgenommen und liess einen doppelt deklarierten Bezeichner durch. Closes #10 Closes #11 Closes #12 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
212 lines
7.7 KiB
TypeScript
212 lines
7.7 KiB
TypeScript
import Link from "next/link";
|
|
import { notFound, redirect } from "next/navigation";
|
|
import { ArrowLeft, Package } from "lucide-react";
|
|
import { aktuelleSitzung } from "@/lib/auth";
|
|
import { fuerOrganisation } from "@/lib/db";
|
|
import { alsDatum } from "@/lib/utils";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { FristAnzeige, StatusAbzeichen } from "@/components/stammdaten/anzeige";
|
|
|
|
export const metadata = { title: "Fahrzeug" };
|
|
|
|
/**
|
|
* Fahrzeugdetail mit der Beladung, nach Geräteräumen geordnet.
|
|
*
|
|
* Genau so sucht der Gerätewart: nicht "wo ist die Motorsäge", sondern "was
|
|
* ist in G3". Deshalb steht die Beladeordnung im Mittelpunkt und nicht eine
|
|
* alphabetische Geräteliste.
|
|
*/
|
|
export default async function FahrzeugSeite({ params }: { params: Promise<{ id: string }> }) {
|
|
const sitzung = await aktuelleSitzung();
|
|
if (!sitzung) redirect("/anmelden");
|
|
|
|
const { id } = await params;
|
|
|
|
const fahrzeug = await fuerOrganisation(sitzung.organisationId, (db) =>
|
|
db.fahrzeug.findUnique({
|
|
where: { id },
|
|
include: {
|
|
standort: { select: { id: true, name: true } },
|
|
geraeteraeume: {
|
|
where: { geloeschtAm: null },
|
|
orderBy: [{ sortierung: "asc" }, { bezeichnung: "asc" }],
|
|
include: {
|
|
geraete: {
|
|
where: { geloeschtAm: null },
|
|
orderBy: { bezeichnung: "asc" },
|
|
select: {
|
|
id: true,
|
|
inventarnummer: true,
|
|
bezeichnung: true,
|
|
status: true,
|
|
naechstePruefung: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
geraete: {
|
|
where: { geloeschtAm: null, geraeteraumId: null },
|
|
orderBy: { bezeichnung: "asc" },
|
|
select: {
|
|
id: true,
|
|
inventarnummer: true,
|
|
bezeichnung: true,
|
|
status: true,
|
|
naechstePruefung: true,
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
|
|
if (!fahrzeug) notFound();
|
|
|
|
/*
|
|
* eslint-disable-next-line react/jsx-key -- Fehlalarm: Diese JSX-Elemente
|
|
* sind nicht die Kinder einer Liste, sondern die zweiten Glieder von
|
|
* Wertepaaren. Gerendert werden sie unten in einer map, deren Wrapper den
|
|
* key traegt (`<div key={bezeichnung}>`). Ein key an dieser Stelle waere
|
|
* wirkungslos und wuerde nur verschleiern, wo er wirklich sitzt.
|
|
*/
|
|
/* eslint-disable react/jsx-key */
|
|
const angaben: Array<[string, React.ReactNode]> = [
|
|
["Typ nach DIN", fahrzeug.dinTyp ?? "—"],
|
|
["Kennzeichen", <span className="tabellenzahlen">{fahrzeug.kennzeichen ?? "—"}</span>],
|
|
["Hersteller", fahrzeug.hersteller ?? "—"],
|
|
["Aufbauhersteller", fahrzeug.aufbauhersteller ?? "—"],
|
|
[
|
|
"Fahrgestellnummer",
|
|
<span className="tabellenzahlen">{fahrzeug.fahrgestellnummer ?? "—"}</span>,
|
|
],
|
|
["Baujahr", <span className="tabellenzahlen">{fahrzeug.baujahr ?? "—"}</span>],
|
|
["Erstzulassung", alsDatum(fahrzeug.erstzulassung)],
|
|
["Indienststellung", alsDatum(fahrzeug.indienststellung)],
|
|
["Nächster TÜV", <FristAnzeige datum={fahrzeug.tuevBis} vorwarnTage={60} />],
|
|
["Sicherheitsprüfung (UVV)", <FristAnzeige datum={fahrzeug.uvvBis} vorwarnTage={60} />],
|
|
["Sitzplätze", <span className="tabellenzahlen">{fahrzeug.sitzplaetze ?? "—"}</span>],
|
|
[
|
|
"Löschwasser",
|
|
fahrzeug.tankinhalt ? (
|
|
<span className="tabellenzahlen">{fahrzeug.tankinhalt.toLocaleString("de-DE")} l</span>
|
|
) : (
|
|
"—"
|
|
),
|
|
],
|
|
["Standort", fahrzeug.standort?.name ?? "—"],
|
|
];
|
|
/* eslint-enable react/jsx-key */
|
|
|
|
const alleRaeume = [
|
|
...fahrzeug.geraeteraeume,
|
|
// Was keinem Raum zugeordnet ist, bekommt einen eigenen Abschnitt statt
|
|
// stillschweigend zu verschwinden.
|
|
...(fahrzeug.geraete.length > 0
|
|
? [
|
|
{
|
|
id: "__ohne__",
|
|
bezeichnung: "Ohne Raumangabe",
|
|
beschreibung: "Verlastet, aber keinem Geräteraum zugeordnet",
|
|
geraete: fahrzeug.geraete,
|
|
},
|
|
]
|
|
: []),
|
|
];
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div>
|
|
<Button variant="ghost" size="sm" asChild className="-ml-2 mb-1">
|
|
<Link href="/fahrzeuge">
|
|
<ArrowLeft aria-hidden />
|
|
Zurück zu den Fahrzeugen
|
|
</Link>
|
|
</Button>
|
|
<h1 className="text-2xl font-semibold tracking-tight">{fahrzeug.funkrufname}</h1>
|
|
<p className="text-sm text-[var(--muted-foreground)]">
|
|
{fahrzeug.dinTyp ? `${fahrzeug.dinTyp} — ` : ""}
|
|
{fahrzeug.bezeichnung}
|
|
</p>
|
|
</div>
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Angaben</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<dl className="grid gap-x-6 gap-y-3 sm:grid-cols-2 lg:grid-cols-3">
|
|
{angaben.map(([bezeichnung, wert]) => (
|
|
<div key={bezeichnung}>
|
|
<dt className="text-xs uppercase tracking-wide text-[var(--muted-foreground)]">
|
|
{bezeichnung}
|
|
</dt>
|
|
<dd className="text-sm">{wert}</dd>
|
|
</div>
|
|
))}
|
|
</dl>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Beladung</CardTitle>
|
|
<CardDescription>
|
|
Nach Geräteräumen geordnet, in der Reihenfolge der Beladeordnung.
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-5">
|
|
{alleRaeume.length === 0 && (
|
|
<p className="text-sm text-[var(--muted-foreground)]">
|
|
Für dieses Fahrzeug sind noch keine Geräteräume angelegt.
|
|
</p>
|
|
)}
|
|
|
|
{alleRaeume.map((raum) => (
|
|
<div key={raum.id}>
|
|
<div className="mb-2 flex items-baseline gap-2">
|
|
<Badge variant="secondary">{raum.bezeichnung}</Badge>
|
|
<span className="text-xs text-[var(--muted-foreground)]">
|
|
{raum.beschreibung}
|
|
</span>
|
|
<span className="ml-auto text-xs text-[var(--muted-foreground)]">
|
|
{raum.geraete.length}{" "}
|
|
{raum.geraete.length === 1 ? "Gerät" : "Geräte"}
|
|
</span>
|
|
</div>
|
|
|
|
{raum.geraete.length === 0 ? (
|
|
<p className="pl-1 text-sm text-[var(--muted-foreground)]">leer</p>
|
|
) : (
|
|
<ul className="divide-y divide-[var(--border)] rounded-md border border-[var(--border)]">
|
|
{raum.geraete.map((geraet) => (
|
|
<li
|
|
key={geraet.id}
|
|
className="flex flex-wrap items-center gap-x-3 gap-y-1 px-3 py-2 text-sm"
|
|
>
|
|
<Package className="size-4 text-[var(--muted-foreground)]" aria-hidden />
|
|
<Link
|
|
href={`/geraete/${geraet.id}`}
|
|
className="font-medium underline-offset-4 hover:underline"
|
|
>
|
|
{geraet.bezeichnung}
|
|
</Link>
|
|
<span className="tabellenzahlen text-xs text-[var(--muted-foreground)]">
|
|
{geraet.inventarnummer}
|
|
</span>
|
|
<span className="ml-auto flex items-center gap-3">
|
|
<FristAnzeige datum={geraet.naechstePruefung} />
|
|
<StatusAbzeichen status={geraet.status} />
|
|
</span>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
))}
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|