244 lines
8.0 KiB
React
244 lines
8.0 KiB
React
|
|
import { useCallback, useEffect, useState } from 'react'
|
||
|
|
import { ChevronDown, ChevronUp, LayoutGrid } from 'lucide-react'
|
||
|
|
import { Link } from 'react-router-dom'
|
||
|
|
import { api, formatFastApiDetail } from '../utils/api'
|
||
|
|
import { DASHBOARD_LAB_WIDGET_META } from '../app/areas/dashboardLab/dashboardLabWidgets'
|
||
|
|
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'
|
||
|
|
|
||
|
|
const metaById = Object.fromEntries(DASHBOARD_LAB_WIDGET_META.map((m) => [m.id, m]))
|
||
|
|
|
||
|
|
function moveWidget(layout, index, delta) {
|
||
|
|
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() {
|
||
|
|
const [refreshTick, setRefreshTick] = useState(0)
|
||
|
|
const bump = () => setRefreshTick((t) => t + 1)
|
||
|
|
const [bundle, setBundle] = useState(null)
|
||
|
|
const [layout, setLayout] = useState(null)
|
||
|
|
const [err, setErr] = useState(null)
|
||
|
|
const [busy, setBusy] = useState(false)
|
||
|
|
const [msg, setMsg] = useState(null)
|
||
|
|
|
||
|
|
const load = useCallback(async () => {
|
||
|
|
setErr(null)
|
||
|
|
try {
|
||
|
|
const b = await api.getAppDashboardLayout()
|
||
|
|
setBundle(b)
|
||
|
|
setLayout(b.layout)
|
||
|
|
} catch (e) {
|
||
|
|
setErr(formatFastApiDetail(null, e.message))
|
||
|
|
}
|
||
|
|
}, [])
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
load()
|
||
|
|
}, [load])
|
||
|
|
|
||
|
|
const save = async () => {
|
||
|
|
if (!layout) return
|
||
|
|
setBusy(true)
|
||
|
|
setMsg(null)
|
||
|
|
setErr(null)
|
||
|
|
try {
|
||
|
|
await api.putAppDashboardLayout(layout)
|
||
|
|
setMsg('Layout gespeichert.')
|
||
|
|
await load()
|
||
|
|
} catch (e) {
|
||
|
|
setErr(formatFastApiDetail(null, e.message))
|
||
|
|
} finally {
|
||
|
|
setBusy(false)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const reset = async () => {
|
||
|
|
if (!confirm('Persönliches Layout löschen und Standard wiederherstellen?')) return
|
||
|
|
setBusy(true)
|
||
|
|
setMsg(null)
|
||
|
|
setErr(null)
|
||
|
|
try {
|
||
|
|
const r = await api.resetAppDashboardLayout()
|
||
|
|
setLayout(r.layout)
|
||
|
|
setMsg('Auf Standard zurückgesetzt.')
|
||
|
|
await load()
|
||
|
|
} catch (e) {
|
||
|
|
setErr(formatFastApiDetail(null, e.message))
|
||
|
|
} finally {
|
||
|
|
setBusy(false)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const applyDefaultLocal = () => {
|
||
|
|
if (bundle?.default_layout) {
|
||
|
|
setLayout(structuredClone(bundle.default_layout))
|
||
|
|
setMsg('Standard geladen (noch nicht gespeichert).')
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
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) {
|
||
|
|
return (
|
||
|
|
<div style={{ padding: 24, maxWidth: 640, margin: '0 auto' }}>
|
||
|
|
<p style={{ color: '#D85A30' }}>{err}</p>
|
||
|
|
<button type="button" className="btn btn-secondary" onClick={load}>
|
||
|
|
Erneut laden
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
if (!layout) {
|
||
|
|
return (
|
||
|
|
<div style={{ padding: 48, textAlign: 'center' }}>
|
||
|
|
<div className="spinner" style={{ width: 32, height: 32, margin: '0 auto' }} />
|
||
|
|
</div>
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
const enabledOrder = layout.widgets.filter((w) => w.enabled)
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div style={{ paddingBottom: 96, textAlign: 'left', maxWidth: 920, margin: '0 auto' }}>
|
||
|
|
<div style={{ marginBottom: 20 }}>
|
||
|
|
<Link
|
||
|
|
to="/settings"
|
||
|
|
className="btn btn-secondary"
|
||
|
|
style={{ display: 'inline-flex', marginBottom: 12, textDecoration: 'none' }}
|
||
|
|
>
|
||
|
|
← Einstellungen
|
||
|
|
</Link>
|
||
|
|
<h1 className="page-title" style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||
|
|
<LayoutGrid size={26} color="var(--accent)" />
|
||
|
|
App-Bereich: Dashboard-Lab
|
||
|
|
</h1>
|
||
|
|
<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
|
||
|
|
Datenbank gespeichert — getrennt vom Produktiv-Dashboard. Vergleich:{' '}
|
||
|
|
<Link to="/pilot/viz" style={{ color: 'var(--accent)' }}>
|
||
|
|
Pilot-Übersicht (festes Layout)
|
||
|
|
</Link>
|
||
|
|
.
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div
|
||
|
|
className="card"
|
||
|
|
style={{
|
||
|
|
marginBottom: 20,
|
||
|
|
borderStyle: 'dashed',
|
||
|
|
borderColor: 'var(--border2)',
|
||
|
|
background: 'var(--surface2)',
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
<div className="card-title" style={{ fontSize: 14 }}>
|
||
|
|
Layout (v1)
|
||
|
|
</div>
|
||
|
|
{bundle && (
|
||
|
|
<p style={{ fontSize: 12, color: 'var(--text2)', marginBottom: 12 }}>
|
||
|
|
Status: {bundle.custom ? 'individuell gespeichert' : 'Standard (nicht in DB)'}
|
||
|
|
</p>
|
||
|
|
)}
|
||
|
|
{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 0 12px' }}>
|
||
|
|
{layout.widgets.map((w, i) => {
|
||
|
|
const label = metaById[w.id]?.label || w.id
|
||
|
|
return (
|
||
|
|
<li
|
||
|
|
key={w.id}
|
||
|
|
style={{
|
||
|
|
display: 'flex',
|
||
|
|
alignItems: 'center',
|
||
|
|
gap: 8,
|
||
|
|
flexWrap: 'wrap',
|
||
|
|
padding: '8px 0',
|
||
|
|
borderBottom: '1px solid var(--border)',
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
<label style={{ display: 'flex', alignItems: 'center', gap: 8, flex: '1 1 140px' }}>
|
||
|
|
<input
|
||
|
|
type="checkbox"
|
||
|
|
checked={w.enabled}
|
||
|
|
onChange={() => setLayout((L) => toggleWidget(L, i))}
|
||
|
|
/>
|
||
|
|
<span style={{ fontSize: 14 }}>{label}</span>
|
||
|
|
</label>
|
||
|
|
<div style={{ display: 'flex', gap: 6 }}>
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
className="btn btn-secondary"
|
||
|
|
style={{ padding: '6px 10px' }}
|
||
|
|
aria-label="Nach oben"
|
||
|
|
onClick={() => setLayout((L) => moveWidget(L, i, -1))}
|
||
|
|
>
|
||
|
|
<ChevronUp size={18} />
|
||
|
|
</button>
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
className="btn btn-secondary"
|
||
|
|
style={{ padding: '6px 10px' }}
|
||
|
|
aria-label="Nach unten"
|
||
|
|
onClick={() => setLayout((L) => moveWidget(L, i, 1))}
|
||
|
|
>
|
||
|
|
<ChevronDown size={18} />
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
</li>
|
||
|
|
)
|
||
|
|
})}
|
||
|
|
</ul>
|
||
|
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||
|
|
<button type="button" className="btn btn-primary" disabled={busy} onClick={save}>
|
||
|
|
Speichern
|
||
|
|
</button>
|
||
|
|
<button type="button" className="btn btn-secondary" disabled={busy} onClick={reset}>
|
||
|
|
Zurücksetzen (DB)
|
||
|
|
</button>
|
||
|
|
<button type="button" className="btn btn-secondary" disabled={busy} onClick={applyDefaultLocal}>
|
||
|
|
Standard in Editor laden
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{enabledOrder.map((w) => renderWidget(w.id))}
|
||
|
|
</div>
|
||
|
|
)
|
||
|
|
}
|