diff --git a/src/components/tiles/CareerActivityTile.tsx b/src/components/tiles/CareerActivityTile.tsx index 7baec2e..8503330 100644 --- a/src/components/tiles/CareerActivityTile.tsx +++ b/src/components/tiles/CareerActivityTile.tsx @@ -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 { documents } from '@/data/documents' +import { consultations } from '@/data/consultations' + +const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches type ActivityType = 'role' | 'project' | 'cert' | 'edu' @@ -11,6 +15,8 @@ interface ActivityEntry { meta: string date: string 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[] = [] // Roles from consultations - // Entry 1: Interim Head (2024-2025) entries.push({ id: 'interim-head-2025', type: 'role', @@ -29,9 +34,9 @@ function buildTimeline(): ActivityEntry[] { meta: 'NHS Norfolk & Waveney ICB', date: '2024 – 2025', sortYear: 2024, + consultationId: 'interim-head-2025', }) - // Entry 3: Senior Data Analyst (2021-2024) - concept calls this "Senior Data Analyst — Medicines Optimisation" entries.push({ id: 'deputy-head-2024', type: 'role', @@ -39,9 +44,9 @@ function buildTimeline(): ActivityEntry[] { meta: 'NHS Norfolk & Waveney ICB', date: '2021 – 2024', sortYear: 2021, + consultationId: 'deputy-head-2024', }) - // Entry 6: Prescribing Data Pharmacist (2018-2021) entries.push({ id: 'high-cost-drugs-2022', type: 'role', @@ -49,20 +54,20 @@ function buildTimeline(): ActivityEntry[] { meta: 'NHS Norwich CCG', date: '2018 – 2021', sortYear: 2018, + consultationId: 'pharmacy-manager-2017', }) - // Entry 8: Community Pharmacist (2016-2018) - from Tesco roles entries.push({ - id: 'pharmacy-manager-2017', + id: 'community-pharmacist-2016', type: 'role', title: 'Community Pharmacist', meta: 'Boots UK', date: '2016 – 2018', sortYear: 2016, + consultationId: 'duty-pharmacist-2016', }) - // Projects from investigations - // Entry 2: £220M Prescribing Budget Oversight (2024) + // Projects entries.push({ id: 'inv-budget', type: 'project', @@ -72,7 +77,6 @@ function buildTimeline(): ActivityEntry[] { sortYear: 2024, }) - // Entry 4: SQL Analytics Transformation (2025) entries.push({ id: 'inv-sql-transform', type: 'project', @@ -82,8 +86,7 @@ function buildTimeline(): ActivityEntry[] { sortYear: 2025, }) - // Certifications from documents - // Entry 5: Power BI Data Analyst Associate (2023) + // Certifications entries.push({ id: 'cert-powerbi', type: 'cert', @@ -93,7 +96,6 @@ function buildTimeline(): ActivityEntry[] { sortYear: 2023, }) - // Entry 7: Clinical Pharmacy Diploma (2019) entries.push({ id: 'cert-diploma', type: 'cert', @@ -103,7 +105,6 @@ function buildTimeline(): ActivityEntry[] { sortYear: 2019, }) - // Entry 10: GPhC Registration (2016) entries.push({ id: 'doc-gphc', type: 'cert', @@ -113,8 +114,7 @@ function buildTimeline(): ActivityEntry[] { sortYear: 2016, }) - // Education from documents - // Entry 9: MPharm (2011-2015) + // Education const mpharm = documents.find((d) => d.id === 'doc-mpharm') if (mpharm) { entries.push({ @@ -127,100 +127,264 @@ function buildTimeline(): ActivityEntry[] { }) } - // Sort newest-first by sortYear (descending), then by entry order for same year return entries.sort((a, b) => { if (b.sortYear !== a.sortYear) return b.sortYear - a.sortYear - // For same year, preserve insertion order (stable sort) return 0 }) } const dotColorMap: Record = { - role: '#0D6E6E', // teal (--accent) - project: '#D97706', // amber - cert: '#059669', // green (--success) - edu: '#7C3AED', // purple + role: '#0D6E6E', + project: '#D97706', + cert: '#059669', + edu: '#7C3AED', +} + +const borderColorMap: Record = { + role: '#0D6E6E', + project: '#D97706', + cert: '#059669', + edu: '#7C3AED', } interface ActivityItemProps { entry: ActivityEntry + isExpanded: boolean + onToggle: () => void } -const ActivityItem: React.FC = ({ entry }) => { +const ActivityItem: React.FC = ({ entry, isExpanded, onToggle }) => { 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 (
{ + if (isExpandable) { + e.currentTarget.style.borderColor = 'var(--accent-border)' + } + }} + onMouseLeave={(e) => { + if (isExpandable && !isExpanded) { + e.currentTarget.style.borderColor = 'var(--border-light)' + } }} > - {/* Dot */} -
- - {/* Content */} -
+ {/* Item header row */} +
- {entry.title} -
-
- {entry.meta} -
-
- {entry.date} + /> +
+
+ {entry.title} +
+
+ {entry.meta} +
+
+ {entry.date} +
+ + {/* Expanded content */} + + {isExpanded && consultation && ( + +
+ {/* Role title */} +
+ {consultation.role} +
+ + {/* Achievement bullets */} + {consultation.examination.length > 0 && ( +
    + {consultation.examination.map((item, i) => ( +
  • + + • + + {item} +
  • + ))} +
+ )} + + {/* Coded entries */} + {consultation.codedEntries && consultation.codedEntries.length > 0 && ( +
+ {consultation.codedEntries.map((entry) => ( + + {entry.code} + + ))} +
+ )} +
+
+ )} +
) } export const CareerActivityTile: React.FC = () => { const timeline = buildTimeline() + const [expandedItemId, setExpandedItemId] = useState(null) + + const handleToggle = useCallback( + (id: string) => { + setExpandedItemId((prev) => (prev === id ? null : id)) + }, + [], + ) return ( - {/* Activity grid - 2 columns on desktop, 1 on mobile */}
{timeline.map((entry) => ( - + handleToggle(entry.id)} + /> ))}
diff --git a/src/components/tiles/CoreSkillsTile.tsx b/src/components/tiles/CoreSkillsTile.tsx index 933848e..35a9812 100644 --- a/src/components/tiles/CoreSkillsTile.tsx +++ b/src/components/tiles/CoreSkillsTile.tsx @@ -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 { Card, CardHeader } from '../Card' 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 = { BarChart3, @@ -10,86 +16,234 @@ const iconMap = { FileCode2, } +interface SkillItemProps { + skill: SkillMedication + isExpanded: boolean + onToggle: () => void +} + +function SkillItem({ skill, isExpanded, onToggle }: SkillItemProps) { + 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 ( +
{ + e.currentTarget.style.borderColor = 'var(--accent-border)' + }} + onMouseLeave={(e) => { + if (!isExpanded) { + e.currentTarget.style.borderColor = 'var(--border-light)' + } + }} + > + {/* Item header row */} +
+ {/* Icon container */} +
+ {IconComponent && } +
+ + {/* Text block */} +
+
+ {skill.name} +
+
+ {skill.frequency} · Since {skill.startYear} · {skill.yearsOfExperience} yrs +
+
+ + {/* Status badge */} +
+ {skill.status} +
+
+ + {/* Expanded content: prescribing history timeline */} + + {isExpanded && medication && medication.prescribingHistory && ( + +
+ {/* Timeline entries */} +
+ {medication.prescribingHistory.map((entry, i) => ( +
+ {/* Timeline dot */} +
+ + {/* Content */} +
+
+ {entry.year} +
+
+ {entry.description} +
+
+
+ ))} +
+
+ + )} + +
+ ) +} + export function CoreSkillsTile() { + const [expandedItemId, setExpandedItemId] = useState(null) + + const handleToggle = useCallback( + (id: string) => { + setExpandedItemId((prev) => (prev === id ? null : id)) + }, + [], + ) + return (
- {skills.map((skill) => { - const IconComponent = iconMap[skill.icon as keyof typeof iconMap] - - return ( -
- {/* Icon container */} -
- {IconComponent && } -
- - {/* Text block */} -
-
- {skill.name} -
-
- {skill.frequency} · Since {skill.startYear} · {skill.yearsOfExperience} yrs -
-
- - {/* Status badge */} -
- {skill.status} -
-
- ) - })} + {skills.map((skill) => ( + handleToggle(skill.id)} + /> + ))}
) diff --git a/src/components/tiles/ProjectsTile.tsx b/src/components/tiles/ProjectsTile.tsx index 9a68144..864b44b 100644 --- a/src/components/tiles/ProjectsTile.tsx +++ b/src/components/tiles/ProjectsTile.tsx @@ -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 { Card, CardHeader } from '../Card' +import type { Investigation } from '@/types/pmr' -/** - * Projects tile - displays active projects as interactive items - * Full-width card, last tile in the dashboard grid - * Data sourced from investigations.ts - */ +const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches const statusColorMap: Record = { Complete: '#059669', @@ -13,7 +13,249 @@ const statusColorMap: Record = { Live: '#059669', } +interface ProjectItemProps { + project: Investigation + isExpanded: boolean + onToggle: () => void +} + +function ProjectItem({ project, isExpanded, onToggle }: ProjectItemProps) { + const dotColor = statusColorMap[project.status] || '#0D6E6E' + 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 ( +
{ + e.currentTarget.style.borderColor = 'var(--accent-border)' + }} + onMouseLeave={(e) => { + if (!isExpanded) { + e.currentTarget.style.borderColor = 'var(--border-light)' + } + }} + > + {/* Item header row */} +
+
+ {project.name} + + {project.requestedYear} + +
+ + {/* Expanded content */} + + {isExpanded && ( + +
+ {/* Methodology */} + {project.methodology && ( +

+ {project.methodology} +

+ )} + + {/* Tech stack tags */} + {project.techStack && project.techStack.length > 0 && ( +
+ {project.techStack.map((tech) => ( + + {tech} + + ))} +
+ )} + + {/* Results */} + {project.results && project.results.length > 0 && ( +
    + {project.results.map((result, i) => ( +
  • + + • + + {result} +
  • + ))} +
+ )} + + {/* External link */} + {project.externalUrl && ( + 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)' + }} + > + + View Results + + )} +
+
+ )} +
+
+ ) +} + export function ProjectsTile() { + const [expandedItemId, setExpandedItemId] = useState(null) + + const handleToggle = useCallback( + (id: string) => { + setExpandedItemId((prev) => (prev === id ? null : id)) + }, + [], + ) + return ( @@ -22,76 +264,12 @@ export function ProjectsTile() { {investigations.map((project) => ( handleToggle(project.id)} /> ))}
) } - -interface ProjectItemProps { - name: string - status: 'Complete' | 'Ongoing' | 'Live' - year: number -} - -function ProjectItem({ name, status, year }: ProjectItemProps) { - const dotColor = statusColorMap[status] || '#0D6E6E' - const isLive = status === 'Live' - - return ( -
{ - e.currentTarget.style.borderColor = 'var(--accent-border)' - }} - onMouseLeave={(e) => { - e.currentTarget.style.borderColor = 'var(--border-light)' - }} - > - {/* Status dot */} -
- - {/* Project name */} - {name} - - {/* Year badge */} - - {year} - -
- ) -}