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:
@@ -4,6 +4,7 @@ import { useDetailPanel } from '@/contexts/DetailPanelContext'
|
||||
import { useFocusTrap } from '@/hooks/useFocusTrap'
|
||||
import { DetailPanelContent } from '@/types/pmr'
|
||||
import type { CardHeaderProps } from './Card'
|
||||
import { KPIDetail } from './detail/KPIDetail'
|
||||
|
||||
// Width mapping from content type
|
||||
const widthMap: Record<DetailPanelContent['type'], 'narrow' | 'wide'> = {
|
||||
@@ -207,7 +208,11 @@ export function DetailPanel() {
|
||||
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
|
||||
style={{
|
||||
fontFamily: 'var(--font-ui)',
|
||||
@@ -222,6 +227,7 @@ export function DetailPanel() {
|
||||
Content renderers will be implemented in subsequent user stories.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user