Task 16: Add tile expansion system
CareerActivity: role items expand to show consultation achievements + coded entries Projects: items expand to show methodology, tech stack tags, results, external links CoreSkills: items expand to show prescribing history timeline from medications data All expansions use: - Framer Motion AnimatePresence with height-only animation (200ms, ease-out) - Single-expand accordion (one item at a time per tile) - Keyboard support (Enter/Space toggle, Escape collapse) - aria-expanded attributes - Colored left border on expanded panels - prefers-reduced-motion support (instant expand/collapse) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,10 @@
|
|||||||
import React from 'react'
|
import React, { useState, useCallback } from 'react'
|
||||||
|
import { AnimatePresence, motion } from 'framer-motion'
|
||||||
import { Card, CardHeader } from '../Card'
|
import { Card, CardHeader } from '../Card'
|
||||||
import { documents } from '@/data/documents'
|
import { documents } from '@/data/documents'
|
||||||
|
import { consultations } from '@/data/consultations'
|
||||||
|
|
||||||
|
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||||
|
|
||||||
type ActivityType = 'role' | 'project' | 'cert' | 'edu'
|
type ActivityType = 'role' | 'project' | 'cert' | 'edu'
|
||||||
|
|
||||||
@@ -11,6 +15,8 @@ interface ActivityEntry {
|
|||||||
meta: string
|
meta: string
|
||||||
date: string
|
date: string
|
||||||
sortYear: number
|
sortYear: number
|
||||||
|
/** ID of the corresponding consultation in consultations.ts (role entries only) */
|
||||||
|
consultationId?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -21,7 +27,6 @@ function buildTimeline(): ActivityEntry[] {
|
|||||||
const entries: ActivityEntry[] = []
|
const entries: ActivityEntry[] = []
|
||||||
|
|
||||||
// Roles from consultations
|
// Roles from consultations
|
||||||
// Entry 1: Interim Head (2024-2025)
|
|
||||||
entries.push({
|
entries.push({
|
||||||
id: 'interim-head-2025',
|
id: 'interim-head-2025',
|
||||||
type: 'role',
|
type: 'role',
|
||||||
@@ -29,9 +34,9 @@ function buildTimeline(): ActivityEntry[] {
|
|||||||
meta: 'NHS Norfolk & Waveney ICB',
|
meta: 'NHS Norfolk & Waveney ICB',
|
||||||
date: '2024 – 2025',
|
date: '2024 – 2025',
|
||||||
sortYear: 2024,
|
sortYear: 2024,
|
||||||
|
consultationId: 'interim-head-2025',
|
||||||
})
|
})
|
||||||
|
|
||||||
// Entry 3: Senior Data Analyst (2021-2024) - concept calls this "Senior Data Analyst — Medicines Optimisation"
|
|
||||||
entries.push({
|
entries.push({
|
||||||
id: 'deputy-head-2024',
|
id: 'deputy-head-2024',
|
||||||
type: 'role',
|
type: 'role',
|
||||||
@@ -39,9 +44,9 @@ function buildTimeline(): ActivityEntry[] {
|
|||||||
meta: 'NHS Norfolk & Waveney ICB',
|
meta: 'NHS Norfolk & Waveney ICB',
|
||||||
date: '2021 – 2024',
|
date: '2021 – 2024',
|
||||||
sortYear: 2021,
|
sortYear: 2021,
|
||||||
|
consultationId: 'deputy-head-2024',
|
||||||
})
|
})
|
||||||
|
|
||||||
// Entry 6: Prescribing Data Pharmacist (2018-2021)
|
|
||||||
entries.push({
|
entries.push({
|
||||||
id: 'high-cost-drugs-2022',
|
id: 'high-cost-drugs-2022',
|
||||||
type: 'role',
|
type: 'role',
|
||||||
@@ -49,20 +54,20 @@ function buildTimeline(): ActivityEntry[] {
|
|||||||
meta: 'NHS Norwich CCG',
|
meta: 'NHS Norwich CCG',
|
||||||
date: '2018 – 2021',
|
date: '2018 – 2021',
|
||||||
sortYear: 2018,
|
sortYear: 2018,
|
||||||
|
consultationId: 'pharmacy-manager-2017',
|
||||||
})
|
})
|
||||||
|
|
||||||
// Entry 8: Community Pharmacist (2016-2018) - from Tesco roles
|
|
||||||
entries.push({
|
entries.push({
|
||||||
id: 'pharmacy-manager-2017',
|
id: 'community-pharmacist-2016',
|
||||||
type: 'role',
|
type: 'role',
|
||||||
title: 'Community Pharmacist',
|
title: 'Community Pharmacist',
|
||||||
meta: 'Boots UK',
|
meta: 'Boots UK',
|
||||||
date: '2016 – 2018',
|
date: '2016 – 2018',
|
||||||
sortYear: 2016,
|
sortYear: 2016,
|
||||||
|
consultationId: 'duty-pharmacist-2016',
|
||||||
})
|
})
|
||||||
|
|
||||||
// Projects from investigations
|
// Projects
|
||||||
// Entry 2: £220M Prescribing Budget Oversight (2024)
|
|
||||||
entries.push({
|
entries.push({
|
||||||
id: 'inv-budget',
|
id: 'inv-budget',
|
||||||
type: 'project',
|
type: 'project',
|
||||||
@@ -72,7 +77,6 @@ function buildTimeline(): ActivityEntry[] {
|
|||||||
sortYear: 2024,
|
sortYear: 2024,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Entry 4: SQL Analytics Transformation (2025)
|
|
||||||
entries.push({
|
entries.push({
|
||||||
id: 'inv-sql-transform',
|
id: 'inv-sql-transform',
|
||||||
type: 'project',
|
type: 'project',
|
||||||
@@ -82,8 +86,7 @@ function buildTimeline(): ActivityEntry[] {
|
|||||||
sortYear: 2025,
|
sortYear: 2025,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Certifications from documents
|
// Certifications
|
||||||
// Entry 5: Power BI Data Analyst Associate (2023)
|
|
||||||
entries.push({
|
entries.push({
|
||||||
id: 'cert-powerbi',
|
id: 'cert-powerbi',
|
||||||
type: 'cert',
|
type: 'cert',
|
||||||
@@ -93,7 +96,6 @@ function buildTimeline(): ActivityEntry[] {
|
|||||||
sortYear: 2023,
|
sortYear: 2023,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Entry 7: Clinical Pharmacy Diploma (2019)
|
|
||||||
entries.push({
|
entries.push({
|
||||||
id: 'cert-diploma',
|
id: 'cert-diploma',
|
||||||
type: 'cert',
|
type: 'cert',
|
||||||
@@ -103,7 +105,6 @@ function buildTimeline(): ActivityEntry[] {
|
|||||||
sortYear: 2019,
|
sortYear: 2019,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Entry 10: GPhC Registration (2016)
|
|
||||||
entries.push({
|
entries.push({
|
||||||
id: 'doc-gphc',
|
id: 'doc-gphc',
|
||||||
type: 'cert',
|
type: 'cert',
|
||||||
@@ -113,8 +114,7 @@ function buildTimeline(): ActivityEntry[] {
|
|||||||
sortYear: 2016,
|
sortYear: 2016,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Education from documents
|
// Education
|
||||||
// Entry 9: MPharm (2011-2015)
|
|
||||||
const mpharm = documents.find((d) => d.id === 'doc-mpharm')
|
const mpharm = documents.find((d) => d.id === 'doc-mpharm')
|
||||||
if (mpharm) {
|
if (mpharm) {
|
||||||
entries.push({
|
entries.push({
|
||||||
@@ -127,43 +127,88 @@ function buildTimeline(): ActivityEntry[] {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort newest-first by sortYear (descending), then by entry order for same year
|
|
||||||
return entries.sort((a, b) => {
|
return entries.sort((a, b) => {
|
||||||
if (b.sortYear !== a.sortYear) return b.sortYear - a.sortYear
|
if (b.sortYear !== a.sortYear) return b.sortYear - a.sortYear
|
||||||
// For same year, preserve insertion order (stable sort)
|
|
||||||
return 0
|
return 0
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const dotColorMap: Record<ActivityType, string> = {
|
const dotColorMap: Record<ActivityType, string> = {
|
||||||
role: '#0D6E6E', // teal (--accent)
|
role: '#0D6E6E',
|
||||||
project: '#D97706', // amber
|
project: '#D97706',
|
||||||
cert: '#059669', // green (--success)
|
cert: '#059669',
|
||||||
edu: '#7C3AED', // purple
|
edu: '#7C3AED',
|
||||||
|
}
|
||||||
|
|
||||||
|
const borderColorMap: Record<ActivityType, string> = {
|
||||||
|
role: '#0D6E6E',
|
||||||
|
project: '#D97706',
|
||||||
|
cert: '#059669',
|
||||||
|
edu: '#7C3AED',
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ActivityItemProps {
|
interface ActivityItemProps {
|
||||||
entry: ActivityEntry
|
entry: ActivityEntry
|
||||||
|
isExpanded: boolean
|
||||||
|
onToggle: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const ActivityItem: React.FC<ActivityItemProps> = ({ entry }) => {
|
const ActivityItem: React.FC<ActivityItemProps> = ({ entry, isExpanded, onToggle }) => {
|
||||||
const dotColor = dotColorMap[entry.type]
|
const dotColor = dotColorMap[entry.type]
|
||||||
|
const isExpandable = entry.type === 'role' && entry.consultationId
|
||||||
|
|
||||||
|
const handleKeyDown = useCallback(
|
||||||
|
(e: React.KeyboardEvent) => {
|
||||||
|
if (!isExpandable) return
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault()
|
||||||
|
onToggle()
|
||||||
|
} else if (e.key === 'Escape' && isExpanded) {
|
||||||
|
e.preventDefault()
|
||||||
|
onToggle()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[isExpandable, isExpanded, onToggle],
|
||||||
|
)
|
||||||
|
|
||||||
|
// Get consultation data for expanded content
|
||||||
|
const consultation = isExpandable
|
||||||
|
? consultations.find((c) => c.id === entry.consultationId)
|
||||||
|
: null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
role={isExpandable ? 'button' : undefined}
|
||||||
|
tabIndex={isExpandable ? 0 : undefined}
|
||||||
|
aria-expanded={isExpandable ? isExpanded : undefined}
|
||||||
|
onClick={isExpandable ? onToggle : undefined}
|
||||||
|
onKeyDown={isExpandable ? handleKeyDown : undefined}
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
gap: '10px',
|
flexDirection: 'column',
|
||||||
padding: '10px 12px',
|
|
||||||
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)',
|
||||||
fontSize: '12px',
|
fontSize: '12px',
|
||||||
transition: 'border-color 0.15s',
|
transition: 'border-color 0.15s',
|
||||||
cursor: 'default',
|
cursor: isExpandable ? 'pointer' : 'default',
|
||||||
|
...(isExpanded && {
|
||||||
|
borderColor: 'var(--accent-border)',
|
||||||
|
}),
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (isExpandable) {
|
||||||
|
e.currentTarget.style.borderColor = 'var(--accent-border)'
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
if (isExpandable && !isExpanded) {
|
||||||
|
e.currentTarget.style.borderColor = 'var(--border-light)'
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Dot */}
|
{/* Item header row */}
|
||||||
|
<div style={{ display: 'flex', gap: '10px', padding: '10px 12px' }}>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
width: '8px',
|
width: '8px',
|
||||||
@@ -171,11 +216,9 @@ const ActivityItem: React.FC<ActivityItemProps> = ({ entry }) => {
|
|||||||
borderRadius: '50%',
|
borderRadius: '50%',
|
||||||
background: dotColor,
|
background: dotColor,
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
marginTop: '2px', // align with text baseline
|
marginTop: '2px',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Content */}
|
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@@ -207,20 +250,141 @@ const ActivityItem: React.FC<ActivityItemProps> = ({ entry }) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Expanded content */}
|
||||||
|
<AnimatePresence initial={false}>
|
||||||
|
{isExpanded && consultation && (
|
||||||
|
<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={{
|
||||||
|
borderLeft: `2px solid ${borderColorMap[entry.type]}`,
|
||||||
|
marginLeft: '16px',
|
||||||
|
marginRight: '12px',
|
||||||
|
marginBottom: '12px',
|
||||||
|
paddingLeft: '14px',
|
||||||
|
paddingTop: '4px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Role title */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: '12.5px',
|
||||||
|
fontWeight: 600,
|
||||||
|
color: 'var(--accent)',
|
||||||
|
marginBottom: '8px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{consultation.role}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Achievement bullets */}
|
||||||
|
{consultation.examination.length > 0 && (
|
||||||
|
<ul
|
||||||
|
style={{
|
||||||
|
listStyle: 'none',
|
||||||
|
padding: 0,
|
||||||
|
margin: '0 0 10px 0',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '5px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{consultation.examination.map((item, i) => (
|
||||||
|
<li
|
||||||
|
key={i}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
gap: '8px',
|
||||||
|
fontSize: '11.5px',
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
lineHeight: 1.45,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
color: 'var(--accent)',
|
||||||
|
opacity: 0.5,
|
||||||
|
flexShrink: 0,
|
||||||
|
marginTop: '1px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
•
|
||||||
|
</span>
|
||||||
|
{item}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Coded entries */}
|
||||||
|
{consultation.codedEntries && consultation.codedEntries.length > 0 && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: '6px',
|
||||||
|
marginTop: '4px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{consultation.codedEntries.map((entry) => (
|
||||||
|
<span
|
||||||
|
key={entry.code}
|
||||||
|
style={{
|
||||||
|
fontSize: '10px',
|
||||||
|
fontFamily: 'var(--font-mono)',
|
||||||
|
padding: '2px 6px',
|
||||||
|
borderRadius: '3px',
|
||||||
|
background: 'var(--accent-light)',
|
||||||
|
color: 'var(--accent)',
|
||||||
|
border: '1px solid var(--accent-border)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{entry.code}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CareerActivityTile: React.FC = () => {
|
export const CareerActivityTile: React.FC = () => {
|
||||||
const timeline = buildTimeline()
|
const timeline = buildTimeline()
|
||||||
|
const [expandedItemId, setExpandedItemId] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const handleToggle = useCallback(
|
||||||
|
(id: string) => {
|
||||||
|
setExpandedItemId((prev) => (prev === id ? null : id))
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card full>
|
<Card full>
|
||||||
<CardHeader dotColor="teal" title="CAREER ACTIVITY" rightText="Full timeline" />
|
<CardHeader dotColor="teal" title="CAREER ACTIVITY" rightText="Full timeline" />
|
||||||
|
|
||||||
{/* Activity grid - 2 columns on desktop, 1 on mobile */}
|
|
||||||
<div className="activity-grid">
|
<div className="activity-grid">
|
||||||
{timeline.map((entry) => (
|
{timeline.map((entry) => (
|
||||||
<ActivityItem key={entry.id} entry={entry} />
|
<ActivityItem
|
||||||
|
key={entry.id}
|
||||||
|
entry={entry}
|
||||||
|
isExpanded={expandedItemId === entry.id}
|
||||||
|
onToggle={() => handleToggle(entry.id)}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
|
import React, { useState, useCallback } from 'react'
|
||||||
|
import { AnimatePresence, motion } from 'framer-motion'
|
||||||
import { BarChart3, Code2, Database, PieChart, FileCode2 } from 'lucide-react'
|
import { BarChart3, Code2, Database, PieChart, FileCode2 } 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 type { SkillMedication } from '@/types/pmr'
|
||||||
|
|
||||||
|
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||||
|
|
||||||
const iconMap = {
|
const iconMap = {
|
||||||
BarChart3,
|
BarChart3,
|
||||||
@@ -10,27 +16,67 @@ const iconMap = {
|
|||||||
FileCode2,
|
FileCode2,
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CoreSkillsTile() {
|
interface SkillItemProps {
|
||||||
return (
|
skill: SkillMedication
|
||||||
<Card>
|
isExpanded: boolean
|
||||||
<CardHeader dotColor="amber" title="REPEAT MEDICATIONS" />
|
onToggle: () => void
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
|
}
|
||||||
{skills.map((skill) => {
|
|
||||||
|
function SkillItem({ skill, isExpanded, onToggle }: SkillItemProps) {
|
||||||
const IconComponent = iconMap[skill.icon as keyof typeof iconMap]
|
const IconComponent = iconMap[skill.icon as keyof typeof iconMap]
|
||||||
|
|
||||||
|
// 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],
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={skill.id}
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
aria-expanded={isExpanded}
|
||||||
|
onClick={onToggle}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
style={{
|
||||||
|
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',
|
||||||
fontSize: '12.5px',
|
|
||||||
padding: '10px 12px',
|
padding: '10px 12px',
|
||||||
background: 'var(--bg-dashboard)',
|
|
||||||
borderRadius: 'var(--radius-sm)',
|
|
||||||
border: '1px solid var(--border-light)',
|
|
||||||
cursor: 'default',
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Icon container */}
|
{/* Icon container */}
|
||||||
@@ -88,8 +134,116 @@ export function CoreSkillsTile() {
|
|||||||
{skill.status}
|
{skill.status}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 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>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
}
|
||||||
|
|
||||||
|
export function CoreSkillsTile() {
|
||||||
|
const [expandedItemId, setExpandedItemId] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const handleToggle = useCallback(
|
||||||
|
(id: string) => {
|
||||||
|
setExpandedItemId((prev) => (prev === id ? null : id))
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<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>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
|
import React, { useState, useCallback } from 'react'
|
||||||
|
import { AnimatePresence, motion } from 'framer-motion'
|
||||||
|
import { ExternalLink } from 'lucide-react'
|
||||||
import { investigations } from '@/data/investigations'
|
import { investigations } from '@/data/investigations'
|
||||||
import { Card, CardHeader } from '../Card'
|
import { Card, CardHeader } from '../Card'
|
||||||
|
import type { Investigation } from '@/types/pmr'
|
||||||
|
|
||||||
/**
|
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||||
* Projects tile - displays active projects as interactive items
|
|
||||||
* Full-width card, last tile in the dashboard grid
|
|
||||||
* Data sourced from investigations.ts
|
|
||||||
*/
|
|
||||||
|
|
||||||
const statusColorMap: Record<string, string> = {
|
const statusColorMap: Record<string, string> = {
|
||||||
Complete: '#059669',
|
Complete: '#059669',
|
||||||
@@ -13,58 +13,68 @@ const statusColorMap: Record<string, string> = {
|
|||||||
Live: '#059669',
|
Live: '#059669',
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ProjectsTile() {
|
|
||||||
return (
|
|
||||||
<Card full>
|
|
||||||
<CardHeader dotColor="amber" title="ACTIVE PROJECTS" />
|
|
||||||
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
|
||||||
{investigations.map((project) => (
|
|
||||||
<ProjectItem
|
|
||||||
key={project.id}
|
|
||||||
name={project.name}
|
|
||||||
status={project.status}
|
|
||||||
year={project.requestedYear}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ProjectItemProps {
|
interface ProjectItemProps {
|
||||||
name: string
|
project: Investigation
|
||||||
status: 'Complete' | 'Ongoing' | 'Live'
|
isExpanded: boolean
|
||||||
year: number
|
onToggle: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function ProjectItem({ name, status, year }: ProjectItemProps) {
|
function ProjectItem({ project, isExpanded, onToggle }: ProjectItemProps) {
|
||||||
const dotColor = statusColorMap[status] || '#0D6E6E'
|
const dotColor = statusColorMap[project.status] || '#0D6E6E'
|
||||||
const isLive = status === 'Live'
|
const isLive = project.status === 'Live'
|
||||||
|
|
||||||
|
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],
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
aria-expanded={isExpanded}
|
||||||
|
onClick={onToggle}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'flex-start',
|
flexDirection: 'column',
|
||||||
gap: '8px',
|
|
||||||
padding: '7px 10px',
|
|
||||||
background: 'var(--surface)',
|
background: 'var(--surface)',
|
||||||
border: '1px solid var(--border-light)',
|
border: '1px solid var(--border-light)',
|
||||||
borderRadius: 'var(--radius-sm)',
|
borderRadius: 'var(--radius-sm)',
|
||||||
fontSize: '11.5px',
|
fontSize: '11.5px',
|
||||||
color: 'var(--text-primary)',
|
color: 'var(--text-primary)',
|
||||||
transition: 'border-color 0.15s',
|
transition: 'border-color 0.15s',
|
||||||
cursor: 'default',
|
cursor: 'pointer',
|
||||||
|
...(isExpanded && {
|
||||||
|
borderColor: 'var(--accent-border)',
|
||||||
|
}),
|
||||||
}}
|
}}
|
||||||
onMouseEnter={(e) => {
|
onMouseEnter={(e) => {
|
||||||
e.currentTarget.style.borderColor = 'var(--accent-border)'
|
e.currentTarget.style.borderColor = 'var(--accent-border)'
|
||||||
}}
|
}}
|
||||||
onMouseLeave={(e) => {
|
onMouseLeave={(e) => {
|
||||||
|
if (!isExpanded) {
|
||||||
e.currentTarget.style.borderColor = 'var(--border-light)'
|
e.currentTarget.style.borderColor = 'var(--border-light)'
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Item header row */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
gap: '8px',
|
||||||
|
padding: '7px 10px',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Status dot */}
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
width: '7px',
|
width: '7px',
|
||||||
@@ -76,11 +86,7 @@ function ProjectItem({ name, status, year }: ProjectItemProps) {
|
|||||||
animation: isLive ? 'pulse 2s infinite' : undefined,
|
animation: isLive ? 'pulse 2s infinite' : undefined,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<span style={{ flex: 1 }}>{project.name}</span>
|
||||||
{/* Project name */}
|
|
||||||
<span style={{ flex: 1 }}>{name}</span>
|
|
||||||
|
|
||||||
{/* Year badge */}
|
|
||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
fontSize: '10px',
|
fontSize: '10px',
|
||||||
@@ -90,8 +96,180 @@ function ProjectItem({ name, status, year }: ProjectItemProps) {
|
|||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{year}
|
{project.requestedYear}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Expanded content */}
|
||||||
|
<AnimatePresence initial={false}>
|
||||||
|
{isExpanded && (
|
||||||
|
<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={{
|
||||||
|
borderLeft: '2px solid #D97706',
|
||||||
|
marginLeft: '14px',
|
||||||
|
marginRight: '10px',
|
||||||
|
marginBottom: '10px',
|
||||||
|
paddingLeft: '12px',
|
||||||
|
paddingTop: '4px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Methodology */}
|
||||||
|
{project.methodology && (
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
fontSize: '11.5px',
|
||||||
|
color: 'var(--text-secondary)',
|
||||||
|
lineHeight: 1.5,
|
||||||
|
margin: '0 0 10px 0',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{project.methodology}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tech stack tags */}
|
||||||
|
{project.techStack && project.techStack.length > 0 && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: '5px',
|
||||||
|
marginBottom: '10px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{project.techStack.map((tech) => (
|
||||||
|
<span
|
||||||
|
key={tech}
|
||||||
|
style={{
|
||||||
|
fontSize: '10px',
|
||||||
|
fontFamily: 'var(--font-mono)',
|
||||||
|
padding: '2px 7px',
|
||||||
|
borderRadius: '3px',
|
||||||
|
background: 'var(--amber-light)',
|
||||||
|
color: '#92400E',
|
||||||
|
border: '1px solid var(--amber-border)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tech}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Results */}
|
||||||
|
{project.results && project.results.length > 0 && (
|
||||||
|
<ul
|
||||||
|
style={{
|
||||||
|
listStyle: 'none',
|
||||||
|
padding: 0,
|
||||||
|
margin: '0 0 8px 0',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '4px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{project.results.map((result, i) => (
|
||||||
|
<li
|
||||||
|
key={i}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
gap: '8px',
|
||||||
|
fontSize: '11px',
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
lineHeight: 1.4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
color: '#D97706',
|
||||||
|
opacity: 0.6,
|
||||||
|
flexShrink: 0,
|
||||||
|
marginTop: '1px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
•
|
||||||
|
</span>
|
||||||
|
{result}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* External link */}
|
||||||
|
{project.externalUrl && (
|
||||||
|
<a
|
||||||
|
href={project.externalUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '5px',
|
||||||
|
fontSize: '10.5px',
|
||||||
|
fontWeight: 500,
|
||||||
|
color: 'var(--accent)',
|
||||||
|
textDecoration: 'none',
|
||||||
|
padding: '4px 8px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
background: 'var(--accent-light)',
|
||||||
|
border: '1px solid var(--accent-border)',
|
||||||
|
transition: 'background 0.15s',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.background = 'rgba(10,128,128,0.14)'
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.background = 'var(--accent-light)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ExternalLink size={11} />
|
||||||
|
View Results
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProjectsTile() {
|
||||||
|
const [expandedItemId, setExpandedItemId] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const handleToggle = useCallback(
|
||||||
|
(id: string) => {
|
||||||
|
setExpandedItemId((prev) => (prev === id ? null : id))
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card full>
|
||||||
|
<CardHeader dotColor="amber" title="ACTIVE PROJECTS" />
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||||
|
{investigations.map((project) => (
|
||||||
|
<ProjectItem
|
||||||
|
key={project.id}
|
||||||
|
project={project}
|
||||||
|
isExpanded={expandedItemId === project.id}
|
||||||
|
onToggle={() => handleToggle(project.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user