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:
co-authored by
Claude Opus 5
parent
47b8e36a38
commit
1744403620
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user