From 8c8f595385be65bbd16b93ef19397e2c17bed27a Mon Sep 17 00:00:00 2001 From: Lars Date: Tue, 7 Apr 2026 10:21:11 +0200 Subject: [PATCH] feat: Refactor PilotVizPage and widget registry for improved layout and rendering - Replaced the previous widget retrieval method with a new layout-based approach in PilotVizPage. - Introduced a PilotVizAdminCard for layout configuration and management. - Updated widget definitions in the registry to include new components and default order. - Enhanced user feedback for widget visibility with a message when no widgets are active. --- .../components/pilot/GoalsSnapshotWidget.jsx | 76 +++++++++++ .../components/pilot/PilotVizAdminCard.jsx | 128 ++++++++++++++++++ .../src/components/pilot/WeightKpiWidget.jsx | 87 ++++++++++++ frontend/src/pages/PilotVizPage.jsx | 51 ++++--- frontend/src/pilot/pilotLayoutStorage.js | 52 +++++++ frontend/src/pilot/widgetRegistry.js | 30 +++- 6 files changed, 398 insertions(+), 26 deletions(-) create mode 100644 frontend/src/components/pilot/GoalsSnapshotWidget.jsx create mode 100644 frontend/src/components/pilot/PilotVizAdminCard.jsx create mode 100644 frontend/src/components/pilot/WeightKpiWidget.jsx create mode 100644 frontend/src/pilot/pilotLayoutStorage.js diff --git a/frontend/src/components/pilot/GoalsSnapshotWidget.jsx b/frontend/src/components/pilot/GoalsSnapshotWidget.jsx new file mode 100644 index 0000000..d2adb68 --- /dev/null +++ b/frontend/src/components/pilot/GoalsSnapshotWidget.jsx @@ -0,0 +1,76 @@ +import { useState, useEffect } from 'react' +import { Link } from 'react-router-dom' +import { Target } from 'lucide-react' +import { api } from '../../utils/api' + +export default function GoalsSnapshotWidget() { + const [count, setCount] = useState(null) + const [loading, setLoading] = useState(true) + const [err, setErr] = useState(null) + + useEffect(() => { + let cancelled = false + ;(async () => { + try { + const list = await api.listGoals() + if (!cancelled) { + setCount(Array.isArray(list) ? list.length : 0) + setErr(null) + } + } catch (e) { + if (!cancelled) { + setErr(e.message || 'Laden fehlgeschlagen') + setCount(null) + } + } finally { + if (!cancelled) setLoading(false) + } + })() + return () => { + cancelled = true + } + }, []) + + if (loading) { + return ( +
+
+
+ ) + } + if (err) { + return
{err}
+ } + + return ( +
+
+ +
+
+
+ {count === 0 + ? 'Noch keine strategischen Ziele' + : `${count} ${count === 1 ? 'Ziel' : 'Ziele'} aktiv`} +
+

+ Focus Areas und Fortschritt – wie auf dem Haupt-Dashboard, hier als Pilot-Widget. +

+ + Zu den Zielen → + +
+
+ ) +} diff --git a/frontend/src/components/pilot/PilotVizAdminCard.jsx b/frontend/src/components/pilot/PilotVizAdminCard.jsx new file mode 100644 index 0000000..77aafdc --- /dev/null +++ b/frontend/src/components/pilot/PilotVizAdminCard.jsx @@ -0,0 +1,128 @@ +import { Settings2, RotateCcw, ChevronUp, ChevronDown } from 'lucide-react' +import { PILOT_WIDGET_DEFS, PILOT_WIDGET_IDS_DEFAULT_ORDER } from '../../pilot/widgetRegistry' +import { resetPilotLayout, savePilotLayout } from '../../pilot/pilotLayoutStorage' + +/** + * Pilot: lokale Konfiguration (Ein/Aus, Reihenfolge). Kein Admin-Recht nötig. + */ +export default function PilotVizAdminCard({ layout, onLayoutChange }) { + const persist = (next) => { + savePilotLayout(next) + onLayoutChange(next) + } + + const move = (index, dir) => { + const nextOrder = [...layout.order] + const j = index + dir + if (j < 0 || j >= nextOrder.length) return + ;[nextOrder[index], nextOrder[j]] = [nextOrder[j], nextOrder[index]] + persist({ ...layout, order: nextOrder }) + } + + const toggle = (id) => { + persist({ + ...layout, + enabled: { ...layout.enabled, [id]: !layout.enabled[id] }, + }) + } + + const handleReset = () => { + onLayoutChange(resetPilotLayout()) + } + + return ( +
+
+ + Widget-Konfiguration (Pilot) +
+

+ Sichtbarkeit und Reihenfolge steuern. Wird nur lokal in diesem Browser gespeichert + (localStorage) – gut zum Ausprobieren vor einer serverseitigen + Profil-Konfiguration. +

+ +
    + {layout.order.map((id, index) => { + const def = PILOT_WIDGET_DEFS[id] + if (!def) return null + const on = !!layout.enabled[id] + return ( +
  • +
    + + +
    +
    +
    {def.title}
    + {id} +
    + +
  • + ) + })} +
+ +
+ + + Standard-Reihenfolge: {PILOT_WIDGET_IDS_DEFAULT_ORDER.join(' → ')} + +
+
+ ) +} diff --git a/frontend/src/components/pilot/WeightKpiWidget.jsx b/frontend/src/components/pilot/WeightKpiWidget.jsx new file mode 100644 index 0000000..3fd0269 --- /dev/null +++ b/frontend/src/components/pilot/WeightKpiWidget.jsx @@ -0,0 +1,87 @@ +import { useState, useEffect } from 'react' +import { Link } from 'react-router-dom' +import dayjs from 'dayjs' +import { api } from '../../utils/api' + +export default function WeightKpiWidget() { + const [stats, setStats] = useState(null) + const [loading, setLoading] = useState(true) + const [err, setErr] = useState(null) + + useEffect(() => { + let cancelled = false + ;(async () => { + try { + const s = await api.weightStats() + if (!cancelled) { + setStats(s) + setErr(null) + } + } catch (e) { + if (!cancelled) { + setErr(e.message || 'Laden fehlgeschlagen') + setStats(null) + } + } finally { + if (!cancelled) setLoading(false) + } + })() + return () => { + cancelled = true + } + }, []) + + if (loading) { + return ( +
+
+
+ ) + } + if (err) { + return
{err}
+ } + const latest = stats?.latest + const prev = stats?.prev + if (!latest) { + return ( +

+ Noch kein Gewicht erfasst.{' '} + + Zur Eingabe + +

+ ) + } + + const delta = + prev && typeof latest.weight === 'number' && typeof prev.weight === 'number' + ? Math.round((latest.weight - prev.weight) * 10) / 10 + : null + const deltaColor = + delta == null ? 'var(--text3)' : delta < 0 ? 'var(--accent)' : delta > 0 ? 'var(--warn)' : 'var(--text3)' + + return ( +
+
+
Aktuelles Gewicht
+
+ {latest.weight} + kg +
+
+ Stand {dayjs(latest.date).format('DD.MM.YYYY')} +
+
+ {delta != null && ( +
+ {delta > 0 ? '+' : ''} + {delta} kg ggü. Vorher +
+ )} + + Verlauf → + +
+ ) +} diff --git a/frontend/src/pages/PilotVizPage.jsx b/frontend/src/pages/PilotVizPage.jsx index a539fab..e6c1d3b 100644 --- a/frontend/src/pages/PilotVizPage.jsx +++ b/frontend/src/pages/PilotVizPage.jsx @@ -1,13 +1,16 @@ +import { useState } from 'react' import { FlaskConical } from 'lucide-react' import { Link } from 'react-router-dom' -import { getPilotWidgetsInOrder } from '../pilot/widgetRegistry' +import { resolvePilotWidgetsForRender } from '../pilot/widgetRegistry' +import { loadPilotLayout } from '../pilot/pilotLayoutStorage' +import PilotVizAdminCard from '../components/pilot/PilotVizAdminCard' /** * Pilot: Widget-Schicht (Layer 3b) parallel zum produktiven Dashboard. - * Daten für Referenzwerte: Layer 1 (backend/data_layer/reference_values.py) → API. */ export default function PilotVizPage() { - const widgets = getPilotWidgetsInOrder() + const [layout, setLayout] = useState(() => loadPilotLayout()) + const widgets = resolvePilotWidgetsForRender(layout) return (
@@ -24,26 +27,34 @@ export default function PilotVizPage() { Pilot: Visualisierungs-Module

- Testumgebung für die zukünftige Widget-Registry. Die produktive Übersicht und der Verlauf bleiben - unverändert. Referenzwerte-Summary wird über die gleiche API geladen wie in der Erfassungs-UI, angeboten - durch den Data Layer (Layer 1). + Testumgebung für die Widget-Registry. Konfiguration unten; produktive Übersicht und Verlauf sind unverändert.

- {widgets.map((def) => { - const { Component } = def - return ( -
-
{def.title}
- {def.description && ( -

- {def.description} -

- )} - -
- ) - })} + + + {widgets.length === 0 ? ( +
+

+ Keine Widgets sichtbar. Aktiviere mindestens eines in der Widget-Konfiguration oben. +

+
+ ) : ( + widgets.map((def) => { + const { Component } = def + return ( +
+
{def.title}
+ {def.description && ( +

+ {def.description} +

+ )} + +
+ ) + }) + )}
) } diff --git a/frontend/src/pilot/pilotLayoutStorage.js b/frontend/src/pilot/pilotLayoutStorage.js new file mode 100644 index 0000000..dc25848 --- /dev/null +++ b/frontend/src/pilot/pilotLayoutStorage.js @@ -0,0 +1,52 @@ +/** + * Pilot-Layout: nur lokal (localStorage), kein Server. + * Dient zum Testen von Sichtbarkeit und Reihenfolge vor einer DB-Lösung. + */ + +import { PILOT_WIDGET_DEFS, PILOT_WIDGET_IDS_DEFAULT_ORDER } from './widgetRegistry' + +const STORAGE_KEY = 'mitai_pilot_viz_layout_v1' + +function defaultLayout() { + const order = [...PILOT_WIDGET_IDS_DEFAULT_ORDER] + const enabled = Object.fromEntries(order.map((id) => [id, true])) + return { version: 1, order, enabled} +} + +function mergeWithRegistry(saved) { + if (!saved || typeof saved !== 'object') return defaultLayout() + const known = new Set(Object.keys(PILOT_WIDGET_DEFS)) + let order = Array.isArray(saved.order) ? saved.order.filter((id) => known.has(id)) : [] + for (const id of PILOT_WIDGET_IDS_DEFAULT_ORDER) { + if (!order.includes(id)) order.push(id) + } + const enabled = { ...defaultLayout().enabled, ...(saved.enabled && typeof saved.enabled === 'object' ? saved.enabled : {}) } + for (const id of Object.keys(PILOT_WIDGET_DEFS)) { + if (typeof enabled[id] !== 'boolean') enabled[id] = true + } + return { version: 1, order, enabled } +} + +export function loadPilotLayout() { + try { + const raw = localStorage.getItem(STORAGE_KEY) + if (!raw) return defaultLayout() + return mergeWithRegistry(JSON.parse(raw)) + } catch { + return defaultLayout() + } +} + +export function savePilotLayout(layout) { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(layout)) + } catch { + /* ignore quota */ + } +} + +export function resetPilotLayout() { + const d = defaultLayout() + savePilotLayout(d) + return d +} diff --git a/frontend/src/pilot/widgetRegistry.js b/frontend/src/pilot/widgetRegistry.js index 3840a56..7c74ddb 100644 --- a/frontend/src/pilot/widgetRegistry.js +++ b/frontend/src/pilot/widgetRegistry.js @@ -1,20 +1,38 @@ import ReferenceValuesSummaryWidget from '../components/pilot/ReferenceValuesSummaryWidget' +import GoalsSnapshotWidget from '../components/pilot/GoalsSnapshotWidget' +import WeightKpiWidget from '../components/pilot/WeightKpiWidget' /** - * Pilot: konfigurierbare Widget-Reihenfolge (Layer 3b-Vorspiel). - * Nur für /pilot/viz – produktives Dashboard bleibt unverändert. + * Pilot: Widget-Registry (Layer 3b-Vorspiel). + * Reihenfolge-Default: PILOT_WIDGET_IDS_DEFAULT_ORDER */ -export const PILOT_WIDGET_ORDER = ['reference_values_summary'] +export const PILOT_WIDGET_IDS_DEFAULT_ORDER = ['weight_kpi', 'goals_teaser', 'reference_values_summary'] export const PILOT_WIDGET_DEFS = { + weight_kpi: { + id: 'weight_kpi', + title: 'Gewicht (KPI)', + description: 'Letzter Eintrag und Delta zum vorherigen Messwert (API weight/stats).', + Component: WeightKpiWidget, + }, + goals_teaser: { + id: 'goals_teaser', + title: 'Strategische Ziele', + description: 'Anzahl aktiver Ziele, Link zur Ziele-Seite (API goals/list).', + Component: GoalsSnapshotWidget, + }, reference_values_summary: { id: 'reference_values_summary', title: 'Referenzwerte', - description: 'Aktuellste Kennwerte pro Typ (Daten via Layer 1 → bestehende API).', + description: 'Aktuellste Kennwerte pro Typ (Layer 1 → /profile-reference-values/summary).', Component: ReferenceValuesSummaryWidget, }, } -export function getPilotWidgetsInOrder() { - return PILOT_WIDGET_ORDER.map((id) => PILOT_WIDGET_DEFS[id]).filter(Boolean) +/** Sichtbare Widgets in gespeicherter Reihenfolge (für Render). */ +export function resolvePilotWidgetsForRender(layout) { + if (!layout?.order) return [] + return layout.order + .filter((id) => layout.enabled[id] && PILOT_WIDGET_DEFS[id]) + .map((id) => PILOT_WIDGET_DEFS[id]) }