diff --git a/web/src/components/DataTable.jsx b/web/src/components/DataTable.jsx
index ccd6a13..c5108e9 100644
--- a/web/src/components/DataTable.jsx
+++ b/web/src/components/DataTable.jsx
@@ -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({
))}
{displayRows.length === 0 && (
- // Während des Ladens NICHT „nichts gefunden" behaupten – die Leer-
- // Meldung kommt erst, wenn die Daten wirklich da (und leer) sind.
-
|
- {loading ? "Wird geladen…" : empty}
- |
+ 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) => (
+
+ {orderedCols.map((c, ci) => (
+ |
+
+ |
+ ))}
+
+ ))
+ ) : (
+ // Während des Ladens NICHT „nichts gefunden" behaupten – die Leer-
+ // Meldung kommt erst, wenn die Daten wirklich da (und leer) sind.
+ |
+ {loading ? "Wird geladen…" : empty}
+ |
+ )
)}
diff --git a/web/src/components/Skeleton.jsx b/web/src/components/Skeleton.jsx
new file mode 100644
index 0000000..82688c9
--- /dev/null
+++ b/web/src/components/Skeleton.jsx
@@ -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 (
+
+ );
+}
+
+/** Mehrere gestapelte Text-Leisten unterschiedlicher Breite. */
+export function SkeletonLines({ lines = 3 }) {
+ const breiten = ["90%", "72%", "82%", "60%", "76%"];
+ return (
+
+ {Array.from({ length: lines }, (_, i) => (
+
+ ))}
+
+ );
+}
+
+/**
+ * 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 (
+
+
+
+
+ );
+ }
+ if (variant === "chart") {
+ // Höhe bewusst null -> keine Inline-Höhe, damit .skel-chart (flex:1) den
+ // Kartenkörper ausfüllt.
+ return ;
+ }
+ // "liste"
+ return (
+
+ {Array.from({ length: 5 }, (_, i) => (
+
+
+
+
+ ))}
+
+ );
+}
diff --git a/web/src/dashboard/cards.jsx b/web/src/dashboard/cards.jsx
index b952a5f..10ced55 100644
--- a/web/src/dashboard/cards.jsx
+++ b/web/src/dashboard/cards.jsx
@@ -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 {fehler}
;
- if (daten == null) return Lädt…
;
+ if (daten == null) return ;
if (Array.isArray(daten) && daten.length === 0) return {leer}
;
return children;
}
@@ -183,7 +184,7 @@ function KarteArtikel({ props: karteProps }) {
return Kein Artikel gewählt – über „Anordnen“ einstellen.
;
}
return (
-
+
{daten && (
@@ -219,7 +220,7 @@ function Kennzahl({ wert, label, icon, ton }) {
function KarteStatus() {
const { daten, fehler } = useDaten(() => api.dashboardStats());
return (
-
+
@@ -234,7 +235,7 @@ function KarteStatus() {
function KarteArtikelzahl() {
const { daten, fehler } = useDaten(() => api.dashboardStats());
return (
-
+
);
@@ -243,7 +244,7 @@ function KarteArtikelzahl() {
function KarteEinheiten() {
const { daten, fehler } = useDaten(() => api.dashboardStats());
return (
-
+
);
@@ -321,7 +322,7 @@ function KarteEinkaufsliste({ props: karteProps }) {
});
if (fehler) return {fehler}
;
- if (!daten) return Lädt…
;
+ if (!daten) return ;
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 {fehler}
;
- if (!daten) return Lädt…
;
+ if (!daten) return ;
// 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 {fehler}
;
- if (!daten) return Lädt…
;
+ if (!daten) return ;
// 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 {fehler}
;
- if (!daten) return Lädt…
;
+ if (!daten) return ;
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 {fehler}
;
- if (!daten) return Lädt…
;
+ if (!daten) return ;
return (
api.dashboardFlow(tage), [tage]);
if (fehler) return {fehler}
;
- if (!daten) return Lädt…
;
+ if (!daten) return ;
return (
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; }