US-011: Redesign CoreSkillsTile with categorised groups and panel triggers

Full-width card with skills grouped by Technical, Healthcare Domain, and
Strategic & Leadership categories. Top 4 per category sorted by proficiency.
Individual skills open detail panel; categories with >4 skills show 'View all'
button triggering panel. Removed old single-expand accordion. Category headers
use sidebar section divider styling.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-13 23:50:19 +00:00
parent 8bdb162a07
commit 980297ea92
+244 -192
View File
@@ -1,250 +1,302 @@
import React, { useState, useCallback } from 'react' import React from 'react'
import { AnimatePresence, motion } from 'framer-motion' import type { LucideIcon } from 'lucide-react'
import { BarChart3, Code2, Database, PieChart, FileCode2 } from 'lucide-react' import {
BarChart3, Code2, Database, PieChart, FileCode2,
Sheet, GitBranch, Workflow, Pill, Users, FileCheck,
TrendingUp, Route, ShieldAlert, Banknote, Handshake,
MessageSquare, UserPlus, RefreshCw, Calculator, Presentation,
ChevronRight,
} from 'lucide-react'
import { Card, CardHeader } from '../Card' import { Card, CardHeader } from '../Card'
import { skills } from '@/data/skills' import { skills } from '@/data/skills'
import { medications } from '@/data/medications' import { useDetailPanel } from '@/contexts/DetailPanelContext'
import type { SkillMedication } from '@/types/pmr' import type { SkillMedication, SkillCategory } from '@/types/pmr'
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches const iconMap: Record<string, LucideIcon> = {
const iconMap = {
BarChart3, BarChart3,
Code2, Code2,
Database, Database,
PieChart, PieChart,
FileCode2, FileCode2,
Sheet,
GitBranch,
Workflow,
Pill,
Users,
FileCheck,
TrendingUp,
Route,
ShieldAlert,
Banknote,
Handshake,
MessageSquare,
UserPlus,
RefreshCw,
Calculator,
Presentation,
} }
interface SkillItemProps { const SKILLS_PER_CATEGORY = 4
const categoryConfig: { id: SkillCategory; label: string }[] = [
{ id: 'Technical', label: 'Technical' },
{ id: 'Domain', label: 'Healthcare Domain' },
{ id: 'Leadership', label: 'Strategic & Leadership' },
]
interface SkillRowProps {
skill: SkillMedication skill: SkillMedication
isExpanded: boolean onClick: () => void
onToggle: () => void
} }
function SkillItem({ skill, isExpanded, onToggle }: SkillItemProps) { function SkillRow({ skill, onClick }: SkillRowProps) {
const IconComponent = iconMap[skill.icon as keyof typeof iconMap] const IconComponent = iconMap[skill.icon]
// Find matching medication for prescribing history const handleKeyDown = (e: React.KeyboardEvent) => {
const medication = medications.find((m) => m.name === skill.name) if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
const handleKeyDown = useCallback( onClick()
(e: React.KeyboardEvent) => { }
if (e.key === 'Enter' || e.key === ' ') { }
e.preventDefault()
onToggle()
} else if (e.key === 'Escape' && isExpanded) {
e.preventDefault()
onToggle()
}
},
[isExpanded, onToggle],
)
return ( return (
<div <div
role="button" role="button"
tabIndex={0} tabIndex={0}
aria-expanded={isExpanded} onClick={onClick}
onClick={onToggle}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
aria-label={`${skill.name}: ${skill.frequency}, ${skill.yearsOfExperience} years experience. Click for details.`}
style={{ style={{
display: 'flex', display: 'flex',
flexDirection: 'column', alignItems: 'center',
fontSize: '12.5px', gap: '10px',
padding: '8px 10px',
background: 'var(--bg-dashboard)', background: 'var(--bg-dashboard)',
borderRadius: 'var(--radius-sm)', borderRadius: 'var(--radius-sm)',
border: '1px solid var(--border-light)', border: '1px solid var(--border-light)',
cursor: 'pointer', cursor: 'pointer',
transition: 'border-color 0.15s', transition: 'border-color 0.15s, box-shadow 0.15s',
...(isExpanded && {
borderColor: 'var(--accent-border)',
}),
}} }}
onMouseEnter={(e) => { onMouseEnter={(e) => {
e.currentTarget.style.borderColor = 'var(--accent-border)' e.currentTarget.style.borderColor = 'var(--accent-border)'
e.currentTarget.style.boxShadow = 'var(--shadow-md)'
}} }}
onMouseLeave={(e) => { onMouseLeave={(e) => {
if (!isExpanded) { e.currentTarget.style.borderColor = 'var(--border-light)'
e.currentTarget.style.borderColor = 'var(--border-light)' e.currentTarget.style.boxShadow = 'none'
}
}} }}
> >
{/* Item header row */} {/* Icon */}
<div
style={{
width: '26px',
height: '26px',
borderRadius: '6px',
background: 'var(--accent-light)',
color: 'var(--accent)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
{IconComponent && <IconComponent size={13} />}
</div>
{/* Text */}
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
fontSize: '12.5px',
fontWeight: 600,
color: 'var(--text-primary)',
lineHeight: 1.3,
}}
>
{skill.name}
</div>
<div
style={{
fontSize: '10.5px',
color: 'var(--text-tertiary)',
fontFamily: '"Geist Mono", monospace',
}}
>
{skill.frequency} · {skill.yearsOfExperience} yrs
</div>
</div>
{/* Status badge */}
<div
style={{
fontSize: '10px',
fontWeight: 500,
padding: '2px 7px',
borderRadius: '20px',
background: 'var(--success-light)',
color: 'var(--success)',
border: '1px solid var(--success-border)',
flexShrink: 0,
}}
>
{skill.status}
</div>
{/* Chevron */}
<ChevronRight
size={14}
style={{ color: 'var(--text-tertiary)', flexShrink: 0 }}
/>
</div>
)
}
interface CategorySectionProps {
label: string
categoryId: SkillCategory
skills: SkillMedication[]
onSkillClick: (skill: SkillMedication) => void
onViewAll: (category: SkillCategory) => void
isFirst: boolean
}
function CategorySection({
label,
categoryId,
skills: categorySkills,
onSkillClick,
onViewAll,
isFirst,
}: CategorySectionProps) {
const visibleSkills = categorySkills.slice(0, SKILLS_PER_CATEGORY)
const remainingCount = categorySkills.length - SKILLS_PER_CATEGORY
return (
<div style={{ marginTop: isFirst ? 0 : '16px' }}>
{/* Category header — sidebar section divider style */}
<div <div
style={{ style={{
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
gap: '10px', gap: '8px',
padding: '10px 12px', marginBottom: '10px',
}} }}
> >
{/* Icon container */} <span
<div
style={{
width: '28px',
height: '28px',
borderRadius: '6px',
background: 'var(--accent-light)',
color: 'var(--accent)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
{IconComponent && <IconComponent size={14} />}
</div>
{/* Text block */}
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
fontWeight: 600,
color: 'var(--text-primary)',
marginBottom: '2px',
}}
>
{skill.name}
</div>
<div
style={{
fontSize: '11px',
color: 'var(--text-tertiary)',
fontFamily: '"Geist Mono", monospace',
}}
>
{skill.frequency} · Since {skill.startYear} · {skill.yearsOfExperience} yrs
</div>
</div>
{/* Status badge */}
<div
style={{ style={{
fontSize: '10px', fontSize: '10px',
fontWeight: 500, fontWeight: 600,
padding: '3px 8px', textTransform: 'uppercase',
borderRadius: '20px', letterSpacing: '0.06em',
background: 'var(--success-light)', color: 'var(--text-tertiary)',
color: 'var(--success)', whiteSpace: 'nowrap',
border: '1px solid var(--success-border)',
flexShrink: 0,
}} }}
> >
{skill.status} {label}
</div> </span>
<div
style={{
flex: 1,
height: '1px',
background: 'var(--border-light)',
}}
/>
<span
style={{
fontSize: '10px',
color: 'var(--text-tertiary)',
fontFamily: '"Geist Mono", monospace',
whiteSpace: 'nowrap',
}}
>
{categorySkills.length} items
</span>
</div> </div>
{/* Expanded content: prescribing history timeline */} {/* Skill rows */}
<AnimatePresence initial={false}> <div style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
{isExpanded && medication && medication.prescribingHistory && ( {visibleSkills.map((skill) => (
<motion.div <SkillRow
initial={{ height: 0 }} key={skill.id}
animate={{ height: 'auto' }} skill={skill}
exit={{ height: 0 }} onClick={() => onSkillClick(skill)}
transition={ />
prefersReducedMotion ))}
? { duration: 0 } </div>
: { duration: 0.2, ease: 'easeOut' }
}
style={{ overflow: 'hidden' }}
>
<div
style={{
marginLeft: '12px',
marginRight: '12px',
marginBottom: '12px',
paddingLeft: '14px',
paddingTop: '4px',
borderLeft: '2px solid var(--accent)',
}}
>
{/* Timeline entries */}
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: '8px',
}}
>
{medication.prescribingHistory.map((entry, i) => (
<div
key={i}
style={{
display: 'flex',
gap: '10px',
alignItems: 'flex-start',
}}
>
{/* Timeline dot */}
<div
style={{
width: '6px',
height: '6px',
borderRadius: '50%',
background: 'var(--accent)',
flexShrink: 0,
marginTop: '4px',
}}
/>
{/* Content */} {/* View all button */}
<div style={{ flex: 1, minWidth: 0 }}> {remainingCount > 0 && (
<div <button
style={{ onClick={() => onViewAll(categoryId)}
fontSize: '12px', style={{
fontWeight: 600, display: 'flex',
fontFamily: '"Geist Mono", monospace', alignItems: 'center',
color: 'var(--text-primary)', gap: '4px',
marginBottom: '2px', marginTop: '8px',
}} padding: '4px 0',
> background: 'none',
{entry.year} border: 'none',
</div> cursor: 'pointer',
<div fontSize: '11px',
style={{ fontWeight: 500,
fontSize: '12px', color: 'var(--accent)',
color: 'var(--text-secondary)', fontFamily: 'inherit',
lineHeight: 1.4, transition: 'color 0.15s',
}} }}
> onMouseEnter={(e) => {
{entry.description} e.currentTarget.style.color = 'var(--accent-hover)'
</div> }}
</div> onMouseLeave={(e) => {
</div> e.currentTarget.style.color = 'var(--accent)'
))} }}
</div> aria-label={`View all ${categorySkills.length} ${label} skills`}
</div> >
</motion.div> View all ({categorySkills.length})
)} <ChevronRight size={12} />
</AnimatePresence> </button>
)}
</div> </div>
) )
} }
export function CoreSkillsTile() { export function CoreSkillsTile() {
const [expandedItemId, setExpandedItemId] = useState<string | null>(null) const { openPanel } = useDetailPanel()
const handleToggle = useCallback( // Group skills by category, sorted by proficiency descending
(id: string) => { const groupedSkills = categoryConfig.map(({ id, label }) => ({
setExpandedItemId((prev) => (prev === id ? null : id)) id,
}, label,
[], skills: skills
) .filter((s) => s.category === id)
.sort((a, b) => b.proficiency - a.proficiency),
}))
const handleSkillClick = (skill: SkillMedication) => {
openPanel({ type: 'skill', skill })
}
const handleViewAll = (category: SkillCategory) => {
openPanel({ type: 'skills-all', category })
}
return ( return (
<Card full tileId="core-skills"> <Card full tileId="core-skills">
<CardHeader dotColor="amber" title="REPEAT MEDICATIONS" /> <CardHeader
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}> dotColor="amber"
{skills.map((skill) => ( title="REPEAT MEDICATIONS"
<SkillItem rightText="Active prescriptions"
key={skill.id} />
skill={skill} {groupedSkills.map((group, index) => (
isExpanded={expandedItemId === skill.id} <CategorySection
onToggle={() => handleToggle(skill.id)} key={group.id}
/> label={group.label}
))} categoryId={group.id}
</div> skills={group.skills}
onSkillClick={handleSkillClick}
onViewAll={handleViewAll}
isFirst={index === 0}
/>
))}
</Card> </Card>
) )
} }