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 { AnimatePresence, motion } from 'framer-motion'
import { BarChart3, Code2, Database, PieChart, FileCode2 } from 'lucide-react'
import React from 'react'
import type { LucideIcon } 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 { skills } from '@/data/skills'
import { medications } from '@/data/medications'
import type { SkillMedication } from '@/types/pmr'
import { useDetailPanel } from '@/contexts/DetailPanelContext'
import type { SkillMedication, SkillCategory } from '@/types/pmr'
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
const iconMap = {
const iconMap: Record<string, LucideIcon> = {
BarChart3,
Code2,
Database,
PieChart,
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
isExpanded: boolean
onToggle: () => void
onClick: () => void
}
function SkillItem({ skill, isExpanded, onToggle }: SkillItemProps) {
const IconComponent = iconMap[skill.icon as keyof typeof iconMap]
function SkillRow({ skill, onClick }: SkillRowProps) {
const IconComponent = iconMap[skill.icon]
// Find matching medication for prescribing history
const medication = medications.find((m) => m.name === skill.name)
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
onToggle()
} else if (e.key === 'Escape' && isExpanded) {
e.preventDefault()
onToggle()
}
},
[isExpanded, onToggle],
)
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
onClick()
}
}
return (
<div
role="button"
tabIndex={0}
aria-expanded={isExpanded}
onClick={onToggle}
onClick={onClick}
onKeyDown={handleKeyDown}
aria-label={`${skill.name}: ${skill.frequency}, ${skill.yearsOfExperience} years experience. Click for details.`}
style={{
display: 'flex',
flexDirection: 'column',
fontSize: '12.5px',
alignItems: 'center',
gap: '10px',
padding: '8px 10px',
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)',
}),
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) => {
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
style={{
display: 'flex',
alignItems: 'center',
gap: '10px',
padding: '10px 12px',
gap: '8px',
marginBottom: '10px',
}}
>
{/* Icon container */}
<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
<span
style={{
fontSize: '10px',
fontWeight: 500,
padding: '3px 8px',
borderRadius: '20px',
background: 'var(--success-light)',
color: 'var(--success)',
border: '1px solid var(--success-border)',
flexShrink: 0,
fontWeight: 600,
textTransform: 'uppercase',
letterSpacing: '0.06em',
color: 'var(--text-tertiary)',
whiteSpace: 'nowrap',
}}
>
{skill.status}
</div>
{label}
</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>
{/* Expanded content: prescribing history timeline */}
<AnimatePresence initial={false}>
{isExpanded && medication && medication.prescribingHistory && (
<motion.div
initial={{ height: 0 }}
animate={{ height: 'auto' }}
exit={{ height: 0 }}
transition={
prefersReducedMotion
? { duration: 0 }
: { 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',
}}
/>
{/* Skill rows */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
{visibleSkills.map((skill) => (
<SkillRow
key={skill.id}
skill={skill}
onClick={() => onSkillClick(skill)}
/>
))}
</div>
{/* Content */}
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
fontSize: '12px',
fontWeight: 600,
fontFamily: '"Geist Mono", monospace',
color: 'var(--text-primary)',
marginBottom: '2px',
}}
>
{entry.year}
</div>
<div
style={{
fontSize: '12px',
color: 'var(--text-secondary)',
lineHeight: 1.4,
}}
>
{entry.description}
</div>
</div>
</div>
))}
</div>
</div>
</motion.div>
)}
</AnimatePresence>
{/* 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>
)}
</div>
)
}
export function CoreSkillsTile() {
const [expandedItemId, setExpandedItemId] = useState<string | null>(null)
const { openPanel } = useDetailPanel()
const handleToggle = useCallback(
(id: string) => {
setExpandedItemId((prev) => (prev === id ? null : id))
},
[],
)
// Group skills by category, sorted by proficiency descending
const groupedSkills = categoryConfig.map(({ id, label }) => ({
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 (
<Card full tileId="core-skills">
<CardHeader dotColor="amber" title="REPEAT MEDICATIONS" />
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
{skills.map((skill) => (
<SkillItem
key={skill.id}
skill={skill}
isExpanded={expandedItemId === skill.id}
onToggle={() => handleToggle(skill.id)}
/>
))}
</div>
<CardHeader
dotColor="amber"
title="REPEAT MEDICATIONS"
rightText="Active prescriptions"
/>
{groupedSkills.map((group, index) => (
<CategorySection
key={group.id}
label={group.label}
categoryId={group.id}
skills={group.skills}
onSkillClick={handleSkillClick}
onViewAll={handleViewAll}
isFirst={index === 0}
/>
))}
</Card>
)
}