diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3a6b583..b478ed2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,7 +8,35 @@ Die Regeln dazu stehen in [docs/VERSIONIERUNG.md](docs/VERSIONIERUNG.md).
## [Unveröffentlicht]
-Noch nichts. Nächster Schritt ist Phase 2 (Wartungs- und Prüfmanagement).
+Nächster Schritt ist Phase 2 (Wartungs- und Prüfmanagement).
+
+### Hinzugefügt
+
+- **Formular zum Anlegen und Bearbeiten eines Geräts** (`/geraete/neu` und
+ `/geraete//bearbeiten`). Damit ist die letzte offene Lücke aus Phase 1
+ geschlossen: Ein Neuzugang lässt sich einzeln erfassen, ohne den Umweg über
+ eine Importdatei.
+ - Die Lagerung wird als Umschalter geführt statt als zwei Auswahlfelder, die
+ man versehentlich beide füllen kann.
+ - Prüftermin und Lebensdauerende werden schon beim Tippen ausgerechnet und
+ angezeigt — mit denselben Funktionen, mit denen der Server anschließend
+ rechnet.
+ - Ist nur ein Standort angelegt, wird er vorbelegt.
+- 7 weitere Playwright-Tests zum Formular (nun 58 statt 44, zwei davon unter
+ Touch-Emulation begründet übersprungen).
+
+### Behoben
+
+- **Das Formular stürzte 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. Das Spreaden davon ergab
+ lauter `undefined`. Die Werte liegen jetzt in einem eigenen Modul ohne
+ `"use client"`.
+- **Das Sternchen an Pflichtfeldern wurde vorgelesen.** Eine Sprachausgabe
+ nannte das Feld „Bezeichnung Stern". Das Sternchen trägt jetzt `aria-hidden`;
+ dass ein Feld ausgefüllt werden muss, sagt das `required`-Attribut.
+- Fehlermeldungen in Formularen sind eindeutig ansteuerbar (`data-testid`) —
+ Next.js hängt einen eigenen Routen-Ansager mit derselben Rolle in jede Seite.
## [0.2.0] — 2026-09-03
diff --git a/docs/ANLEITUNG.md b/docs/ANLEITUNG.md
index 7606e9f..507bdcd 100644
--- a/docs/ANLEITUNG.md
+++ b/docs/ANLEITUNG.md
@@ -186,6 +186,55 @@ bewusst keine zweite Historie: zwei Quellen könnten auseinanderlaufen.
---
+## 5a. Ein Gerät anlegen oder ändern
+
+
+
+Über **Neues Gerät** in der Geräteliste, oder über **Bearbeiten** auf der
+Detailseite eines vorhandenen Geräts. Für größere Bestände ist der Import der
+schnellere Weg; dieses Formular ist für den Neuzugang zwischendurch.
+
+Pflicht sind nur zwei Angaben: **Inventarnummer** und **Bezeichnung**. Alles
+andere kann später nachgetragen werden.
+
+### Der Gerätetyp nimmt Arbeit ab
+
+Wird oben ein Gerätetyp gewählt, gelten dessen Prüfintervall, Prüfvorschrift
+und Lebensdauer — man muss sie nicht eintragen. Die Felder darunter bleiben
+trotzdem änderbar: Was dort steht, überschreibt die Vorgabe für dieses eine
+Gerät. Das braucht man, wenn der Hersteller für eine bestimmte Charge etwas
+anderes vorschreibt.
+
+Ist die Bezeichnung noch leer, wird sie aus dem Typ übernommen.
+
+### „Daraus ergibt sich"
+
+Der graue Kasten zeigt, was aus den Angaben folgt: wann die nächste Prüfung
+fällig wird und wann die Nutzungsdauer endet. Er rechnet beim Tippen mit,
+sodass man vor dem Speichern sieht, was herauskommt. Gerechnet wird mit
+denselben Regeln wie auf dem Server — beim Speichern rechnet dieser erneut und
+behält das letzte Wort.
+
+Steht dort „keine", fehlt entweder das Prüfintervall oder die Indienststellung.
+Das ist kein Fehler: Für Geräte ohne Prüfpflicht ist es der Normalfall.
+
+### Wo liegt es?
+
+Entweder an einem Standort **oder** auf einem Fahrzeug. Der Umschalter lässt
+nur eines von beidem zu — deshalb kann hier gar nicht erst der Fehler
+entstehen, den die Datenbank sonst abweisen würde.
+
+Bei „Auf einem Fahrzeug" lässt sich zusätzlich der Geräteraum angeben. Er darf
+leer bleiben: Manches liegt lose in der Kabine. Wird das Fahrzeug gewechselt,
+wird die Raumangabe zurückgesetzt — der Raum gehört zum Fahrzeug.
+
+Gibt es in der Wehr nur einen Standort, ist er beim Anlegen schon ausgewählt.
+
+> Jede Änderung landet im Verlauf des Geräts. Wer wissen will, wann ein
+> Prüftermin verschoben wurde und von wem, findet es dort.
+
+---
+
## 6. Etiketten drucken
Der Bogen ist auf das gängige Format mit **65 Etiketten je DIN-A4-Seite**
diff --git a/docs/AUFWANDSSCHAETZUNG.md b/docs/AUFWANDSSCHAETZUNG.md
index d97a11e..7724ba6 100644
--- a/docs/AUFWANDSSCHAETZUNG.md
+++ b/docs/AUFWANDSSCHAETZUNG.md
@@ -31,7 +31,7 @@ Faktoren, die in die Schätzung eingehen:
| Phase | Inhalt | Vergleichsaufwand | Sitzungszeit geschätzt | Sitzungszeit tatsächlich |
|---|---|---:|---:|---:|
| 0 | Fundament | 12–16 PT | — | **1:00 h** |
-| 1 | Stammdaten | 10–14 PT | 2,5–3,5 h | **1:15 h** |
+| 1 | Stammdaten | 10–14 PT | 2,5–3,5 h | **2:05 h** |
| 2 | Wartung und Prüfung | 18–24 PT | 4,5–6 h | offen |
| 3 | Einsätze und Übungen | 12–16 PT | 3–4 h | offen |
| 4 | Kiosk-PWA mit Offline | 16–22 PT | 4–5,5 h | offen |
@@ -72,7 +72,7 @@ das nachträglich einbaut, zahlt ein Vielfaches.
## Phase 1 — Stammdaten
-**Vergleichsaufwand: 10–14 PT** · **Tatsächlich: 1:15 h Sitzungszeit**
+**Vergleichsaufwand: 10–14 PT** · **Tatsächlich: 2:05 h Sitzungszeit** (1:15 h Hauptteil, 0:50 h Nachtrag)
| Baustein | PT |
|---|---:|
@@ -97,8 +97,11 @@ Tabellenbibliothek nötig war. Nicht eingeplant war die Zeitzonen-Falle in der
Fristenrechnung; sie hätte in Phase 2 deutlich mehr gekostet, wenn sie erst
dort aufgefallen wäre.
-Offen geblieben ist ein Formular zum einzelnen Anlegen von Geräten
-(geschätzt 0,5 PT), siehe [DEVLOG.md](DEVLOG.md).
+Das zunächst offen gebliebene Formular zum einzelnen Anlegen von Geräten wurde
+als Nachtrag ergänzt — geschätzt waren 0,5 PT, tatsächlich 0:50 h Sitzungszeit.
+Die Hälfte davon ging für zwei Dinge drauf, die nichts mit dem Formular zu tun
+hatten: eine Server/Client-Grenze, die stillschweigend `undefined` liefert, und
+Datumsfelder, die sich in Chromium nicht automatisiert füllen lassen.
## Phase 2 — Wartung und Prüfung
diff --git a/docs/DEVLOG.md b/docs/DEVLOG.md
index 05dd5e0..791008d 100644
--- a/docs/DEVLOG.md
+++ b/docs/DEVLOG.md
@@ -12,7 +12,65 @@ Die aufgewendete Zeit wandert nach jedem Abschnitt in die Spalte
|---|---:|---:|---|
| Phase 0 — Fundament | 1:00 h | 1 | 03.09.2026 |
| Phase 1 — Stammdaten | 1:15 h | 1 | 03.09.2026 |
-| **Summe** | **2:15 h** | **2** | |
+| Phase 1 — Nachtrag Geräteformular | 0:50 h | 1 | 03.09.2026 |
+| **Summe** | **3:05 h** | **3** | |
+
+---
+
+## 2026-09-03 · Phase 1 — Nachtrag: Geräteformular
+
+**08:30 – 09:20 · 0:50 h**
+
+### Was entstanden ist
+
+Die letzte offene Lücke aus Phase 1: ein Formular zum Anlegen und Bearbeiten
+eines einzelnen Geräts. Bis dahin konnte man Geräte nur über den Import
+erfassen — für einen Bestand richtig, für den einen nachbeschafften Gerät
+umständlich.
+
+| Datei | Inhalt |
+|---|---|
+| `components/stammdaten/geraet-formular.tsx` | das Formular, geteilt von Anlegen und Bearbeiten |
+| `components/stammdaten/geraet-formular-werte.ts` | Typ und Vorgabewerte, bewusst ohne `"use client"` |
+| `components/ui/textarea.tsx` | fehlender Baustein |
+| `app/(app)/geraete/neu/page.tsx` | Anlegen |
+| `app/(app)/geraete/[id]/bearbeiten/page.tsx` | Bearbeiten |
+| `tests/e2e/geraet-formular.spec.ts` | 7 Tests |
+
+### Entscheidungen
+
+**Die Lagerung ist ein Umschalter, keine zwei Auswahlfelder.** Standort und
+Fahrzeug schließen sich aus. Zwei nebeneinanderstehende Felder laden dazu ein,
+beide zu füllen; die Datenbank weist das ab, aber eine Fehlermeldung nach dem
+Absenden ist schlechter als ein Formular, in dem der Fehler gar nicht möglich
+ist. Beim Wechsel des Fahrzeugs wird der Geräteraum geleert — er gehört zum
+Fahrzeug.
+
+**Die Fristen werden schon beim Tippen vorgerechnet.** Wer ein Prüfintervall
+einträgt, sieht sofort, wann die nächste Prüfung fällig wird. Gerechnet wird
+mit denselben Funktionen wie auf dem Server; beim Speichern rechnet der Server
+erneut und behält das letzte Wort. Die Vorschau ist Anzeige, keine Quelle.
+
+**Bei nur einem Standort wird er vorbelegt.** Das ist bei den meisten Wehren
+der Fall, und ein Pflichtfeld weniger ist ein Klick weniger.
+
+### Wo es gehakt hat
+
+| Problem | Ursache | Lösung |
+|---|---|---|
+| Formular stürzte beim ersten Rechnen ab | `LEERES_GERAET` lag im `"use client"`-Modul; eine Server-Komponente bekommt beim Import nur eine Client-Referenz, das Spreaden ergibt `undefined` | Werte in ein eigenes Modul ohne `"use client"` |
+| E2E fand die Felder nicht | `getByLabel` vergleicht den Text des `
) : undefined
}
/>
diff --git a/src/app/(app)/geraete/neu/page.tsx b/src/app/(app)/geraete/neu/page.tsx
new file mode 100644
index 0000000..378939b
--- /dev/null
+++ b/src/app/(app)/geraete/neu/page.tsx
@@ -0,0 +1,55 @@
+import Link from "next/link";
+import { redirect } from "next/navigation";
+import { ArrowLeft } from "lucide-react";
+import { aktuelleSitzung } from "@/lib/auth";
+import { hatRecht } from "@/lib/authz";
+import { fuerOrganisation } from "@/lib/db";
+import { Button } from "@/components/ui/button";
+import { GeraetFormular } from "@/components/stammdaten/geraet-formular";
+import { LEERES_GERAET } from "@/components/stammdaten/geraet-formular-werte";
+
+export const metadata = { title: "Neues Gerät" };
+
+/**
+ * Ein einzelnes Gerät anlegen.
+ *
+ * Der übliche Weg für einen Bestand ist der Import; dieses Formular ist für
+ * den Neuzugang zwischendurch — die eine Motorsäge, die nachbeschafft wurde.
+ */
+export default async function NeuesGeraetSeite() {
+ const sitzung = await aktuelleSitzung();
+ if (!sitzung) redirect("/anmelden");
+ if (!hatRecht(sitzung, "geraet.bearbeiten")) redirect("/geraete");
+
+ // Gibt es nur einen Standort, wird er vorbelegt — das ist bei den meisten
+ // Wehren der Fall, und ein Pflichtfeld weniger ist ein Klick weniger.
+ const standorte = await fuerOrganisation(sitzung.organisationId, (db) =>
+ db.standort.findMany({
+ where: { geloeschtAm: null, aktiv: true },
+ select: { id: true },
+ take: 2,
+ }),
+ );
+
+ const vorgabe = {
+ ...LEERES_GERAET,
+ standortId: standorte.length === 1 ? (standorte[0]?.id ?? null) : null,
+ };
+
+ return (
+
+
+
+
+
+ );
+}
diff --git a/src/components/stammdaten/anzeige.tsx b/src/components/stammdaten/anzeige.tsx
index 00eacc4..b131a26 100644
--- a/src/components/stammdaten/anzeige.tsx
+++ b/src/components/stammdaten/anzeige.tsx
@@ -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 (
{meldung}
diff --git a/src/components/stammdaten/geraet-formular-werte.ts b/src/components/stammdaten/geraet-formular-werte.ts
new file mode 100644
index 0000000..37f3c49
--- /dev/null
+++ b/src/components/stammdaten/geraet-formular-werte.ts
@@ -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);
+}
diff --git a/src/components/stammdaten/geraet-formular.tsx b/src/components/stammdaten/geraet-formular.tsx
new file mode 100644
index 0000000..fec1c75
--- /dev/null
+++ b/src/components/stammdaten/geraet-formular.tsx
@@ -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(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 (
+
+ );
+}
diff --git a/src/components/ui/textarea.tsx b/src/components/ui/textarea.tsx
new file mode 100644
index 0000000..353d558
--- /dev/null
+++ b/src/components/ui/textarea.tsx
@@ -0,0 +1,21 @@
+import * as React from "react";
+import { cn } from "@/lib/utils";
+
+const Textarea = React.forwardRef>(
+ ({ className, ...props }, ref) => (
+
+ ),
+);
+Textarea.displayName = "Textarea";
+
+export { Textarea };
diff --git a/tests/e2e/geraet-formular.spec.ts b/tests/e2e/geraet-formular.spec.ts
new file mode 100644
index 0000000..a677295
--- /dev/null
+++ b/tests/e2e/geraet-formular.spec.ts
@@ -0,0 +1,202 @@
+import { expect, test, type Locator, type Page } from "@playwright/test";
+
+/**
+ * E2E-Tests für das Geräteformular.
+ *
+ * Der interessante Teil ist nicht das Speichern, sondern die Lagerungsregel:
+ * Ein Gerät steht entweder an einem Standort oder auf einem Fahrzeug. Das
+ * Formular führt das als Umschalter, damit der Fehler gar nicht erst möglich
+ * ist — und die Fristenvorschau zeigt vorab, was der Server daraus rechnen wird.
+ *
+ * Zwei Eigenheiten, die die Selektoren hier erklären:
+ *
+ * - `getByLabel` vergleicht den Text des `