refactor: integrate KpiTilesOverview component for enhanced KPI display
- Introduced the KpiTilesOverview component to streamline the presentation of KPI tiles, replacing the previous BodyKpiOverview implementation. - Updated the PilotKpiBoard and History components to utilize the new KpiTilesOverview for better touch and hover interactions. - Refactored CSS styles to accommodate the new component structure, ensuring a responsive design across devices. - Enhanced the logic for generating KPI tiles, improving data handling and user experience.
This commit is contained in:
parent
8c60601ed1
commit
08b7aa0ca1
|
|
@ -199,13 +199,16 @@ body { font-family: var(--font); background: var(--bg); color: var(--text1); -we
|
|||
.page-title { font-size: 20px; font-weight: 700; margin-bottom: 16px; }
|
||||
|
||||
/* Verlauf: Mobile Tabs horizontale Leiste, Desktop vertikal links (P4 / RESPONSIVE_UI §5.2) */
|
||||
/* Körper-Verlauf: KPI-Übersicht (Hover = Details, kein Klick) */
|
||||
/* KPI-Kachel-Raster: gemeinsam für Verlauf Körper, Dashboard KPI-Board, …
|
||||
Desktop: title-Tooltip; Touch: ℹ → Bottom-Sheet (siehe KpiTilesOverview.jsx) */
|
||||
.kpi-tiles-grid,
|
||||
.body-kpi-overview {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(158px, 1fr));
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.kpi-tiles-card,
|
||||
.body-kpi-card {
|
||||
background: var(--surface2);
|
||||
border-radius: 10px;
|
||||
|
|
@ -215,12 +218,19 @@ body { font-family: var(--font); background: var(--bg); color: var(--text1); -we
|
|||
text-align: left;
|
||||
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
@media (hover: none) {
|
||||
.kpi-tiles-card,
|
||||
.body-kpi-card {
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
.kpi-tiles-card:hover,
|
||||
.body-kpi-card:hover {
|
||||
border-color: var(--border2);
|
||||
box-shadow: 0 1px 5px rgba(0, 0, 0, 0.07);
|
||||
}
|
||||
|
||||
/* Kennzahlen: Touch — iOS hat kein Hover; ℹ öffnet Bottom-Sheet */
|
||||
.kpi-tiles-info-btn,
|
||||
.body-kpi-info-btn {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
|
|
@ -239,11 +249,13 @@ body { font-family: var(--font); background: var(--bg); color: var(--text1); -we
|
|||
cursor: pointer;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
.kpi-tiles-info-btn:active,
|
||||
.body-kpi-info-btn:active {
|
||||
background: var(--surface);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.kpi-tiles-touch-backdrop,
|
||||
.body-kpi-touch-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
|
|
@ -254,14 +266,19 @@ body { font-family: var(--font); background: var(--bg); color: var(--text1); -we
|
|||
padding: 0 12px;
|
||||
padding-bottom: max(12px, env(safe-area-inset-bottom));
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
animation: body-kpi-fade-in 0.15s ease;
|
||||
animation: kpi-tiles-fade-in 0.15s ease;
|
||||
}
|
||||
|
||||
@keyframes kpi-tiles-fade-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
@keyframes body-kpi-fade-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
.kpi-tiles-touch-sheet,
|
||||
.body-kpi-touch-sheet {
|
||||
width: 100%;
|
||||
max-width: 520px;
|
||||
|
|
@ -276,6 +293,7 @@ body { font-family: var(--font); background: var(--bg); color: var(--text1); -we
|
|||
box-shadow: 0 -8px 32px rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
|
||||
.kpi-tiles-touch-sheet__head,
|
||||
.body-kpi-touch-sheet__head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
|
|
@ -284,6 +302,7 @@ body { font-family: var(--font); background: var(--bg); color: var(--text1); -we
|
|||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.kpi-tiles-touch-sheet__title,
|
||||
.body-kpi-touch-sheet__title {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
|
|
@ -294,6 +313,7 @@ body { font-family: var(--font); background: var(--bg); color: var(--text1); -we
|
|||
min-width: 0;
|
||||
}
|
||||
|
||||
.kpi-tiles-touch-sheet__close,
|
||||
.body-kpi-touch-sheet__close {
|
||||
flex-shrink: 0;
|
||||
width: 40px;
|
||||
|
|
@ -310,10 +330,12 @@ body { font-family: var(--font); background: var(--bg); color: var(--text1); -we
|
|||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.kpi-tiles-touch-sheet__close:active,
|
||||
.body-kpi-touch-sheet__close:active {
|
||||
background: var(--surface2);
|
||||
}
|
||||
|
||||
.kpi-tiles-touch-sheet__body,
|
||||
.body-kpi-touch-sheet__body {
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
|
|
@ -322,6 +344,7 @@ body { font-family: var(--font); background: var(--bg); color: var(--text1); -we
|
|||
word-break: break-word;
|
||||
}
|
||||
|
||||
.kpi-tiles-touch-sheet__body--muted,
|
||||
.body-kpi-touch-sheet__body--muted {
|
||||
color: var(--text3);
|
||||
font-style: italic;
|
||||
|
|
|
|||
156
frontend/src/components/KpiTilesOverview.jsx
Normal file
156
frontend/src/components/KpiTilesOverview.jsx
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
import { useState, useEffect, useId } from 'react'
|
||||
import { Info } from 'lucide-react'
|
||||
import { getStatusColor } from '../utils/interpret'
|
||||
|
||||
/**
|
||||
* Zerlegt eine KPI-Kachel für Bottom-Sheet / Tooltip.
|
||||
* @param {{ hoverTop?: string, category?: string, hoverBody?: string, keys?: string[] }} t
|
||||
*/
|
||||
export function kpiTileDetailParts(t) {
|
||||
const registryLine = t.keys?.length ? `Registry: ${t.keys.join(', ')}` : ''
|
||||
const body = [t.hoverBody, registryLine].filter(Boolean).join('\n\n')
|
||||
return { title: t.hoverTop || t.category || 'Kennzahl', body }
|
||||
}
|
||||
|
||||
/** Ein Zeilentext wie natives `title` (Desktop-Hover). */
|
||||
export function buildKpiTileTitleString(t) {
|
||||
return [t.hoverTop, t.hoverBody, t.keys?.length ? `Registry: ${t.keys.join(', ')}` : '']
|
||||
.filter(Boolean)
|
||||
.join('\n\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* Standard-KPI-Kacheln: Desktop `title`-Tooltip, Touch ℹ → Bottom-Sheet (gleicher Inhalt).
|
||||
*
|
||||
* Erwartete Kachel-Felder:
|
||||
* - `key` (string, eindeutig)
|
||||
* - `category` (string) — Zeilenkopf
|
||||
* - `value` (ReactNode) — Hauptwert
|
||||
* - `status` — für Farbstreifen: `good` | `warn` | `bad`
|
||||
* - optional: `icon`, `sublabel`, `verdict`, `valueColor`, `hoverTop`, `hoverBody`, `keys`
|
||||
*/
|
||||
export default function KpiTilesOverview({
|
||||
tiles,
|
||||
heading = 'Kennzahlen',
|
||||
showTouchHint = true,
|
||||
gridClassName = 'kpi-tiles-grid',
|
||||
marginBottom = 12,
|
||||
}) {
|
||||
const [touchUi, setTouchUi] = useState(false)
|
||||
const [openKey, setOpenKey] = useState(null)
|
||||
const sheetTitleId = useId()
|
||||
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia('(hover: none)')
|
||||
const apply = () => setTouchUi(mq.matches)
|
||||
apply()
|
||||
mq.addEventListener('change', apply)
|
||||
return () => mq.removeEventListener('change', apply)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!openKey) return
|
||||
const onKey = e => { if (e.key === 'Escape') setOpenKey(null) }
|
||||
const prev = document.body.style.overflow
|
||||
document.body.style.overflow = 'hidden'
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => {
|
||||
document.body.style.overflow = prev
|
||||
window.removeEventListener('keydown', onKey)
|
||||
}
|
||||
}, [openKey])
|
||||
|
||||
if (!tiles?.length) return null
|
||||
|
||||
const openTile = openKey ? tiles.find(x => x.key === openKey) : null
|
||||
const openParts = openTile ? kpiTileDetailParts(openTile) : null
|
||||
|
||||
const showVerdict = (v) => v != null && String(v).trim() !== '' && String(v).trim() !== '—'
|
||||
|
||||
return (
|
||||
<div style={{ marginBottom }}>
|
||||
{heading ? (
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 8 }}>{heading}</div>
|
||||
) : null}
|
||||
{showTouchHint && touchUi && (
|
||||
<div style={{ fontSize: 10, color: 'var(--text3)', marginBottom: 8, lineHeight: 1.4 }}>
|
||||
<Info size={12} style={{ display: 'inline', verticalAlign: 'middle', marginRight: 4 }} aria-hidden />
|
||||
Auf dem Smartphone: <strong>ℹ</strong> für Erklärung und Details.
|
||||
</div>
|
||||
)}
|
||||
<div className={gridClassName}>
|
||||
{tiles.map(t => {
|
||||
const accent = getStatusColor(t.status)
|
||||
const tip = buildKpiTileTitleString(t)
|
||||
return (
|
||||
<div
|
||||
key={t.key}
|
||||
className="kpi-tiles-card"
|
||||
style={{ borderLeft: `4px solid ${accent}`, position: 'relative' }}
|
||||
title={touchUi ? undefined : tip}
|
||||
>
|
||||
{touchUi && (
|
||||
<button
|
||||
type="button"
|
||||
className="kpi-tiles-info-btn"
|
||||
aria-label={`Details: ${t.category || t.hoverTop || 'Kennzahl'}`}
|
||||
aria-expanded={openKey === t.key}
|
||||
onClick={() => setOpenKey(k => (k === t.key ? null : t.key))}
|
||||
>
|
||||
<Info size={16} strokeWidth={2.25} aria-hidden />
|
||||
</button>
|
||||
)}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 6, paddingRight: touchUi ? 28 : 0 }}>
|
||||
{t.icon != null && t.icon !== false ? (
|
||||
<span style={{ fontSize: 14, lineHeight: 1 }}>{t.icon}</span>
|
||||
) : (
|
||||
<span style={{ width: 0, flexShrink: 0 }} aria-hidden />
|
||||
)}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text3)', textTransform: 'uppercase', letterSpacing: '0.04em' }}>{t.category}</div>
|
||||
<div style={{ fontSize: 18, fontWeight: 700, color: t.valueColor || 'var(--text1)', marginTop: 2, lineHeight: 1.2 }}>{t.value}</div>
|
||||
{t.sublabel ? (
|
||||
<div style={{ fontSize: 10, color: 'var(--text3)', marginTop: 2, lineHeight: 1.25 }}>{t.sublabel}</div>
|
||||
) : null}
|
||||
</div>
|
||||
{showVerdict(t.verdict) ? (
|
||||
<div style={{ textAlign: 'right', flexShrink: 0 }}>
|
||||
<div style={{ fontSize: 10, fontWeight: 700, color: accent, lineHeight: 1.2 }}>{t.verdict}</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{openParts && (
|
||||
<div
|
||||
className="kpi-tiles-touch-backdrop"
|
||||
role="presentation"
|
||||
onClick={() => setOpenKey(null)}
|
||||
>
|
||||
<div
|
||||
className="kpi-tiles-touch-sheet"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={sheetTitleId}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<div className="kpi-tiles-touch-sheet__head">
|
||||
<h3 id={sheetTitleId} className="kpi-tiles-touch-sheet__title">{openParts.title}</h3>
|
||||
<button type="button" className="kpi-tiles-touch-sheet__close" onClick={() => setOpenKey(null)} aria-label="Schließen">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
{openParts.body ? (
|
||||
<div className="kpi-tiles-touch-sheet__body">{openParts.body}</div>
|
||||
) : (
|
||||
<div className="kpi-tiles-touch-sheet__body kpi-tiles-touch-sheet__body--muted">Keine weiteren Details.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { useState, useEffect, useMemo, useCallback } from 'react'
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import dayjs from 'dayjs'
|
||||
import { api } from '../../utils/api'
|
||||
|
|
@ -6,6 +6,7 @@ import { getBfCategory } from '../../utils/calc'
|
|||
import { useProfile } from '../../context/ProfileContext'
|
||||
import { KPI_KCAL_WINDOW_DEFAULT } from '../../widgetSystem/bodyChartDays'
|
||||
import { kpiTileOrderFromConfig } from '../../widgetSystem/kpiBoardTiles'
|
||||
import KpiTilesOverview from '../KpiTilesOverview'
|
||||
|
||||
const MAX_KPI = 9
|
||||
|
||||
|
|
@ -113,62 +114,63 @@ export default function PilotKpiBoard({ refreshTick = 0, kpiConfig }) {
|
|||
return buildAutoTileIds(refTiles, hasBf, hasKcal)
|
||||
}, [manualOrder, refTiles, bf, avgKcal])
|
||||
|
||||
const pushTileForId = useCallback(
|
||||
(id, out) => {
|
||||
if (id === 'body_fat') {
|
||||
if (!bf) return
|
||||
out.push(
|
||||
<div key="kpi-bf" className="card" style={{ margin: 0, padding: 12, boxSizing: 'border-box' }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text3)' }}>Körperfett</div>
|
||||
<div style={{ fontSize: 17, fontWeight: 700, marginTop: 4, color: bf.cat?.color || 'var(--text1)' }}>
|
||||
{bf.pct}%
|
||||
</div>
|
||||
<div style={{ fontSize: 10, color: 'var(--text3)', marginTop: 4 }}>{bf.cat?.label || 'Caliper'}</div>
|
||||
</div>,
|
||||
)
|
||||
return
|
||||
}
|
||||
if (id === 'avg_kcal') {
|
||||
if (avgKcal == null) return
|
||||
out.push(
|
||||
<div key="kpi-kcal" className="card" style={{ margin: 0, padding: 12, boxSizing: 'border-box' }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text3)' }}>
|
||||
Ø Kalorien ({KPI_KCAL_WINDOW_DEFAULT}T)
|
||||
</div>
|
||||
<div style={{ fontSize: 17, fontWeight: 700, marginTop: 4, color: '#EF9F27' }}>{avgKcal} kcal</div>
|
||||
<div style={{ fontSize: 10, color: 'var(--text3)', marginTop: 4 }}>Ernährung</div>
|
||||
</div>,
|
||||
)
|
||||
return
|
||||
}
|
||||
const tk = parseRefTypeKey(id)
|
||||
if (!tk) return
|
||||
const tile = refByKey.get(tk)
|
||||
if (!tile?.latest) return
|
||||
const l = tile.latest
|
||||
out.push(
|
||||
<div key={`ref-${tk}`} className="card" style={{ margin: 0, padding: 12, boxSizing: 'border-box' }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text3)' }}>{tile.type_label}</div>
|
||||
<div style={{ fontSize: 17, fontWeight: 700, marginTop: 4 }}>
|
||||
{formatRefVal(l)}
|
||||
{l.unit ? (
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text2)', marginLeft: 4 }}>{l.unit}</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div style={{ fontSize: 10, color: 'var(--text3)', marginTop: 4 }}>Ref.wert</div>
|
||||
</div>,
|
||||
)
|
||||
},
|
||||
[bf, avgKcal, refByKey],
|
||||
)
|
||||
|
||||
const visibleTiles = useMemo(() => {
|
||||
const kpiTiles = useMemo(() => {
|
||||
const out = []
|
||||
for (const id of orderIds) {
|
||||
pushTileForId(id, out)
|
||||
if (id === 'body_fat') {
|
||||
if (!bf) continue
|
||||
out.push({
|
||||
key: 'kpi-bf',
|
||||
status: 'good',
|
||||
category: 'Körperfett',
|
||||
icon: '🫧',
|
||||
value: `${bf.pct}%`,
|
||||
sublabel: bf.cat?.label || 'Caliper',
|
||||
valueColor: bf.cat?.color,
|
||||
hoverTop: 'Körperfett (Caliper)',
|
||||
hoverBody:
|
||||
`Letzte Messung: ${bf.date ? dayjs(bf.date).format('DD.MM.YYYY') : '—'}.\n` +
|
||||
'Wert aus dem Caliper-Log; die Farbe/Kategorie richtet sich nach Geschlecht und üblicher Spanne.',
|
||||
})
|
||||
continue
|
||||
}
|
||||
if (id === 'avg_kcal') {
|
||||
if (avgKcal == null) continue
|
||||
out.push({
|
||||
key: 'kpi-kcal',
|
||||
status: 'good',
|
||||
category: `Ø Kalorien (${KPI_KCAL_WINDOW_DEFAULT}T)`,
|
||||
icon: '🍽️',
|
||||
value: `${avgKcal} kcal`,
|
||||
sublabel: 'Ernährung',
|
||||
valueColor: '#EF9F27',
|
||||
hoverTop: `Ø Kalorien (${KPI_KCAL_WINDOW_DEFAULT} Tage)`,
|
||||
hoverBody:
|
||||
`Durchschnitt der täglichen Kalorien aus dem Ernährungs-Log über die letzten ${KPI_KCAL_WINDOW_DEFAULT} Tage (Mittel über alle geladenen Tageseinträge im Fenster).`,
|
||||
})
|
||||
continue
|
||||
}
|
||||
const tk = parseRefTypeKey(id)
|
||||
if (!tk) continue
|
||||
const tile = refByKey.get(tk)
|
||||
if (!tile?.latest) continue
|
||||
const l = tile.latest
|
||||
const valStr = formatRefVal(l)
|
||||
const withUnit = l.unit ? `${valStr} ${l.unit}`.trim() : valStr
|
||||
out.push({
|
||||
key: `ref-${tk}`,
|
||||
status: 'good',
|
||||
category: tile.type_label,
|
||||
icon: '📌',
|
||||
value: withUnit,
|
||||
sublabel: 'Ref.wert',
|
||||
hoverTop: tile.type_label,
|
||||
hoverBody:
|
||||
'Persönlicher Referenzwert aus dem Profil. Verwaltung unter Einstellungen → Referenzwerte.',
|
||||
})
|
||||
}
|
||||
return out
|
||||
}, [orderIds, pushTileForId])
|
||||
}, [orderIds, bf, avgKcal, refByKey])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
|
|
@ -185,7 +187,7 @@ export default function PilotKpiBoard({ refreshTick = 0, kpiConfig }) {
|
|||
)
|
||||
}
|
||||
|
||||
if (visibleTiles.length === 0) {
|
||||
if (kpiTiles.length === 0) {
|
||||
return (
|
||||
<div className="card section-gap">
|
||||
<div className="card-title">Kennzahlen</div>
|
||||
|
|
@ -216,7 +218,13 @@ export default function PilotKpiBoard({ refreshTick = 0, kpiConfig }) {
|
|||
? 'Ausgewählte Kacheln in festgelegter Reihenfolge (ohne Daten werden Kacheln ausgelassen).'
|
||||
: `Bis ${MAX_KPI} Kacheln: Referenzwerte, Körperfett, Ø Kalorien (${KPI_KCAL_WINDOW_DEFAULT}T).`}
|
||||
</p>
|
||||
<div className="ref-value-tiles-grid">{visibleTiles}</div>
|
||||
<KpiTilesOverview
|
||||
tiles={kpiTiles}
|
||||
heading={null}
|
||||
showTouchHint
|
||||
gridClassName="ref-value-tiles-grid"
|
||||
marginBottom={0}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import {
|
|||
XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid,
|
||||
ReferenceLine, PieChart, Pie, Cell, ComposedChart
|
||||
} from 'recharts'
|
||||
import { ChevronRight, Brain, ChevronDown, ChevronUp, Trash2, Info } from 'lucide-react'
|
||||
import { ChevronRight, Brain, ChevronDown, ChevronUp, Trash2 } from 'lucide-react'
|
||||
import { api } from '../utils/api'
|
||||
import { photoMonthKey, photoSortKey, formatPhotoCaption } from '../utils/photoDisplay'
|
||||
import { getBfCategory } from '../utils/calc'
|
||||
|
|
@ -15,6 +15,7 @@ import Markdown from '../utils/Markdown'
|
|||
import TrainingTypeDistribution from '../components/TrainingTypeDistribution'
|
||||
import NutritionCharts from '../components/NutritionCharts'
|
||||
import RecoveryCharts from '../components/RecoveryCharts'
|
||||
import KpiTilesOverview from '../components/KpiTilesOverview'
|
||||
import dayjs from 'dayjs'
|
||||
import 'dayjs/locale/de'
|
||||
dayjs.locale('de')
|
||||
|
|
@ -232,122 +233,6 @@ function buildBodyKpiTiles({
|
|||
return tiles
|
||||
}
|
||||
|
||||
function kpiTileDetailParts(t) {
|
||||
const registryLine = t.keys?.length ? `Registry: ${t.keys.join(', ')}` : ''
|
||||
const body = [t.hoverBody, registryLine].filter(Boolean).join('\n\n')
|
||||
return { title: t.hoverTop || t.category, body }
|
||||
}
|
||||
|
||||
/** KPI-Kacheln: Desktop — Hover (`title`). Touch — ℹ öffnet gleichen Text im Bottom-Sheet (iOS hat kein Hover). */
|
||||
function BodyKpiOverview({ tiles }) {
|
||||
const [touchUi, setTouchUi] = useState(false)
|
||||
const [openKey, setOpenKey] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia('(hover: none)')
|
||||
const apply = () => setTouchUi(mq.matches)
|
||||
apply()
|
||||
mq.addEventListener('change', apply)
|
||||
return () => mq.removeEventListener('change', apply)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!openKey) return
|
||||
const onKey = e => { if (e.key === 'Escape') setOpenKey(null) }
|
||||
const prev = document.body.style.overflow
|
||||
document.body.style.overflow = 'hidden'
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => {
|
||||
document.body.style.overflow = prev
|
||||
window.removeEventListener('keydown', onKey)
|
||||
}
|
||||
}, [openKey])
|
||||
|
||||
if (!tiles?.length) return null
|
||||
|
||||
const openTile = openKey ? tiles.find(x => x.key === openKey) : null
|
||||
const openParts = openTile ? kpiTileDetailParts(openTile) : null
|
||||
|
||||
return (
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 8 }}>Kennzahlen</div>
|
||||
{touchUi && (
|
||||
<div style={{ fontSize: 10, color: 'var(--text3)', marginBottom: 8, lineHeight: 1.4 }}>
|
||||
<Info size={12} style={{ display: 'inline', verticalAlign: 'middle', marginRight: 4 }} aria-hidden />
|
||||
Auf dem Smartphone: <strong>ℹ</strong> für Erklärung und Details.
|
||||
</div>
|
||||
)}
|
||||
<div className="body-kpi-overview">
|
||||
{tiles.map(t => {
|
||||
const accent = getStatusColor(t.status)
|
||||
const tip = [t.hoverTop, t.hoverBody, t.keys?.length ? `Registry: ${t.keys.join(', ')}` : ''].filter(Boolean).join('\n\n')
|
||||
return (
|
||||
<div
|
||||
key={t.key}
|
||||
className="body-kpi-card"
|
||||
style={{ borderLeft: `4px solid ${accent}`, position: 'relative' }}
|
||||
title={touchUi ? undefined : tip}
|
||||
>
|
||||
{touchUi && (
|
||||
<button
|
||||
type="button"
|
||||
className="body-kpi-info-btn"
|
||||
aria-label={`Details: ${t.category}`}
|
||||
aria-expanded={openKey === t.key}
|
||||
onClick={() => setOpenKey(k => (k === t.key ? null : t.key))}
|
||||
>
|
||||
<Info size={16} strokeWidth={2.25} aria-hidden />
|
||||
</button>
|
||||
)}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 6, paddingRight: touchUi ? 28 : 0 }}>
|
||||
<span style={{ fontSize: 14, lineHeight: 1 }}>{t.icon}</span>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text3)', textTransform: 'uppercase', letterSpacing: '0.04em' }}>{t.category}</div>
|
||||
<div style={{ fontSize: 18, fontWeight: 700, color: t.valueColor || 'var(--text1)', marginTop: 2, lineHeight: 1.2 }}>{t.value}</div>
|
||||
{t.sublabel && (
|
||||
<div style={{ fontSize: 10, color: 'var(--text3)', marginTop: 2, lineHeight: 1.25 }}>{t.sublabel}</div>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ textAlign: 'right', flexShrink: 0 }}>
|
||||
<div style={{ fontSize: 10, fontWeight: 700, color: accent, lineHeight: 1.2 }}>{t.verdict}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{openParts && (
|
||||
<div
|
||||
className="body-kpi-touch-backdrop"
|
||||
role="presentation"
|
||||
onClick={() => setOpenKey(null)}
|
||||
>
|
||||
<div
|
||||
className="body-kpi-touch-sheet"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="body-kpi-touch-title"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<div className="body-kpi-touch-sheet__head">
|
||||
<h3 id="body-kpi-touch-title" className="body-kpi-touch-sheet__title">{openParts.title}</h3>
|
||||
<button type="button" className="body-kpi-touch-sheet__close" onClick={() => setOpenKey(null)} aria-label="Schließen">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
{openParts.body ? (
|
||||
<div className="body-kpi-touch-sheet__body">{openParts.body}</div>
|
||||
) : (
|
||||
<div className="body-kpi-touch-sheet__body body-kpi-touch-sheet__body--muted">Keine weiteren Details.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function BodyGoalsStrip({ grouped }) {
|
||||
const nav = useNavigate()
|
||||
const goals = (grouped?.body || []).filter(g => g.status === 'active').slice(0, 4)
|
||||
|
|
@ -613,7 +498,7 @@ function BodySection({ profile, insights, onRequest, loadingSlug, filterActiveSl
|
|||
</div>
|
||||
)}
|
||||
|
||||
<BodyKpiOverview tiles={kpiTiles} />
|
||||
<KpiTilesOverview tiles={kpiTiles} />
|
||||
|
||||
{vizLoading && (
|
||||
<div style={{ fontSize: 11, color: 'var(--text3)', marginBottom: 8 }}>Aktualisiere…</div>
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user