mitai-jinkendo/frontend/src/components/KpiTilesOverview.jsx

157 lines
6.1 KiB
React
Raw Normal View History

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>
)
}