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>
@@ -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/<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
|
||||
|
||||
|
||||
@@ -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**
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 `<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
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -53,6 +53,8 @@ Die Wehr trägt ein, was sie hat.
|
||||
- CSV-/Excel-Import mit Spaltenzuordnung und Vorschau
|
||||
- Gerätehistorie aus dem Audit-Log
|
||||
- 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
|
||||
QR-Code am Regal wiederfinden.
|
||||
|
||||
|
Before Width: | Height: | Size: 91 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 201 KiB After Width: | Height: | Size: 201 KiB |
|
Before Width: | Height: | Size: 201 KiB After Width: | Height: | Size: 201 KiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 227 KiB After Width: | Height: | Size: 231 KiB |
|
Before Width: | Height: | Size: 235 KiB After Width: | Height: | Size: 240 KiB |
|
Before Width: | Height: | Size: 153 KiB After Width: | Height: | Size: 153 KiB |
|
Before Width: | Height: | Size: 153 KiB After Width: | Height: | Size: 153 KiB |
|
After Width: | Height: | Size: 300 KiB |
|
After Width: | Height: | Size: 307 KiB |
@@ -74,6 +74,12 @@ const AUFNAHMEN: Aufnahme[] = [
|
||||
beschreibung: "Import-Assistent",
|
||||
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("Passwort").fill(ADMIN_PASSWORT);
|
||||
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");
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { ArrowLeft, Printer } from "lucide-react";
|
||||
import { ArrowLeft, Pencil, Printer } from "lucide-react";
|
||||
import { aktuelleSitzung } from "@/lib/auth";
|
||||
import { hatRecht } from "@/lib/authz";
|
||||
import { fuerOrganisation } from "@/lib/db";
|
||||
import { qrAlsDataUri, qrZiel } from "@/lib/stammdaten/qr";
|
||||
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} />
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{hatRecht(sitzung, "geraet.bearbeiten") && (
|
||||
<Button asChild>
|
||||
<Link href={`/geraete/${geraet.id}/bearbeiten`}>
|
||||
<Pencil aria-hidden />
|
||||
Bearbeiten
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Columns3,
|
||||
Plus,
|
||||
Printer,
|
||||
QrCode,
|
||||
Search,
|
||||
@@ -326,12 +327,20 @@ export function GeraeteTabelle({ darfBearbeiten }: { darfBearbeiten: boolean })
|
||||
</Button>
|
||||
|
||||
{darfBearbeiten && (
|
||||
<Button variant="outline" asChild>
|
||||
<Link href="/geraete/importieren">
|
||||
<Upload aria-hidden />
|
||||
Importieren
|
||||
</Link>
|
||||
</Button>
|
||||
<>
|
||||
<Button variant="outline" asChild>
|
||||
<Link href="/geraete/importieren">
|
||||
<Upload aria-hidden />
|
||||
Importieren
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild>
|
||||
<Link href="/geraete/neu">
|
||||
<Plus aria-hidden />
|
||||
Neues Gerät
|
||||
</Link>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -521,12 +530,20 @@ export function GeraeteTabelle({ darfBearbeiten }: { darfBearbeiten: boolean })
|
||||
}
|
||||
aktion={
|
||||
!suche && darfBearbeiten ? (
|
||||
<Button asChild>
|
||||
<Link href="/geraete/importieren">
|
||||
<Upload aria-hidden />
|
||||
Aus Datei importieren
|
||||
</Link>
|
||||
</Button>
|
||||
<div className="flex flex-wrap justify-center gap-2">
|
||||
<Button asChild>
|
||||
<Link href="/geraete/importieren">
|
||||
<Upload aria-hidden />
|
||||
Aus Datei importieren
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="outline" asChild>
|
||||
<Link href="/geraete/neu">
|
||||
<Plus aria-hidden />
|
||||
Einzelnes Gerät anlegen
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||