Web: Skeleton-Loading (schimmernde Ladeplatzhalter)
Statt nacktem 'Lädt…'/'Wird geladen…' jetzt geformte, schimmernde Platzhalter an den zentralen Stellen: - Neuer Baustein components/Skeleton.jsx (Skeleton/SkeletonLines/CardSkeleton), Sweep-Animation, Farben aus CSS-Tokens (Dark-Mode automatisch), respektiert prefers-reduced-motion. - DataTable: bei Erstladung Platzhalterzeilen in Spaltenform (deckt alle 8 Tabellenseiten). Nachladen mit vorhandenen Zeilen laesst Daten stehen. - Dashboard-Karten: Zustand-Wrapper + Inline-Diagramm-Lader zeigen KPI-/Listen-/Diagramm-Skelette passend zur Kartenform. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import Icon from "./Icon";
|
||||
import { Skeleton } from "./Skeleton";
|
||||
|
||||
/**
|
||||
* Excel-artige Tabelle in der bestehenden Designsprache: feste Filterzeile je
|
||||
@@ -394,11 +395,30 @@ export default function DataTable({
|
||||
</tr>
|
||||
))}
|
||||
{displayRows.length === 0 && (
|
||||
// Während des Ladens NICHT „nichts gefunden" behaupten – die Leer-
|
||||
// Meldung kommt erst, wenn die Daten wirklich da (und leer) sind.
|
||||
<tr><td className="empty" colSpan={orderedCols.length}>
|
||||
{loading ? "Wird geladen…" : empty}
|
||||
</td></tr>
|
||||
loading && rows.length === 0 ? (
|
||||
// Erstladung: schimmernde Platzhalterzeilen in Spaltenform statt
|
||||
// eines nackten „Wird geladen…". (Beim Nachladen mit bereits
|
||||
// vorhandenen Zeilen greift dieser Zweig nicht - die Daten
|
||||
// bleiben stehen.)
|
||||
Array.from({ length: 8 }, (_, r) => (
|
||||
<tr key={`skel-${r}`} className="skel-tr" aria-hidden="true">
|
||||
{orderedCols.map((c, ci) => (
|
||||
<td key={c.key} className={c.align === "num" ? "num" : ""}>
|
||||
<Skeleton
|
||||
w={c.align === "num" ? "40%" : `${[80, 64, 72, 58, 86][(r + ci) % 5]}%`}
|
||||
style={c.align === "num" ? { marginLeft: "auto" } : undefined}
|
||||
/>
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))
|
||||
) : (
|
||||
// Während des Ladens NICHT „nichts gefunden" behaupten – die Leer-
|
||||
// Meldung kommt erst, wenn die Daten wirklich da (und leer) sind.
|
||||
<tr><td className="empty" colSpan={orderedCols.length}>
|
||||
{loading ? "Wird geladen…" : empty}
|
||||
</td></tr>
|
||||
)
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
61
web/src/components/Skeleton.jsx
Normal file
61
web/src/components/Skeleton.jsx
Normal file
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Schimmernde Ladeplatzhalter ("Skeleton"). Nur CSS-Tokens (--surface-2 /
|
||||
* --border), passt sich daher automatisch an Hell/Dunkel an. Reine Deko, deshalb
|
||||
* durchgehend aria-hidden – Screenreader sollen keine leeren Balken vorlesen.
|
||||
*/
|
||||
|
||||
/** Eine einzelne schimmernde Leiste. Breite/Höhe als px oder CSS-Wert ("60%"). */
|
||||
export function Skeleton({ w, h = 14, radius, className = "", style }) {
|
||||
return (
|
||||
<span
|
||||
className={`skeleton ${className}`.trim()}
|
||||
aria-hidden="true"
|
||||
style={{ width: w, height: h, borderRadius: radius, ...style }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** Mehrere gestapelte Text-Leisten unterschiedlicher Breite. */
|
||||
export function SkeletonLines({ lines = 3 }) {
|
||||
const breiten = ["90%", "72%", "82%", "60%", "76%"];
|
||||
return (
|
||||
<span className="skel-stack" aria-hidden="true">
|
||||
{Array.from({ length: lines }, (_, i) => (
|
||||
<Skeleton key={i} w={breiten[i % breiten.length]} />
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Kartenkörper-Skelett fürs Dashboard, grob in der Form des späteren Inhalts:
|
||||
* - "kpi" – Label-Zeile + große Wertzahl
|
||||
* - "liste" – ein paar Zeilen mit je zwei Werten (Standard)
|
||||
* - "chart" – ein großer Block, füllt die Karte
|
||||
*/
|
||||
export function CardSkeleton({ variant = "liste" }) {
|
||||
if (variant === "kpi") {
|
||||
return (
|
||||
<span className="skel-stack" aria-hidden="true">
|
||||
<Skeleton w="45%" h={12} />
|
||||
<Skeleton w="60%" h={26} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (variant === "chart") {
|
||||
// Höhe bewusst null -> keine Inline-Höhe, damit .skel-chart (flex:1) den
|
||||
// Kartenkörper ausfüllt.
|
||||
return <Skeleton className="skel-chart" w="100%" h={null} />;
|
||||
}
|
||||
// "liste"
|
||||
return (
|
||||
<span className="skel-stack" aria-hidden="true">
|
||||
{Array.from({ length: 5 }, (_, i) => (
|
||||
<span className="skel-row" key={i}>
|
||||
<Skeleton w={`${72 - i * 4}%`} />
|
||||
<Skeleton w="18%" />
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { api } from "../api";
|
||||
import Icon from "../components/Icon";
|
||||
import { CardSkeleton } from "../components/Skeleton";
|
||||
import { useSettings } from "../settings";
|
||||
import {
|
||||
amountText, articlePrimary, articleSecondary, fmt, relativeExpiry,
|
||||
@@ -35,9 +36,9 @@ function useDaten(laden, abhaengig = []) {
|
||||
return { daten, fehler };
|
||||
}
|
||||
|
||||
function Zustand({ fehler, daten, children, leer = "Keine Daten." }) {
|
||||
function Zustand({ fehler, daten, children, leer = "Keine Daten.", skelett = "liste" }) {
|
||||
if (fehler) return <div className="alert error"><Icon name="alert" size={16} />{fehler}</div>;
|
||||
if (daten == null) return <div className="empty">Lädt…</div>;
|
||||
if (daten == null) return <CardSkeleton variant={skelett} />;
|
||||
if (Array.isArray(daten) && daten.length === 0) return <div className="empty">{leer}</div>;
|
||||
return children;
|
||||
}
|
||||
@@ -183,7 +184,7 @@ function KarteArtikel({ props: karteProps }) {
|
||||
return <div className="empty">Kein Artikel gewählt – über „Anordnen“ einstellen.</div>;
|
||||
}
|
||||
return (
|
||||
<Zustand fehler={fehler} daten={daten}>
|
||||
<Zustand fehler={fehler} daten={daten} skelett="kpi">
|
||||
{daten && (
|
||||
<div className="card-stack">
|
||||
<div className="kpi">
|
||||
@@ -219,7 +220,7 @@ function Kennzahl({ wert, label, icon, ton }) {
|
||||
function KarteStatus() {
|
||||
const { daten, fehler } = useDaten(() => api.dashboardStats());
|
||||
return (
|
||||
<Zustand fehler={fehler} daten={daten}>
|
||||
<Zustand fehler={fehler} daten={daten} skelett="kpi">
|
||||
<div className="kpi-row">
|
||||
<Kennzahl icon="clock" label="Bald ablaufend" wert={daten?.expiring_soon ?? 0}
|
||||
ton={daten?.expiring_soon ? "warn" : ""} />
|
||||
@@ -234,7 +235,7 @@ function KarteStatus() {
|
||||
function KarteArtikelzahl() {
|
||||
const { daten, fehler } = useDaten(() => api.dashboardStats());
|
||||
return (
|
||||
<Zustand fehler={fehler} daten={daten}>
|
||||
<Zustand fehler={fehler} daten={daten} skelett="kpi">
|
||||
<Kennzahl icon="package" label="Artikel mit Bestand" wert={daten?.products_in_stock ?? 0} />
|
||||
</Zustand>
|
||||
);
|
||||
@@ -243,7 +244,7 @@ function KarteArtikelzahl() {
|
||||
function KarteEinheiten() {
|
||||
const { daten, fehler } = useDaten(() => api.dashboardStats());
|
||||
return (
|
||||
<Zustand fehler={fehler} daten={daten}>
|
||||
<Zustand fehler={fehler} daten={daten} skelett="kpi">
|
||||
<Kennzahl icon="box" label="Artikeleinheiten im Lager" wert={fmt(daten?.article_units ?? 0)} />
|
||||
</Zustand>
|
||||
);
|
||||
@@ -321,7 +322,7 @@ function KarteEinkaufsliste({ props: karteProps }) {
|
||||
});
|
||||
|
||||
if (fehler) return <div className="alert error"><Icon name="alert" size={16} />{fehler}</div>;
|
||||
if (!daten) return <div className="empty">Lädt…</div>;
|
||||
if (!daten) return <CardSkeleton variant="liste" />;
|
||||
|
||||
const orte = ortId ? daten.orte.filter((o) => o.location_id === ortId) : daten.orte;
|
||||
const gesamtLeer = !daten.produkte.length && !daten.gruppen.length;
|
||||
@@ -433,7 +434,7 @@ function KarteAblaufRing({ props: karteProps }) {
|
||||
// Ob „Ohne MHD" (Gegenstände/undatierte Chargen) mitgezählt wird – Karten-Einstellung.
|
||||
const mitOhneMhd = karteProps?.mit_ohne_mhd ?? true;
|
||||
if (fehler) return <div className="alert error"><Icon name="alert" size={16} />{fehler}</div>;
|
||||
if (!daten) return <div className="empty">Lädt…</div>;
|
||||
if (!daten) return <CardSkeleton variant="chart" />;
|
||||
|
||||
// Zustandsfarben, nie Serienfarben – und immer mit Beschriftung.
|
||||
const segmente = [
|
||||
@@ -452,7 +453,7 @@ function KarteKategorienRing({ props: karteProps }) {
|
||||
const tiefe = karteProps?.tiefe ? Number(karteProps.tiefe) : undefined;
|
||||
const { daten, fehler } = useDaten(() => api.dashboardByCategory(tiefe), [tiefe]);
|
||||
if (fehler) return <div className="alert error"><Icon name="alert" size={16} />{fehler}</div>;
|
||||
if (!daten) return <div className="empty">Lädt…</div>;
|
||||
if (!daten) return <CardSkeleton variant="chart" />;
|
||||
|
||||
// Detailansicht: jede Kategorie einzeln statt der Bündelung zu „Andere“.
|
||||
const segmente = mitFarben(
|
||||
@@ -466,7 +467,7 @@ function KarteKategorienRing({ props: karteProps }) {
|
||||
function KarteKategorienSaeulen() {
|
||||
const { daten, fehler } = useDaten(() => api.dashboardByCategory());
|
||||
if (fehler) return <div className="alert error"><Icon name="alert" size={16} />{fehler}</div>;
|
||||
if (!daten) return <div className="empty">Lädt…</div>;
|
||||
if (!daten) return <CardSkeleton variant="chart" />;
|
||||
|
||||
const zeilen = daten.slice(0, 8).map((k) => ({
|
||||
name: k.name,
|
||||
@@ -513,7 +514,7 @@ function KarteBestandsverlauf({ tage = 90 }) {
|
||||
const { daten, fehler } = useDaten(() => api.dashboardTimeline(tage), [tage]);
|
||||
|
||||
if (fehler) return <div className="alert error"><Icon name="alert" size={16} />{fehler}</div>;
|
||||
if (!daten) return <div className="empty">Lädt…</div>;
|
||||
if (!daten) return <CardSkeleton variant="chart" />;
|
||||
|
||||
return (
|
||||
<Line
|
||||
@@ -572,7 +573,7 @@ function KarteBewegungsverlauf({ tage = 30 }) {
|
||||
const { daten, fehler } = useDaten(() => api.dashboardFlow(tage), [tage]);
|
||||
|
||||
if (fehler) return <div className="alert error"><Icon name="alert" size={16} />{fehler}</div>;
|
||||
if (!daten) return <div className="empty">Lädt…</div>;
|
||||
if (!daten) return <CardSkeleton variant="chart" />;
|
||||
|
||||
return (
|
||||
<Wasserfall
|
||||
|
||||
@@ -892,8 +892,35 @@ select.zeitraum:hover { color: var(--text); }
|
||||
/* Wer Bewegung reduziert haben moechte, bekommt sie eingeblendet ohne Fahrt. */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.toast { animation: none; }
|
||||
.skeleton { animation: none; }
|
||||
}
|
||||
|
||||
/* ---------- Skeleton (schimmernde Ladeplatzhalter) ---------- */
|
||||
/* Farben aus Tokens -> passt sich Hell/Dunkel von allein an. Ueber den grauen
|
||||
Grundton wandert ein hellerer Streifen (Sweep) von rechts nach links. */
|
||||
.skeleton {
|
||||
display: block;
|
||||
background: linear-gradient(90deg,
|
||||
var(--surface-2) 25%, var(--border) 37%, var(--surface-2) 63%);
|
||||
background-size: 400% 100%;
|
||||
animation: skeleton-glanz 1.4s ease infinite;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
@keyframes skeleton-glanz {
|
||||
from { background-position: 100% 0; }
|
||||
to { background-position: 0 0; }
|
||||
}
|
||||
.skel-stack { display: flex; flex-direction: column; gap: var(--sp-3); }
|
||||
.skel-row {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
gap: var(--sp-3);
|
||||
}
|
||||
.skel-row .skeleton { flex: none; }
|
||||
/* Diagramm-Platzhalter fuellt den Kartenkoerper. */
|
||||
.skel-chart { flex: 1; min-height: 120px; height: auto; }
|
||||
/* Tabellen-Platzhalterzeilen: kein Hover-Hintergrund. */
|
||||
.table tbody tr.skel-tr:hover { background: transparent; }
|
||||
|
||||
/* ---------- Login ---------- */
|
||||
.login-wrap { min-height: 100vh; display: grid; place-items: center; padding: var(--sp-4); }
|
||||
.login-card { width: 100%; max-width: 360px; }
|
||||
|
||||
Reference in New Issue
Block a user