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 { 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>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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