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 { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import Icon from "./Icon";
|
import Icon from "./Icon";
|
||||||
|
import { Skeleton } from "./Skeleton";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Excel-artige Tabelle in der bestehenden Designsprache: feste Filterzeile je
|
* Excel-artige Tabelle in der bestehenden Designsprache: feste Filterzeile je
|
||||||
@@ -394,11 +395,30 @@ export default function DataTable({
|
|||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
{displayRows.length === 0 && (
|
{displayRows.length === 0 && (
|
||||||
|
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-
|
// Während des Ladens NICHT „nichts gefunden" behaupten – die Leer-
|
||||||
// Meldung kommt erst, wenn die Daten wirklich da (und leer) sind.
|
// Meldung kommt erst, wenn die Daten wirklich da (und leer) sind.
|
||||||
<tr><td className="empty" colSpan={orderedCols.length}>
|
<tr><td className="empty" colSpan={orderedCols.length}>
|
||||||
{loading ? "Wird geladen…" : empty}
|
{loading ? "Wird geladen…" : empty}
|
||||||
</td></tr>
|
</td></tr>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</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 { Link } from "react-router-dom";
|
||||||
import { api } from "../api";
|
import { api } from "../api";
|
||||||
import Icon from "../components/Icon";
|
import Icon from "../components/Icon";
|
||||||
|
import { CardSkeleton } from "../components/Skeleton";
|
||||||
import { useSettings } from "../settings";
|
import { useSettings } from "../settings";
|
||||||
import {
|
import {
|
||||||
amountText, articlePrimary, articleSecondary, fmt, relativeExpiry,
|
amountText, articlePrimary, articleSecondary, fmt, relativeExpiry,
|
||||||
@@ -35,9 +36,9 @@ function useDaten(laden, abhaengig = []) {
|
|||||||
return { daten, fehler };
|
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 (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>;
|
if (Array.isArray(daten) && daten.length === 0) return <div className="empty">{leer}</div>;
|
||||||
return children;
|
return children;
|
||||||
}
|
}
|
||||||
@@ -183,7 +184,7 @@ function KarteArtikel({ props: karteProps }) {
|
|||||||
return <div className="empty">Kein Artikel gewählt – über „Anordnen“ einstellen.</div>;
|
return <div className="empty">Kein Artikel gewählt – über „Anordnen“ einstellen.</div>;
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<Zustand fehler={fehler} daten={daten}>
|
<Zustand fehler={fehler} daten={daten} skelett="kpi">
|
||||||
{daten && (
|
{daten && (
|
||||||
<div className="card-stack">
|
<div className="card-stack">
|
||||||
<div className="kpi">
|
<div className="kpi">
|
||||||
@@ -219,7 +220,7 @@ function Kennzahl({ wert, label, icon, ton }) {
|
|||||||
function KarteStatus() {
|
function KarteStatus() {
|
||||||
const { daten, fehler } = useDaten(() => api.dashboardStats());
|
const { daten, fehler } = useDaten(() => api.dashboardStats());
|
||||||
return (
|
return (
|
||||||
<Zustand fehler={fehler} daten={daten}>
|
<Zustand fehler={fehler} daten={daten} skelett="kpi">
|
||||||
<div className="kpi-row">
|
<div className="kpi-row">
|
||||||
<Kennzahl icon="clock" label="Bald ablaufend" wert={daten?.expiring_soon ?? 0}
|
<Kennzahl icon="clock" label="Bald ablaufend" wert={daten?.expiring_soon ?? 0}
|
||||||
ton={daten?.expiring_soon ? "warn" : ""} />
|
ton={daten?.expiring_soon ? "warn" : ""} />
|
||||||
@@ -234,7 +235,7 @@ function KarteStatus() {
|
|||||||
function KarteArtikelzahl() {
|
function KarteArtikelzahl() {
|
||||||
const { daten, fehler } = useDaten(() => api.dashboardStats());
|
const { daten, fehler } = useDaten(() => api.dashboardStats());
|
||||||
return (
|
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} />
|
<Kennzahl icon="package" label="Artikel mit Bestand" wert={daten?.products_in_stock ?? 0} />
|
||||||
</Zustand>
|
</Zustand>
|
||||||
);
|
);
|
||||||
@@ -243,7 +244,7 @@ function KarteArtikelzahl() {
|
|||||||
function KarteEinheiten() {
|
function KarteEinheiten() {
|
||||||
const { daten, fehler } = useDaten(() => api.dashboardStats());
|
const { daten, fehler } = useDaten(() => api.dashboardStats());
|
||||||
return (
|
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)} />
|
<Kennzahl icon="box" label="Artikeleinheiten im Lager" wert={fmt(daten?.article_units ?? 0)} />
|
||||||
</Zustand>
|
</Zustand>
|
||||||
);
|
);
|
||||||
@@ -321,7 +322,7 @@ function KarteEinkaufsliste({ props: karteProps }) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (fehler) return <div className="alert error"><Icon name="alert" size={16} />{fehler}</div>;
|
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 orte = ortId ? daten.orte.filter((o) => o.location_id === ortId) : daten.orte;
|
||||||
const gesamtLeer = !daten.produkte.length && !daten.gruppen.length;
|
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.
|
// Ob „Ohne MHD" (Gegenstände/undatierte Chargen) mitgezählt wird – Karten-Einstellung.
|
||||||
const mitOhneMhd = karteProps?.mit_ohne_mhd ?? true;
|
const mitOhneMhd = karteProps?.mit_ohne_mhd ?? true;
|
||||||
if (fehler) return <div className="alert error"><Icon name="alert" size={16} />{fehler}</div>;
|
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.
|
// Zustandsfarben, nie Serienfarben – und immer mit Beschriftung.
|
||||||
const segmente = [
|
const segmente = [
|
||||||
@@ -452,7 +453,7 @@ function KarteKategorienRing({ props: karteProps }) {
|
|||||||
const tiefe = karteProps?.tiefe ? Number(karteProps.tiefe) : undefined;
|
const tiefe = karteProps?.tiefe ? Number(karteProps.tiefe) : undefined;
|
||||||
const { daten, fehler } = useDaten(() => api.dashboardByCategory(tiefe), [tiefe]);
|
const { daten, fehler } = useDaten(() => api.dashboardByCategory(tiefe), [tiefe]);
|
||||||
if (fehler) return <div className="alert error"><Icon name="alert" size={16} />{fehler}</div>;
|
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“.
|
// Detailansicht: jede Kategorie einzeln statt der Bündelung zu „Andere“.
|
||||||
const segmente = mitFarben(
|
const segmente = mitFarben(
|
||||||
@@ -466,7 +467,7 @@ function KarteKategorienRing({ props: karteProps }) {
|
|||||||
function KarteKategorienSaeulen() {
|
function KarteKategorienSaeulen() {
|
||||||
const { daten, fehler } = useDaten(() => api.dashboardByCategory());
|
const { daten, fehler } = useDaten(() => api.dashboardByCategory());
|
||||||
if (fehler) return <div className="alert error"><Icon name="alert" size={16} />{fehler}</div>;
|
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) => ({
|
const zeilen = daten.slice(0, 8).map((k) => ({
|
||||||
name: k.name,
|
name: k.name,
|
||||||
@@ -513,7 +514,7 @@ function KarteBestandsverlauf({ tage = 90 }) {
|
|||||||
const { daten, fehler } = useDaten(() => api.dashboardTimeline(tage), [tage]);
|
const { daten, fehler } = useDaten(() => api.dashboardTimeline(tage), [tage]);
|
||||||
|
|
||||||
if (fehler) return <div className="alert error"><Icon name="alert" size={16} />{fehler}</div>;
|
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 (
|
return (
|
||||||
<Line
|
<Line
|
||||||
@@ -572,7 +573,7 @@ function KarteBewegungsverlauf({ tage = 30 }) {
|
|||||||
const { daten, fehler } = useDaten(() => api.dashboardFlow(tage), [tage]);
|
const { daten, fehler } = useDaten(() => api.dashboardFlow(tage), [tage]);
|
||||||
|
|
||||||
if (fehler) return <div className="alert error"><Icon name="alert" size={16} />{fehler}</div>;
|
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 (
|
return (
|
||||||
<Wasserfall
|
<Wasserfall
|
||||||
|
|||||||
@@ -892,8 +892,35 @@ select.zeitraum:hover { color: var(--text); }
|
|||||||
/* Wer Bewegung reduziert haben moechte, bekommt sie eingeblendet ohne Fahrt. */
|
/* Wer Bewegung reduziert haben moechte, bekommt sie eingeblendet ohne Fahrt. */
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.toast { animation: none; }
|
.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 ---------- */
|
||||||
.login-wrap { min-height: 100vh; display: grid; place-items: center; padding: var(--sp-4); }
|
.login-wrap { min-height: 100vh; display: grid; place-items: center; padding: var(--sp-4); }
|
||||||
.login-card { width: 100%; max-width: 360px; }
|
.login-card { width: 100%; max-width: 360px; }
|
||||||
|
|||||||
Reference in New Issue
Block a user