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); }