feat: Enhance Dashboard-Lab with widget catalog integration and layout updates
- Integrated a new API endpoint for fetching the widget catalog in the Dashboard-Lab. - Updated the dashboard layout schema to utilize the widget catalog for dynamic widget management. - Refactored DashboardLabPage and PilotVizPage to leverage the new widget rendering system. - Removed deprecated widget metadata from the frontend, streamlining the widget management process. - Bumped app_dashboard version to 1.1.0 to reflect the new features and improvements.
This commit is contained in:
parent
e5f6e6c10d
commit
f6c5f96768
|
|
@ -1,7 +1,7 @@
|
||||||
"""
|
"""
|
||||||
Dashboard-Layout v1 (Nutzer-Lab): Validierung und Standard-Layout.
|
Dashboard-Layout v1 (Nutzer-Lab): Validierung und Standard-Layout.
|
||||||
|
|
||||||
Single Source für erlaubte Widget-IDs (sync mit Frontend widgetRegistry).
|
Erlaubte Widget-IDs und Standard-Reihenfolge: widget_catalog.WIDGET_CATALOG
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
@ -9,27 +9,22 @@ from typing import Any, Literal
|
||||||
|
|
||||||
from pydantic import BaseModel, Field, model_validator
|
from pydantic import BaseModel, Field, model_validator
|
||||||
|
|
||||||
ALLOWED_WIDGET_IDS: frozenset[str] = frozenset(
|
from widget_catalog import ALLOWED_WIDGET_IDS, WIDGET_CATALOG
|
||||||
{
|
|
||||||
"welcome",
|
# Abwärtskompatibel (Tests importieren weiterhin aus diesem Modul)
|
||||||
"quick_capture",
|
__all__ = [
|
||||||
"kpi_board",
|
"ALLOWED_WIDGET_IDS",
|
||||||
"body_overview",
|
"DashboardLayoutPayload",
|
||||||
"activity_overview",
|
"DashboardWidgetEntry",
|
||||||
}
|
"coalesce_effective_layout",
|
||||||
)
|
"default_layout_dict",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def default_layout_dict() -> dict[str, Any]:
|
def default_layout_dict() -> dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"widgets": [
|
"widgets": [{"id": e["id"], "enabled": True} for e in WIDGET_CATALOG],
|
||||||
{"id": "welcome", "enabled": True},
|
|
||||||
{"id": "quick_capture", "enabled": True},
|
|
||||||
{"id": "kpi_board", "enabled": True},
|
|
||||||
{"id": "body_overview", "enabled": True},
|
|
||||||
{"id": "activity_overview", "enabled": True},
|
|
||||||
],
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,10 +12,18 @@ from auth import require_auth
|
||||||
from dashboard_layout_schema import DashboardLayoutPayload, coalesce_effective_layout, default_layout_dict
|
from dashboard_layout_schema import DashboardLayoutPayload, coalesce_effective_layout, default_layout_dict
|
||||||
from db import get_cursor, get_db
|
from db import get_cursor, get_db
|
||||||
from routers.profiles import get_pid
|
from routers.profiles import get_pid
|
||||||
|
from widget_catalog import catalog_response
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/app", tags=["app-dashboard-lab"])
|
router = APIRouter(prefix="/api/app", tags=["app-dashboard-lab"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/widgets/catalog")
|
||||||
|
def get_widgets_catalog(session: dict = Depends(require_auth)) -> dict[str, Any]:
|
||||||
|
"""Metadaten aller registrierbaren Dashboard-Widgets (IDs, Titel)."""
|
||||||
|
_ = session
|
||||||
|
return catalog_response()
|
||||||
|
|
||||||
|
|
||||||
@router.get("/dashboard-layout")
|
@router.get("/dashboard-layout")
|
||||||
def get_dashboard_layout(
|
def get_dashboard_layout(
|
||||||
x_profile_id: Optional[str] = Header(default=None),
|
x_profile_id: Optional[str] = Header(default=None),
|
||||||
|
|
|
||||||
25
backend/tests/test_widget_catalog.py
Normal file
25
backend/tests/test_widget_catalog.py
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
"""Widget-Katalog: Konsistenz (IDs, Default-Layout, Katalog-Response)."""
|
||||||
|
|
||||||
|
from dashboard_layout_schema import default_layout_dict
|
||||||
|
from widget_catalog import ALLOWED_WIDGET_IDS, WIDGET_CATALOG, catalog_response
|
||||||
|
|
||||||
|
|
||||||
|
def test_catalog_ids_unique_and_match_allowed():
|
||||||
|
ids = [e["id"] for e in WIDGET_CATALOG]
|
||||||
|
assert len(ids) == len(set(ids))
|
||||||
|
assert frozenset(ids) == ALLOWED_WIDGET_IDS
|
||||||
|
|
||||||
|
|
||||||
|
def test_default_layout_follows_catalog_order():
|
||||||
|
d = default_layout_dict()
|
||||||
|
assert d["version"] == 1
|
||||||
|
got = [w["id"] for w in d["widgets"]]
|
||||||
|
assert got == [e["id"] for e in WIDGET_CATALOG]
|
||||||
|
assert all(w["enabled"] is True for w in d["widgets"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_catalog_response_shape():
|
||||||
|
r = catalog_response()
|
||||||
|
assert r["catalog_version"] == 1
|
||||||
|
assert len(r["widgets"]) == len(WIDGET_CATALOG)
|
||||||
|
assert {w["id"] for w in r["widgets"]} == ALLOWED_WIDGET_IDS
|
||||||
|
|
@ -30,7 +30,7 @@ MODULE_VERSIONS = {
|
||||||
"importdata": "1.0.0",
|
"importdata": "1.0.0",
|
||||||
"membership": "2.1.0",
|
"membership": "2.1.0",
|
||||||
"workflow": "0.6.0", # Phase 4: End Node Template Engine
|
"workflow": "0.6.0", # Phase 4: End Node Template Engine
|
||||||
"app_dashboard": "1.0.0", # Dashboard-Lab Layout API (/api/app)
|
"app_dashboard": "1.1.0", # Dashboard-Lab + GET /widgets/catalog (Widget-System Iteration 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
CHANGELOG = [
|
CHANGELOG = [
|
||||||
|
|
|
||||||
55
backend/widget_catalog.py
Normal file
55
backend/widget_catalog.py
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
"""
|
||||||
|
Öffentlicher Widget-Katalog (Dashboard-Lab / später Produkt-Dashboard).
|
||||||
|
|
||||||
|
Single Source für: erlaubte IDs, Standard-Reihenfolge, Anzeige-Metadaten für API/GUI.
|
||||||
|
Frontend-Komponenten registrieren dieselben IDs lokal (siehe widgetSystem/registerPilotLabWidgets).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, TypedDict
|
||||||
|
|
||||||
|
|
||||||
|
class WidgetCatalogEntry(TypedDict):
|
||||||
|
id: str
|
||||||
|
title: str
|
||||||
|
description: str
|
||||||
|
|
||||||
|
|
||||||
|
# Reihenfolge in der Liste = Standard-Layout (alle default_enabled: True im Default-Layout)
|
||||||
|
WIDGET_CATALOG: list[WidgetCatalogEntry] = [
|
||||||
|
{
|
||||||
|
"id": "welcome",
|
||||||
|
"title": "Willkommen",
|
||||||
|
"description": "Begrüßung und Kurzkontext",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "quick_capture",
|
||||||
|
"title": "Schnelleingabe",
|
||||||
|
"description": "Gewicht und Vitalwerte erfassen",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "kpi_board",
|
||||||
|
"title": "KPI-Kacheln",
|
||||||
|
"description": "Referenzwerte, KF%, Kalorien",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "body_overview",
|
||||||
|
"title": "Körper (Chart)",
|
||||||
|
"description": "Gewicht & Kennzahlen",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "activity_overview",
|
||||||
|
"title": "Aktivität",
|
||||||
|
"description": "Training & Konsistenz",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
ALLOWED_WIDGET_IDS: frozenset[str] = frozenset(e["id"] for e in WIDGET_CATALOG)
|
||||||
|
|
||||||
|
|
||||||
|
def catalog_response() -> dict[str, Any]:
|
||||||
|
"""Payload für GET /api/app/widgets/catalog."""
|
||||||
|
return {
|
||||||
|
"catalog_version": 1,
|
||||||
|
"widgets": list(WIDGET_CATALOG),
|
||||||
|
}
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
/**
|
|
||||||
* Dashboard-Lab: Widget-Metadaten (IDs müssen mit backend/dashboard_layout_schema.py übereinstimmen).
|
|
||||||
*/
|
|
||||||
export const DASHBOARD_LAB_WIDGET_META = [
|
|
||||||
{ id: 'welcome', label: 'Willkommen' },
|
|
||||||
{ id: 'quick_capture', label: 'Schnelleingabe' },
|
|
||||||
{ id: 'kpi_board', label: 'KPI-Kacheln' },
|
|
||||||
{ id: 'body_overview', label: 'Körper (Chart)' },
|
|
||||||
{ id: 'activity_overview', label: 'Aktivität' },
|
|
||||||
]
|
|
||||||
|
|
@ -2,45 +2,34 @@ import { useCallback, useEffect, useState } from 'react'
|
||||||
import { ChevronDown, ChevronUp, LayoutGrid } from 'lucide-react'
|
import { ChevronDown, ChevronUp, LayoutGrid } from 'lucide-react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { api, formatFastApiDetail } from '../utils/api'
|
import { api, formatFastApiDetail } from '../utils/api'
|
||||||
import { DASHBOARD_LAB_WIDGET_META } from '../app/areas/dashboardLab/dashboardLabWidgets'
|
import { WidgetRenderer } from '../widgetSystem/dashboardWidgetRegistry'
|
||||||
import PilotWelcome from '../components/pilot/PilotWelcome'
|
import { ensurePilotLabWidgetsRegistered } from '../widgetSystem/registerPilotLabWidgets'
|
||||||
import PilotQuickCapture from '../components/pilot/PilotQuickCapture'
|
import { moveWidget, toggleWidget } from '../widgetSystem/layoutEditor'
|
||||||
import PilotKpiBoard from '../components/pilot/PilotKpiBoard'
|
|
||||||
import PilotBodySection from '../components/pilot/PilotBodySection'
|
|
||||||
import PilotActivitySection from '../components/pilot/PilotActivitySection'
|
|
||||||
|
|
||||||
const metaById = Object.fromEntries(DASHBOARD_LAB_WIDGET_META.map((m) => [m.id, m]))
|
function catalogMetaById(catalog) {
|
||||||
|
if (!catalog?.widgets?.length) return {}
|
||||||
function moveWidget(layout, index, delta) {
|
return Object.fromEntries(catalog.widgets.map((w) => [w.id, w]))
|
||||||
const next = [...layout.widgets]
|
|
||||||
const j = index + delta
|
|
||||||
if (j < 0 || j >= next.length) return layout
|
|
||||||
;[next[index], next[j]] = [next[j], next[index]]
|
|
||||||
return { ...layout, widgets: next }
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleWidget(layout, index) {
|
|
||||||
const next = layout.widgets.map((w, i) =>
|
|
||||||
i === index ? { ...w, enabled: !w.enabled } : w
|
|
||||||
)
|
|
||||||
const anyOn = next.some((w) => w.enabled)
|
|
||||||
if (!anyOn) return layout
|
|
||||||
return { ...layout, widgets: next }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function DashboardLabPage() {
|
export default function DashboardLabPage() {
|
||||||
|
ensurePilotLabWidgetsRegistered()
|
||||||
|
|
||||||
const [refreshTick, setRefreshTick] = useState(0)
|
const [refreshTick, setRefreshTick] = useState(0)
|
||||||
const bump = () => setRefreshTick((t) => t + 1)
|
const requestRefresh = () => setRefreshTick((t) => t + 1)
|
||||||
|
const [catalog, setCatalog] = useState(null)
|
||||||
const [bundle, setBundle] = useState(null)
|
const [bundle, setBundle] = useState(null)
|
||||||
const [layout, setLayout] = useState(null)
|
const [layout, setLayout] = useState(null)
|
||||||
const [err, setErr] = useState(null)
|
const [err, setErr] = useState(null)
|
||||||
const [busy, setBusy] = useState(false)
|
const [busy, setBusy] = useState(false)
|
||||||
const [msg, setMsg] = useState(null)
|
const [msg, setMsg] = useState(null)
|
||||||
|
|
||||||
|
const metaById = catalogMetaById(catalog)
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
setErr(null)
|
setErr(null)
|
||||||
try {
|
try {
|
||||||
const b = await api.getAppDashboardLayout()
|
const [cat, b] = await Promise.all([api.getAppWidgetsCatalog(), api.getAppDashboardLayout()])
|
||||||
|
setCatalog(cat)
|
||||||
setBundle(b)
|
setBundle(b)
|
||||||
setLayout(b.layout)
|
setLayout(b.layout)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -92,23 +81,6 @@ export default function DashboardLabPage() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const renderWidget = (id) => {
|
|
||||||
switch (id) {
|
|
||||||
case 'welcome':
|
|
||||||
return <PilotWelcome key="welcome" />
|
|
||||||
case 'quick_capture':
|
|
||||||
return <PilotQuickCapture key="quick_capture" onSaved={bump} />
|
|
||||||
case 'kpi_board':
|
|
||||||
return <PilotKpiBoard key="kpi_board" refreshTick={refreshTick} />
|
|
||||||
case 'body_overview':
|
|
||||||
return <PilotBodySection key="body_overview" refreshTick={refreshTick} />
|
|
||||||
case 'activity_overview':
|
|
||||||
return <PilotActivitySection key="activity_overview" refreshTick={refreshTick} />
|
|
||||||
default:
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (err && !layout) {
|
if (err && !layout) {
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: 24, maxWidth: 640, margin: '0 auto' }}>
|
<div style={{ padding: 24, maxWidth: 640, margin: '0 auto' }}>
|
||||||
|
|
@ -128,8 +100,6 @@ export default function DashboardLabPage() {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const enabledOrder = layout.widgets.filter((w) => w.enabled)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ paddingBottom: 96, textAlign: 'left', maxWidth: 920, margin: '0 auto' }}>
|
<div style={{ paddingBottom: 96, textAlign: 'left', maxWidth: 920, margin: '0 auto' }}>
|
||||||
<div style={{ marginBottom: 20 }}>
|
<div style={{ marginBottom: 20 }}>
|
||||||
|
|
@ -145,10 +115,10 @@ export default function DashboardLabPage() {
|
||||||
App-Bereich: Dashboard-Lab
|
App-Bereich: Dashboard-Lab
|
||||||
</h1>
|
</h1>
|
||||||
<p style={{ fontSize: 13, color: 'var(--text2)', lineHeight: 1.6, marginTop: 8 }}>
|
<p style={{ fontSize: 13, color: 'var(--text2)', lineHeight: 1.6, marginTop: 8 }}>
|
||||||
Geschützte Route (Login erforderlich). Widget-Reihenfolge und Sichtbarkeit werden pro Profil in der
|
Widget-System (Iteration 1): Katalog vom Server, Registry im Frontend, Renderer für alle
|
||||||
Datenbank gespeichert — getrennt vom Produktiv-Dashboard. Vergleich:{' '}
|
Pilot-Module. Layout wird pro Profil persistiert — getrennt vom Produktiv-Dashboard. Vergleich:{' '}
|
||||||
<Link to="/pilot/viz" style={{ color: 'var(--accent)' }}>
|
<Link to="/pilot/viz" style={{ color: 'var(--accent)' }}>
|
||||||
Pilot-Übersicht (festes Layout)
|
Pilot-Übersicht (festes Standard-Layout)
|
||||||
</Link>
|
</Link>
|
||||||
.
|
.
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -171,15 +141,11 @@ export default function DashboardLabPage() {
|
||||||
Status: {bundle.custom ? 'individuell gespeichert' : 'Standard (nicht in DB)'}
|
Status: {bundle.custom ? 'individuell gespeichert' : 'Standard (nicht in DB)'}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{err && (
|
{err && <p style={{ fontSize: 12, color: '#D85A30', marginBottom: 8 }}>{err}</p>}
|
||||||
<p style={{ fontSize: 12, color: '#D85A30', marginBottom: 8 }}>{err}</p>
|
{msg && <p style={{ fontSize: 12, color: 'var(--accent)', marginBottom: 8 }}>{msg}</p>}
|
||||||
)}
|
|
||||||
{msg && (
|
|
||||||
<p style={{ fontSize: 12, color: 'var(--accent)', marginBottom: 8 }}>{msg}</p>
|
|
||||||
)}
|
|
||||||
<ul style={{ listStyle: 'none', padding: 0, margin: '0 0 12px' }}>
|
<ul style={{ listStyle: 'none', padding: 0, margin: '0 0 12px' }}>
|
||||||
{layout.widgets.map((w, i) => {
|
{layout.widgets.map((w, i) => {
|
||||||
const label = metaById[w.id]?.label || w.id
|
const label = metaById[w.id]?.title || w.id
|
||||||
return (
|
return (
|
||||||
<li
|
<li
|
||||||
key={w.id}
|
key={w.id}
|
||||||
|
|
@ -237,7 +203,7 @@ export default function DashboardLabPage() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{enabledOrder.map((w) => renderWidget(w.id))}
|
<WidgetRenderer layout={layout} refreshTick={refreshTick} requestRefresh={requestRefresh} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,19 @@
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { FlaskConical } from 'lucide-react'
|
import { FlaskConical } from 'lucide-react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import PilotWelcome from '../components/pilot/PilotWelcome'
|
import { WidgetRenderer } from '../widgetSystem/dashboardWidgetRegistry'
|
||||||
import PilotQuickCapture from '../components/pilot/PilotQuickCapture'
|
import { ensurePilotLabWidgetsRegistered } from '../widgetSystem/registerPilotLabWidgets'
|
||||||
import PilotKpiBoard from '../components/pilot/PilotKpiBoard'
|
import { DEFAULT_LAB_LAYOUT } from '../widgetSystem/defaultLabLayout'
|
||||||
import PilotBodySection from '../components/pilot/PilotBodySection'
|
|
||||||
import PilotActivitySection from '../components/pilot/PilotActivitySection'
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pilot-Übersicht nach Product-Spec:
|
* Pilot-Übersicht nach Product-Spec (festes Standard-Layout).
|
||||||
* Willkommen → Schnelleingabe (Gewicht + Vitalwerte) → KPIs (Referenzen + KF% + Ø kcal, max. 9)
|
* Nutzt dasselbe Widget-Rendering wie /app/dashboard-lab.
|
||||||
* → Bereich Körper (Gewicht-Chart 30 T, Ø7/Ø14, Bewertung wie Verlauf)
|
|
||||||
* → Bereich Aktivität (Trainingstyp 30 T, Konsistenz).
|
|
||||||
*/
|
*/
|
||||||
export default function PilotVizPage() {
|
export default function PilotVizPage() {
|
||||||
|
ensurePilotLabWidgetsRegistered()
|
||||||
|
|
||||||
const [refreshTick, setRefreshTick] = useState(0)
|
const [refreshTick, setRefreshTick] = useState(0)
|
||||||
const bump = () => setRefreshTick((t) => t + 1)
|
const requestRefresh = () => setRefreshTick((t) => t + 1)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ paddingBottom: 96, textAlign: 'left', maxWidth: 920, margin: '0 auto' }}>
|
<div style={{ paddingBottom: 96, textAlign: 'left', maxWidth: 920, margin: '0 auto' }}>
|
||||||
|
|
@ -37,11 +35,11 @@ export default function PilotVizPage() {
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<PilotWelcome />
|
<WidgetRenderer
|
||||||
<PilotQuickCapture onSaved={bump} />
|
layout={DEFAULT_LAB_LAYOUT}
|
||||||
<PilotKpiBoard refreshTick={refreshTick} />
|
refreshTick={refreshTick}
|
||||||
<PilotBodySection refreshTick={refreshTick} />
|
requestRefresh={requestRefresh}
|
||||||
<PilotActivitySection refreshTick={refreshTick} />
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -70,7 +70,8 @@ export const api = {
|
||||||
getProfile: () => req('/profile'),
|
getProfile: () => req('/profile'),
|
||||||
updateActiveProfile:(d)=> req('/profile', jput(d)),
|
updateActiveProfile:(d)=> req('/profile', jput(d)),
|
||||||
|
|
||||||
// App-Bereich: Dashboard-Lab (Layout JSON, Issue #65)
|
// App-Bereich: Dashboard-Lab (Layout JSON, Issue #65) + Widget-Katalog
|
||||||
|
getAppWidgetsCatalog: () => req('/app/widgets/catalog'),
|
||||||
getAppDashboardLayout: () => req('/app/dashboard-layout'),
|
getAppDashboardLayout: () => req('/app/dashboard-layout'),
|
||||||
putAppDashboardLayout: (layout) => req('/app/dashboard-layout', jput(layout)),
|
putAppDashboardLayout: (layout) => req('/app/dashboard-layout', jput(layout)),
|
||||||
resetAppDashboardLayout: () => req('/app/dashboard-layout/reset', { method: 'POST' }),
|
resetAppDashboardLayout: () => req('/app/dashboard-layout/reset', { method: 'POST' }),
|
||||||
|
|
|
||||||
60
frontend/src/widgetSystem/dashboardWidgetRegistry.jsx
Normal file
60
frontend/src/widgetSystem/dashboardWidgetRegistry.jsx
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
/** @typedef {{ refreshTick: number, requestRefresh: () => void }} WidgetRenderContext */
|
||||||
|
|
||||||
|
const registry = new Map()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {{ id: string, Component: import('react').ComponentType<any>, mapProps?: (ctx: WidgetRenderContext) => Record<string, unknown> }} spec
|
||||||
|
*/
|
||||||
|
export function registerDashboardWidget(spec) {
|
||||||
|
if (!spec?.id || !spec?.Component) {
|
||||||
|
console.warn('registerDashboardWidget: id und Component erforderlich', spec)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
registry.set(spec.id, spec)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRegisteredWidgetIds() {
|
||||||
|
return [...registry.keys()]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearDashboardWidgetRegistry() {
|
||||||
|
registry.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nur für Tests: Registry neu füllen.
|
||||||
|
*/
|
||||||
|
export function __resetDashboardWidgetRegistryForTests() {
|
||||||
|
registry.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} id
|
||||||
|
* @param {WidgetRenderContext} ctx
|
||||||
|
*/
|
||||||
|
export function renderRegisteredWidget(id, ctx) {
|
||||||
|
const spec = registry.get(id)
|
||||||
|
if (!spec) {
|
||||||
|
return (
|
||||||
|
<div key={id} className="card" style={{ borderColor: 'var(--danger, #D85A30)', marginBottom: 16 }}>
|
||||||
|
<strong>Unbekanntes Widget</strong>
|
||||||
|
<div style={{ fontSize: 13, color: 'var(--text2)' }}>{id}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const { Component } = spec
|
||||||
|
const props = spec.mapProps ? spec.mapProps(ctx) : {}
|
||||||
|
return <Component key={id} {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rendert alle aktivierten Widgets in Layout-Reihenfolge.
|
||||||
|
* @param {{ version: number, widgets: Array<{ id: string, enabled: boolean }> }} layout
|
||||||
|
* @param {WidgetRenderContext} ctx
|
||||||
|
*/
|
||||||
|
export function WidgetRenderer({ layout, refreshTick, requestRefresh }) {
|
||||||
|
if (!layout?.widgets?.length) return null
|
||||||
|
const ctx = { refreshTick, requestRefresh }
|
||||||
|
const enabled = layout.widgets.filter((w) => w.enabled)
|
||||||
|
return <>{enabled.map((w) => renderRegisteredWidget(w.id, ctx))}</>
|
||||||
|
}
|
||||||
15
frontend/src/widgetSystem/defaultLabLayout.js
Normal file
15
frontend/src/widgetSystem/defaultLabLayout.js
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
/**
|
||||||
|
* Standard-Layout v1 (nur Pilot-Fallback ohne API).
|
||||||
|
* Reihenfolge: gleich backend/widget_catalog.WIDGET_CATALOG bei Änderung dort mitpflegen
|
||||||
|
* (oder später nur noch aus GET /api/app/dashboard-layout default_layout beziehen).
|
||||||
|
*/
|
||||||
|
export const DEFAULT_LAB_LAYOUT = {
|
||||||
|
version: 1,
|
||||||
|
widgets: [
|
||||||
|
{ id: 'welcome', enabled: true },
|
||||||
|
{ id: 'quick_capture', enabled: true },
|
||||||
|
{ id: 'kpi_board', enabled: true },
|
||||||
|
{ id: 'body_overview', enabled: true },
|
||||||
|
{ id: 'activity_overview', enabled: true },
|
||||||
|
],
|
||||||
|
}
|
||||||
16
frontend/src/widgetSystem/layoutEditor.js
Normal file
16
frontend/src/widgetSystem/layoutEditor.js
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
export function moveWidget(layout, index, delta) {
|
||||||
|
const next = [...layout.widgets]
|
||||||
|
const j = index + delta
|
||||||
|
if (j < 0 || j >= next.length) return layout
|
||||||
|
const t = next[index]
|
||||||
|
next[index] = next[j]
|
||||||
|
next[j] = t
|
||||||
|
return { ...layout, widgets: next }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toggleWidget(layout, index) {
|
||||||
|
const next = layout.widgets.map((w, i) => (i === index ? { ...w, enabled: !w.enabled } : w))
|
||||||
|
const anyOn = next.some((w) => w.enabled)
|
||||||
|
if (!anyOn) return layout
|
||||||
|
return { ...layout, widgets: next }
|
||||||
|
}
|
||||||
47
frontend/src/widgetSystem/registerPilotLabWidgets.js
Normal file
47
frontend/src/widgetSystem/registerPilotLabWidgets.js
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
/**
|
||||||
|
* Pilot/Lab-Widgets registrieren. IDs müssen zu backend/widget_catalog.WIDGET_CATALOG passen.
|
||||||
|
*/
|
||||||
|
import PilotWelcome from '../components/pilot/PilotWelcome'
|
||||||
|
import PilotQuickCapture from '../components/pilot/PilotQuickCapture'
|
||||||
|
import PilotKpiBoard from '../components/pilot/PilotKpiBoard'
|
||||||
|
import PilotBodySection from '../components/pilot/PilotBodySection'
|
||||||
|
import PilotActivitySection from '../components/pilot/PilotActivitySection'
|
||||||
|
import { registerDashboardWidget } from './dashboardWidgetRegistry'
|
||||||
|
|
||||||
|
let _registered = false
|
||||||
|
|
||||||
|
export function ensurePilotLabWidgetsRegistered() {
|
||||||
|
if (_registered) return
|
||||||
|
_registered = true
|
||||||
|
|
||||||
|
registerDashboardWidget({
|
||||||
|
id: 'welcome',
|
||||||
|
Component: PilotWelcome,
|
||||||
|
mapProps: () => ({}),
|
||||||
|
})
|
||||||
|
registerDashboardWidget({
|
||||||
|
id: 'quick_capture',
|
||||||
|
Component: PilotQuickCapture,
|
||||||
|
mapProps: (ctx) => ({ onSaved: ctx.requestRefresh }),
|
||||||
|
})
|
||||||
|
registerDashboardWidget({
|
||||||
|
id: 'kpi_board',
|
||||||
|
Component: PilotKpiBoard,
|
||||||
|
mapProps: (ctx) => ({ refreshTick: ctx.refreshTick }),
|
||||||
|
})
|
||||||
|
registerDashboardWidget({
|
||||||
|
id: 'body_overview',
|
||||||
|
Component: PilotBodySection,
|
||||||
|
mapProps: (ctx) => ({ refreshTick: ctx.refreshTick }),
|
||||||
|
})
|
||||||
|
registerDashboardWidget({
|
||||||
|
id: 'activity_overview',
|
||||||
|
Component: PilotActivitySection,
|
||||||
|
mapProps: (ctx) => ({ refreshTick: ctx.refreshTick }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @internal Nur für Tests */
|
||||||
|
export function __resetPilotLabRegistrationForTests() {
|
||||||
|
_registered = false
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user