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
+191 -139
View File
@@ -1,89 +1,96 @@
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)
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') { if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault() e.preventDefault()
onToggle() onClick()
} 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}
style={{ aria-label={`${skill.name}: ${skill.frequency}, ${skill.yearsOfExperience} years experience. Click for details.`}
display: 'flex',
flexDirection: 'column',
fontSize: '12.5px',
background: 'var(--bg-dashboard)',
borderRadius: 'var(--radius-sm)',
border: '1px solid var(--border-light)',
cursor: 'pointer',
transition: 'border-color 0.15s',
...(isExpanded && {
borderColor: 'var(--accent-border)',
}),
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = 'var(--accent-border)'
}}
onMouseLeave={(e) => {
if (!isExpanded) {
e.currentTarget.style.borderColor = 'var(--border-light)'
}
}}
>
{/* Item header row */}
<div
style={{ style={{
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
gap: '10px', gap: '10px',
padding: '10px 12px', padding: '8px 10px',
background: 'var(--bg-dashboard)',
borderRadius: 'var(--radius-sm)',
border: '1px solid var(--border-light)',
cursor: 'pointer',
transition: 'border-color 0.15s, box-shadow 0.15s',
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = 'var(--accent-border)'
e.currentTarget.style.boxShadow = 'var(--shadow-md)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = 'var(--border-light)'
e.currentTarget.style.boxShadow = 'none'
}} }}
> >
{/* Icon container */} {/* Icon */}
<div <div
style={{ style={{
width: '28px', width: '26px',
height: '28px', height: '26px',
borderRadius: '6px', borderRadius: '6px',
background: 'var(--accent-light)', background: 'var(--accent-light)',
color: 'var(--accent)', color: 'var(--accent)',
@@ -93,28 +100,29 @@ function SkillItem({ skill, isExpanded, onToggle }: SkillItemProps) {
flexShrink: 0, flexShrink: 0,
}} }}
> >
{IconComponent && <IconComponent size={14} />} {IconComponent && <IconComponent size={13} />}
</div> </div>
{/* Text block */} {/* Text */}
<div style={{ flex: 1, minWidth: 0 }}> <div style={{ flex: 1, minWidth: 0 }}>
<div <div
style={{ style={{
fontSize: '12.5px',
fontWeight: 600, fontWeight: 600,
color: 'var(--text-primary)', color: 'var(--text-primary)',
marginBottom: '2px', lineHeight: 1.3,
}} }}
> >
{skill.name} {skill.name}
</div> </div>
<div <div
style={{ style={{
fontSize: '11px', fontSize: '10.5px',
color: 'var(--text-tertiary)', color: 'var(--text-tertiary)',
fontFamily: '"Geist Mono", monospace', fontFamily: '"Geist Mono", monospace',
}} }}
> >
{skill.frequency} · Since {skill.startYear} · {skill.yearsOfExperience} yrs {skill.frequency} · {skill.yearsOfExperience} yrs
</div> </div>
</div> </div>
@@ -123,7 +131,7 @@ function SkillItem({ skill, isExpanded, onToggle }: SkillItemProps) {
style={{ style={{
fontSize: '10px', fontSize: '10px',
fontWeight: 500, fontWeight: 500,
padding: '3px 8px', padding: '2px 7px',
borderRadius: '20px', borderRadius: '20px',
background: 'var(--success-light)', background: 'var(--success-light)',
color: 'var(--success)', color: 'var(--success)',
@@ -133,118 +141,162 @@ function SkillItem({ skill, isExpanded, onToggle }: SkillItemProps) {
> >
{skill.status} {skill.status}
</div> </div>
</div>
{/* Expanded content: prescribing history timeline */} {/* Chevron */}
<AnimatePresence initial={false}> <ChevronRight
{isExpanded && medication && medication.prescribingHistory && ( size={14}
<motion.div style={{ color: 'var(--text-tertiary)', flexShrink: 0 }}
initial={{ height: 0 }} />
animate={{ height: 'auto' }} </div>
exit={{ height: 0 }} )
transition={
prefersReducedMotion
? { duration: 0 }
: { duration: 0.2, ease: 'easeOut' }
} }
style={{ overflow: 'hidden' }}
> interface CategorySectionProps {
<div label: string
style={{ categoryId: SkillCategory
marginLeft: '12px', skills: SkillMedication[]
marginRight: '12px', onSkillClick: (skill: SkillMedication) => void
marginBottom: '12px', onViewAll: (category: SkillCategory) => void
paddingLeft: '14px', isFirst: boolean
paddingTop: '4px', }
borderLeft: '2px solid var(--accent)',
}} function CategorySection({
> label,
{/* Timeline entries */} 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',
flexDirection: 'column', alignItems: 'center',
gap: '8px', gap: '8px',
marginBottom: '10px',
}} }}
> >
{medication.prescribingHistory.map((entry, i) => ( <span
<div
key={i}
style={{ style={{
display: 'flex', fontSize: '10px',
gap: '10px', fontWeight: 600,
alignItems: 'flex-start', textTransform: 'uppercase',
letterSpacing: '0.06em',
color: 'var(--text-tertiary)',
whiteSpace: 'nowrap',
}} }}
> >
{/* Timeline dot */} {label}
</span>
<div <div
style={{ style={{
width: '6px', flex: 1,
height: '6px', height: '1px',
borderRadius: '50%', background: 'var(--border-light)',
background: 'var(--accent)',
flexShrink: 0,
marginTop: '4px',
}} }}
/> />
<span
{/* Content */}
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{ style={{
fontSize: '12px', fontSize: '10px',
fontWeight: 600, color: 'var(--text-tertiary)',
fontFamily: '"Geist Mono", monospace', fontFamily: '"Geist Mono", monospace',
color: 'var(--text-primary)', whiteSpace: 'nowrap',
marginBottom: '2px',
}} }}
> >
{entry.year} {categorySkills.length} items
</div> </span>
<div
style={{
fontSize: '12px',
color: 'var(--text-secondary)',
lineHeight: 1.4,
}}
>
{entry.description}
</div>
</div>
</div> </div>
{/* Skill rows */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
{visibleSkills.map((skill) => (
<SkillRow
key={skill.id}
skill={skill}
onClick={() => onSkillClick(skill)}
/>
))} ))}
</div> </div>
</div>
</motion.div> {/* View all button */}
{remainingCount > 0 && (
<button
onClick={() => onViewAll(categoryId)}
style={{
display: 'flex',
alignItems: 'center',
gap: '4px',
marginTop: '8px',
padding: '4px 0',
background: 'none',
border: 'none',
cursor: 'pointer',
fontSize: '11px',
fontWeight: 500,
color: 'var(--accent)',
fontFamily: 'inherit',
transition: 'color 0.15s',
}}
onMouseEnter={(e) => {
e.currentTarget.style.color = 'var(--accent-hover)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.color = 'var(--accent)'
}}
aria-label={`View all ${categorySkills.length} ${label} skills`}
>
View all ({categorySkills.length})
<ChevronRight size={12} />
</button>
)} )}
</AnimatePresence>
</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}
skills={group.skills}
onSkillClick={handleSkillClick}
onViewAll={handleViewAll}
isFirst={index === 0}
/> />
))} ))}
</div>
</Card> </Card>
) )
} }