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.
This commit is contained in:
parent
932bceb1e1
commit
8c8f595385
76
frontend/src/components/pilot/GoalsSnapshotWidget.jsx
Normal file
76
frontend/src/components/pilot/GoalsSnapshotWidget.jsx
Normal file
|
|
@ -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 (
|
||||
<div style={{ padding: 16, textAlign: 'center' }}>
|
||||
<div className="spinner" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (err) {
|
||||
return <div style={{ fontSize: 13, color: 'var(--danger)' }}>{err}</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 14, flexWrap: 'wrap' }}>
|
||||
<div
|
||||
style={{
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 12,
|
||||
background: 'var(--accent-light)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Target size={22} color="var(--accent)" />
|
||||
</div>
|
||||
<div style={{ flex: '1 1 200px' }}>
|
||||
<div style={{ fontSize: 15, fontWeight: 600, color: 'var(--text1)' }}>
|
||||
{count === 0
|
||||
? 'Noch keine strategischen Ziele'
|
||||
: `${count} ${count === 1 ? 'Ziel' : 'Ziele'} aktiv`}
|
||||
</div>
|
||||
<p style={{ fontSize: 13, color: 'var(--text2)', margin: '8px 0 0', lineHeight: 1.5 }}>
|
||||
Focus Areas und Fortschritt – wie auf dem Haupt-Dashboard, hier als Pilot-Widget.
|
||||
</p>
|
||||
<Link to="/goals" className="btn btn-secondary" style={{ marginTop: 12, display: 'inline-block', fontSize: 12, padding: '6px 12px', textDecoration: 'none' }}>
|
||||
Zu den Zielen →
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
128
frontend/src/components/pilot/PilotVizAdminCard.jsx
Normal file
128
frontend/src/components/pilot/PilotVizAdminCard.jsx
Normal file
|
|
@ -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 (
|
||||
<section
|
||||
className="card section-gap"
|
||||
style={{
|
||||
borderStyle: 'solid',
|
||||
borderColor: 'var(--accent)',
|
||||
borderWidth: 1,
|
||||
background: 'var(--surface)',
|
||||
}}
|
||||
>
|
||||
<div className="card-title" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<Settings2 size={18} color="var(--accent)" />
|
||||
Widget-Konfiguration (Pilot)
|
||||
</div>
|
||||
<p style={{ fontSize: 12, color: 'var(--text2)', marginTop: 4, marginBottom: 14, lineHeight: 1.5 }}>
|
||||
Sichtbarkeit und Reihenfolge steuern. Wird nur <strong>lokal in diesem Browser</strong> gespeichert
|
||||
(<code style={{ fontSize: 11 }}>localStorage</code>) – gut zum Ausprobieren vor einer serverseitigen
|
||||
Profil-Konfiguration.
|
||||
</p>
|
||||
|
||||
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{layout.order.map((id, index) => {
|
||||
const def = PILOT_WIDGET_DEFS[id]
|
||||
if (!def) return null
|
||||
const on = !!layout.enabled[id]
|
||||
return (
|
||||
<li
|
||||
key={id}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
flexWrap: 'wrap',
|
||||
padding: '10px 12px',
|
||||
borderRadius: 10,
|
||||
background: on ? 'var(--surface2)' : 'var(--surface)',
|
||||
border: '1px solid var(--border)',
|
||||
opacity: on ? 1 : 0.72,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
style={{ padding: '4px 8px', minWidth: 36 }}
|
||||
title="Nach oben"
|
||||
disabled={index === 0}
|
||||
onClick={() => move(index, -1)}
|
||||
>
|
||||
<ChevronUp size={16} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
style={{ padding: '4px 8px', minWidth: 36 }}
|
||||
title="Nach unten"
|
||||
disabled={index === layout.order.length - 1}
|
||||
onClick={() => move(index, 1)}
|
||||
>
|
||||
<ChevronDown size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 140 }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--text1)' }}>{def.title}</div>
|
||||
<code style={{ fontSize: 10, color: 'var(--text3)' }}>{id}</code>
|
||||
</div>
|
||||
<label
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
fontSize: 13,
|
||||
cursor: 'pointer',
|
||||
userSelect: 'none',
|
||||
}}
|
||||
>
|
||||
<input type="checkbox" checked={on} onChange={() => toggle(id)} />
|
||||
sichtbar
|
||||
</label>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
|
||||
<div style={{ marginTop: 14, display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center' }}>
|
||||
<button type="button" className="btn btn-secondary" onClick={handleReset} style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}>
|
||||
<RotateCcw size={14} />
|
||||
Standard wiederherstellen
|
||||
</button>
|
||||
<span style={{ fontSize: 11, color: 'var(--text3)' }}>
|
||||
Standard-Reihenfolge: {PILOT_WIDGET_IDS_DEFAULT_ORDER.join(' → ')}
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
87
frontend/src/components/pilot/WeightKpiWidget.jsx
Normal file
87
frontend/src/components/pilot/WeightKpiWidget.jsx
Normal file
|
|
@ -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 (
|
||||
<div style={{ padding: 16, textAlign: 'center' }}>
|
||||
<div className="spinner" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (err) {
|
||||
return <div style={{ fontSize: 13, color: 'var(--danger)' }}>{err}</div>
|
||||
}
|
||||
const latest = stats?.latest
|
||||
const prev = stats?.prev
|
||||
if (!latest) {
|
||||
return (
|
||||
<p style={{ fontSize: 13, color: 'var(--text2)', margin: 0 }}>
|
||||
Noch kein Gewicht erfasst.{' '}
|
||||
<Link to="/weight" style={{ color: 'var(--accent)' }}>
|
||||
Zur Eingabe
|
||||
</Link>
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 16, alignItems: 'flex-end' }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text3)', marginBottom: 4 }}>Aktuelles Gewicht</div>
|
||||
<div style={{ fontSize: 26, fontWeight: 800, color: '#378ADD', lineHeight: 1 }}>
|
||||
{latest.weight}
|
||||
<span style={{ fontSize: 14, fontWeight: 600, color: 'var(--text2)', marginLeft: 4 }}>kg</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text3)', marginTop: 6 }}>
|
||||
Stand {dayjs(latest.date).format('DD.MM.YYYY')}
|
||||
</div>
|
||||
</div>
|
||||
{delta != null && (
|
||||
<div style={{ fontSize: 13, fontWeight: 600, color: deltaColor }}>
|
||||
{delta > 0 ? '+' : ''}
|
||||
{delta} kg <span style={{ fontWeight: 400, color: 'var(--text3)' }}>ggü. Vorher</span>
|
||||
</div>
|
||||
)}
|
||||
<Link to="/history" className="btn btn-secondary" style={{ fontSize: 12, padding: '6px 12px', textDecoration: 'none' }}>
|
||||
Verlauf →
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<div style={{ paddingBottom: 96, textAlign: 'left', maxWidth: 900, margin: '0 auto' }}>
|
||||
|
|
@ -24,26 +27,34 @@ export default function PilotVizPage() {
|
|||
Pilot: Visualisierungs-Module
|
||||
</h1>
|
||||
<p style={{ fontSize: 14, color: 'var(--text2)', lineHeight: 1.6, marginTop: 8 }}>
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{widgets.map((def) => {
|
||||
const { Component } = def
|
||||
return (
|
||||
<section key={def.id} className="card section-gap" style={{ overflow: 'hidden' }}>
|
||||
<div className="card-title">{def.title}</div>
|
||||
{def.description && (
|
||||
<p style={{ fontSize: 13, color: 'var(--text2)', marginTop: 4, marginBottom: 14, lineHeight: 1.5 }}>
|
||||
{def.description}
|
||||
</p>
|
||||
)}
|
||||
<Component />
|
||||
</section>
|
||||
)
|
||||
})}
|
||||
<PilotVizAdminCard layout={layout} onLayoutChange={setLayout} />
|
||||
|
||||
{widgets.length === 0 ? (
|
||||
<div className="card section-gap" style={{ textAlign: 'center', padding: 24 }}>
|
||||
<p style={{ fontSize: 14, color: 'var(--text2)', margin: 0 }}>
|
||||
Keine Widgets sichtbar. Aktiviere mindestens eines in der <strong>Widget-Konfiguration</strong> oben.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
widgets.map((def) => {
|
||||
const { Component } = def
|
||||
return (
|
||||
<section key={def.id} className="card section-gap" style={{ overflow: 'hidden' }}>
|
||||
<div className="card-title">{def.title}</div>
|
||||
{def.description && (
|
||||
<p style={{ fontSize: 13, color: 'var(--text2)', marginTop: 4, marginBottom: 14, lineHeight: 1.5 }}>
|
||||
{def.description}
|
||||
</p>
|
||||
)}
|
||||
<Component />
|
||||
</section>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
52
frontend/src/pilot/pilotLayoutStorage.js
Normal file
52
frontend/src/pilot/pilotLayoutStorage.js
Normal file
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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])
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user