2026-04-19 17:00:05 +02:00
|
|
|
|
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`
|
2026-04-19 17:36:45 +02:00
|
|
|
|
* - optional: `hint` — Kurz-Hinweis/Warnung direkt auf der Kachel (z. B. Ernährung bei warn/bad)
|
2026-04-19 17:00:05 +02:00
|
|
|
|
*/
|
|
|
|
|
|
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)
|
2026-04-19 17:36:45 +02:00
|
|
|
|
const cardHint = t.hint ? String(t.hint) : null
|
2026-04-19 17:00:05 +02:00
|
|
|
|
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>
|
2026-04-19 17:36:45 +02:00
|
|
|
|
{cardHint ? (
|
|
|
|
|
|
<div
|
|
|
|
|
|
className="kpi-tiles-card__hint"
|
|
|
|
|
|
style={{
|
|
|
|
|
|
marginTop: 10,
|
|
|
|
|
|
padding: '8px 10px',
|
|
|
|
|
|
borderRadius: 8,
|
|
|
|
|
|
fontSize: 10,
|
|
|
|
|
|
lineHeight: 1.45,
|
|
|
|
|
|
color: 'var(--text2)',
|
|
|
|
|
|
background: 'var(--surface2)',
|
|
|
|
|
|
borderLeft: `3px solid ${accent}`,
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
{cardHint}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : null}
|
2026-04-19 17:00:05 +02:00
|
|
|
|
</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>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|