feat(ui): UI2-002/003/004/005 Sheet-Navigation, FormField-Einsatz, Skeleton, Table-Wrapper
CI / backend-tests (push) Successful in 2m45s
CI / frontend-build (push) Successful in 18s

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CVgbozhYmuEhiEJHffRXCV
This commit is contained in:
patrickandClaude Sonnet 5 committed 2026-09-10 12:37:16 +02:00
1 parent 91cfb1087f
commit d41d04c26c
14 files changed
+292 -104

No files matched your search

+33 -4
View File
@@ -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 -2
View File
@@ -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}
+43
View File
@@ -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
);
}
+8
View File
@@ -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>;
}
+38 -10
View File
@@ -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
+3 -2
View File
@@ -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>
+3 -2
View File
@@ -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>
+3 -2
View File
@@ -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>
+52 -24
View File
@@ -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();