Geraeteformular zum Anlegen und Bearbeiten ergaenzen

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>
This commit is contained in:
Florian Wilde
2026-09-03 09:20:30 +02:00
co-authored by Claude Opus 5
parent 47b8e36a38
commit 1744403620
26 changed files with 1237 additions and 22 deletions
+15 -2
View File
@@ -131,11 +131,24 @@ export function LadeZeilen({ zeilen = 8, spalten = 6 }: { zeilen?: number; spalt
);
}
/** Fehlermeldung, die sagt, was schiefging — nicht nur, dass etwas schiefging. */
export function Fehlermeldung({ meldung }: { meldung: string }) {
/**
* Fehlermeldung, die sagt, was schiefging — nicht nur, dass etwas schiefging.
*
* Die Kennung ist nötig, weil Next.js einen eigenen Routen-Ansager mit der
* Rolle "alert" in jede Seite hängt. Ohne sie ist nicht eindeutig, welches der
* beiden gemeint ist.
*/
export function Fehlermeldung({
meldung,
kennung = "formularfehler",
}: {
meldung: string;
kennung?: string;
}) {
return (
<div
role="alert"
data-testid={kennung}
className="rounded-md border border-[var(--destructive)]/40 bg-[var(--destructive)]/10 p-3 text-sm text-[var(--destructive)]"
>
{meldung}
@@ -0,0 +1,63 @@
import type { GeraetStatus } from "@prisma/client";
/**
* Form und Vorgabewerte des Geräteformulars.
*
* Bewusst ein eigenes Modul ohne "use client": Die Seiten, die das Formular
* einbinden, sind Server-Komponenten und bauen die Vorgabewerte zusammen.
* Lägen diese Werte im Formularmodul selbst, bekäme der Server beim Import
* keine echten Daten, sondern nur eine Client-Referenz — das Spreaden davon
* ergibt lauter undefined, und das Formular stürzt beim ersten Rechnen ab.
*/
export interface GeraetFormularWerte {
id?: string;
inventarnummer: string;
bezeichnung: string;
geraetetypId: string | null;
hersteller: string;
modell: string;
seriennummer: string;
herstellungsdatum: string;
indienststellung: string;
anschaffungspreis: string;
status: GeraetStatus;
pruefintervallMonate: string;
letztePruefung: string;
lebensdauerJahre: string;
lagerungsart: "standort" | "fahrzeug";
standortId: string | null;
fahrzeugId: string | null;
geraeteraumId: string | null;
lagerort: string;
bemerkung: string;
}
export const LEERES_GERAET: GeraetFormularWerte = {
inventarnummer: "",
bezeichnung: "",
geraetetypId: null,
hersteller: "",
modell: "",
seriennummer: "",
herstellungsdatum: "",
indienststellung: "",
anschaffungspreis: "",
status: "EINSATZBEREIT",
pruefintervallMonate: "",
letztePruefung: "",
lebensdauerJahre: "",
lagerungsart: "standort",
standortId: null,
fahrzeugId: null,
geraeteraumId: null,
lagerort: "",
bemerkung: "",
};
/** Wandelt ein Datum aus der Datenbank in den Wert eines date-Feldes. */
export function alsFeldwert(datum: Date | string | null | undefined): string {
if (!datum) return "";
const wert = datum instanceof Date ? datum : new Date(datum);
return wert.toISOString().slice(0, 10);
}
@@ -0,0 +1,609 @@
"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>
);
}
+21
View File
@@ -0,0 +1,21 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Textarea = React.forwardRef<HTMLTextAreaElement, React.ComponentProps<"textarea">>(
({ className, ...props }, ref) => (
<textarea
ref={ref}
className={cn(
"flex min-h-20 w-full rounded-md border border-[var(--input)] bg-[var(--background)] px-3 py-2 text-sm shadow-sm",
"placeholder:text-[var(--muted-foreground)]",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ring)] focus-visible:ring-offset-1 focus-visible:ring-offset-[var(--background)]",
"disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
/>
),
);
Textarea.displayName = "Textarea";
export { Textarea };