Geräteformular zum Anlegen und Bearbeiten #2
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
## 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**
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
|
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",
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
<>
|
||||||
<Link href="/geraete/importieren">
|
<Button variant="outline" asChild>
|
||||||
<Upload aria-hidden />
|
<Link href="/geraete/importieren">
|
||||||
Importieren
|
<Upload aria-hidden />
|
||||||
</Link>
|
Importieren
|
||||||
</Button>
|
</Link>
|
||||||
|
</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 ? (
|
||||||
<Button asChild>
|
<div className="flex flex-wrap justify-center gap-2">
|
||||||
<Link href="/geraete/importieren">
|
<Button asChild>
|
||||||
<Upload aria-hidden />
|
<Link href="/geraete/importieren">
|
||||||
Aus Datei importieren
|
<Upload aria-hidden />
|
||||||
</Link>
|
Aus Datei importieren
|
||||||
</Button>
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" asChild>
|
||||||
|
<Link href="/geraete/neu">
|
||||||
|
<Plus aria-hidden />
|
||||||
|
Einzelnes Gerät anlegen
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
) : undefined
|
) : 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 (
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||