"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(vorgabe); const [fehler, setFehler] = React.useState(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(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 (

{titel}

{beschreibung}

{fehler && } Was ist es?
setzen("inventarnummer", e.target.value)} placeholder="INV-0050" required maxLength={60} autoComplete="off" />

Muss innerhalb der Wehr eindeutig sein. Sie steht später auf dem Etikett und im QR-Code.

setzen("bezeichnung", e.target.value)} placeholder="Pressluftatmer PA 94" required maxLength={160} />

Der Typ gibt Prüfintervall, Prüfvorschrift und Lebensdauer vor. Wer unten etwas einträgt, überschreibt die Vorgabe für dieses eine Gerät.

setzen("hersteller", e.target.value)} maxLength={120} />
setzen("modell", e.target.value)} maxLength={120} />
setzen("seriennummer", e.target.value)} maxLength={120} autoComplete="off" />

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.

Alter, Prüfung und Lebensdauer Leer gelassene Felder übernehmen die Vorgabe des Gerätetyps.
setzen("herstellungsdatum", e.target.value)} />
setzen("indienststellung", e.target.value)} />
setzen("anschaffungspreis", e.target.value)} placeholder="1899,00" />
setzen("pruefintervallMonate", e.target.value)} placeholder={ gewaehlterTyp?.pruefintervallMonate ? String(gewaehlterTyp.pruefintervallMonate) : "" } />
setzen("letztePruefung", e.target.value)} />
setzen("lebensdauerJahre", e.target.value)} placeholder={ gewaehlterTyp?.lebensdauerJahre ? String(gewaehlterTyp.lebensdauerJahre) : "" } />
{/* Vorschau: was der Server daraus rechnen wird. */}

Daraus ergibt sich

Nächste Prüfung:
{vorschau.naechstePruefung ? alsDatum(vorschau.naechstePruefung) : "keine"}
Lebensdauer bis:
{vorschau.lebensdauerBis ? alsDatum(vorschau.lebensdauerBis) : "unbegrenzt"}
{vorschau.intervall !== null && !werte.pruefintervallMonate && gewaehlterTyp?.pruefintervallMonate && (

Prüfintervall {vorschau.intervall} Monate — aus dem Gerätetyp „{gewaehlterTyp.name}“.

)} {!vorschau.naechstePruefung && (

Ohne Prüfintervall und ohne Indienststellung gibt es keinen Termin. Das ist der Normalfall für Geräte ohne Prüfpflicht.

)}
Wo liegt es? Entweder an einem Standort oder auf einem Fahrzeug — beides zugleich geht nicht. {/* Umschalter statt zwei Feldern, die man beide füllen kann. */}
{( [ ["standort", "An einem Standort"], ["fahrzeug", "Auf einem Fahrzeug"], ] as const ).map(([wert, text]) => ( ))}
{werte.lagerungsart === "standort" ? (
) : (

Darf leer bleiben: manches liegt lose in der Kabine.

)}
setzen("lagerort", e.target.value)} placeholder="Regal 3, Fach B" maxLength={160} />