diff --git a/arbeitskacheln/22_ui_redesign_v2.md b/arbeitskacheln/22_ui_redesign_v2.md index 7b380ee..dd73734 100644 --- a/arbeitskacheln/22_ui_redesign_v2.md +++ b/arbeitskacheln/22_ui_redesign_v2.md @@ -56,7 +56,12 @@ Grundlage, hier werden zusätzliche, im Code konkret nachgewiesene Lücken gesch - **Akzeptanzkriterien:** AdminPage-Tabs auf schmalem Viewport über Sheet erreichbar, bestehende Tab-Inhalte unverändert. - **Tests:** keine neuen (reines UI/Routing). -- **DoD:** offen. +- **DoD:** ✅ umgesetzt (2026-09-10). `components/ui/sheet.tsx` (Portal, + Escape schließt, von unten hereingleitendes Panel), `AdminPage.tsx` + verzweigt über bestehenden `useIsDesktop()`-Hook: Desktop weiterhin die + Tab-Leiste, Mobile ein "Menü: "-Button, der das Sheet mit + allen sichtbaren Tabs öffnet. Kein neues Sidebar-Layout — Sheet-Overlay wie + in der Kachel beschrieben reicht für den AdminPage-Anwendungsfall. ## UI2-003 — Formular-Feld-Komposition @@ -76,7 +81,15 @@ Grundlage, hier werden zusätzliche, im Code konkret nachgewiesene Lücken gesch - **Akzeptanzkriterien:** beide Formulare nutzen den neuen Baustein, Verhalten unverändert. - **Tests:** bestehende Vitest-Tests bleiben grün. -- **DoD:** offen. +- **DoD:** ✅ umgesetzt (2026-09-10). `components/ui/form-field.tsx` existierte + bereits aus UI2-009 (Render-Prop-Baustein mit aria-invalid/aria-describedby), + hier erstmals in echten Formularen eingesetzt: `ObjektAnlegenFormular.tsx` + (Vorlage/Standort/Code/Name/Fahrzeug) und `ObjekttypSection.tsx` + (Bereich/Kategorie/Name im Anlegen-Formular). Beide Formulare hatten bisher + KEINE Feld-Validierung (nur ein globaler `onFehler`-Banner) — dabei minimal + ergänzt: Pflichtfelder zeigen nach einem gescheiterten Absende-Versuch + ("versucht"-Flag) eine Inline-Fehlermeldung statt nur den Banner, echte + a11y-Verdrahtung statt reines Layout wie in der Kachel-Referenz gefordert. - **Referenz (vertiefte Starter-Kit-Analyse 2026-09-09):** `form.tsx` im Quell-Repo verdrahtet a11y konkret statt nur visuell — `FormLabel` wird bei Fehler rot, `FormControl` setzt `aria-invalid`, `FormMessage` nur bei vorhandenem Error gerendert, @@ -123,7 +136,14 @@ Grundlage, hier werden zusätzliche, im Code konkret nachgewiesene Lücken gesch - **Akzeptanzkriterien:** sichtbarer Skeleton-Zustand vor Dateneintreffen, kein Layout-Sprung beim Einblenden der echten Daten. - **Tests:** keine neuen. -- **DoD:** offen. +- **DoD:** ✅ umgesetzt (2026-09-10). `components/ui/skeleton.tsx` + (`animate-pulse` + `--color-surface-sunken`), eingesetzt in + `ReadinessVerlaufTile.tsx` und `FaelligeWartungenTile.tsx` (den beiden + self-fetching READY-006/NOTIF-003-Kacheln) statt reinem "Lade…"-Text. + Übrige Dashboard-Tiles laden weiterhin über den zentralen + `DashboardPage.ladeDaten()`-Promise.all-Block (ein gemeinsamer `laedt`-Flag + für alle) — Skeleton-Umstellung dort wäre ein größerer Umbau dieses + gemeinsamen Ladezustands, nicht Teil dieser Kachel. ## UI2-005 — Scrollbarer Table-Wrapper für Materiallisten @@ -139,7 +159,16 @@ Grundlage, hier werden zusätzliche, im Code konkret nachgewiesene Lücken gesch - **Rechte:** keine Änderung - **Akzeptanzkriterien:** Tabelle scrollt intern, Seite selbst nie horizontal. - **Tests:** keine neuen. -- **DoD:** offen. +- **DoD:** ✅ umgesetzt (2026-09-10). `components/ui/table-wrapper.tsx` ersetzt + die bisherigen Ad-hoc-`
`-Stellen in + `AktePage.tsx`, `TankbuchPanel.tsx`, `AusgabeSection.tsx`, + `LagerbewegungSection.tsx`, `HistorieSection.tsx`. **Korrektur zur + Kachel-Referenz:** `KontrollPage.tsx` nutzt gar keine `` (Card-Listen- + Layout), daher dort nicht angewendet — stattdessen alle tatsächlich + vorhandenen `
`-Stellen im Code migriert. + `ObjektListPage.tsx` bewusst ausgenommen: dessen Tabelle wird nur bei + `useIsDesktop()===true` gerendert, auf schmalem Viewport zeigt die Seite + ohnehin Karten statt Tabelle. ## UI2-006 — Toast/Benachrichtigungskomponente diff --git a/frontend/src/components/TankbuchPanel.tsx b/frontend/src/components/TankbuchPanel.tsx index d8fe808..dd2073b 100644 --- a/frontend/src/components/TankbuchPanel.tsx +++ b/frontend/src/components/TankbuchPanel.tsx @@ -3,6 +3,7 @@ import { useEffect, useState } from "react"; import { ApiError, apiRequest } from "../api/client"; import type { TankbuchEintrag } from "../api/types"; import { AlertDialog } from "./ui/alert-dialog"; +import { TableWrapper } from "./ui/table-wrapper"; interface Props { objektId: number; @@ -119,7 +120,7 @@ export function TankbuchPanel({ objektId, onFehler }: Props) { {laedt &&

Lade…

} {!laedt && eintraege.length === 0 &&

Noch keine Betankungen erfasst.

} {!laedt && eintraege.length > 0 && ( -
+
@@ -148,7 +149,7 @@ export function TankbuchPanel({ objektId, onFehler }: Props) { ))}
-
+ )} void; + children: React.ReactNode; +} + +// UI2-002: einfaches Mobile-Overlay (von unten hereingleitendes Panel) für +// Navigation, die auf Desktop als normale Leiste/Sidebar läuft - kein +// natives Drag&Drop (CLAUDE.md-Regel), reiner Klick-zum-Schließen. +export function Sheet({ offen, titel, onSchliessen, children }: Props) { + useEffect(() => { + if (!offen) return; + function onKeyDown(e: KeyboardEvent) { + if (e.key === "Escape") onSchliessen(); + } + document.addEventListener("keydown", onKeyDown); + return () => document.removeEventListener("keydown", onKeyDown); + }, [offen, onSchliessen]); + + if (!offen) return null; + + return createPortal( +
+
e.stopPropagation()} + > +
+ {titel &&

{titel}

} + +
+ {children} +
+
, + document.body + ); +} diff --git a/frontend/src/components/ui/skeleton.tsx b/frontend/src/components/ui/skeleton.tsx new file mode 100644 index 0000000..e4354a4 --- /dev/null +++ b/frontend/src/components/ui/skeleton.tsx @@ -0,0 +1,8 @@ +import { cn } from "../../lib/utils"; + +// UI2-004: einfacher Lade-Platzhalter statt Spinner/leerem Bereich - weniger +// Layout-Sprung, wenn echte Daten eintreffen (Höhe kann per className/style +// vorgegeben werden, damit sie zur späteren Kachel passt). +export function Skeleton({ className }: { className?: string }) { + return
; +} diff --git a/frontend/src/components/ui/table-wrapper.tsx b/frontend/src/components/ui/table-wrapper.tsx new file mode 100644 index 0000000..a9e4cf0 --- /dev/null +++ b/frontend/src/components/ui/table-wrapper.tsx @@ -0,0 +1,8 @@ +import type { ReactNode } from "react"; + +// UI2-005: gemeinsamer Wrapper statt der bisherigen Ad-hoc- +// `
`-Stellen je Seite - Tabelle scrollt +// intern auf schmalem Tablet-Viewport, die Seite selbst nie horizontal. +export function TableWrapper({ children }: { children: ReactNode }) { + return
{children}
; +} diff --git a/frontend/src/pages/AdminPage.tsx b/frontend/src/pages/AdminPage.tsx index 2a69d70..2d2e3a3 100644 --- a/frontend/src/pages/AdminPage.tsx +++ b/frontend/src/pages/AdminPage.tsx @@ -4,6 +4,8 @@ import { useSearchParams } from "react-router-dom"; import { apiRequest } from "../api/client"; import { AusgabeSection } from "./admin/AusgabeSection"; import { useAuth } from "../auth/AuthContext"; +import { useIsDesktop } from "../hooks/useIsDesktop"; +import { Sheet } from "../components/ui/sheet"; import type { Benutzer, Bereich, Beladungsvorlage, Einheit, Einsatzfunktion, Fach, Kategorie, Material, Nummernschema, Objekt, Objekttyp, Person, Qualifikationstyp, Standort } from "../api/types"; import { BenutzerSection } from "./admin/BenutzerSection"; import { BereichSection } from "./admin/BereichSection"; @@ -98,6 +100,8 @@ const TABS: { key: Tab; label: string; rollen: ("administration" | "materialvera */ export function AdminPage() { const { rollen, istAdmin } = useAuth(); + const istDesktop = useIsDesktop(); + const [menuOffen, setMenuOffen] = useState(false); const sichtbareTabs = TABS.filter((t) => t.rollen.some((r) => rollen.includes(r))); const [bereiche, setBereiche] = useState([]); const [kategorien, setKategorien] = useState([]); @@ -183,17 +187,41 @@ export function AdminPage() {

{istAdmin ? "Administration" : "Verwaltung"}

{fehler &&
{fehler}
} - + ) : ( +
+ - ))} - + setMenuOffen(false)}> +
+ {sichtbareTabs.map((t) => ( + + ))} +
+
+
+ )} {aktiverTab === "objekte" && ( Beladung {akte.beladung.length === 0 &&

Keine Positionen zugeordnet.

} {akte.beladung.length > 0 && ( -
+ @@ -219,7 +220,7 @@ export function AktePage() { ))}
-
+ )}
diff --git a/frontend/src/pages/admin/AusgabeSection.tsx b/frontend/src/pages/admin/AusgabeSection.tsx index 01d97e6..a945b29 100644 --- a/frontend/src/pages/admin/AusgabeSection.tsx +++ b/frontend/src/pages/admin/AusgabeSection.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from "react"; import { apiRequest } from "../../api/client"; import type { Ausgabe, Benutzer, Material } from "../../api/types"; +import { TableWrapper } from "../../components/ui/table-wrapper"; interface Props { materialien: Material[]; @@ -136,7 +137,7 @@ export function AusgabeSection({ materialien, benutzer, onFehler }: Props) { {!laedt && ausgaben.length === 0 &&

Keine Ausgaben.

} {!laedt && ausgaben.length > 0 && ( -
+ @@ -173,7 +174,7 @@ export function AusgabeSection({ materialien, benutzer, onFehler }: Props) { ))}
-
+ )}
diff --git a/frontend/src/pages/admin/HistorieSection.tsx b/frontend/src/pages/admin/HistorieSection.tsx index cc55d2e..5cb2c01 100644 --- a/frontend/src/pages/admin/HistorieSection.tsx +++ b/frontend/src/pages/admin/HistorieSection.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from "react"; import { apiRequest } from "../../api/client"; import type { HistorieEintrag } from "../../api/types"; +import { TableWrapper } from "../../components/ui/table-wrapper"; interface Props { onFehler: (text: string) => void; @@ -70,7 +71,7 @@ export function HistorieSection({ onFehler }: Props) { {!laedt && eintraege.length === 0 &&

Keine Einträge.

} {!laedt && eintraege.length > 0 && ( -
+ @@ -99,7 +100,7 @@ export function HistorieSection({ onFehler }: Props) { ))}
-
+ )} diff --git a/frontend/src/pages/admin/LagerbewegungSection.tsx b/frontend/src/pages/admin/LagerbewegungSection.tsx index a569040..5be9cad 100644 --- a/frontend/src/pages/admin/LagerbewegungSection.tsx +++ b/frontend/src/pages/admin/LagerbewegungSection.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from "react"; import { apiRequest } from "../../api/client"; import type { Lagerbewegung, Objekt, Standort } from "../../api/types"; +import { TableWrapper } from "../../components/ui/table-wrapper"; interface Props { objekte: Objekt[]; @@ -70,7 +71,7 @@ export function LagerbewegungSection({ objekte, standorte, onFehler }: Props) { {!laedt && bewegungen.length === 0 &&

Keine Bewegungen.

} {!laedt && bewegungen.length > 0 && ( -
+ @@ -93,7 +94,7 @@ export function LagerbewegungSection({ objekte, standorte, onFehler }: Props) { ))}
-
+ )} diff --git a/frontend/src/pages/admin/ObjektAnlegenFormular.tsx b/frontend/src/pages/admin/ObjektAnlegenFormular.tsx index b3a1764..22528bc 100644 --- a/frontend/src/pages/admin/ObjektAnlegenFormular.tsx +++ b/frontend/src/pages/admin/ObjektAnlegenFormular.tsx @@ -2,6 +2,7 @@ import { useState } from "react"; import { apiRequest, ApiError } from "../../api/client"; import type { Beladungsvorlage, Objekt, Objekttyp, Standort } from "../../api/types"; +import { FormField } from "../../components/ui/form-field"; interface Props { objekte: Objekt[]; @@ -20,6 +21,7 @@ export function ObjektAnlegenFormular({ objekte, objekttypen, vorlagen, standort const [standortId, setStandortId] = useState(""); const [fahrzeugId, setFahrzeugId] = useState(""); const [wirdAngelegt, setWirdAngelegt] = useState(false); + const [versucht, setVersucht] = useState(false); // Option A+C (Karte 11 Nachtrag): Dropdown zeigt nur Zugfahrzeuge (Objekttyp- // Flag ist_zugfahrzeug) ohne eigene Kopplung - serverseitig zusätzlich per @@ -64,8 +66,9 @@ export function ObjektAnlegenFormular({ objekte, objekttypen, vorlagen, standort } async function anlegen() { - if (vorlageId === "" || standortId === "") { - onFehler("Vorlage und Standort auswählen."); + setVersucht(true); + if (vorlageId === "" || standortId === "" || !code || !name) { + onFehler("Vorlage, Standort, Code und Name sind Pflichtfelder."); return; } setWirdAngelegt(true); @@ -101,59 +104,79 @@ export function ObjektAnlegenFormular({ objekte, objekttypen, vorlagen, standort

Keine aktive Vorlage vorhanden – erst Excel-Import oder Vorlage per API anlegen.

) : (
-
- - -
-
- - -
-
- -
- + {(feldProps) => ( + setCode(e.target.value)} /> -
-
-
- - setName(e.target.value)} /> -
-
- - -
+ value={vorlageId} + onChange={(e) => setVorlageId(Number(e.target.value) || "")} + > + + {vorlagen.map((v) => ( + + ))} + + )} + + + {(feldProps) => ( + + )} + + + {(feldProps) => ( +
+ setPraefix(e.target.value)} + placeholder="Präfix (RK, FZ…)" + /> + + setCode(e.target.value)} /> +
+ )} +
+ + {(feldProps) => ( + setName(e.target.value)} /> + )} + + + {(feldProps) => ( + + )} + diff --git a/frontend/src/pages/admin/ObjekttypSection.tsx b/frontend/src/pages/admin/ObjekttypSection.tsx index ff7dc6d..3e8e611 100644 --- a/frontend/src/pages/admin/ObjekttypSection.tsx +++ b/frontend/src/pages/admin/ObjekttypSection.tsx @@ -2,6 +2,7 @@ import { useState } from "react"; import { apiRequest } from "../../api/client"; import type { Bereich, Kategorie, Nummernschema, Objekttyp } from "../../api/types"; +import { FormField } from "../../components/ui/form-field"; interface Props { bereiche: Bereich[]; @@ -31,6 +32,7 @@ export function ObjekttypSection({ const [schemaBearbeitenId, setSchemaBearbeitenId] = useState(null); const [schemaPraefix, setSchemaPraefix] = useState(""); const [schemaStellenzahl, setSchemaStellenzahl] = useState(5); + const [versucht, setVersucht] = useState(false); function schemaFuer(objekttypId: number): Nummernschema | undefined { return nummernschemata.find((n) => n.objekttyp_id === objekttypId); @@ -56,8 +58,9 @@ export function ObjekttypSection({ } async function anlegen() { - if (bereichId === "") { - onFehler("Bereich auswählen."); + setVersucht(true); + if (bereichId === "" || !name) { + onFehler("Bereich und Name sind Pflichtfelder."); return; } setWirdAngelegt(true); @@ -116,27 +119,47 @@ export function ObjekttypSection({

Erst einen Bereich anlegen.

) : (
-
- - -
-
- setName(e.target.value)} placeholder="Name" /> -