Schliesst die letzte offene Luecke aus Phase 1. Bis dahin liessen sich Geraete
nur ueber den Import erfassen — fuer einen Bestand richtig, fuer ein einzelnes
nachbeschafftes Geraet umstaendlich.
Neu:
- /geraete/neu und /geraete/<id>/bearbeiten, beide ueber dieselbe Komponente
- Die Lagerung ist ein Umschalter statt zweier Auswahlfelder. Standort und
Fahrzeug schliessen sich aus; so kann der Fehler gar nicht erst entstehen,
den die Datenbank sonst abweisen wuerde. Beim Wechsel des Fahrzeugs wird die
Raumangabe zurueckgesetzt.
- Prueftermin und Lebensdauerende werden beim Tippen vorgerechnet, mit
denselben Funktionen wie auf dem Server. Beim Speichern rechnet der Server
erneut und behaelt das letzte Wort.
- Gibt es nur einen Standort, ist er beim Anlegen vorbelegt.
- Knopf "Neues Geraet" in der Liste, "Bearbeiten" auf der Detailseite
Behoben:
- Das Formular stuerzte beim ersten Rechnen ab. Die Vorgabewerte lagen in einem
"use client"-Modul und wurden von einer Server-Komponente importiert; dort
kommt nur eine Client-Referenz an, kein Objekt, und das Spreaden ergibt lauter
undefined. Die Werte liegen jetzt in geraet-formular-werte.ts ohne
"use client".
- Das Sternchen an Pflichtfeldern wurde von Sprachausgaben mitgelesen
("Bezeichnung Stern"). Es traegt jetzt aria-hidden; dass ein Feld ausgefuellt
werden muss, sagt bereits das required-Attribut.
- Fehlermeldungen in Formularen sind eindeutig ansteuerbar. Next.js haengt
einen eigenen Routen-Ansager mit der Rolle "alert" in jede Seite.
Tests:
- 7 neue Playwright-Tests zum Formular, darunter die Lagerungsregel und die
Fristenvorschau
- Zwei davon laufen nicht unter Touch-Emulation: Chromium nimmt dort keine
zeichenweise Eingabe in Datumsfelder an. Begruendet uebersprungen, die
Rechnung selbst deckt qr-und-fristen.test.ts ab.
- Insgesamt 104 Unit-Tests und 56 E2E-Tests gruen
Anleitung, Changelog, Devlog, Aufwandsschaetzung und Roadmap fortgeschrieben.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
610 lines
22 KiB
TypeScript
610 lines
22 KiB
TypeScript
"use client";
|
|
|
|
import * as React from "react";
|
|
import { useRouter } from "next/navigation";
|
|
import Link from "next/link";
|
|
import type { GeraetStatus } from "@prisma/client";
|
|
import { Info, Save, X } from "lucide-react";
|
|
import { trpc } from "@/lib/trpc/client";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { Fehlermeldung, GERAET_STATUS_TEXT } from "@/components/stammdaten/anzeige";
|
|
import { alsDatum, cn } from "@/lib/utils";
|
|
import {
|
|
effektiverWert,
|
|
lebensdauerEnde,
|
|
naechstePruefung,
|
|
} from "@/lib/stammdaten/fristen";
|
|
import type { GeraetFormularWerte } from "@/components/stammdaten/geraet-formular-werte";
|
|
|
|
/**
|
|
* Formular zum Anlegen und Bearbeiten eines Geräts.
|
|
*
|
|
* Zwei Dinge sind hier wichtiger als das Formular selbst:
|
|
*
|
|
* 1. Die Lagerung ist eine Entweder-oder-Entscheidung. Das Formular führt
|
|
* sie als Umschalter statt als zwei Auswahlfelder, die man versehentlich
|
|
* beide füllen kann — die Datenbank würde das ohnehin abweisen, aber eine
|
|
* Fehlermeldung nach dem Absenden ist schlechter als ein Formular, in dem
|
|
* der Fehler gar nicht erst möglich ist.
|
|
*
|
|
* 2. Prüftermin und Lebensdauerende werden schon beim Tippen ausgerechnet und
|
|
* angezeigt. Gerechnet wird mit denselben Funktionen wie auf dem Server
|
|
* (lib/stammdaten/fristen.ts) — beim Speichern rechnet der Server erneut
|
|
* und behält das letzte Wort.
|
|
*
|
|
* Das Sternchen an Pflichtfeldern trägt aria-hidden: Es ist eine optische
|
|
* Markierung. Ohne das läse eine Sprachausgabe „Bezeichnung Stern" vor. Dass
|
|
* ein Feld ausgefüllt werden muss, sagt bereits das required-Attribut.
|
|
*/
|
|
|
|
/** Und zurück. Ein leeres Feld bedeutet "nicht gesetzt", nicht "1970". */
|
|
function alsDatumOderNull(wert: string): Date | null {
|
|
if (!wert) return null;
|
|
const [jahr, monat, tag] = wert.split("-").map(Number);
|
|
if (!jahr || !monat || !tag) return null;
|
|
return new Date(Date.UTC(jahr, monat - 1, tag, 12, 0, 0));
|
|
}
|
|
|
|
function alsZahlOderNull(wert: string): number | null {
|
|
const bereinigt = wert.trim().replace(",", ".");
|
|
if (!bereinigt) return null;
|
|
const zahl = Number(bereinigt);
|
|
return Number.isFinite(zahl) ? zahl : null;
|
|
}
|
|
|
|
const KEINE = "__keine__";
|
|
|
|
export function GeraetFormular({
|
|
vorgabe,
|
|
titel,
|
|
beschreibung,
|
|
}: {
|
|
vorgabe: GeraetFormularWerte;
|
|
titel: string;
|
|
beschreibung: string;
|
|
}) {
|
|
const router = useRouter();
|
|
const werkzeuge = trpc.useUtils();
|
|
|
|
const [werte, setWerte] = React.useState<GeraetFormularWerte>(vorgabe);
|
|
const [fehler, setFehler] = React.useState<string | null>(null);
|
|
|
|
const standorte = trpc.standort.auswahl.useQuery();
|
|
const fahrzeuge = trpc.fahrzeug.auswahl.useQuery();
|
|
const geraetetypen = trpc.geraetetyp.auswahl.useQuery();
|
|
const raeume = trpc.geraeteraum.zuFahrzeug.useQuery(
|
|
{ fahrzeugId: werte.fahrzeugId ?? "" },
|
|
{ enabled: werte.lagerungsart === "fahrzeug" && Boolean(werte.fahrzeugId) },
|
|
);
|
|
|
|
const bearbeiten = Boolean(werte.id);
|
|
|
|
function setzen<K extends keyof GeraetFormularWerte>(feld: K, wert: GeraetFormularWerte[K]) {
|
|
setWerte((alt) => ({ ...alt, [feld]: wert }));
|
|
}
|
|
|
|
const gewaehlterTyp = geraetetypen.data?.find((typ) => typ.id === werte.geraetetypId);
|
|
|
|
/**
|
|
* Vorschau der Fristen. Rechnet mit denselben Funktionen wie der Server, damit
|
|
* beim Speichern nichts Überraschendes herauskommt.
|
|
*/
|
|
const vorschau = React.useMemo(() => {
|
|
const intervall = effektiverWert(
|
|
alsZahlOderNull(werte.pruefintervallMonate),
|
|
gewaehlterTyp?.pruefintervallMonate ?? null,
|
|
);
|
|
const jahre = effektiverWert(
|
|
alsZahlOderNull(werte.lebensdauerJahre),
|
|
gewaehlterTyp?.lebensdauerJahre ?? null,
|
|
);
|
|
|
|
return {
|
|
intervall,
|
|
naechstePruefung: naechstePruefung({
|
|
letztePruefung: alsDatumOderNull(werte.letztePruefung),
|
|
indienststellung: alsDatumOderNull(werte.indienststellung),
|
|
intervallMonate: intervall,
|
|
}),
|
|
lebensdauerBis: lebensdauerEnde({
|
|
herstellungsdatum: alsDatumOderNull(werte.herstellungsdatum),
|
|
indienststellung: alsDatumOderNull(werte.indienststellung),
|
|
lebensdauerJahre: jahre,
|
|
bezug: gewaehlterTyp?.lebensdauerBezug ?? "INDIENSTSTELLUNG",
|
|
}),
|
|
jahre,
|
|
};
|
|
}, [werte, gewaehlterTyp]);
|
|
|
|
const gemeinsam = {
|
|
onSuccess: (geraet: { id: string }) => {
|
|
void werkzeuge.geraet.liste.invalidate();
|
|
void werkzeuge.organisation.kennzahlen.invalidate();
|
|
router.push(`/geraete/${geraet.id}`);
|
|
router.refresh();
|
|
},
|
|
onError: (ausnahme: { message: string }) => setFehler(ausnahme.message),
|
|
};
|
|
|
|
const anlegen = trpc.geraet.anlegen.useMutation(gemeinsam);
|
|
const aendern = trpc.geraet.aendern.useMutation(gemeinsam);
|
|
const laeuft = anlegen.isPending || aendern.isPending;
|
|
|
|
function absenden(ereignis: React.FormEvent) {
|
|
ereignis.preventDefault();
|
|
setFehler(null);
|
|
|
|
const lagerung =
|
|
werte.lagerungsart === "fahrzeug"
|
|
? {
|
|
standortId: null,
|
|
fahrzeugId: werte.fahrzeugId,
|
|
geraeteraumId: werte.geraeteraumId,
|
|
}
|
|
: { standortId: werte.standortId, fahrzeugId: null, geraeteraumId: null };
|
|
|
|
if (werte.lagerungsart === "fahrzeug" && !werte.fahrzeugId) {
|
|
setFehler("Bitte ein Fahrzeug auswählen — oder auf „Standort“ umschalten.");
|
|
return;
|
|
}
|
|
if (werte.lagerungsart === "standort" && !werte.standortId) {
|
|
setFehler("Bitte einen Standort auswählen — oder auf „Fahrzeug“ umschalten.");
|
|
return;
|
|
}
|
|
|
|
const daten = {
|
|
inventarnummer: werte.inventarnummer.trim(),
|
|
bezeichnung: werte.bezeichnung.trim(),
|
|
geraetetypId: werte.geraetetypId,
|
|
hersteller: werte.hersteller.trim() || null,
|
|
modell: werte.modell.trim() || null,
|
|
seriennummer: werte.seriennummer.trim() || null,
|
|
herstellungsdatum: alsDatumOderNull(werte.herstellungsdatum),
|
|
indienststellung: alsDatumOderNull(werte.indienststellung),
|
|
anschaffungspreis: alsZahlOderNull(werte.anschaffungspreis),
|
|
status: werte.status,
|
|
pruefintervallMonate: alsZahlOderNull(werte.pruefintervallMonate),
|
|
letztePruefung: alsDatumOderNull(werte.letztePruefung),
|
|
lebensdauerJahre: alsZahlOderNull(werte.lebensdauerJahre),
|
|
lagerort: werte.lagerort.trim() || null,
|
|
bemerkung: werte.bemerkung.trim() || null,
|
|
...lagerung,
|
|
};
|
|
|
|
if (werte.id) {
|
|
aendern.mutate({ id: werte.id, ...daten });
|
|
} else {
|
|
anlegen.mutate(daten);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<form onSubmit={absenden} className="space-y-6">
|
|
<div>
|
|
<h1 className="text-2xl font-semibold tracking-tight">{titel}</h1>
|
|
<p className="text-sm text-[var(--muted-foreground)]">{beschreibung}</p>
|
|
</div>
|
|
|
|
{fehler && <Fehlermeldung meldung={fehler} />}
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Was ist es?</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="grid gap-4 sm:grid-cols-2">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="inventarnummer">
|
|
Inventarnummer <span className="text-[var(--destructive)]" aria-hidden>*</span>
|
|
</Label>
|
|
<Input
|
|
id="inventarnummer"
|
|
value={werte.inventarnummer}
|
|
onChange={(e) => setzen("inventarnummer", e.target.value)}
|
|
placeholder="INV-0050"
|
|
required
|
|
maxLength={60}
|
|
autoComplete="off"
|
|
/>
|
|
<p className="text-xs text-[var(--muted-foreground)]">
|
|
Muss innerhalb der Wehr eindeutig sein. Sie steht später auf dem Etikett und
|
|
im QR-Code.
|
|
</p>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="bezeichnung">
|
|
Bezeichnung <span className="text-[var(--destructive)]" aria-hidden>*</span>
|
|
</Label>
|
|
<Input
|
|
id="bezeichnung"
|
|
value={werte.bezeichnung}
|
|
onChange={(e) => setzen("bezeichnung", e.target.value)}
|
|
placeholder="Pressluftatmer PA 94"
|
|
required
|
|
maxLength={160}
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-2 sm:col-span-2">
|
|
<Label htmlFor="geraetetyp">Gerätetyp</Label>
|
|
<Select
|
|
value={werte.geraetetypId ?? KEINE}
|
|
onValueChange={(wert) => {
|
|
const typId = wert === KEINE ? null : wert;
|
|
const typ = geraetetypen.data?.find((eintrag) => eintrag.id === typId);
|
|
setWerte((alt) => ({
|
|
...alt,
|
|
geraetetypId: typId,
|
|
// Leere Felder aus dem Typ vorbelegen — Ausgefülltes bleibt.
|
|
bezeichnung: alt.bezeichnung || (typ?.name ?? ""),
|
|
}));
|
|
}}
|
|
>
|
|
<SelectTrigger id="geraetetyp">
|
|
<SelectValue placeholder="— keiner —" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value={KEINE}>— keiner —</SelectItem>
|
|
{geraetetypen.data?.map((typ) => (
|
|
<SelectItem key={typ.id} value={typ.id}>
|
|
{typ.name}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
<p className="text-xs text-[var(--muted-foreground)]">
|
|
Der Typ gibt Prüfintervall, Prüfvorschrift und Lebensdauer vor. Wer unten
|
|
etwas einträgt, überschreibt die Vorgabe für dieses eine Gerät.
|
|
</p>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="hersteller">Hersteller</Label>
|
|
<Input
|
|
id="hersteller"
|
|
value={werte.hersteller}
|
|
onChange={(e) => setzen("hersteller", e.target.value)}
|
|
maxLength={120}
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="modell">Modell</Label>
|
|
<Input
|
|
id="modell"
|
|
value={werte.modell}
|
|
onChange={(e) => setzen("modell", e.target.value)}
|
|
maxLength={120}
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="seriennummer">Seriennummer</Label>
|
|
<Input
|
|
id="seriennummer"
|
|
value={werte.seriennummer}
|
|
onChange={(e) => setzen("seriennummer", e.target.value)}
|
|
maxLength={120}
|
|
autoComplete="off"
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="status">Status</Label>
|
|
<Select
|
|
value={werte.status}
|
|
onValueChange={(wert) => setzen("status", wert as GeraetStatus)}
|
|
>
|
|
<SelectTrigger id="status">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{Object.entries(GERAET_STATUS_TEXT).map(([wert, text]) => (
|
|
<SelectItem key={wert} value={wert}>
|
|
{text}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
<p className="text-xs text-[var(--muted-foreground)]">
|
|
Aus einer überfälligen Prüfung folgt „Prüfung fällig“ von selbst. Gesperrt,
|
|
ausgesondert und in Reparatur bleiben so stehen, wie sie gesetzt wurden.
|
|
</p>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Alter, Prüfung und Lebensdauer</CardTitle>
|
|
<CardDescription>
|
|
Leer gelassene Felder übernehmen die Vorgabe des Gerätetyps.
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<div className="grid gap-4 sm:grid-cols-3">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="herstellungsdatum">Herstellungsdatum</Label>
|
|
<Input
|
|
id="herstellungsdatum"
|
|
type="date"
|
|
value={werte.herstellungsdatum}
|
|
onChange={(e) => setzen("herstellungsdatum", e.target.value)}
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="indienststellung">Indienststellung</Label>
|
|
<Input
|
|
id="indienststellung"
|
|
type="date"
|
|
value={werte.indienststellung}
|
|
onChange={(e) => setzen("indienststellung", e.target.value)}
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="anschaffungspreis">Anschaffungspreis (Euro)</Label>
|
|
<Input
|
|
id="anschaffungspreis"
|
|
inputMode="decimal"
|
|
value={werte.anschaffungspreis}
|
|
onChange={(e) => setzen("anschaffungspreis", e.target.value)}
|
|
placeholder="1899,00"
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="pruefintervall">Prüfintervall (Monate)</Label>
|
|
<Input
|
|
id="pruefintervall"
|
|
type="number"
|
|
min={1}
|
|
max={600}
|
|
value={werte.pruefintervallMonate}
|
|
onChange={(e) => setzen("pruefintervallMonate", e.target.value)}
|
|
placeholder={
|
|
gewaehlterTyp?.pruefintervallMonate
|
|
? String(gewaehlterTyp.pruefintervallMonate)
|
|
: ""
|
|
}
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="letztePruefung">Letzte Prüfung</Label>
|
|
<Input
|
|
id="letztePruefung"
|
|
type="date"
|
|
value={werte.letztePruefung}
|
|
onChange={(e) => setzen("letztePruefung", e.target.value)}
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="lebensdauer">Lebensdauer (Jahre)</Label>
|
|
<Input
|
|
id="lebensdauer"
|
|
type="number"
|
|
min={1}
|
|
max={100}
|
|
value={werte.lebensdauerJahre}
|
|
onChange={(e) => setzen("lebensdauerJahre", e.target.value)}
|
|
placeholder={
|
|
gewaehlterTyp?.lebensdauerJahre ? String(gewaehlterTyp.lebensdauerJahre) : ""
|
|
}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Vorschau: was der Server daraus rechnen wird. */}
|
|
<div className="flex items-start gap-3 rounded-md border border-[var(--border)] bg-[var(--muted)] p-3 text-sm">
|
|
<Info className="mt-0.5 size-4 shrink-0 text-[var(--muted-foreground)]" aria-hidden />
|
|
<div className="space-y-1">
|
|
<p className="font-medium">Daraus ergibt sich</p>
|
|
<dl className="grid gap-x-6 gap-y-1 sm:grid-cols-2">
|
|
<div className="flex gap-2">
|
|
<dt className="text-[var(--muted-foreground)]">Nächste Prüfung:</dt>
|
|
<dd className="tabellenzahlen">
|
|
{vorschau.naechstePruefung ? alsDatum(vorschau.naechstePruefung) : "keine"}
|
|
</dd>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<dt className="text-[var(--muted-foreground)]">Lebensdauer bis:</dt>
|
|
<dd className="tabellenzahlen">
|
|
{vorschau.lebensdauerBis ? alsDatum(vorschau.lebensdauerBis) : "unbegrenzt"}
|
|
</dd>
|
|
</div>
|
|
</dl>
|
|
{vorschau.intervall !== null &&
|
|
!werte.pruefintervallMonate &&
|
|
gewaehlterTyp?.pruefintervallMonate && (
|
|
<p className="text-xs text-[var(--muted-foreground)]">
|
|
Prüfintervall {vorschau.intervall} Monate — aus dem Gerätetyp
|
|
„{gewaehlterTyp.name}“.
|
|
</p>
|
|
)}
|
|
{!vorschau.naechstePruefung && (
|
|
<p className="text-xs text-[var(--muted-foreground)]">
|
|
Ohne Prüfintervall und ohne Indienststellung gibt es keinen Termin. Das ist
|
|
der Normalfall für Geräte ohne Prüfpflicht.
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Wo liegt es?</CardTitle>
|
|
<CardDescription>
|
|
Entweder an einem Standort oder auf einem Fahrzeug — beides zugleich geht nicht.
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
{/* Umschalter statt zwei Feldern, die man beide füllen kann. */}
|
|
<div
|
|
role="radiogroup"
|
|
aria-label="Art der Lagerung"
|
|
className="inline-flex rounded-md border border-[var(--border)] p-0.5"
|
|
>
|
|
{(
|
|
[
|
|
["standort", "An einem Standort"],
|
|
["fahrzeug", "Auf einem Fahrzeug"],
|
|
] as const
|
|
).map(([wert, text]) => (
|
|
<button
|
|
key={wert}
|
|
type="button"
|
|
role="radio"
|
|
aria-checked={werte.lagerungsart === wert}
|
|
onClick={() =>
|
|
setWerte((alt) => ({
|
|
...alt,
|
|
lagerungsart: wert,
|
|
...(wert === "standort"
|
|
? { fahrzeugId: null, geraeteraumId: null }
|
|
: { standortId: null }),
|
|
}))
|
|
}
|
|
className={cn(
|
|
"rounded px-3 py-1.5 text-sm transition-colors",
|
|
werte.lagerungsart === wert
|
|
? "bg-[var(--primary)] text-[var(--primary-foreground)] font-medium"
|
|
: "hover:bg-[var(--accent)]",
|
|
)}
|
|
>
|
|
{text}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{werte.lagerungsart === "standort" ? (
|
|
<div className="space-y-2 sm:max-w-md">
|
|
<Label htmlFor="standort">
|
|
Standort <span className="text-[var(--destructive)]" aria-hidden>*</span>
|
|
</Label>
|
|
<Select
|
|
value={werte.standortId ?? ""}
|
|
onValueChange={(wert) => setzen("standortId", wert)}
|
|
>
|
|
<SelectTrigger id="standort">
|
|
<SelectValue placeholder="Bitte auswählen" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{standorte.data?.map((standort) => (
|
|
<SelectItem key={standort.id} value={standort.id}>
|
|
{standort.name}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
) : (
|
|
<div className="grid gap-4 sm:grid-cols-2">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="fahrzeug">
|
|
Fahrzeug <span className="text-[var(--destructive)]" aria-hidden>*</span>
|
|
</Label>
|
|
<Select
|
|
value={werte.fahrzeugId ?? ""}
|
|
onValueChange={(wert) =>
|
|
// Der Raum gehört zum Fahrzeug — beim Wechsel muss er weg.
|
|
setWerte((alt) => ({ ...alt, fahrzeugId: wert, geraeteraumId: null }))
|
|
}
|
|
>
|
|
<SelectTrigger id="fahrzeug">
|
|
<SelectValue placeholder="Bitte auswählen" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{fahrzeuge.data?.map((fahrzeug) => (
|
|
<SelectItem key={fahrzeug.id} value={fahrzeug.id}>
|
|
{fahrzeug.funkrufname}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="geraeteraum">Geräteraum</Label>
|
|
<Select
|
|
value={werte.geraeteraumId ?? KEINE}
|
|
onValueChange={(wert) =>
|
|
setzen("geraeteraumId", wert === KEINE ? null : wert)
|
|
}
|
|
disabled={!werte.fahrzeugId}
|
|
>
|
|
<SelectTrigger id="geraeteraum">
|
|
<SelectValue placeholder="— ohne Raumangabe —" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value={KEINE}>— ohne Raumangabe —</SelectItem>
|
|
{raeume.data?.map((raum) => (
|
|
<SelectItem key={raum.id} value={raum.id}>
|
|
{raum.bezeichnung}
|
|
{raum.beschreibung ? ` — ${raum.beschreibung}` : ""}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
<p className="text-xs text-[var(--muted-foreground)]">
|
|
Darf leer bleiben: manches liegt lose in der Kabine.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="space-y-2 sm:max-w-md">
|
|
<Label htmlFor="lagerort">Genaue Stelle</Label>
|
|
<Input
|
|
id="lagerort"
|
|
value={werte.lagerort}
|
|
onChange={(e) => setzen("lagerort", e.target.value)}
|
|
placeholder="Regal 3, Fach B"
|
|
maxLength={160}
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="bemerkung">Bemerkung</Label>
|
|
<Textarea
|
|
id="bemerkung"
|
|
value={werte.bemerkung}
|
|
onChange={(e) => setzen("bemerkung", e.target.value)}
|
|
maxLength={1000}
|
|
rows={3}
|
|
/>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<div className="flex flex-wrap gap-2">
|
|
<Button type="submit" disabled={laeuft}>
|
|
<Save aria-hidden />
|
|
{laeuft ? "Wird gespeichert …" : bearbeiten ? "Änderungen speichern" : "Gerät anlegen"}
|
|
</Button>
|
|
<Button type="button" variant="outline" asChild>
|
|
<Link href={werte.id ? `/geraete/${werte.id}` : "/geraete"}>
|
|
<X aria-hidden />
|
|
Abbrechen
|
|
</Link>
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
);
|
|
}
|