US-017: Create KPIDetail renderer for detail panel

Created src/components/detail/KPIDetail.tsx that renders rich KPI story
content inside the detail panel. Wired into DetailPanel so content.type
=== 'kpi' renders this component.

Component displays:
- Large headline number (48px, colored by kpi.colorVariant)
- KPI label and subtitle
- Period badge (if story.period exists)
- Context paragraph (story.context)
- Your role paragraph (story.role)
- Key outcomes as bullet list (story.outcomes)

Graceful fallback implemented: if story is undefined, shows kpi.value
and kpi.explanation instead.

Styling matches dashboard design system with fonts (Elvaro Grotesque,
Geist Mono), colors (CSS custom properties), and spacing conventions.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-14 01:40:58 +00:00
parent 0c87d9f5a4
commit f38e67252b
2 changed files with 217 additions and 15 deletions
+7 -1
View File
@@ -4,6 +4,7 @@ import { useDetailPanel } from '@/contexts/DetailPanelContext'
import { useFocusTrap } from '@/hooks/useFocusTrap' import { useFocusTrap } from '@/hooks/useFocusTrap'
import { DetailPanelContent } from '@/types/pmr' import { DetailPanelContent } from '@/types/pmr'
import type { CardHeaderProps } from './Card' import type { CardHeaderProps } from './Card'
import { KPIDetail } from './detail/KPIDetail'
// Width mapping from content type // Width mapping from content type
const widthMap: Record<DetailPanelContent['type'], 'narrow' | 'wide'> = { const widthMap: Record<DetailPanelContent['type'], 'narrow' | 'wide'> = {
@@ -207,7 +208,11 @@ export function DetailPanel() {
padding: '24px', padding: '24px',
}} }}
> >
{/* Placeholder content - actual renderers will be added in later stories */} {/* Render content based on type */}
{content.type === 'kpi' && <KPIDetail kpi={content.kpi} />}
{/* Other content types - placeholder for future stories */}
{content.type !== 'kpi' && (
<div <div
style={{ style={{
fontFamily: 'var(--font-ui)', fontFamily: 'var(--font-ui)',
@@ -222,6 +227,7 @@ export function DetailPanel() {
Content renderers will be implemented in subsequent user stories. Content renderers will be implemented in subsequent user stories.
</p> </p>
</div> </div>
)}
</div> </div>
</div> </div>
</> </>
+196
View File
@@ -0,0 +1,196 @@
import type { KPI } from '@/types/pmr'
interface KPIDetailProps {
kpi: KPI
}
// Color map for KPI values
const colorMap: Record<KPI['colorVariant'], string> = {
green: '#059669',
amber: '#D97706',
teal: '#0D6E6E',
}
export function KPIDetail({ kpi }: KPIDetailProps) {
// If story exists, render rich content; otherwise fallback to explanation
if (!kpi.story) {
return (
<div
style={{
fontFamily: 'var(--font-ui)',
fontSize: '14px',
lineHeight: '1.6',
color: 'var(--text-primary)',
}}
>
<div
style={{
fontSize: '32px',
fontWeight: 700,
color: colorMap[kpi.colorVariant],
marginBottom: '16px',
}}
>
{kpi.value}
</div>
<p>{kpi.explanation}</p>
</div>
)
}
const { context, role, outcomes, period } = kpi.story
return (
<div
style={{
fontFamily: 'var(--font-ui)',
display: 'flex',
flexDirection: 'column',
gap: '24px',
}}
>
{/* Headline number */}
<div>
<div
style={{
fontSize: '48px',
fontWeight: 700,
color: colorMap[kpi.colorVariant],
lineHeight: '1',
marginBottom: '8px',
}}
>
{kpi.value}
</div>
<div
style={{
fontSize: '14px',
fontWeight: 600,
color: 'var(--text-primary)',
}}
>
{kpi.label}
</div>
<div
style={{
fontSize: '12px',
fontFamily: 'var(--font-geist)',
color: 'var(--text-tertiary)',
marginTop: '2px',
}}
>
{kpi.sub}
</div>
</div>
{/* Period badge (if present) */}
{period && (
<div
style={{
display: 'inline-block',
padding: '4px 10px',
backgroundColor: 'var(--accent-light)',
color: 'var(--accent)',
fontSize: '11px',
fontWeight: 600,
borderRadius: 'var(--radius-sm)',
fontFamily: 'var(--font-geist)',
alignSelf: 'flex-start',
}}
>
{period}
</div>
)}
{/* Context paragraph */}
<div>
<h3
style={{
fontSize: '12px',
fontWeight: 600,
color: 'var(--text-secondary)',
textTransform: 'uppercase',
letterSpacing: '0.05em',
marginBottom: '8px',
}}
>
Context
</h3>
<p
style={{
fontSize: '14px',
lineHeight: '1.6',
color: 'var(--text-primary)',
margin: 0,
}}
>
{context}
</p>
</div>
{/* Your role paragraph */}
<div>
<h3
style={{
fontSize: '12px',
fontWeight: 600,
color: 'var(--text-secondary)',
textTransform: 'uppercase',
letterSpacing: '0.05em',
marginBottom: '8px',
}}
>
Your Role
</h3>
<p
style={{
fontSize: '14px',
lineHeight: '1.6',
color: 'var(--text-primary)',
margin: 0,
}}
>
{role}
</p>
</div>
{/* Outcome bullets */}
<div>
<h3
style={{
fontSize: '12px',
fontWeight: 600,
color: 'var(--text-secondary)',
textTransform: 'uppercase',
letterSpacing: '0.05em',
marginBottom: '8px',
}}
>
Key Outcomes
</h3>
<ul
style={{
margin: 0,
paddingLeft: '20px',
display: 'flex',
flexDirection: 'column',
gap: '8px',
}}
>
{outcomes.map((outcome, index) => (
<li
key={index}
style={{
fontSize: '14px',
lineHeight: '1.6',
color: 'var(--text-primary)',
}}
>
{outcome}
</li>
))}
</ul>
</div>
</div>
)
}