Indvidual Dashboard V0.9 #67

Merged
Lars merged 27 commits from develop into main 2026-04-08 10:56:02 +02:00
2 changed files with 286 additions and 84 deletions
Showing only changes of commit ff95ef63c7 - Show all commits

View File

@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Link } from 'react-router-dom'
import { ChevronDown, ChevronUp, LayoutDashboard, Search } from 'lucide-react'
import { ChevronDown, ChevronUp, GripVertical, LayoutDashboard, Plus, Search, X } from 'lucide-react'
import { api, formatFastApiDetail } from '../utils/api'
import { ensurePilotLabWidgetsRegistered } from '../widgetSystem/registerPilotLabWidgets'
import {
@ -11,7 +11,12 @@ import {
} from '../widgetSystem/bodyChartDays'
import KpiBoardConfigEditor from '../widgetSystem/KpiBoardConfigEditor'
import QuickCaptureConfigEditor from '../widgetSystem/QuickCaptureConfigEditor'
import { moveWidget, normalizeLayoutForEditor, toggleWidget } from '../widgetSystem/layoutEditor'
import {
moveWidget,
moveWidgetToIndex,
normalizeLayoutForEditor,
toggleWidget,
} from '../widgetSystem/layoutEditor'
const CHART_DAYS_WIDGET_IDS = new Set([
'body_overview',
@ -20,6 +25,8 @@ const CHART_DAYS_WIDGET_IDS = new Set([
'recovery_charts_panel',
])
const DESKTOP_DND_MQ = '(min-width: 768px)'
function catalogMetaById(catalog) {
if (!catalog?.widgets?.length) return {}
return Object.fromEntries(catalog.widgets.map((w) => [w.id, w]))
@ -31,11 +38,44 @@ export default function DashboardConfigurePage() {
const [bundle, setBundle] = useState(null)
const [catalog, setCatalog] = useState(null)
const [layout, setLayout] = useState(null)
const [search, setSearch] = useState('')
const [addPanelOpen, setAddPanelOpen] = useState(false)
const [pickerSearch, setPickerSearch] = useState('')
const [viewportDesktop, setViewportDesktop] = useState(() =>
typeof window !== 'undefined' ? window.matchMedia(DESKTOP_DND_MQ).matches : false
)
const [busy, setBusy] = useState(false)
const [msg, setMsg] = useState(null)
const [err, setErr] = useState(null)
const [chartDaysDraftByWidgetId, setChartDaysDraftByWidgetId] = useState({})
const [dragOverFullIndex, setDragOverFullIndex] = useState(null)
const pickerSearchRef = useRef(null)
const dndEnabled = viewportDesktop
useEffect(() => {
const mq = window.matchMedia(DESKTOP_DND_MQ)
const fn = () => setViewportDesktop(mq.matches)
fn()
mq.addEventListener('change', fn)
return () => mq.removeEventListener('change', fn)
}, [])
useEffect(() => {
if (!addPanelOpen) return
const t = window.setTimeout(() => pickerSearchRef.current?.focus(), 50)
const prev = document.body.style.overflow
document.body.style.overflow = 'hidden'
const onKey = (e) => {
if (e.key === 'Escape') setAddPanelOpen(false)
}
window.addEventListener('keydown', onKey)
return () => {
window.clearTimeout(t)
document.body.style.overflow = prev
window.removeEventListener('keydown', onKey)
}
}, [addPanelOpen])
const metaById = useMemo(() => catalogMetaById(catalog), [catalog])
@ -78,6 +118,11 @@ export default function DashboardConfigurePage() {
load()
}, [load])
const openAddPanel = () => {
setPickerSearch('')
setAddPanelOpen(true)
}
const save = async () => {
if (!layout) return
let toSave = layout
@ -121,7 +166,7 @@ export default function DashboardConfigurePage() {
}
}
const searchLower = search.trim().toLowerCase()
const pickerLower = pickerSearch.trim().toLowerCase()
const libraryIndices = useMemo(() => {
if (!layout?.widgets) return []
@ -130,12 +175,12 @@ export default function DashboardConfigurePage() {
.filter((i) => {
const w = layout.widgets[i]
if (w.enabled || !isWidgetCatalogAllowed(w.id)) return false
if (!searchLower) return true
if (!pickerLower) return true
const m = metaById[w.id]
const hay = `${m?.title || ''} ${m?.description || ''} ${w.id}`.toLowerCase()
return hay.includes(searchLower)
return hay.includes(pickerLower)
})
}, [layout, searchLower, metaById, isWidgetCatalogAllowed])
}, [layout, pickerLower, metaById, isWidgetCatalogAllowed])
const activeIndices = useMemo(() => {
if (!layout?.widgets) return []
@ -144,6 +189,47 @@ export default function DashboardConfigurePage() {
.filter((i) => layout.widgets[i].enabled && isWidgetCatalogAllowed(layout.widgets[i].id))
}, [layout, isWidgetCatalogAllowed])
const addableCount = useMemo(() => {
if (!layout?.widgets) return 0
return layout.widgets.filter((w) => !w.enabled && isWidgetCatalogAllowed(w.id)).length
}, [layout, isWidgetCatalogAllowed])
const onDragStartRow = (e, fullIndex) => {
if (!dndEnabled) return
e.dataTransfer.effectAllowed = 'move'
e.dataTransfer.setData('text/plain', String(fullIndex))
try {
e.dataTransfer.setDragImage(e.currentTarget, 0, 0)
} catch {
/* ok */
}
}
const onDragOverRow = (e, fullIndex) => {
if (!dndEnabled) return
e.preventDefault()
e.dataTransfer.dropEffect = 'move'
setDragOverFullIndex(fullIndex)
}
const onDragLeaveRow = () => {
setDragOverFullIndex(null)
}
const onDropRow = (e, dropFullIndex) => {
if (!dndEnabled) return
e.preventDefault()
setDragOverFullIndex(null)
const raw = e.dataTransfer.getData('text/plain')
const from = Number.parseInt(raw, 10)
if (!Number.isFinite(from)) return
setLayout((L) => normalizeLayoutForEditor(moveWidgetToIndex(L, from, dropFullIndex)))
}
const onDragEndRow = () => {
setDragOverFullIndex(null)
}
if (err && !layout) {
return (
<div style={{ padding: 24, maxWidth: 640, margin: '0 auto' }}>
@ -178,8 +264,8 @@ export default function DashboardConfigurePage() {
Übersicht anpassen
</h1>
<p style={{ fontSize: 13, color: 'var(--text2)', lineHeight: 1.6, marginTop: 8 }}>
Wähle Kacheln für deine Startseite. Änderungen gelten nur für dein Profil der System-Standard bleibt erhalten,
bis du speicherst. Gesperrte Kacheln (Abonnement) erscheinen nicht in der Auswahl.
Kacheln für die Startseite sortieren und entfernen. Neue Kacheln über Kachel hinzufügen mit Suche direkt
im eigenen Fenster, ohne langes Scrollen.
</p>
{!bundle?.custom && (
<p style={{ fontSize: 12, color: 'var(--accent)', marginTop: 10, lineHeight: 1.5 }}>
@ -190,30 +276,28 @@ export default function DashboardConfigurePage() {
</div>
<div className="card section-gap" style={{ marginBottom: 16 }}>
<label style={{ fontSize: 12, color: 'var(--text2)', display: 'block', marginBottom: 6 }}>
Widgets durchsuchen (Bibliothek unten)
</label>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Search size={18} color="var(--text3)" style={{ flexShrink: 0 }} />
<input
type="search"
className="form-input"
placeholder="Titel, Beschreibung oder ID …"
value={search}
onChange={(e) => setSearch(e.target.value)}
aria-label="Widgets durchsuchen"
style={{ flex: 1 }}
/>
<div className="card-title" style={{ fontSize: 15, display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
<span>
Aktive Kacheln · {activeIndices.length}
{dndEnabled && (
<span style={{ fontSize: 12, fontWeight: 400, color: 'var(--text3)', marginLeft: 8 }}>
(ab Desktop: ziehen oder Pfeile)
</span>
)}
</span>
<button type="button" className="btn btn-primary" style={{ fontSize: 13 }} onClick={openAddPanel} disabled={addableCount === 0}>
<Plus size={16} style={{ marginRight: 6, verticalAlign: 'middle' }} />
Kachel hinzufügen{addableCount > 0 ? ` (${addableCount})` : ''}
</button>
</div>
</div>
<div className="card section-gap" style={{ marginBottom: 16 }}>
<div className="card-title" style={{ fontSize: 15 }}>
Auf der Übersicht aktiv · {activeIndices.length}
</div>
{err && <p style={{ fontSize: 12, color: '#D85A30', marginBottom: 8 }}>{err}</p>}
{msg && <p style={{ fontSize: 12, color: 'var(--accent)', marginBottom: 8 }}>{msg}</p>}
<ul style={{ listStyle: 'none', padding: 0, margin: 0 }}>
{addableCount === 0 && (
<p style={{ fontSize: 12, color: 'var(--text3)', marginTop: 8, marginBottom: 0 }}>
Alle freigeschalteten Kacheln sind aktiv.
</p>
)}
{err && <p style={{ fontSize: 12, color: '#D85A30', marginTop: 12, marginBottom: 0 }}>{err}</p>}
{msg && <p style={{ fontSize: 12, color: 'var(--accent)', marginTop: 12, marginBottom: 0 }}>{msg}</p>}
<ul style={{ listStyle: 'none', padding: 0, margin: '12px 0 0' }}>
{activeIndices.map((i) => {
const w = layout.widgets[i]
const label = metaById[w.id]?.title || w.id
@ -221,15 +305,34 @@ export default function DashboardConfigurePage() {
w.config?.chart_days != null
? normalizeBodyChartDays(w.config.chart_days)
: BODY_CHART_DAYS_DEFAULT
const dragOver = dragOverFullIndex === i
return (
<li
key={w.id}
onDragOver={(e) => onDragOverRow(e, i)}
onDragLeave={onDragLeaveRow}
onDrop={(e) => onDropRow(e, i)}
style={{
padding: '10px 0',
borderBottom: '1px solid var(--border)',
background: dragOver ? 'var(--surface2)' : undefined,
borderRadius: dragOver ? 8 : undefined,
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
{dndEnabled && (
<span
draggable
role="button"
tabIndex={0}
aria-label={`${label} verschieben`}
style={{ cursor: 'grab', color: 'var(--text3)', display: 'flex', touchAction: 'none' }}
onDragStart={(e) => onDragStartRow(e, i)}
onDragEnd={onDragEndRow}
>
<GripVertical size={18} />
</span>
)}
<label style={{ display: 'flex', alignItems: 'center', gap: 8, flex: '1 1 160px' }}>
<input type="checkbox" checked={w.enabled} onChange={() => setLayout((L) => toggleWidget(L, i))} />
<span style={{ fontSize: 14 }}>{label}</span>
@ -349,57 +452,6 @@ export default function DashboardConfigurePage() {
</ul>
</div>
<div className="card section-gap" style={{ marginBottom: 20 }}>
<div className="card-title" style={{ fontSize: 15 }}>
Bibliothek · hinzufügen
</div>
{libraryIndices.length === 0 ? (
<p style={{ fontSize: 13, color: 'var(--text3)', margin: 0 }}>
{searchLower ? 'Keine passenden inaktiven Widgets.' : 'Alle verfügbaren Kacheln sind schon aktiv.'}
</p>
) : (
<ul style={{ listStyle: 'none', padding: 0, margin: 0 }}>
{libraryIndices.map((i) => {
const w = layout.widgets[i]
const m = metaById[w.id]
return (
<li
key={w.id}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 12,
padding: '10px 0',
borderBottom: '1px solid var(--border)',
}}
>
<div>
<div style={{ fontSize: 14, fontWeight: 600 }}>{m?.title || w.id}</div>
<div style={{ fontSize: 12, color: 'var(--text3)', marginTop: 2 }}>{m?.description || ''}</div>
</div>
<button
type="button"
className="btn btn-primary"
style={{ flexShrink: 0, fontSize: 12, padding: '8px 14px' }}
onClick={() =>
setLayout((L) =>
normalizeLayoutForEditor({
...L,
widgets: L.widgets.map((x, j) => (j === i ? { ...x, enabled: true } : x)),
})
)
}
>
Hinzufügen
</button>
</li>
)
})}
</ul>
)}
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
<button type="button" className="btn btn-primary" disabled={busy} onClick={save}>
Speichern
@ -411,6 +463,142 @@ export default function DashboardConfigurePage() {
Zur Übersicht
</Link>
</div>
{addPanelOpen && (
<div
style={{
position: 'fixed',
inset: 0,
zIndex: 300,
display: 'flex',
alignItems: viewportDesktop ? 'center' : 'flex-end',
justifyContent: 'center',
padding: viewportDesktop ? 16 : 0,
paddingBottom: viewportDesktop ? 16 : undefined,
}}
>
<button
type="button"
aria-label="Schließen"
style={{
position: 'absolute',
inset: 0,
border: 'none',
padding: 0,
margin: 0,
background: 'rgba(0,0,0,0.45)',
cursor: 'pointer',
}}
onClick={() => setAddPanelOpen(false)}
/>
<div
role="dialog"
aria-modal="true"
aria-labelledby="dashboard-add-widget-title"
onClick={(e) => e.stopPropagation()}
style={{
position: 'relative',
width: '100%',
maxWidth: viewportDesktop ? 520 : '100%',
maxHeight: viewportDesktop ? 'min(85vh, 640px)' : 'min(92vh, 100%)',
background: 'var(--surface)',
borderRadius: viewportDesktop ? 12 : '16px 16px 0 0',
boxShadow: viewportDesktop ? '0 12px 40px rgba(0,0,0,0.2)' : '0 -4px 24px rgba(0,0,0,0.12)',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
paddingBottom: 'max(12px, env(safe-area-inset-bottom))',
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 12,
padding: '14px 16px',
borderBottom: '1px solid var(--border)',
flexShrink: 0,
}}
>
<h2 id="dashboard-add-widget-title" className="card-title" style={{ fontSize: 16, margin: 0 }}>
Kachel hinzufügen
</h2>
<button
type="button"
className="btn btn-secondary"
style={{ padding: '8px 10px' }}
aria-label="Schließen"
onClick={() => setAddPanelOpen(false)}
>
<X size={20} />
</button>
</div>
<div style={{ padding: '12px 16px', flexShrink: 0, borderBottom: '1px solid var(--border)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Search size={18} color="var(--text3)" />
<input
ref={pickerSearchRef}
type="search"
className="form-input"
placeholder="Suchen nach Titel, Beschreibung, ID …"
value={pickerSearch}
onChange={(e) => setPickerSearch(e.target.value)}
aria-label="Widgets durchsuchen"
style={{ flex: 1 }}
/>
</div>
</div>
<div style={{ overflowY: 'auto', flex: 1, padding: '8px 16px 16px' }}>
{libraryIndices.length === 0 ? (
<p style={{ fontSize: 13, color: 'var(--text3)', margin: 12 }}>
{pickerLower ? 'Keine Treffer.' : 'Keine weiteren Kacheln verfügbar.'}
</p>
) : (
<ul style={{ listStyle: 'none', padding: 0, margin: 0 }}>
{libraryIndices.map((i) => {
const w = layout.widgets[i]
const m = metaById[w.id]
return (
<li
key={w.id}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 12,
padding: '12px 0',
borderBottom: '1px solid var(--border)',
}}
>
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 14, fontWeight: 600 }}>{m?.title || w.id}</div>
<div style={{ fontSize: 12, color: 'var(--text3)', marginTop: 2 }}>{m?.description || ''}</div>
</div>
<button
type="button"
className="btn btn-primary"
style={{ flexShrink: 0, fontSize: 12, padding: '8px 14px' }}
onClick={() => {
setLayout((L) =>
normalizeLayoutForEditor({
...L,
widgets: L.widgets.map((x, j) => (j === i ? { ...x, enabled: true } : x)),
})
)
}}
>
Hinzufügen
</button>
</li>
)
})}
</ul>
)}
</div>
</div>
</div>
)}
</div>
)
}

View File

@ -25,3 +25,17 @@ export function toggleWidget(layout, index) {
if (!anyOn) return layout
return { ...layout, widgets: next }
}
/**
* Verschiebt eine Zeile von fromIndex nach dropIndex (Indizes im vollen layout.widgets).
* Semantik wie üblich: Element landet an Position dropIndex (nach Entfernen an der alten Stelle).
*/
export function moveWidgetToIndex(layout, fromIndex, dropIndex) {
if (!layout?.widgets?.length) return layout
if (fromIndex < 0 || fromIndex >= layout.widgets.length) return layout
if (dropIndex < 0 || dropIndex > layout.widgets.length) return layout
if (fromIndex === dropIndex) return layout
const next = [...layout.widgets]
next.splice(dropIndex, 0, next.splice(fromIndex, 1)[0])
return { ...layout, widgets: next }
}