diff --git a/backend/app/routers/dashboard.py b/backend/app/routers/dashboard.py
index da89069..e0c9fcd 100644
--- a/backend/app/routers/dashboard.py
+++ b/backend/app/routers/dashboard.py
@@ -49,14 +49,16 @@ router = APIRouter(prefix="/dashboard", tags=["dashboard"])
ENFORCED_KEY = "dashboard_enforced"
# Eingebaute Anordnung – wird benutzt, solange weder eigene noch Vorgabe existiert.
+# Höhen zählen Rasterzeilen à 40 px; die Oberfläche hebt zu flache Karten
+# zusätzlich auf ihre Mindesthöhe an.
BUILTIN_LAYOUT: list[dict] = [
- {"i": "actions", "x": 0, "y": 0, "w": 4, "h": 1},
- {"i": "status", "x": 4, "y": 0, "w": 8, "h": 1},
- {"i": "expiring", "x": 0, "y": 1, "w": 6, "h": 3},
- {"i": "shopping", "x": 6, "y": 1, "w": 6, "h": 3},
- {"i": "expiry-donut", "x": 0, "y": 4, "w": 4, "h": 3},
- {"i": "category-donut", "x": 4, "y": 4, "w": 4, "h": 3},
- {"i": "stock-timeline", "x": 8, "y": 4, "w": 4, "h": 3},
+ {"i": "actions", "x": 0, "y": 0, "w": 4, "h": 3},
+ {"i": "status", "x": 4, "y": 0, "w": 8, "h": 3},
+ {"i": "expiring", "x": 0, "y": 3, "w": 6, "h": 6},
+ {"i": "shopping", "x": 6, "y": 3, "w": 6, "h": 6},
+ {"i": "expiry-donut", "x": 0, "y": 9, "w": 4, "h": 6},
+ {"i": "category-donut", "x": 4, "y": 9, "w": 4, "h": 6},
+ {"i": "stock-timeline", "x": 8, "y": 9, "w": 4, "h": 6},
]
diff --git a/web/src/dashboard/Vorschau.jsx b/web/src/dashboard/Vorschau.jsx
new file mode 100644
index 0000000..900169d
--- /dev/null
+++ b/web/src/dashboard/Vorschau.jsx
@@ -0,0 +1,116 @@
+/**
+ * Kleine Skizzen für das Menü „Karte hinzufügen“.
+ *
+ * Bewusst abstrakte Zeichnungen statt einer echten Vorschau: Eine Live-Vorschau
+ * müsste für jede Karte Daten laden, nur um ein Menü zu zeigen. Die Skizze sagt
+ * in einem Blick, ob es eine Tabelle, ein Ring oder eine Linie wird – genau das,
+ * was der Name allein nicht verrät.
+ */
+const BREITE = 72;
+const HOEHE = 44;
+
+function Rahmen({ children }) {
+ return (
+
+ );
+}
+
+const SKIZZEN = {
+ aktion: (
+
+
+
+
+ ),
+
+ kennzahl: (
+
+
+
+
+ ),
+
+ kennzahlen: (
+
+ {[8, 28, 48].map((x, i) => (
+
+
+
+
+ ))}
+
+ ),
+
+ tabelle: (
+
+
+ {[18, 25, 32].map((y) => (
+
+
+
+
+ ))}
+
+ ),
+
+ ring: (
+
+ {/* Kreisumfang bei r=11: ~69 – daraus die Abschnitte */}
+
+
+
+
+
+ {[14, 21, 28].map((y, i) => (
+
+ ))}
+
+ ),
+
+ balken: (
+
+ {[12, 21, 30].map((y, i) => (
+
+
+
+
+
+ ))}
+
+ ),
+
+ linie: (
+
+
+
+
+ ),
+
+ linien: (
+
+
+
+
+
+ ),
+};
+
+export default function Vorschau({ art }) {
+ return SKIZZEN[art] ?? SKIZZEN.tabelle;
+}
diff --git a/web/src/dashboard/cards.jsx b/web/src/dashboard/cards.jsx
index 029510d..9de426c 100644
--- a/web/src/dashboard/cards.jsx
+++ b/web/src/dashboard/cards.jsx
@@ -272,30 +272,56 @@ function KarteKategorienSaeulen() {
);
}
-function KarteBestandsverlauf({ tage = 90 }) {
+/** Zeitraum-Auswahl für die Verlaufskarten. */
+const ZEITRAEUME = [
+ { tage: 7, label: "7 Tage" },
+ { tage: 30, label: "30 Tage" },
+ { tage: 90, label: "90 Tage" },
+ { tage: 180, label: "6 Monate" },
+ { tage: 365, label: "1 Jahr" },
+];
+
+function ZeitraumWahl({ wert, setzen }) {
+ return (
+
+ );
+}
+
+function KarteBestandsverlauf() {
+ const [tage, setTage] = useState(90);
const { daten, fehler } = useDaten(() => api.dashboardTimeline(tage), [tage]);
- if (fehler) return
{fehler}
;
- if (!daten) return Lädt…
;
return (
- kurzesDatum(p.date))}
- serien={[{
- name: "Artikeleinheiten",
- color: tokenFarbe("--accent", "#3f51b5"),
- werte: daten.map((p) => p.article_units),
- }]}
- einheit="Einheiten"
- />
+
+
+ {fehler ? (
+
{fehler}
+ ) : !daten ? (
+
Lädt…
+ ) : (
+
kurzesDatum(p.date))}
+ serien={[{
+ name: "Artikeleinheiten",
+ color: tokenFarbe("--accent", "#3f51b5"),
+ werte: daten.map((p) => p.article_units),
+ }]}
+ einheit="Einheiten"
+ />
+ )}
+
);
}
function KarteArtikelverlauf() {
const [produkte, setProdukte] = useState([]);
const [gewaehlt, setGewaehlt] = useState("");
+ const [tage, setTage] = useState(90);
const { daten, fehler } = useDaten(
- () => (gewaehlt ? api.dashboardTimeline(90, gewaehlt) : Promise.resolve([])),
- [gewaehlt],
+ () => (gewaehlt ? api.dashboardTimeline(tage, gewaehlt) : Promise.resolve([])),
+ [gewaehlt, tage],
);
useEffect(() => {
@@ -308,11 +334,13 @@ function KarteArtikelverlauf() {
return (
-
+
+
+
+
{fehler ? (
{fehler}
) : (
@@ -330,79 +358,123 @@ function KarteArtikelverlauf() {
);
}
-function KarteBewegungsverlauf({ tage = 30 }) {
+function KarteBewegungsverlauf() {
+ const [tage, setTage] = useState(30);
const { daten, fehler } = useDaten(() => api.dashboardActivity(tage), [tage]);
- if (fehler) return
{fehler}
;
- if (!daten) return
Lädt…
;
return (
-
kurzesDatum(p.date))}
- serien={[
- { name: "Eingelagert", color: tokenFarbe("--ok", "#2e7d55"), werte: daten.map((p) => p.checked_in) },
- { name: "Ausgelagert", color: tokenFarbe("--warn", "#b06f14"), werte: daten.map((p) => p.checked_out) },
- ]}
- einheit="Vorgänge"
- />
+
+
+ {fehler ? (
+
{fehler}
+ ) : !daten ? (
+
Lädt…
+ ) : (
+
kurzesDatum(p.date))}
+ serien={[
+ { name: "Eingelagert", color: tokenFarbe("--ok", "#2e7d55"), werte: daten.map((p) => p.checked_in) },
+ { name: "Ausgelagert", color: tokenFarbe("--warn", "#b06f14"), werte: daten.map((p) => p.checked_out) },
+ ]}
+ einheit="Vorgänge"
+ />
+ )}
+
);
}
// ---------------------------------------------------------------- Verzeichnis
+/**
+ * Höhen sind Rasterzeilen à 40 px (plus 16 px Abstand je weiterer Zeile).
+ * Die Mindesthöhe ist bewusst so gewählt, dass der Inhalt ohne Rollbalken
+ * hineinpasst – vorher war sie gleich der Standardhöhe und beide zu klein.
+ *
+ * nutzbare Höhe = h * 40 + (h - 1) * 16 − 41 (Kopf) − 32 (Innenabstand)
+ * h=3 → 79 px h=4 → 119 px h=6 → 199 px h=7 → 239 px
+ */
export const KARTEN = {
actions: {
- titel: "Schnellzugriff", icon: "checkin", standard: { w: 4, h: 1 }, min: { w: 3, h: 1 },
+ titel: "Schnellzugriff", icon: "checkin", vorschau: "aktion",
+ beschreibung: "Knöpfe zum Ein- und Auslagern",
+ standard: { w: 4, h: 3 }, min: { w: 3, h: 3 },
komponente: KarteAktionen,
},
status: {
- titel: "Status", icon: "dashboard", standard: { w: 8, h: 1 }, min: { w: 4, h: 1 },
+ titel: "Status", icon: "dashboard", vorschau: "kennzahlen",
+ beschreibung: "Bald ablaufend, abgelaufen, nachzukaufen",
+ standard: { w: 8, h: 3 }, min: { w: 4, h: 3 },
komponente: KarteStatus,
},
"kpi-products": {
- titel: "Artikel mit Bestand", icon: "package", standard: { w: 3, h: 1 }, min: { w: 2, h: 1 },
+ titel: "Artikel mit Bestand", icon: "package", vorschau: "kennzahl",
+ beschreibung: "Anzahl verschiedener Artikel im Lager",
+ standard: { w: 3, h: 3 }, min: { w: 2, h: 3 },
komponente: KarteArtikelzahl,
},
"kpi-units": {
- titel: "Artikeleinheiten", icon: "box", standard: { w: 3, h: 1 }, min: { w: 2, h: 1 },
+ titel: "Artikeleinheiten", icon: "box", vorschau: "kennzahl",
+ beschreibung: "Summe aller Gebinde im Lager",
+ standard: { w: 3, h: 3 }, min: { w: 2, h: 3 },
komponente: KarteEinheiten,
},
expiring: {
- titel: "Bald ablaufend", icon: "clock", standard: { w: 6, h: 3 }, min: { w: 4, h: 2 },
+ titel: "Bald ablaufend", icon: "clock", vorschau: "tabelle",
+ beschreibung: "Chargen innerhalb der Warnfrist",
+ standard: { w: 6, h: 6 }, min: { w: 4, h: 4 },
komponente: () => ,
},
expired: {
- titel: "Abgelaufen", icon: "alert", standard: { w: 6, h: 2 }, min: { w: 4, h: 2 },
+ titel: "Abgelaufen", icon: "alert", vorschau: "tabelle",
+ beschreibung: "Nur überfällige Chargen",
+ standard: { w: 6, h: 5 }, min: { w: 4, h: 4 },
komponente: () => ,
},
shopping: {
- titel: "Einkaufsliste", icon: "cart", standard: { w: 6, h: 3 }, min: { w: 4, h: 2 },
+ titel: "Einkaufsliste", icon: "cart", vorschau: "tabelle",
+ beschreibung: "Produkte und Gruppen unter Mindestbestand",
+ standard: { w: 6, h: 6 }, min: { w: 4, h: 4 },
komponente: KarteEinkaufsliste,
},
movements: {
- titel: "Letzte Bewegungen", icon: "history", standard: { w: 6, h: 3 }, min: { w: 4, h: 2 },
+ titel: "Letzte Bewegungen", icon: "history", vorschau: "tabelle",
+ beschreibung: "Wer hat zuletzt was ein- oder ausgelagert",
+ standard: { w: 6, h: 6 }, min: { w: 4, h: 4 },
komponente: KarteBewegungen,
},
"expiry-donut": {
- titel: "Ablauf-Anteile", icon: "clock", standard: { w: 4, h: 3 }, min: { w: 3, h: 3 },
+ titel: "Ablauf-Anteile", icon: "clock", vorschau: "ring",
+ beschreibung: "Ring: in Ordnung, bald ablaufend, abgelaufen",
+ standard: { w: 4, h: 6 }, min: { w: 3, h: 5 },
komponente: KarteAblaufRing,
},
"category-donut": {
- titel: "Kategorien-Anteile", icon: "tag", standard: { w: 4, h: 3 }, min: { w: 3, h: 3 },
+ titel: "Kategorien-Anteile", icon: "tag", vorschau: "ring",
+ beschreibung: "Ring: welche Kategorie wie viel ausmacht",
+ standard: { w: 4, h: 6 }, min: { w: 3, h: 5 },
komponente: KarteKategorienRing,
},
"category-bars": {
- titel: "Kategorien nach Zustand", icon: "tag", standard: { w: 8, h: 3 }, min: { w: 4, h: 2 },
+ titel: "Kategorien nach Zustand", icon: "tag", vorschau: "balken",
+ beschreibung: "Balken je Kategorie, nach Ablauf aufgeteilt",
+ standard: { w: 8, h: 6 }, min: { w: 4, h: 5 },
komponente: KarteKategorienSaeulen,
},
"stock-timeline": {
- titel: "Bestandsverlauf", icon: "history", standard: { w: 8, h: 3 }, min: { w: 4, h: 2 },
+ titel: "Bestandsverlauf", icon: "history", vorschau: "linie",
+ beschreibung: "Artikeleinheiten im Lager über 90 Tage",
+ standard: { w: 8, h: 6 }, min: { w: 4, h: 5 },
komponente: KarteBestandsverlauf,
},
"product-timeline": {
- titel: "Verlauf eines Artikels", icon: "package", standard: { w: 8, h: 3 }, min: { w: 4, h: 3 },
+ titel: "Verlauf eines Artikels", icon: "package", vorschau: "linie",
+ beschreibung: "Ein wählbarer Artikel über 90 Tage",
+ standard: { w: 8, h: 7 }, min: { w: 4, h: 6 },
komponente: KarteArtikelverlauf,
},
"activity-timeline": {
- titel: "Ein- und Auslagerungen", icon: "history", standard: { w: 8, h: 3 }, min: { w: 4, h: 2 },
+ titel: "Ein- und Auslagerungen", icon: "history", vorschau: "linien",
+ beschreibung: "Vorgänge je Tag über 30 Tage",
+ standard: { w: 8, h: 6 }, min: { w: 4, h: 5 },
komponente: KarteBewegungsverlauf,
},
};
diff --git a/web/src/pages/Dashboard.jsx b/web/src/pages/Dashboard.jsx
index c60ad46..2eda4a0 100644
--- a/web/src/pages/Dashboard.jsx
+++ b/web/src/pages/Dashboard.jsx
@@ -5,11 +5,15 @@ import { useAuth } from "../auth";
import Icon from "../components/Icon";
import { useConfirm } from "../confirm";
import { KARTEN, KARTEN_IDS } from "../dashboard/cards";
+import Vorschau from "../dashboard/Vorschau";
const Raster = WidthProvider(GridLayout);
const SPALTEN = 12;
-const ZEILENHOEHE = 96;
+// Feine Zeilen: Damit lässt sich die Höhe in kleinen Schritten anpassen. Mit den
+// vorherigen 96 px war schon die kleinste Stufe entweder zu klein (Rollbalken)
+// oder unnötig hoch.
+const ZEILENHOEHE = 40;
/**
* Startseite als frei zusammenstellbares Raster.
@@ -48,9 +52,20 @@ export default function Dashboard() {
useEffect(() => { laden(); }, []);
- /** Unbekannte Karten aussortieren – z.B. nach einem Update, das eine entfernt. */
+ /**
+ * Unbekannte Karten aussortieren – z.B. nach einem Update, das eine entfernt.
+ * Zusätzlich zu kleine Karten auf ihre Mindestgröße heben: Gespeicherte
+ * Anordnungen aus einer früheren Fassung haben Höhen aus einem gröberen
+ * Raster und wären sonst zu flach für ihren Inhalt.
+ */
function bereinige(liste) {
- return (liste || []).filter((k) => KARTEN[k.i]);
+ return (liste || [])
+ .filter((k) => KARTEN[k.i])
+ .map((k) => ({
+ ...k,
+ w: Math.max(k.w, KARTEN[k.i].min.w),
+ h: Math.max(k.h, KARTEN[k.i].min.h),
+ }));
}
const verfuegbar = useMemo(
@@ -173,15 +188,17 @@ export default function Dashboard() {
Karte hinzufügen
{menuOffen && (
-
+
{verfuegbar.map((id) => (
-
-
-
-
+
))}
-
+
)}
diff --git a/web/src/styles.css b/web/src/styles.css
index b6af666..cab0329 100644
--- a/web/src/styles.css
+++ b/web/src/styles.css
@@ -495,7 +495,10 @@ td select { width: auto; min-width: 0; max-width: 100%; }
}
.dash-card-head h2 { margin: 0; flex: 1; font-size: 0.9rem; }
.dash-card-head .icon { color: var(--muted); }
-.dash-card-body { flex: 1; min-height: 0; overflow: auto; padding: var(--sp-4); }
+.dash-card-body {
+ flex: 1; min-height: 0; overflow: auto; padding: var(--sp-4);
+ display: flex; flex-direction: column;
+}
/* Anfasser nur im Bearbeitungsmodus - sonst stoert er beim Lesen. */
.dash-grid .react-resizable-handle { display: none; }
@@ -536,6 +539,25 @@ td select { width: auto; min-width: 0; max-width: 100%; }
}
.menu .link-btn:hover { background: var(--surface-2); text-decoration: none; }
+/* Auswahl mit Skizze: Der Name allein verraet nicht, ob eine Karte eine
+ Tabelle, ein Ring oder eine Linie wird. */
+.karten-menu { min-width: 340px; max-height: 60vh; }
+.karten-wahl {
+ display: flex; align-items: center; gap: var(--sp-3);
+ width: 100%; padding: var(--sp-2); border: none; background: none;
+ border-radius: var(--radius-sm); cursor: pointer; text-align: left;
+ font: inherit; color: var(--text);
+}
+.karten-wahl:hover { background: var(--surface-2); }
+.karten-wahl .vorschau { width: 72px; height: 44px; flex: 0 0 auto; }
+.karten-text { display: flex; flex-direction: column; min-width: 0; }
+.karten-titel { font-weight: 600; font-size: 0.875rem; }
+
+/* Auswahlfelder in Karten (Zeitraum, Artikel) */
+.card-toolbar { display: flex; gap: var(--sp-2); flex-wrap: wrap; }
+.card-toolbar select { margin-top: 0; width: auto; min-width: 0; flex: 1 1 140px; }
+select.zeitraum { margin-top: 0; width: auto; min-width: 0; flex: 0 0 auto; align-self: flex-start; }
+
/* ---------- Startseite: Kartentypen ---------- */
.card-actions { display: flex; gap: var(--sp-2); flex-wrap: wrap; }
.card-stack { display: flex; flex-direction: column; gap: var(--sp-3); height: 100%; }
@@ -551,7 +573,13 @@ td select { width: auto; min-width: 0; max-width: 100%; }
.kpi.danger .kpi-value { color: var(--danger); }
/* ---------- Diagramme ---------- */
-.chart-donut { display: flex; align-items: center; gap: var(--sp-4); flex-wrap: wrap; }
+/* Der Ring fuellt die Karte und sitzt mittig – vorher klebte er oben und
+ darunter blieb eine grosse leere Flaeche stehen. */
+.chart-donut {
+ display: flex; align-items: center; justify-content: center;
+ gap: var(--sp-4); flex-wrap: wrap;
+ flex: 1; min-height: 0;
+}
.donut-svg { width: 160px; height: 160px; flex: 0 0 auto; }
.donut-svg circle { transition: stroke-width 120ms ease; cursor: default; }
.donut-value { font-size: 20px; font-weight: 680; fill: var(--text); }