Geräteformular zum Anlegen und Bearbeiten (#2)

This commit was merged in pull request #2.
This commit is contained in:
2026-09-03 07:53:22 +00:00
26 changed files with 1237 additions and 22 deletions
+29 -1
View File
@@ -8,7 +8,35 @@ Die Regeln dazu stehen in [docs/VERSIONIERUNG.md](docs/VERSIONIERUNG.md).
## [Unveröffentlicht] ## [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/<id>/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 ## [0.2.0] — 2026-09-03
+49
View File
@@ -186,6 +186,55 @@ bewusst keine zweite Historie: zwei Quellen könnten auseinanderlaufen.
--- ---
## 5a. Ein Gerät anlegen oder ändern
![Formular für ein neues Gerät](screenshots/10-geraet-neu-light.png)
Ü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 ## 6. Etiketten drucken
Der Bogen ist auf das gängige Format mit **65 Etiketten je DIN-A4-Seite** Der Bogen ist auf das gängige Format mit **65 Etiketten je DIN-A4-Seite**
+7 -4
View File
@@ -31,7 +31,7 @@ Faktoren, die in die Schätzung eingehen:
| Phase | Inhalt | Vergleichsaufwand | Sitzungszeit geschätzt | Sitzungszeit tatsächlich | | Phase | Inhalt | Vergleichsaufwand | Sitzungszeit geschätzt | Sitzungszeit tatsächlich |
|---|---|---:|---:|---:| |---|---|---:|---:|---:|
| 0 | Fundament | 12–16 PT | — | **1:00 h** | | 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 | | 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 | | 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 | | 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 ## 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 | | 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 Fristenrechnung; sie hätte in Phase 2 deutlich mehr gekostet, wenn sie erst
dort aufgefallen wäre. dort aufgefallen wäre.
Offen geblieben ist ein Formular zum einzelnen Anlegen von Geräten Das zunächst offen gebliebene Formular zum einzelnen Anlegen von Geräten wurde
(geschätzt 0,5 PT), siehe [DEVLOG.md](DEVLOG.md). 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 ## Phase 2 — Wartung und Prüfung
+59 -1
View File
@@ -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 0 — Fundament | 1:00 h | 1 | 03.09.2026 |
| Phase 1 — Stammdaten | 1:15 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 `<label>`, nicht den zugänglichen Namen — am Pflichtfeld steht dort ein Sternchen | Tests suchen über die Rolle; das Sternchen bekam zusätzlich `aria-hidden`, weil es sonst vorgelesen wird |
| `fill()` blieb bei Datumsfeldern wirkungslos | Chromium nimmt bei `input[type=date]` keinen gesetzten Wert über `fill()` an | Datum wird getippt, wie es ein Mensch auch täte |
| Zwei Tests scheiterten unter Touch-Emulation | dort öffnet Chromium den Auswähler des Betriebssystems statt Zeichen anzunehmen | begründet übersprungen; die Rechnung selbst deckt der Unit-Test ab |
| Anmeldung im Screenshot-Skript lief ins Zeitlimit | bei kaltem `.next` übersetzt `next dev` die Route erst beim ersten Aufruf | Zeitlimit von 20 auf 90 Sekunden |
### Nachweise
- `npm run typecheck` — ohne Ausgabe
- `npm run test` — 104 Tests grün
- `npm run test:e2e` — 56 grün, 2 begründet übersprungen
- `npm run build` — 19 Routen
- Testgeräte aus dem E2E-Lauf wurden aus der Entwicklungsdatenbank entfernt
--- ---
+2
View File
@@ -53,6 +53,8 @@ Die Wehr trägt ein, was sie hat.
- CSV-/Excel-Import mit Spaltenzuordnung und Vorschau - CSV-/Excel-Import mit Spaltenzuordnung und Vorschau
- Gerätehistorie aus dem Audit-Log - Gerätehistorie aus dem Audit-Log
- Qualifikationen mit Ablaufwarnung auf der Übersicht - Qualifikationen mit Ablaufwarnung auf der Übersicht
- Formular zum Anlegen und Bearbeiten eines einzelnen Geräts, mit
Fristenvorschau beim Ausfüllen
**Danach kann die Wehr:** ihren Bestand vollständig führen und jedes Gerät per **Danach kann die Wehr:** ihren Bestand vollständig führen und jedes Gerät per
QR-Code am Regal wiederfinden. QR-Code am Regal wiederfinden.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 201 KiB

After

Width:  |  Height:  |  Size: 201 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 201 KiB

After

Width:  |  Height:  |  Size: 201 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 227 KiB

After

Width:  |  Height:  |  Size: 231 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 235 KiB

After

Width:  |  Height:  |  Size: 240 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 153 KiB

After

Width:  |  Height:  |  Size: 153 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 153 KiB

After

Width:  |  Height:  |  Size: 153 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 300 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 307 KiB

+9 -1
View File
@@ -74,6 +74,12 @@ const AUFNAHMEN: Aufnahme[] = [
beschreibung: "Import-Assistent", beschreibung: "Import-Assistent",
angemeldet: true, angemeldet: true,
}, },
{
name: "10-geraet-neu",
pfad: "/geraete/neu",
beschreibung: "Formular für ein neues Gerät",
angemeldet: true,
},
]; ];
/** /**
@@ -125,7 +131,9 @@ async function anmelden(seite: Page): Promise<void> {
await seite.getByLabel("E-Mail-Adresse").fill(ADMIN_EMAIL); await seite.getByLabel("E-Mail-Adresse").fill(ADMIN_EMAIL);
await seite.getByLabel("Passwort").fill(ADMIN_PASSWORT); await seite.getByLabel("Passwort").fill(ADMIN_PASSWORT);
await seite.getByRole("button", { name: "Anmelden" }).click(); await seite.getByRole("button", { name: "Anmelden" }).click();
await seite.waitForURL(`${BASIS}/`, { timeout: 20_000 }); // Grosszuegig: bei kaltem .next uebersetzt "next dev" die Route erst beim
// ersten Aufruf, und das dauert deutlich laenger als eine Anmeldung.
await seite.waitForURL(`${BASIS}/`, { timeout: 90_000 });
await seite.waitForLoadState("networkidle"); await seite.waitForLoadState("networkidle");
} }
@@ -0,0 +1,77 @@
import Link from "next/link";
import { notFound, 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,
alsFeldwert,
} from "@/components/stammdaten/geraet-formular-werte";
export const metadata = { title: "Gerät bearbeiten" };
/** Ein vorhandenes Gerät bearbeiten. */
export default async function GeraetBearbeitenSeite({
params,
}: {
params: Promise<{ id: string }>;
}) {
const sitzung = await aktuelleSitzung();
if (!sitzung) redirect("/anmelden");
const { id } = await params;
if (!hatRecht(sitzung, "geraet.bearbeiten")) redirect(`/geraete/${id}`);
const geraet = await fuerOrganisation(sitzung.organisationId, (db) =>
db.geraet.findUnique({ where: { id } }),
);
if (!geraet) notFound();
const vorgabe = {
...LEERES_GERAET,
id: geraet.id,
inventarnummer: geraet.inventarnummer,
bezeichnung: geraet.bezeichnung,
geraetetypId: geraet.geraetetypId,
hersteller: geraet.hersteller ?? "",
modell: geraet.modell ?? "",
seriennummer: geraet.seriennummer ?? "",
herstellungsdatum: alsFeldwert(geraet.herstellungsdatum),
indienststellung: alsFeldwert(geraet.indienststellung),
// Decimal aus Prisma: als deutscher Text ins Feld, damit das Komma bleibt.
anschaffungspreis: geraet.anschaffungspreis
? String(geraet.anschaffungspreis).replace(".", ",")
: "",
status: geraet.status,
pruefintervallMonate: geraet.pruefintervallMonate?.toString() ?? "",
letztePruefung: alsFeldwert(geraet.letztePruefung),
lebensdauerJahre: geraet.lebensdauerJahre?.toString() ?? "",
lagerungsart: (geraet.fahrzeugId ? "fahrzeug" : "standort") as "fahrzeug" | "standort",
standortId: geraet.standortId,
fahrzeugId: geraet.fahrzeugId,
geraeteraumId: geraet.geraeteraumId,
lagerort: geraet.lagerort ?? "",
bemerkung: geraet.bemerkung ?? "",
};
return (
<div className="mx-auto max-w-4xl space-y-4">
<Button variant="ghost" size="sm" asChild className="-ml-2">
<Link href={`/geraete/${geraet.id}`}>
<ArrowLeft aria-hidden />
Zurück zum Gerät
</Link>
</Button>
<GeraetFormular
vorgabe={vorgabe}
titel={geraet.bezeichnung}
beschreibung={`Inventarnummer ${geraet.inventarnummer}. Jede Änderung wird protokolliert.`}
/>
</div>
);
}
+11 -1
View File
@@ -1,8 +1,9 @@
import { notFound, redirect } from "next/navigation"; import { notFound, redirect } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import Image from "next/image"; import Image from "next/image";
import { ArrowLeft, Printer } from "lucide-react"; import { ArrowLeft, Pencil, Printer } from "lucide-react";
import { aktuelleSitzung } from "@/lib/auth"; import { aktuelleSitzung } from "@/lib/auth";
import { hatRecht } from "@/lib/authz";
import { fuerOrganisation } from "@/lib/db"; import { fuerOrganisation } from "@/lib/db";
import { qrAlsDataUri, qrZiel } from "@/lib/stammdaten/qr"; import { qrAlsDataUri, qrZiel } from "@/lib/stammdaten/qr";
import { alsDatum, alsDatumZeit, alsEuro } from "@/lib/utils"; import { alsDatum, alsDatumZeit, alsEuro } from "@/lib/utils";
@@ -100,6 +101,15 @@ export default async function GeraetSeite({ params }: { params: Promise<{ id: st
<StatusAbzeichen status={geraet.status} /> <StatusAbzeichen status={geraet.status} />
</p> </p>
</div> </div>
{hatRecht(sitzung, "geraet.bearbeiten") && (
<Button asChild>
<Link href={`/geraete/${geraet.id}/bearbeiten`}>
<Pencil aria-hidden />
Bearbeiten
</Link>
</Button>
)}
</div> </div>
<div className="grid gap-6 lg:grid-cols-3"> <div className="grid gap-6 lg:grid-cols-3">
+17
View File
@@ -9,6 +9,7 @@ import {
ChevronLeft, ChevronLeft,
ChevronRight, ChevronRight,
Columns3, Columns3,
Plus,
Printer, Printer,
QrCode, QrCode,
Search, Search,
@@ -326,12 +327,20 @@ export function GeraeteTabelle({ darfBearbeiten }: { darfBearbeiten: boolean })
</Button> </Button>
{darfBearbeiten && ( {darfBearbeiten && (
<>
<Button variant="outline" asChild> <Button variant="outline" asChild>
<Link href="/geraete/importieren"> <Link href="/geraete/importieren">
<Upload aria-hidden /> <Upload aria-hidden />
Importieren Importieren
</Link> </Link>
</Button> </Button>
<Button asChild>
<Link href="/geraete/neu">
<Plus aria-hidden />
Neues Gerät
</Link>
</Button>
</>
)} )}
</div> </div>
@@ -521,12 +530,20 @@ export function GeraeteTabelle({ darfBearbeiten }: { darfBearbeiten: boolean })
} }
aktion={ aktion={
!suche && darfBearbeiten ? ( !suche && darfBearbeiten ? (
<div className="flex flex-wrap justify-center gap-2">
<Button asChild> <Button asChild>
<Link href="/geraete/importieren"> <Link href="/geraete/importieren">
<Upload aria-hidden /> <Upload aria-hidden />
Aus Datei importieren Aus Datei importieren
</Link> </Link>
</Button> </Button>
<Button variant="outline" asChild>
<Link href="/geraete/neu">
<Plus aria-hidden />
Einzelnes Gerät anlegen
</Link>
</Button>
</div>
) : undefined ) : undefined
} }
/> />
+55
View File
@@ -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 (
<div className="mx-auto max-w-4xl space-y-4">
<Button variant="ghost" size="sm" asChild className="-ml-2">
<Link href="/geraete">
<ArrowLeft aria-hidden />
Zurück zur Liste
</Link>
</Button>
<GeraetFormular
vorgabe={vorgabe}
titel="Neues Gerät"
beschreibung="Für einzelne Neuzugänge. Größere Bestände gehen schneller über den Import."
/>
</div>
);
}
+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 ( return (
<div <div
role="alert" role="alert"
data-testid={kennung}
className="rounded-md border border-[var(--destructive)]/40 bg-[var(--destructive)]/10 p-3 text-sm text-[var(--destructive)]" className="rounded-md border border-[var(--destructive)]/40 bg-[var(--destructive)]/10 p-3 text-sm text-[var(--destructive)]"
> >
{meldung} {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 };
+202
View File
@@ -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 `<label>`, nicht den zugänglichen
* Namen. An Pflichtfeldern steht dort ein Sternchen, das für Hilfsmittel
* ausgeblendet ist. Deshalb wird über die Rolle gesucht: der Name, den eine
* Sprachausgabe vorliest, ist auch der, auf den sich der Test verlässt.
* - `fill()` greift bei `input[type=date]` in Chromium nicht durch. Datumsfelder
* werden deshalb getippt, so wie es ein Mensch auch täte.
*/
const ADMIN_EMAIL = process.env.SEED_ADMIN_EMAIL ?? "admin@beispielwehr.de";
const ADMIN_PASSWORT = process.env.SEED_ADMIN_PASSWORT ?? "Feuerwehr2026";
/** Eindeutig je Testlauf, damit sich parallele Läufe nicht ins Gehege kommen. */
function testnummer(kennzeichen: string): string {
return `E2E-${kennzeichen}-${Date.now().toString().slice(-6)}`;
}
/** Tippt ein Datum in ein date-Feld. Reihenfolge nach deutscher Locale. */
async function datumTippen(feld: Locator, tag: string, monat: string, jahr: string) {
await feld.click();
await feld.pressSequentially(`${tag}${monat}${jahr}`, { delay: 30 });
}
function feld(page: Page, name: string): Locator {
return page.getByRole("textbox", { name, exact: true });
}
function zahlfeld(page: Page, name: string): Locator {
return page.getByRole("spinbutton", { name, exact: true });
}
function auswahl(page: Page, name: string): Locator {
return page.getByRole("combobox", { name, exact: true });
}
async function anmelden(page: Page): Promise<void> {
await page.goto("/anmelden");
await page.getByLabel("E-Mail-Adresse").fill(ADMIN_EMAIL);
await page.getByLabel("Passwort").fill(ADMIN_PASSWORT);
await page.getByRole("button", { name: "Anmelden" }).click();
await page.waitForURL("/");
}
/** Füllt die beiden Pflichtfelder und wählt eine Lagerung. */
async function grunddatenAusfuellen(page: Page, nummer: string, bezeichnung: string) {
await feld(page, "Inventarnummer").fill(nummer);
await feld(page, "Bezeichnung").fill(bezeichnung);
}
test.beforeEach(async ({ page }) => {
await anmelden(page);
});
test.describe("Gerät anlegen", () => {
test("legt ein Gerät an einem Standort an und öffnet es danach", async ({ page }) => {
const nummer = testnummer("STANDORT");
await page.goto("/geraete/neu");
await expect(page.getByRole("heading", { name: "Neues Gerät" })).toBeVisible();
await grunddatenAusfuellen(page, nummer, "Testgerät Standort");
await feld(page, "Hersteller").fill("Prüffirma");
await auswahl(page, "Standort").click();
await page.getByRole("option").first().click();
await page.getByRole("button", { name: "Gerät anlegen" }).click();
await page.waitForURL(/\/geraete\/[0-9a-f-]{36}$/);
await expect(page.getByRole("heading", { name: "Testgerät Standort" })).toBeVisible();
await expect(page.getByText(nummer).first()).toBeVisible();
});
test("legt ein Gerät auf einem Fahrzeug samt Geräteraum an", async ({ page }) => {
const nummer = testnummer("FAHRZEUG");
await page.goto("/geraete/neu");
await grunddatenAusfuellen(page, nummer, "Testgerät Fahrzeug");
await page.getByRole("radio", { name: "Auf einem Fahrzeug" }).click();
await auswahl(page, "Fahrzeug").click();
await page.getByRole("option", { name: /Florian Musterhausen 40\/1/ }).click();
await auswahl(page, "Geräteraum").click();
await page.getByRole("option", { name: /^G1/ }).click();
await page.getByRole("button", { name: "Gerät anlegen" }).click();
await page.waitForURL(/\/geraete\/[0-9a-f-]{36}$/);
await expect(page.getByText(/Verlastet auf/)).toBeVisible();
await expect(page.getByText(/G1/).first()).toBeVisible();
});
test("verlangt eine Angabe zur Lagerung, statt sie stillschweigend wegzulassen", async ({
page,
}) => {
await page.goto("/geraete/neu");
await grunddatenAusfuellen(page, testnummer("OHNE"), "Testgerät ohne Ort");
// Auf Fahrzeug umschalten, aber keines wählen.
await page.getByRole("radio", { name: "Auf einem Fahrzeug" }).click();
await page.getByRole("button", { name: "Gerät anlegen" }).click();
await expect(page.getByTestId("formularfehler")).toContainText("Fahrzeug auswählen");
// Nichts gespeichert: die Adresse hat sich nicht geändert.
await expect(page).toHaveURL(/\/geraete\/neu$/);
});
test("rechnet die Fristen schon beim Ausfüllen vor", async ({ page }, angaben) => {
// Unter Touch-Emulation nimmt Chromium keine zeichenweise Eingabe in
// Datumsfelder an — dort öffnet sich der Auswähler des Betriebssystems.
// Die Rechnung selbst prüfen die Unit-Tests in qr-und-fristen.test.ts;
// hier geht es nur um die Verdrahtung, und die deckt der Desktop ab.
test.skip(
angaben.project.name === "tablet",
"Datumsfelder lassen sich unter Touch-Emulation nicht tippen.",
);
await page.goto("/geraete/neu");
await expect(page.getByText("Nächste Prüfung:")).toBeVisible();
await expect(page.getByText("keine", { exact: true })).toBeVisible();
await datumTippen(feld(page, "Indienststellung"), "01", "03", "2026");
await zahlfeld(page, "Prüfintervall (Monate)").fill("12");
await expect(page.getByText("01.03.2027")).toBeVisible();
await zahlfeld(page, "Lebensdauer (Jahre)").fill("10");
await expect(page.getByText("01.03.2036")).toBeVisible();
});
test("übernimmt Prüfintervall und Lebensdauer aus dem Gerätetyp", async ({ page }, angaben) => {
test.skip(
angaben.project.name === "tablet",
"Datumsfelder lassen sich unter Touch-Emulation nicht tippen.",
);
await page.goto("/geraete/neu");
await auswahl(page, "Gerätetyp").click();
await page.getByRole("option", { name: "Pressluftatmer PA 94" }).click();
// Die Bezeichnung wird aus dem Typ vorbelegt, solange sie leer ist.
await expect(feld(page, "Bezeichnung")).toHaveValue("Pressluftatmer PA 94");
await datumTippen(feld(page, "Indienststellung"), "01", "03", "2026");
await expect(page.getByText(/aus dem Gerätetyp/)).toBeVisible();
await expect(page.getByText("01.03.2027")).toBeVisible();
// Pressluftatmer haben 20 Jahre Lebensdauer ab Indienststellung.
await expect(page.getByText("01.03.2046")).toBeVisible();
});
});
test.describe("Gerät bearbeiten", () => {
test("ändert ein vorhandenes Gerät und protokolliert es", async ({ page }) => {
await page.goto("/geraete");
await page.getByRole("link", { name: "INV-0002" }).click();
await page.getByRole("link", { name: "Bearbeiten" }).click();
await expect(feld(page, "Inventarnummer")).toHaveValue("INV-0002");
const bemerkung = `Vom E2E-Test geändert um ${Date.now()}`;
await feld(page, "Bemerkung").fill(bemerkung);
await page.getByRole("button", { name: "Änderungen speichern" }).click();
await page.waitForURL(/\/geraete\/[0-9a-f-]{36}$/);
await expect(page.getByText(bemerkung)).toBeVisible();
// Die Änderung steht im Verlauf. Bei wiederholten Läufen sammeln sich dort
// mehrere Einträge — der Log ist append-only, das ist so gewollt.
await expect(page.getByText(/Gerät INV-0002 geändert/).first()).toBeVisible();
});
test("schaltet die Lagerung von Fahrzeug auf Standort um", async ({ page }) => {
await page.goto("/geraete");
await page.getByRole("link", { name: "INV-0003" }).click();
await page.getByRole("link", { name: "Bearbeiten" }).click();
await page.getByRole("radio", { name: "An einem Standort" }).click();
await auswahl(page, "Standort").click();
await page.getByRole("option").first().click();
await page.getByRole("button", { name: "Änderungen speichern" }).click();
await page.waitForURL(/\/geraete\/[0-9a-f-]{36}$/);
await expect(page.getByText(/^Standort /)).toBeVisible();
});
});