feat(ui): UI2-002/003/004/005 Sheet-Navigation, FormField-Einsatz, Skeleton, Table-Wrapper
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CVgbozhYmuEhiEJHffRXCV
This commit is contained in:
1 parent
91cfb1087f
commit
d41d04c26c
14 files changed
+292
-104
No files matched your search
@@ -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ü: <aktueller Tab>"-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-`<div style={{ overflowX: "auto" }}>`-Stellen in
|
||||
`AktePage.tsx`, `TankbuchPanel.tsx`, `AusgabeSection.tsx`,
|
||||
`LagerbewegungSection.tsx`, `HistorieSection.tsx`. **Korrektur zur
|
||||
Kachel-Referenz:** `KontrollPage.tsx` nutzt gar keine `<table>` (Card-Listen-
|
||||
Layout), daher dort nicht angewendet — stattdessen alle tatsächlich
|
||||
vorhandenen `<table className="table">`-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
|
||||
|
||||
|
||||
@@ -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 && <p className="text-muted">Lade…</p>}
|
||||
{!laedt && eintraege.length === 0 && <p className="text-muted">Noch keine Betankungen erfasst.</p>}
|
||||
{!laedt && eintraege.length > 0 && (
|
||||
<div style={{ overflowX: "auto" }}>
|
||||
<TableWrapper>
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -148,7 +149,7 @@ export function TankbuchPanel({ objektId, onFehler }: Props) {
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</TableWrapper>
|
||||
)}
|
||||
<AlertDialog
|
||||
offen={zumLoeschen !== null}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { useEffect } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
interface Props {
|
||||
offen: boolean;
|
||||
titel?: string;
|
||||
onSchliessen: () => 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(
|
||||
<div className="fixed inset-0 z-50 flex items-end bg-black/40" onClick={onSchliessen}>
|
||||
<div
|
||||
className="max-h-[80vh] w-full overflow-y-auto rounded-t-lg border-t border-[var(--color-border)] bg-[var(--color-card)] p-4"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="row-between" style={{ marginBottom: "0.75rem" }}>
|
||||
{titel && <h2 style={{ margin: 0 }}>{titel}</h2>}
|
||||
<button className="btn btn-secondary" onClick={onSchliessen}>
|
||||
Schließen
|
||||
</button>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
@@ -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 <div className={cn("animate-pulse rounded-md bg-[var(--color-surface-sunken)]", className)} />;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
// UI2-005: gemeinsamer Wrapper statt der bisherigen Ad-hoc-
|
||||
// `<div style={{ overflowX: "auto" }}>`-Stellen je Seite - Tabelle scrollt
|
||||
// intern auf schmalem Tablet-Viewport, die Seite selbst nie horizontal.
|
||||
export function TableWrapper({ children }: { children: ReactNode }) {
|
||||
return <div style={{ overflowX: "auto" }}>{children}</div>;
|
||||
}
|
||||
@@ -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<Bereich[]>([]);
|
||||
const [kategorien, setKategorien] = useState<Kategorie[]>([]);
|
||||
@@ -183,17 +187,41 @@ export function AdminPage() {
|
||||
<h1>{istAdmin ? "Administration" : "Verwaltung"}</h1>
|
||||
{fehler && <div className="alert alert-danger">{fehler}</div>}
|
||||
|
||||
<nav className="row" style={{ marginBottom: "1.25rem", flexWrap: "wrap", gap: "0.5rem" }}>
|
||||
{sichtbareTabs.map((t) => (
|
||||
<button
|
||||
key={t.key}
|
||||
className={aktiverTab === t.key ? "btn btn-primary" : "btn btn-secondary"}
|
||||
onClick={() => setTab(t.key)}
|
||||
>
|
||||
{t.label}
|
||||
{istDesktop ? (
|
||||
<nav className="row" style={{ marginBottom: "1.25rem", flexWrap: "wrap", gap: "0.5rem" }}>
|
||||
{sichtbareTabs.map((t) => (
|
||||
<button
|
||||
key={t.key}
|
||||
className={aktiverTab === t.key ? "btn btn-primary" : "btn btn-secondary"}
|
||||
onClick={() => setTab(t.key)}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
) : (
|
||||
<div style={{ marginBottom: "1.25rem" }}>
|
||||
<button className="btn btn-secondary" onClick={() => setMenuOffen(true)}>
|
||||
Menü: {sichtbareTabs.find((t) => t.key === aktiverTab)?.label ?? "Wählen…"}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
<Sheet offen={menuOffen} titel="Bereich wählen" onSchliessen={() => setMenuOffen(false)}>
|
||||
<div className="stack">
|
||||
{sichtbareTabs.map((t) => (
|
||||
<button
|
||||
key={t.key}
|
||||
className={aktiverTab === t.key ? "btn btn-primary" : "btn btn-secondary"}
|
||||
onClick={() => {
|
||||
setTab(t.key);
|
||||
setMenuOffen(false);
|
||||
}}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Sheet>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{aktiverTab === "objekte" && (
|
||||
<ObjektSection
|
||||
|
||||
@@ -8,6 +8,7 @@ import { DokumentePanel } from "../components/DokumentePanel";
|
||||
import { HistorieIntegritaetBadge } from "../components/HistorieIntegritaetBadge";
|
||||
import { NotizenPanel } from "../components/NotizenPanel";
|
||||
import { ReadinessVerlaufPanel } from "../components/ReadinessVerlaufPanel";
|
||||
import { TableWrapper } from "../components/ui/table-wrapper";
|
||||
import { TankbuchPanel } from "../components/TankbuchPanel";
|
||||
import { WartungSection } from "../components/WartungSection";
|
||||
import { ReadinessBadge } from "../components/status/ReadinessBadge";
|
||||
@@ -190,7 +191,7 @@ export function AktePage() {
|
||||
<h3 style={{ marginTop: 0 }}>Beladung</h3>
|
||||
{akte.beladung.length === 0 && <p className="text-muted">Keine Positionen zugeordnet.</p>}
|
||||
{akte.beladung.length > 0 && (
|
||||
<div style={{ overflowX: "auto" }}>
|
||||
<TableWrapper>
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -219,7 +220,7 @@ export function AktePage() {
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</TableWrapper>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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 && <p className="text-muted">Keine Ausgaben.</p>}
|
||||
|
||||
{!laedt && ausgaben.length > 0 && (
|
||||
<div style={{ overflowX: "auto" }}>
|
||||
<TableWrapper>
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -173,7 +174,7 @@ export function AusgabeSection({ materialien, benutzer, onFehler }: Props) {
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</TableWrapper>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -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 && <p className="text-muted">Keine Einträge.</p>}
|
||||
|
||||
{!laedt && eintraege.length > 0 && (
|
||||
<div style={{ overflowX: "auto" }}>
|
||||
<TableWrapper>
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -99,7 +100,7 @@ export function HistorieSection({ onFehler }: Props) {
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</TableWrapper>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -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 && <p className="text-muted">Keine Bewegungen.</p>}
|
||||
|
||||
{!laedt && bewegungen.length > 0 && (
|
||||
<div style={{ overflowX: "auto" }}>
|
||||
<TableWrapper>
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -93,7 +94,7 @@ export function LagerbewegungSection({ objekte, standorte, onFehler }: Props) {
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</TableWrapper>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -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<number | "">("");
|
||||
const [fahrzeugId, setFahrzeugId] = useState<number | "">("");
|
||||
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
|
||||
<p className="text-muted">Keine aktive Vorlage vorhanden – erst Excel-Import oder Vorlage per API anlegen.</p>
|
||||
) : (
|
||||
<div className="stack">
|
||||
<div className="field">
|
||||
<label>Vorlage</label>
|
||||
<select className="input" value={vorlageId} onChange={(e) => setVorlageId(Number(e.target.value) || "")}>
|
||||
<option value="">Vorlage wählen…</option>
|
||||
{vorlagen.map((v) => (
|
||||
<option key={v.id} value={v.id}>
|
||||
{v.name} (v{v.version})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Standort</label>
|
||||
<select className="input" value={standortId} onChange={(e) => setStandortId(Number(e.target.value) || "")}>
|
||||
<option value="">Standort wählen…</option>
|
||||
{standorte.map((s) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Code (eindeutig)</label>
|
||||
<div className="row" style={{ flexWrap: "wrap" }}>
|
||||
<input
|
||||
<FormField label="Vorlage" fehler={versucht && vorlageId === "" ? "Pflichtfeld" : null}>
|
||||
{(feldProps) => (
|
||||
<select
|
||||
{...feldProps}
|
||||
className="input"
|
||||
style={{ maxWidth: "6rem" }}
|
||||
value={praefix}
|
||||
onChange={(e) => setPraefix(e.target.value)}
|
||||
placeholder="Präfix (RK, FZ…)"
|
||||
/>
|
||||
<button type="button" className="btn btn-secondary" onClick={codeVorschlagen}>
|
||||
Code vorschlagen
|
||||
</button>
|
||||
<input className="input" value={code} onChange={(e) => setCode(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Name</label>
|
||||
<input className="input" value={name} onChange={(e) => setName(e.target.value)} />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Fahrzeug (optional)</label>
|
||||
<select className="input" value={fahrzeugId} onChange={(e) => setFahrzeugId(Number(e.target.value) || "")}>
|
||||
<option value="">Kein Fahrzeug</option>
|
||||
{zugfahrzeuge.map((x) => (
|
||||
<option key={x.id} value={x.id}>
|
||||
{x.name} ({x.code})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
value={vorlageId}
|
||||
onChange={(e) => setVorlageId(Number(e.target.value) || "")}
|
||||
>
|
||||
<option value="">Vorlage wählen…</option>
|
||||
{vorlagen.map((v) => (
|
||||
<option key={v.id} value={v.id}>
|
||||
{v.name} (v{v.version})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</FormField>
|
||||
<FormField label="Standort" fehler={versucht && standortId === "" ? "Pflichtfeld" : null}>
|
||||
{(feldProps) => (
|
||||
<select
|
||||
{...feldProps}
|
||||
className="input"
|
||||
value={standortId}
|
||||
onChange={(e) => setStandortId(Number(e.target.value) || "")}
|
||||
>
|
||||
<option value="">Standort wählen…</option>
|
||||
{standorte.map((s) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</FormField>
|
||||
<FormField label="Code (eindeutig)" fehler={versucht && !code ? "Pflichtfeld" : null}>
|
||||
{(feldProps) => (
|
||||
<div className="row" style={{ flexWrap: "wrap" }}>
|
||||
<input
|
||||
className="input"
|
||||
style={{ maxWidth: "6rem" }}
|
||||
value={praefix}
|
||||
onChange={(e) => setPraefix(e.target.value)}
|
||||
placeholder="Präfix (RK, FZ…)"
|
||||
/>
|
||||
<button type="button" className="btn btn-secondary" onClick={codeVorschlagen}>
|
||||
Code vorschlagen
|
||||
</button>
|
||||
<input {...feldProps} className="input" value={code} onChange={(e) => setCode(e.target.value)} />
|
||||
</div>
|
||||
)}
|
||||
</FormField>
|
||||
<FormField label="Name" fehler={versucht && !name ? "Pflichtfeld" : null}>
|
||||
{(feldProps) => (
|
||||
<input {...feldProps} className="input" value={name} onChange={(e) => setName(e.target.value)} />
|
||||
)}
|
||||
</FormField>
|
||||
<FormField label="Fahrzeug (optional)">
|
||||
{(feldProps) => (
|
||||
<select
|
||||
{...feldProps}
|
||||
className="input"
|
||||
value={fahrzeugId}
|
||||
onChange={(e) => setFahrzeugId(Number(e.target.value) || "")}
|
||||
>
|
||||
<option value="">Kein Fahrzeug</option>
|
||||
{zugfahrzeuge.map((x) => (
|
||||
<option key={x.id} value={x.id}>
|
||||
{x.name} ({x.code})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</FormField>
|
||||
<button className="btn btn-primary" onClick={anlegen} disabled={!code || !name || wirdAngelegt}>
|
||||
Anlegen
|
||||
</button>
|
||||
|
||||
@@ -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<number | null>(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({
|
||||
<p className="text-muted">Erst einen Bereich anlegen.</p>
|
||||
) : (
|
||||
<div className="stack" style={{ marginBottom: objekttypen.length ? "1.25rem" : 0 }}>
|
||||
<div className="row">
|
||||
<select className="input" value={bereichId} onChange={(e) => setBereichId(Number(e.target.value) || "")}>
|
||||
<option value="">Bereich wählen…</option>
|
||||
{bereiche.map((b) => (
|
||||
<option key={b.id} value={b.id}>
|
||||
{b.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select className="input" value={kategorieId} onChange={(e) => setKategorieId(Number(e.target.value) || "")}>
|
||||
<option value="">Kategorie (optional)…</option>
|
||||
{kategorien.map((k) => (
|
||||
<option key={k.id} value={k.id}>
|
||||
{k.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="row">
|
||||
<input className="input" value={name} onChange={(e) => setName(e.target.value)} placeholder="Name" />
|
||||
<label className="row" style={{ alignItems: "center", gap: "0.4rem" }}>
|
||||
<div className="row" style={{ flexWrap: "wrap", alignItems: "flex-start" }}>
|
||||
<FormField label="Bereich" fehler={versucht && bereichId === "" ? "Pflichtfeld" : null}>
|
||||
{(feldProps) => (
|
||||
<select
|
||||
{...feldProps}
|
||||
className="input"
|
||||
value={bereichId}
|
||||
onChange={(e) => setBereichId(Number(e.target.value) || "")}
|
||||
>
|
||||
<option value="">Bereich wählen…</option>
|
||||
{bereiche.map((b) => (
|
||||
<option key={b.id} value={b.id}>
|
||||
{b.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</FormField>
|
||||
<FormField label="Kategorie (optional)">
|
||||
{(feldProps) => (
|
||||
<select
|
||||
{...feldProps}
|
||||
className="input"
|
||||
value={kategorieId}
|
||||
onChange={(e) => setKategorieId(Number(e.target.value) || "")}
|
||||
>
|
||||
<option value="">Kategorie (optional)…</option>
|
||||
{kategorien.map((k) => (
|
||||
<option key={k.id} value={k.id}>
|
||||
{k.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</FormField>
|
||||
<FormField label="Name" fehler={versucht && !name ? "Pflichtfeld" : null}>
|
||||
{(feldProps) => (
|
||||
<input {...feldProps} className="input" value={name} onChange={(e) => setName(e.target.value)} />
|
||||
)}
|
||||
</FormField>
|
||||
<label className="row" style={{ alignItems: "center", gap: "0.4rem", marginTop: "1.5rem" }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={istZugfahrzeug}
|
||||
@@ -144,7 +167,12 @@ export function ObjekttypSection({
|
||||
/>
|
||||
Zugfahrzeug (koppelbar)
|
||||
</label>
|
||||
<button className="btn btn-primary" onClick={anlegen} disabled={!name || wirdAngelegt}>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
style={{ marginTop: "1.5rem" }}
|
||||
onClick={anlegen}
|
||||
disabled={!name || wirdAngelegt}
|
||||
>
|
||||
Anlegen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { memo, useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
import { apiRequest } from "../../../api/client";
|
||||
import { Skeleton } from "../../../components/ui/skeleton";
|
||||
import { EmptyState } from "../EmptyState";
|
||||
|
||||
interface FaelligeWartung {
|
||||
@@ -28,7 +29,14 @@ function FaelligeWartungenTileKomponente() {
|
||||
}, []);
|
||||
|
||||
if (fehler) return <p className="text-muted">Konnte nicht geladen werden.</p>;
|
||||
if (eintraege === null) return <p className="text-muted">Lade…</p>;
|
||||
if (eintraege === null)
|
||||
return (
|
||||
<div className="stack" style={{ gap: "0.5rem" }}>
|
||||
<Skeleton className="h-5 w-full" />
|
||||
<Skeleton className="h-5 w-full" />
|
||||
<Skeleton className="h-5 w-2/3" />
|
||||
</div>
|
||||
);
|
||||
if (eintraege.length === 0) return <EmptyState text="Keine fälligen Wartungen." />;
|
||||
|
||||
return (
|
||||
|
||||
@@ -2,6 +2,7 @@ import { memo, useEffect, useState } from "react";
|
||||
|
||||
import { apiRequest } from "../../../api/client";
|
||||
import { useAuth } from "../../../auth/AuthContext";
|
||||
import { Skeleton } from "../../../components/ui/skeleton";
|
||||
|
||||
interface Punkt {
|
||||
zeitpunkt: string;
|
||||
@@ -44,7 +45,14 @@ function ReadinessVerlaufTileKomponente() {
|
||||
// (READY-006: Trend-Reporting ist Leitungsthema, kein Helfer-Feature).
|
||||
if (!berechtigt) return <p className="text-muted">Nur für Administration/Leitungsverantwortliche.</p>;
|
||||
if (fehler) return <p className="text-muted">Verlauf konnte nicht geladen werden.</p>;
|
||||
if (punkte === null) return <p className="text-muted">Lade…</p>;
|
||||
if (punkte === null)
|
||||
return (
|
||||
<div className="stack" style={{ gap: "0.5rem" }}>
|
||||
<Skeleton className="h-5 w-2/3" />
|
||||
<Skeleton className="h-5 w-full" />
|
||||
<Skeleton className="h-5 w-full" />
|
||||
</div>
|
||||
);
|
||||
if (punkte.length === 0) return <p className="text-muted">Noch keine Snapshots vorhanden.</p>;
|
||||
|
||||
const zeitpunkte = [...new Set(punkte.map((p) => p.zeitpunkt))].sort();
|
||||
|
||||
Reference in new issue
Block a user