Geraeteformular zum Anlegen und Bearbeiten ergaenzen

Schliesst die letzte offene Luecke aus Phase 1. Bis dahin liessen sich Geraete
nur ueber den Import erfassen — fuer einen Bestand richtig, fuer ein einzelnes
nachbeschafftes Geraet umstaendlich.

Neu:
- /geraete/neu und /geraete/<id>/bearbeiten, beide ueber dieselbe Komponente
- Die Lagerung ist ein Umschalter statt zweier Auswahlfelder. Standort und
  Fahrzeug schliessen sich aus; so kann der Fehler gar nicht erst entstehen,
  den die Datenbank sonst abweisen wuerde. Beim Wechsel des Fahrzeugs wird die
  Raumangabe zurueckgesetzt.
- Prueftermin und Lebensdauerende werden beim Tippen vorgerechnet, mit
  denselben Funktionen wie auf dem Server. Beim Speichern rechnet der Server
  erneut und behaelt das letzte Wort.
- Gibt es nur einen Standort, ist er beim Anlegen vorbelegt.
- Knopf "Neues Geraet" in der Liste, "Bearbeiten" auf der Detailseite

Behoben:
- Das Formular stuerzte beim ersten Rechnen ab. Die Vorgabewerte lagen in einem
  "use client"-Modul und wurden von einer Server-Komponente importiert; dort
  kommt nur eine Client-Referenz an, kein Objekt, und das Spreaden ergibt lauter
  undefined. Die Werte liegen jetzt in geraet-formular-werte.ts ohne
  "use client".
- Das Sternchen an Pflichtfeldern wurde von Sprachausgaben mitgelesen
  ("Bezeichnung Stern"). Es traegt jetzt aria-hidden; dass ein Feld ausgefuellt
  werden muss, sagt bereits das required-Attribut.
- Fehlermeldungen in Formularen sind eindeutig ansteuerbar. Next.js haengt
  einen eigenen Routen-Ansager mit der Rolle "alert" in jede Seite.

Tests:
- 7 neue Playwright-Tests zum Formular, darunter die Lagerungsregel und die
  Fristenvorschau
- Zwei davon laufen nicht unter Touch-Emulation: Chromium nimmt dort keine
  zeichenweise Eingabe in Datumsfelder an. Begruendet uebersprungen, die
  Rechnung selbst deckt qr-und-fristen.test.ts ab.
- Insgesamt 104 Unit-Tests und 56 E2E-Tests gruen

Anleitung, Changelog, Devlog, Aufwandsschaetzung und Roadmap fortgeschrieben.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-09-03 09:20:30 +02:00
co-authored by Claude Opus 5
parent 47b8e36a38
commit 1744403620
26 changed files with 1237 additions and 22 deletions
@@ -0,0 +1,77 @@
import Link from "next/link";
import { notFound, redirect } from "next/navigation";
import { ArrowLeft } from "lucide-react";
import { aktuelleSitzung } from "@/lib/auth";
import { hatRecht } from "@/lib/authz";
import { fuerOrganisation } from "@/lib/db";
import { Button } from "@/components/ui/button";
import { GeraetFormular } from "@/components/stammdaten/geraet-formular";
import {
LEERES_GERAET,
alsFeldwert,
} from "@/components/stammdaten/geraet-formular-werte";
export const metadata = { title: "Gerät bearbeiten" };
/** Ein vorhandenes Gerät bearbeiten. */
export default async function GeraetBearbeitenSeite({
params,
}: {
params: Promise<{ id: string }>;
}) {
const sitzung = await aktuelleSitzung();
if (!sitzung) redirect("/anmelden");
const { id } = await params;
if (!hatRecht(sitzung, "geraet.bearbeiten")) redirect(`/geraete/${id}`);
const geraet = await fuerOrganisation(sitzung.organisationId, (db) =>
db.geraet.findUnique({ where: { id } }),
);
if (!geraet) notFound();
const vorgabe = {
...LEERES_GERAET,
id: geraet.id,
inventarnummer: geraet.inventarnummer,
bezeichnung: geraet.bezeichnung,
geraetetypId: geraet.geraetetypId,
hersteller: geraet.hersteller ?? "",
modell: geraet.modell ?? "",
seriennummer: geraet.seriennummer ?? "",
herstellungsdatum: alsFeldwert(geraet.herstellungsdatum),
indienststellung: alsFeldwert(geraet.indienststellung),
// Decimal aus Prisma: als deutscher Text ins Feld, damit das Komma bleibt.
anschaffungspreis: geraet.anschaffungspreis
? String(geraet.anschaffungspreis).replace(".", ",")
: "",
status: geraet.status,
pruefintervallMonate: geraet.pruefintervallMonate?.toString() ?? "",
letztePruefung: alsFeldwert(geraet.letztePruefung),
lebensdauerJahre: geraet.lebensdauerJahre?.toString() ?? "",
lagerungsart: (geraet.fahrzeugId ? "fahrzeug" : "standort") as "fahrzeug" | "standort",
standortId: geraet.standortId,
fahrzeugId: geraet.fahrzeugId,
geraeteraumId: geraet.geraeteraumId,
lagerort: geraet.lagerort ?? "",
bemerkung: geraet.bemerkung ?? "",
};
return (
<div className="mx-auto max-w-4xl space-y-4">
<Button variant="ghost" size="sm" asChild className="-ml-2">
<Link href={`/geraete/${geraet.id}`}>
<ArrowLeft aria-hidden />
Zurück zum Gerät
</Link>
</Button>
<GeraetFormular
vorgabe={vorgabe}
titel={geraet.bezeichnung}
beschreibung={`Inventarnummer ${geraet.inventarnummer}. Jede Änderung wird protokolliert.`}
/>
</div>
);
}
+11 -1
View File
@@ -1,8 +1,9 @@
import { notFound, redirect } from "next/navigation";
import 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">
+29 -12
View File
@@ -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
}
/>
+55
View File
@@ -0,0 +1,55 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { ArrowLeft } from "lucide-react";
import { aktuelleSitzung } from "@/lib/auth";
import { hatRecht } from "@/lib/authz";
import { fuerOrganisation } from "@/lib/db";
import { Button } from "@/components/ui/button";
import { GeraetFormular } from "@/components/stammdaten/geraet-formular";
import { LEERES_GERAET } from "@/components/stammdaten/geraet-formular-werte";
export const metadata = { title: "Neues Gerät" };
/**
* Ein einzelnes Gerät anlegen.
*
* Der übliche Weg für einen Bestand ist der Import; dieses Formular ist für
* den Neuzugang zwischendurch — die eine Motorsäge, die nachbeschafft wurde.
*/
export default async function NeuesGeraetSeite() {
const sitzung = await aktuelleSitzung();
if (!sitzung) redirect("/anmelden");
if (!hatRecht(sitzung, "geraet.bearbeiten")) redirect("/geraete");
// Gibt es nur einen Standort, wird er vorbelegt — das ist bei den meisten
// Wehren der Fall, und ein Pflichtfeld weniger ist ein Klick weniger.
const standorte = await fuerOrganisation(sitzung.organisationId, (db) =>
db.standort.findMany({
where: { geloeschtAm: null, aktiv: true },
select: { id: true },
take: 2,
}),
);
const vorgabe = {
...LEERES_GERAET,
standortId: standorte.length === 1 ? (standorte[0]?.id ?? null) : null,
};
return (
<div className="mx-auto max-w-4xl space-y-4">
<Button variant="ghost" size="sm" asChild className="-ml-2">
<Link href="/geraete">
<ArrowLeft aria-hidden />
Zurück zur Liste
</Link>
</Button>
<GeraetFormular
vorgabe={vorgabe}
titel="Neues Gerät"
beschreibung="Für einzelne Neuzugänge. Größere Bestände gehen schneller über den Import."
/>
</div>
);
}