US-001: Remove unused legacy components and hooks

Delete 23 dead files: old portfolio components (Contact, Education,
Experience, FloatingNav, Footer, Hero, Projects, Skills), legacy PMR
components (PMRInterface, PatientBanner, ClinicalSidebar, Breadcrumb,
MobileBottomNav), all 7 views/ directory files, and 3 unused hooks
(useScrollCondensation, useActiveSection, useScrollReveal).

No imports referenced any of these files — clean removal with zero
build or type errors.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-13 22:57:28 +00:00
parent 72c75fd1a9
commit ee73efce11
23 changed files with 0 additions and 5021 deletions
-96
View File
@@ -1,96 +0,0 @@
import { ChevronRight } from 'lucide-react'
import type { ViewId } from '../types/pmr'
interface BreadcrumbProps {
currentView: ViewId
expandedItem?: {
name: string
type: string
}
onNavigateToView?: (view: ViewId) => void
onCollapseItem?: () => void
}
const viewLabels: Record<ViewId, string> = {
summary: 'Summary',
consultations: 'Experience',
medications: 'Skills',
problems: 'Achievements',
investigations: 'Projects',
documents: 'Education',
referrals: 'Contact',
}
export function Breadcrumb({
currentView,
expandedItem,
onNavigateToView,
onCollapseItem,
}: BreadcrumbProps) {
const handleNavigateToPatientRecord = () => {
if (onNavigateToView) {
onNavigateToView('summary')
}
}
const handleNavigateToCurrentView = () => {
if (onCollapseItem) {
onCollapseItem()
}
}
return (
<nav
className="flex items-center gap-2 mb-6"
aria-label="Breadcrumb"
>
<ol className="flex items-center gap-2">
{/* Patient Record (root) */}
<li>
<button
type="button"
onClick={handleNavigateToPatientRecord}
className="text-[13px] font-ui font-normal text-gray-400 hover:text-pmr-nhsblue transition-colors"
>
Patient Record
</button>
</li>
<li aria-hidden="true">
<ChevronRight size={14} className="text-gray-300" />
</li>
{/* Current view */}
<li>
{expandedItem ? (
<button
type="button"
onClick={handleNavigateToCurrentView}
className="text-[13px] font-ui font-normal text-gray-400 hover:text-pmr-nhsblue transition-colors"
>
{viewLabels[currentView]}
</button>
) : (
<span className="text-[13px] font-ui font-normal text-gray-600" aria-current="page">
{viewLabels[currentView]}
</span>
)}
</li>
{/* Expanded item (if any) */}
{expandedItem && (
<>
<li aria-hidden="true">
<ChevronRight size={14} className="text-gray-300" />
</li>
<li>
<span className="text-[13px] font-ui font-normal text-gray-600" aria-current="page">
{expandedItem.name}
</span>
</li>
</>
)}
</ol>
</nav>
)
}
-406
View File
@@ -1,406 +0,0 @@
import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
import {
ClipboardList,
FileText,
Pill,
AlertTriangle,
FlaskConical,
FolderOpen,
Send,
Search,
X,
} from 'lucide-react'
import type { ViewId } from '../types/pmr'
import { useAccessibility } from '../contexts/AccessibilityContext'
import { buildLegacySearchIndex, groupResultsBySection, type SearchResult } from '../lib/search'
import type { FuseResult } from 'fuse.js'
interface NavItem {
id: ViewId
label: string
icon: React.ReactNode
}
interface ClinicalSidebarProps {
activeView: ViewId
onViewChange: (view: ViewId) => void
isTablet?: boolean
}
const navItems: NavItem[] = [
{ id: 'summary', label: 'Summary', icon: <ClipboardList size={18} /> },
{ id: 'consultations', label: 'Experience', icon: <FileText size={18} /> },
{ id: 'medications', label: 'Skills', icon: <Pill size={18} /> },
{ id: 'problems', label: 'Achievements', icon: <AlertTriangle size={18} /> },
{ id: 'investigations', label: 'Projects', icon: <FlaskConical size={18} /> },
{ id: 'documents', label: 'Education', icon: <FolderOpen size={18} /> },
{ id: 'referrals', label: 'Contact', icon: <Send size={18} /> },
]
function getCurrentTime(): string {
const now = new Date()
return now.toLocaleTimeString('en-GB', {
hour: '2-digit',
minute: '2-digit',
})
}
export function ClinicalSidebar({ activeView, onViewChange, isTablet = false }: ClinicalSidebarProps) {
const [currentTime, setCurrentTime] = useState(getCurrentTime)
const [searchQuery, setSearchQuery] = useState('')
const [isSearchFocused, setIsSearchFocused] = useState(false)
const [focusedIndex, setFocusedIndex] = useState<number | null>(null)
const [hoveredItem, setHoveredItem] = useState<ViewId | null>(null)
const navButtonRefs = useRef<(HTMLButtonElement | null)[]>([])
const { focusAfterLoginRef, setExpandedItem } = useAccessibility()
// Build search index once on mount
const searchIndex = useMemo(() => buildLegacySearchIndex(), [])
const handleNavClick = useCallback(
(view: ViewId) => {
onViewChange(view)
window.location.hash = view
},
[onViewChange]
)
const handleNavKeyDown = useCallback((e: React.KeyboardEvent, index: number) => {
switch (e.key) {
case 'ArrowDown':
e.preventDefault()
if (index < navItems.length - 1) {
setFocusedIndex(index + 1)
navButtonRefs.current[index + 1]?.focus()
}
break
case 'ArrowUp':
e.preventDefault()
if (index > 0) {
setFocusedIndex(index - 1)
navButtonRefs.current[index - 1]?.focus()
}
break
case 'Enter':
case ' ':
e.preventDefault()
handleNavClick(navItems[index].id)
break
case 'Home':
e.preventDefault()
setFocusedIndex(0)
navButtonRefs.current[0]?.focus()
break
case 'End':
e.preventDefault()
setFocusedIndex(navItems.length - 1)
navButtonRefs.current[navItems.length - 1]?.focus()
break
}
}, [handleNavClick])
// Update clock every minute
useEffect(() => {
const interval = setInterval(() => {
setCurrentTime(getCurrentTime())
}, 60000)
return () => clearInterval(interval)
}, [])
// Hash routing
useEffect(() => {
const handleHashChange = () => {
const hash = window.location.hash.slice(1) as ViewId
if (navItems.some(item => item.id === hash)) {
onViewChange(hash)
}
}
handleHashChange()
window.addEventListener('hashchange', handleHashChange)
return () => window.removeEventListener('hashchange', handleHashChange)
}, [onViewChange])
// Alt+1-7 keyboard shortcuts and "/" for search
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.altKey && e.key >= '1' && e.key <= '7') {
e.preventDefault()
const index = parseInt(e.key) - 1
if (navItems[index]) {
const view = navItems[index].id
onViewChange(view)
window.location.hash = view
}
}
if (e.key === '/' && !isSearchFocused && document.activeElement?.tagName !== 'INPUT') {
e.preventDefault()
const searchInput = document.getElementById('sidebar-search')
searchInput?.focus()
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [onViewChange, isSearchFocused])
// Set focus-after-login ref to first nav button
useEffect(() => {
if (navButtonRefs.current[0]) {
;(focusAfterLoginRef as React.MutableRefObject<HTMLButtonElement | null>).current = navButtonRefs.current[0]
}
}, [focusAfterLoginRef])
const handleSearchKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Escape') {
setSearchQuery('')
;(e.target as HTMLInputElement).blur()
}
}
const clearSearch = () => {
setSearchQuery('')
const searchInput = document.getElementById('sidebar-search')
searchInput?.focus()
}
// Fuzzy search with fuse.js
const searchResults = useMemo(() => {
if (!searchQuery.trim() || searchQuery.length < 2) return []
const results = searchIndex.search(searchQuery)
return results.slice(0, 10) // Limit to top 10 results
}, [searchQuery, searchIndex])
// Group results by section for organized display
const groupedResults = useMemo(() => {
if (searchResults.length === 0) return new Map()
return groupResultsBySection(searchResults)
}, [searchResults])
const handleSearchResultClick = useCallback(
(result: FuseResult<SearchResult>) => {
// Navigate to the section
onViewChange(result.item.section)
window.location.hash = result.item.section
// Expand the matching item
setExpandedItem(result.item.id)
// Clear search
setSearchQuery('')
},
[onViewChange, setExpandedItem]
)
// ── Tablet: 56px icon-only sidebar ──
if (isTablet) {
return (
<nav
aria-label="Clinical record navigation"
className="hidden md:flex lg:hidden flex-col w-14 h-full bg-pmr-sidebar border-r border-[#334155] text-white"
>
{/* Header */}
<div className="p-2 border-b border-white/10">
<div className="font-ui font-medium text-[10px] text-white/50 text-center leading-tight">
PMR
</div>
</div>
{/* Navigation */}
<div className="flex-1 py-2 overflow-y-auto">
<ul role="menu" aria-label="Record sections">
{navItems.map((item, index) => (
<li key={item.id} role="none" className="relative">
{index === 1 && (
<div className="mx-2 my-1 border-t border-white/10" role="separator" aria-hidden="true" />
)}
<button
ref={el => { navButtonRefs.current[index] = el }}
type="button"
role="menuitem"
tabIndex={focusedIndex === null ? (index === 0 ? 0 : -1) : (focusedIndex === index ? 0 : -1)}
aria-current={activeView === item.id ? 'page' : undefined}
aria-label={item.label}
onClick={() => handleNavClick(item.id)}
onKeyDown={e => handleNavKeyDown(e, index)}
onMouseEnter={() => setHoveredItem(item.id)}
onMouseLeave={() => setHoveredItem(null)}
className={`
w-full flex items-center justify-center h-11
transition-colors duration-150 relative
focus:outline-none focus-visible:ring-2 focus-visible:ring-pmr-nhsblue/40 focus-visible:ring-inset
${activeView === item.id
? 'text-white bg-white/[0.12] border-l-[3px] border-pmr-nhsblue'
: 'text-white/70 hover:text-white hover:bg-white/[0.08] border-l-[3px] border-transparent'}
`}
>
<span className={activeView === item.id ? 'text-white' : ''}>
{item.icon}
</span>
{/* Tooltip on hover */}
{hoveredItem === item.id && (
<div className="absolute left-full ml-2 px-2.5 py-1.5 bg-gray-900 text-white text-xs rounded whitespace-nowrap z-50 font-ui shadow-lg pointer-events-none">
{item.label}
</div>
)}
</button>
</li>
))}
</ul>
</div>
{/* Footer */}
<div className="p-2 border-t border-white/10">
<div className="font-ui text-[9px] text-[#64748B] text-center leading-relaxed">
<div>A.C</div>
<div>{currentTime}</div>
</div>
</div>
</nav>
)
}
// ── Desktop: 220px full sidebar ──
return (
<nav
aria-label="Clinical record navigation"
className="hidden lg:flex flex-col w-[220px] h-full bg-pmr-sidebar border-r border-[#334155] text-white"
>
{/* Header branding */}
<div className="p-4 border-b border-white/10">
<div className="font-ui font-medium text-[13px] text-white/50 leading-tight">
CareerRecord PMR
</div>
<div className="font-ui text-[11px] text-white/40 mt-0.5">v1.0.0</div>
</div>
{/* Search input */}
<div className="p-3 border-b border-white/10">
<div className="relative">
<Search
size={14}
className="absolute left-2.5 top-1/2 -translate-y-1/2 text-white/40 pointer-events-none"
/>
<input
id="sidebar-search"
type="search"
role="combobox"
aria-label="Search record"
aria-expanded={searchQuery.trim().length >= 2 && groupedResults.size > 0}
aria-controls="search-results-listbox"
aria-autocomplete="list"
placeholder="Search record..."
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
onFocus={() => setIsSearchFocused(true)}
onBlur={() => setIsSearchFocused(false)}
onKeyDown={handleSearchKeyDown}
className="w-full h-9 pl-8 pr-7 bg-white/[0.05] border border-white/10 rounded text-sm font-ui text-white placeholder-white/40 focus:outline-none focus:border-pmr-nhsblue focus:bg-white/[0.10] transition-colors"
/>
{searchQuery && (
<button
type="button"
onClick={clearSearch}
className="absolute right-2 top-1/2 -translate-y-1/2 text-white/40 hover:text-white/70 transition-colors"
aria-label="Clear search"
>
<X size={14} />
</button>
)}
{/* Search results dropdown — grouped by section */}
{searchQuery.trim().length >= 2 && groupedResults.size > 0 && (
<div
id="search-results-listbox"
role="listbox"
aria-label="Search results"
className="absolute top-full left-0 right-0 mt-1 bg-pmr-sidebar border border-white/10 rounded overflow-hidden z-50 max-h-[400px] overflow-y-auto shadow-lg"
>
{Array.from(groupedResults.entries()).map(([sectionLabel, results]) => {
// Find section icon
const navItem = navItems.find(item => item.label === sectionLabel)
return (
<div key={sectionLabel} role="group" aria-label={sectionLabel}>
{/* Section header */}
<div className="px-3 py-1.5 bg-white/[0.05] border-b border-white/10">
<div className="flex items-center gap-2">
{navItem && <span className="text-white/40" aria-hidden="true">{navItem.icon}</span>}
<span className="font-ui text-xs font-semibold uppercase tracking-wide text-white/50">
{sectionLabel}
</span>
<span className="font-ui text-xs text-white/30">
({results.length})
</span>
</div>
</div>
{/* Results for this section */}
{results.map((result: FuseResult<SearchResult>) => (
<button
key={result.item.id}
type="button"
role="option"
aria-selected={false}
onClick={() => handleSearchResultClick(result)}
className="w-full px-3 py-2.5 text-left hover:bg-white/[0.10] transition-colors border-b border-white/5 last:border-b-0"
>
<div className="font-ui text-sm text-white leading-snug">
{result.item.title}
</div>
<div className="font-ui text-xs text-white/50 mt-0.5 line-clamp-1">
{result.item.highlight}
</div>
</button>
))}
</div>
)
})}
</div>
)}
</div>
</div>
{/* Navigation items */}
<div className="flex-1 py-2 overflow-y-auto">
<ul role="menu" aria-label="Record sections">
{navItems.map((item, index) => (
<li key={item.id} role="none">
{index === 1 && (
<div className="mx-3 my-1 border-t border-white/10" role="separator" aria-hidden="true" />
)}
<button
ref={el => { navButtonRefs.current[index] = el }}
type="button"
role="menuitem"
tabIndex={focusedIndex === null ? (index === 0 ? 0 : -1) : (focusedIndex === index ? 0 : -1)}
aria-current={activeView === item.id ? 'page' : undefined}
onClick={() => handleNavClick(item.id)}
onKeyDown={e => handleNavKeyDown(e, index)}
className={`
w-full flex items-center gap-3 h-[44px] px-4
font-ui text-[14px]
transition-colors duration-150
focus:outline-none focus-visible:ring-2 focus-visible:ring-pmr-nhsblue/40 focus-visible:ring-inset
${activeView === item.id
? 'text-white bg-white/[0.12] border-l-[3px] border-pmr-nhsblue font-semibold'
: 'text-white/70 hover:text-white hover:bg-white/[0.08] border-l-[3px] border-transparent font-medium'}
`}
>
<span className={`w-[18px] h-[18px] flex items-center justify-center ${activeView === item.id ? 'text-white' : 'text-white/60'}`}>
{item.icon}
</span>
<span>{item.label}</span>
</button>
</li>
))}
</ul>
</div>
{/* Footer: session info */}
<div className="p-4 border-t border-white/10">
<div className="font-ui text-[11px] text-[#64748B] leading-relaxed">
<div>Session: A.CHARLWOOD</div>
<div>Logged in: {currentTime}</div>
</div>
</div>
</nav>
)
}
-108
View File
@@ -1,108 +0,0 @@
import { motion } from 'framer-motion'
import { Phone, Mail, Linkedin, MapPin } from 'lucide-react'
import { useScrollReveal } from '@/hooks/useScrollReveal'
import type { ContactItem } from '@/types'
const contactData: ContactItem[] = [
{
icon: 'phone',
value: '07795553088',
label: 'Phone',
},
{
icon: 'mail',
value: 'andy@charlwood.xyz',
label: 'Email',
href: 'mailto:andy@charlwood.xyz',
},
{
icon: 'linkedin',
value: 'linkedin.com/in/andrewcharlwood',
label: 'LinkedIn',
href: 'https://linkedin.com/in/andrewcharlwood',
},
{
icon: 'mapPin',
value: 'Norwich, UK',
label: 'Location',
},
]
const iconMap = {
phone: Phone,
mail: Mail,
linkedin: Linkedin,
mapPin: MapPin,
}
const ContactItemCard = ({
item,
delay,
isVisible,
}: {
item: ContactItem
delay: number
isVisible: boolean
}) => {
const Icon = iconMap[item.icon]
return (
<motion.div
initial={{ opacity: 0, y: 24 }}
animate={isVisible ? { opacity: 1, y: 0 } : { opacity: 0, y: 24 }}
transition={{ duration: 0.5, delay, ease: 'easeOut' }}
className="text-center"
>
<div className="w-10 h-10 rounded-full bg-[rgba(0,137,123,0.08)] flex items-center justify-center mx-auto mb-2 text-teal">
<Icon size={18} />
</div>
<div className="font-secondary text-[13px] text-heading break-words">
{item.href ? (
<a
href={item.href}
target={item.href.startsWith('http') ? '_blank' : undefined}
rel={item.href.startsWith('http') ? 'noopener noreferrer' : undefined}
className="text-teal hover:text-[#00796B] transition-colors"
>
{item.value}
</a>
) : (
item.value
)}
</div>
<div className="font-secondary text-[10px] uppercase tracking-wider text-muted mt-0.5">
{item.label}
</div>
</motion.div>
)
}
export function Contact() {
const [sectionRef, isVisible] = useScrollReveal<HTMLElement>({
threshold: 0.1,
})
return (
<section id="contact" ref={sectionRef} className="py-12 xs:py-16 md:py-20">
<motion.h2
initial={{ opacity: 0, y: 12 }}
animate={isVisible ? { opacity: 1, y: 0 } : { opacity: 0, y: 12 }}
transition={{ duration: 0.5 }}
className="font-primary text-2xl font-bold text-heading text-center mb-8"
>
Contact
</motion.h2>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{contactData.map((item, index) => (
<ContactItemCard
key={item.label}
item={item}
delay={0.1 + index * 0.1}
isVisible={isVisible}
/>
))}
</div>
</section>
)
}
-86
View File
@@ -1,86 +0,0 @@
import { motion } from 'framer-motion'
import { useScrollReveal } from '@/hooks/useScrollReveal'
import type { Education as EducationType } from '@/types'
const educationData: EducationType[] = [
{
degree: 'MPharm (Hons) Pharmacy',
institution: 'University of East Anglia',
period: '2011 — 2015',
detail: 'Upper Second-Class Honours (2:1)',
},
{
degree: 'Mary Seacole Leadership Programme',
institution: 'NHS Leadership Academy',
period: '2018',
detail: 'National healthcare leadership development programme.',
},
]
const EducationCard = ({
education,
delay,
isVisible,
}: {
education: EducationType
delay: number
isVisible: boolean
}) => {
return (
<motion.div
initial={{ opacity: 0, y: 24 }}
animate={isVisible ? { opacity: 1, y: 0 } : { opacity: 0, y: 24 }}
transition={{ duration: 0.5, delay, ease: 'easeOut' }}
className="relative bg-white rounded-2xl p-6 shadow-sm overflow-hidden transition-shadow hover:shadow-md hover:-translate-y-0.5"
>
<div className="absolute top-0 left-0 right-0 h-[3px] bg-gradient-to-r from-teal to-coral" />
<h3 className="font-primary text-[17px] font-semibold text-heading leading-tight">
{education.degree}
</h3>
<p className="text-sm text-teal mt-0.5">{education.institution}</p>
<p className="text-[13px] text-muted mt-0.5">{education.period}</p>
<p className="text-sm text-text mt-1.5 leading-relaxed">
{education.detail}
</p>
</motion.div>
)
}
export function Education() {
const [sectionRef, isVisible] = useScrollReveal<HTMLElement>({
threshold: 0.1,
})
return (
<section id="education" ref={sectionRef} className="py-12 xs:py-16 md:py-20">
<motion.h2
initial={{ opacity: 0, y: 12 }}
animate={isVisible ? { opacity: 1, y: 0 } : { opacity: 0, y: 12 }}
transition={{ duration: 0.5 }}
className="font-primary text-2xl font-bold text-heading text-center mb-8"
>
Education
</motion.h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-5">
{educationData.map((education, index) => (
<EducationCard
key={education.degree}
education={education}
delay={0.1 + index * 0.1}
isVisible={isVisible}
/>
))}
</div>
<motion.p
initial={{ opacity: 0 }}
animate={isVisible ? { opacity: 1 } : { opacity: 0 }}
transition={{ duration: 0.5, delay: 0.4 }}
className="text-[13px] text-muted text-center mt-5"
>
A-Levels: Mathematics (A*), Chemistry (B), Politics (C)
</motion.p>
</section>
)
}
-164
View File
@@ -1,164 +0,0 @@
import { motion } from 'framer-motion'
import { useScrollReveal } from '@/hooks/useScrollReveal'
import type { Experience as ExperienceType } from '@/types'
const experiences: ExperienceType[] = [
{
role: 'Interim Head of Population Health & Data Analysis',
org: 'NHS Norfolk & Waveney ICB',
date: 'May 2025 — Nov 2025',
bullets: [
'Led team through organisational transition, maintaining delivery of £14.6M efficiency programme',
'Directed strategic priorities for population health analytics across Norfolk & Waveney (population ~1M)',
'Managed stakeholder relationships with system leaders, provider trusts, and primary care networks',
],
isCurrent: true,
},
{
role: 'Deputy Head of Population Health & Data Analysis',
org: 'NHS Norfolk & Waveney ICB',
date: 'Jul 2024 — Present',
bullets: [
'Deputised for Head of department across all operational and strategic functions',
'Oversaw £220M medicines budget and led programme of cost improvement initiatives',
'Developed Python-based switching algorithm processing 14,000 patients, delivering £2.6M savings',
'Built Blueteq automation system reducing processing time by 70%, saving 200+ hours annually',
'Created PharMetrics dashboard platform for real-time medicines expenditure tracking',
],
isCurrent: true,
},
{
role: 'High-Cost Drugs & Interface Pharmacist',
org: 'NHS Norfolk & Waveney ICB',
date: 'May 2022 — Jul 2024',
bullets: [
'Managed high-cost drugs budget across acute and community settings',
'Led NICE Technology Appraisal implementation and horizon scanning',
'Developed health economic models for biosimilar switching programmes',
'Built data pipelines for automated reporting of medicines expenditure',
],
isCurrent: false,
},
{
role: 'Pharmacy Manager',
org: 'Tesco Pharmacy',
date: 'Nov 2017 — May 2022',
bullets: [
'Managed community pharmacy delivering 3,000+ items monthly',
'Pioneered asthma screening service generating £1M+ national revenue',
'Led team of 6 through COVID-19 pandemic service delivery',
'Completed Mary Seacole NHS Leadership Programme (2018)',
],
isCurrent: false,
},
{
role: 'Duty Pharmacy Manager',
org: 'Tesco Pharmacy',
date: 'Aug 2016 — Nov 2017',
bullets: [
'Supported pharmacy manager in daily operations and clinical services',
'Delivered Medicines Use Reviews and New Medicine Service consultations',
'Maintained controlled drug compliance and clinical governance standards',
],
isCurrent: false,
},
]
const ECGDecoration = () => (
<svg
className="shrink-0 w-[120px] xs:w-[200px] h-[30px]"
viewBox="0 0 200 30"
fill="none"
aria-hidden="true"
>
<path
d="M 0 15 L 40 15 L 50 15 C 53 15 55 12 58 12 C 61 12 63 15 66 15 L 76 15 L 80 20 L 86 2 L 92 22 L 96 15 L 106 15 C 109 15 111 11 114 11 C 117 11 120 15 123 15 L 200 15"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
className="text-teal opacity-30"
/>
</svg>
)
const TimelineEntry = ({
experience,
index,
isVisible,
}: {
experience: ExperienceType
index: number
isVisible: boolean
}) => {
return (
<motion.div
className="relative pl-0 md:pl-[calc(20%+32px)] mb-8 last:mb-0"
initial={{ opacity: 0, y: 24 }}
animate={isVisible ? { opacity: 1, y: 0 } : { opacity: 0, y: 24 }}
transition={{ duration: 0.5, delay: index * 0.1 }}
>
<div
className={`absolute left-[20%] top-2 -translate-x-1/2 w-2.5 h-2.5 rounded-full border-2 border-teal bg-white z-10 hidden md:block ${
experience.isCurrent ? 'bg-teal' : ''
}`}
/>
<motion.div
className="bg-white rounded-2xl p-4 xs:p-6 shadow-sm border-l-[3px] border-transparent hover:shadow-md hover:scale-[1.01] hover:border-l-teal/30 transition-all duration-300"
whileHover={{ scale: 1.01 }}
transition={{ duration: 0.2 }}
>
<h3 className="font-primary text-[17px] font-semibold text-heading leading-tight">
{experience.role}
</h3>
<p className="font-primary text-sm text-teal mt-0.5">{experience.org}</p>
<span className="inline-block px-2.5 py-0.5 mt-1.5 mb-3 bg-teal/8 rounded-full font-secondary text-xs text-teal font-medium">
{experience.date}
</span>
<ul className="space-y-1">
{experience.bullets.map((bullet, i) => (
<li
key={i}
className="relative pl-4 text-sm text-text leading-relaxed before:content-[''] before:absolute before:left-0 before:top-[10px] before:w-[5px] before:h-[5px] before:rounded-full before:bg-teal"
>
{bullet}
</li>
))}
</ul>
</motion.div>
</motion.div>
)
}
export function Experience() {
const [sectionRef, isVisible] = useScrollReveal<HTMLDivElement>({ threshold: 0.1 })
return (
<div
id="experience"
ref={sectionRef}
className="py-12 xs:py-16 md:py-20 opacity-0 translate-y-6 transition-all duration-600 ease-out data-[visible=true]:opacity-100 data-[visible=true]:translate-y-0"
data-visible={isVisible}
>
<div className="flex items-center justify-center gap-4 mb-8">
<h2 className="font-primary text-2xl font-bold text-heading">Experience</h2>
<ECGDecoration />
</div>
<div className="relative">
<div className="absolute left-[20%] top-0 bottom-0 w-0.5 bg-teal/20 hidden md:block" />
<div className="space-y-0">
{experiences.map((exp, i) => (
<TimelineEntry
key={exp.role}
experience={exp}
index={i}
isVisible={isVisible}
/>
))}
</div>
</div>
</div>
)
}
-68
View File
@@ -1,68 +0,0 @@
import { useCallback } from 'react'
import { motion } from 'framer-motion'
import { useActiveSection } from '@/hooks/useActiveSection'
interface NavLink {
id: string
label: string
}
const navLinks: NavLink[] = [
{ id: 'about', label: 'About' },
{ id: 'skills', label: 'Skills' },
{ id: 'experience', label: 'Experience' },
{ id: 'education', label: 'Education' },
{ id: 'projects', label: 'Projects' },
{ id: 'contact', label: 'Contact' },
]
export function FloatingNav() {
const activeSection = useActiveSection()
const scrollToSection = useCallback((sectionId: string) => {
const element = document.getElementById(sectionId)
if (element) {
element.scrollIntoView({ behavior: 'smooth' })
}
}, [])
return (
<motion.nav
className="fixed top-4 left-1/2 -translate-x-1/2 z-[100] max-w-[600px] w-[calc(100%-32px)] md:w-auto bg-white rounded-full py-2 px-4 md:px-6 shadow-md flex items-center gap-1 border border-border overflow-x-auto scrollbar-hide"
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, ease: 'easeOut' }}
>
{navLinks.map((link) => {
const isActive = activeSection === link.id
return (
<button
key={link.id}
onClick={() => scrollToSection(link.id)}
className={`
relative font-secondary text-[11px] xs:text-[13px] font-medium py-1.5 px-2.5 xs:px-3.5 rounded-full
transition-colors duration-300 ease-out whitespace-nowrap
${isActive
? 'text-teal font-semibold'
: 'text-muted hover:text-teal hover:bg-teal-light'
}
`}
>
{link.label}
{isActive && (
<motion.span
className="absolute bottom-0 left-1/2 -translate-x-1/2 w-1 h-1 rounded-full bg-teal"
layoutId="navIndicator"
initial={{ opacity: 0, scale: 0 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0 }}
transition={{ duration: 0.2, ease: 'easeOut' }}
/>
)}
</button>
)
})}
</motion.nav>
)
}
-36
View File
@@ -1,36 +0,0 @@
import { motion } from 'framer-motion'
const Footer: React.FC = () => {
return (
<motion.footer
initial={{ opacity: 0, y: 16 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-50px' }}
transition={{ duration: 0.5, ease: 'easeOut' }}
className="text-center pt-8 xs:pt-12 pb-6 xs:pb-8 border-t border-slate-200"
>
<svg
className="block mx-auto mb-3"
width="120"
height="20"
viewBox="0 0 120 20"
fill="none"
>
<path
d="M 0 10 L 35 10 L 40 10 C 42 10 43 7 45 7 C 47 7 48 10 50 10 L 54 10 L 56 13 L 60 2 L 64 15 L 66 10 L 70 10 C 72 10 73 7 75 7 C 77 7 78 10 80 10 L 120 10"
stroke="#00897B"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
opacity="0.3"
fill="none"
/>
</svg>
<p className="font-secondary text-xs text-muted">
Andy Charlwood &mdash; MPharm, GPhC Registered Pharmacist
</p>
</motion.footer>
)
}
export { Footer }
-85
View File
@@ -1,85 +0,0 @@
import { motion } from 'framer-motion'
interface VitalCardProps {
value: string
label: string
valueSize?: 'default' | 'small' | 'medium'
delay?: number
}
function VitalCard({ value, label, valueSize = 'default', delay = 0 }: VitalCardProps) {
const sizeClasses = {
default: 'text-[28px]',
small: 'text-base',
medium: 'text-lg'
}
return (
<motion.div
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay, ease: 'easeOut' }}
className="bg-card-bg rounded-2xl px-6 py-5 shadow-sm border-t-[3px] border-teal min-w-[160px] text-center transition-all duration-300 hover:shadow-md hover:-translate-y-0.5"
>
<div className={`font-primary font-bold text-heading leading-tight ${sizeClasses[valueSize]}`}>
{value}
</div>
<div className="font-secondary text-[11px] uppercase tracking-wide text-muted mt-1">
{label}
</div>
</motion.div>
)
}
export function Hero() {
return (
<section
id="about"
className="min-h-screen flex flex-col justify-center items-center text-center py-12 xs:py-16 md:py-20"
>
<motion.h1
initial={{ opacity: 0, y: 24 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, ease: 'easeOut' }}
className="font-primary font-bold text-heading leading-tight"
style={{ fontSize: 'clamp(28px, 5vw, 52px)' }}
>
Andy Charlwood
</motion.h1>
<motion.p
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.5, delay: 0.15 }}
className="text-muted text-base mt-2"
>
Deputy Head of Population Health &amp; Data Analysis
</motion.p>
<motion.span
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.5, delay: 0.2 }}
className="inline-block mt-1 px-4 py-1 border border-teal rounded-full text-xs text-teal font-medium"
>
Norwich, UK
</motion.span>
<motion.p
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.5, delay: 0.3 }}
className="mt-6 max-w-[560px] text-text text-[15px] leading-[1.8]"
>
GPhC Registered Pharmacist specialising in medicines optimisation, population health analytics, and NHS efficiency programmes. Bridging clinical pharmacy with data science to drive meaningful improvements in patient outcomes.
</motion.p>
<div className="grid grid-cols-1 xs:grid-cols-2 md:flex gap-4 mt-10 justify-center md:flex-wrap">
<VitalCard value="10+" label="Years Experience" delay={0.4} />
<VitalCard value="Python/SQL/BI" label="Analytics Stack" valueSize="small" delay={0.5} />
<VitalCard value="Pop. Health" label="Focus Area" valueSize="medium" delay={0.6} />
<VitalCard value="NHS N&W" label="System" valueSize="medium" delay={0.7} />
</div>
</section>
)
}
-69
View File
@@ -1,69 +0,0 @@
import { ClipboardList, FileText, Pill, AlertTriangle, FlaskConical, FolderOpen, Send } from 'lucide-react'
import type { ViewId } from '../types/pmr'
interface NavItem {
id: ViewId
label: string
shortLabel: string
icon: React.ReactNode
}
const navItems: NavItem[] = [
{ id: 'summary', label: 'Summary', shortLabel: 'Summary', icon: <ClipboardList size={20} /> },
{ id: 'consultations', label: 'Experience', shortLabel: 'Exp', icon: <FileText size={20} /> },
{ id: 'medications', label: 'Skills', shortLabel: 'Skills', icon: <Pill size={20} /> },
{ id: 'problems', label: 'Achievements', shortLabel: 'Achieve', icon: <AlertTriangle size={20} /> },
{ id: 'investigations', label: 'Projects', shortLabel: 'Projects', icon: <FlaskConical size={20} /> },
{ id: 'documents', label: 'Education', shortLabel: 'Edu', icon: <FolderOpen size={20} /> },
{ id: 'referrals', label: 'Contact', shortLabel: 'Contact', icon: <Send size={20} /> },
]
interface MobileBottomNavProps {
activeView: ViewId
onViewChange: (view: ViewId) => void
}
export function MobileBottomNav({ activeView, onViewChange }: MobileBottomNavProps) {
const handleNavClick = (view: ViewId) => {
onViewChange(view)
window.location.hash = view
}
return (
<nav
className="fixed bottom-0 left-0 right-0 z-50 bg-pmr-sidebar border-t border-white/10"
style={{ paddingBottom: 'env(safe-area-inset-bottom)' }}
role="navigation"
aria-label="Mobile navigation"
>
<ul className="flex items-center justify-around h-14">
{navItems.map((item) => {
const isActive = activeView === item.id
return (
<li key={item.id}>
<button
type="button"
onClick={() => handleNavClick(item.id)}
className={`
flex flex-col items-center justify-center
w-12 h-14 rounded-lg
transition-colors duration-100
${isActive
? 'text-pmr-nhsblue'
: 'text-white/60 hover:text-white/90'}
`}
aria-current={isActive ? 'page' : undefined}
aria-label={item.label}
>
{item.icon}
<span className="text-[10px] mt-0.5 font-ui font-medium">
{item.shortLabel}
</span>
</button>
</li>
)
})}
</ul>
</nav>
)
}
-284
View File
@@ -1,284 +0,0 @@
import { useState, useEffect, useRef, useMemo, useCallback } from 'react'
import { motion, Variants } from 'framer-motion'
import { Search, X, ArrowLeft } from 'lucide-react'
import type { ViewId } from '../types/pmr'
import { ClinicalSidebar } from './ClinicalSidebar'
import { PatientBanner } from './PatientBanner'
import { MobileBottomNav } from './MobileBottomNav'
import { Breadcrumb } from './Breadcrumb'
import { SummaryView } from './views/SummaryView'
import { ConsultationsView } from './views/ConsultationsView'
import { MedicationsView } from './views/MedicationsView'
import { ProblemsView } from './views/ProblemsView'
import { InvestigationsView } from './views/InvestigationsView'
import { DocumentsView } from './views/DocumentsView'
import { ReferralsView } from './views/ReferralsView'
import { useAccessibility } from '../contexts/AccessibilityContext'
import { useBreakpoint } from '../hooks/useBreakpoint'
import { useScrollCondensation } from '../hooks/useScrollCondensation'
interface PMRInterfaceProps {
children?: React.ReactNode
}
function PMRContent({ children }: PMRInterfaceProps) {
const [activeView, setActiveView] = useState<ViewId>(() => {
const hash = window.location.hash.slice(1) as ViewId
const validViews: ViewId[] = [
'summary',
'consultations',
'medications',
'problems',
'investigations',
'documents',
'referrals',
]
return validViews.includes(hash) ? hash : 'summary'
})
const [mobileSearchQuery, setMobileSearchQuery] = useState('')
const viewHeadingRef = useRef<HTMLDivElement>(null)
const [scrollContainer, setScrollContainer] = useState<HTMLElement | null>(null)
const scrollContainerCallbackRef = useCallback((node: HTMLElement | null) => {
setScrollContainer(node)
}, [])
const { requestFocusAfterViewChange, expandedItemId, setExpandedItem } = useAccessibility()
const { isMobile, isTablet } = useBreakpoint()
const { isCondensed } = useScrollCondensation({ threshold: 100, scrollContainer })
const prefersReducedMotion = typeof window !== 'undefined'
? window.matchMedia('(prefers-reduced-motion: reduce)').matches
: false
const bannerVariants = useMemo<Variants>(() => ({
hidden: prefersReducedMotion ? {} : { y: -80, opacity: 0 },
visible: {
y: 0,
opacity: 1,
transition: prefersReducedMotion ? { duration: 0 } : { duration: 0.2, ease: 'easeOut' }
}
}), [prefersReducedMotion])
const sidebarVariants = useMemo<Variants>(() => ({
hidden: prefersReducedMotion ? {} : { x: -220, opacity: 0 },
visible: {
x: 0,
opacity: 1,
transition: prefersReducedMotion ? { duration: 0 } : { duration: 0.25, ease: 'easeOut', delay: 0.05 }
}
}), [prefersReducedMotion])
const contentVariants = useMemo<Variants>(() => ({
hidden: prefersReducedMotion ? {} : { opacity: 0 },
visible: {
opacity: 1,
transition: prefersReducedMotion ? { duration: 0 } : { duration: 0.3, delay: 0.15 }
}
}), [prefersReducedMotion])
const mobileNavVariants = useMemo<Variants>(() => ({
hidden: prefersReducedMotion ? {} : { y: 56, opacity: 0 },
visible: {
y: 0,
opacity: 1,
transition: prefersReducedMotion ? { duration: 0 } : { duration: 0.2, ease: 'easeOut' }
}
}), [prefersReducedMotion])
useEffect(() => {
requestFocusAfterViewChange()
if (viewHeadingRef.current) {
viewHeadingRef.current.focus()
}
}, [activeView, requestFocusAfterViewChange])
const handleViewChange = (view: ViewId) => {
setActiveView(view)
if (expandedItemId) {
setExpandedItem(null)
}
}
const handleNavigate = (view: ViewId) => {
setActiveView(view)
window.location.hash = view
if (expandedItemId) {
setExpandedItem(null)
}
}
const handleBackToSummary = () => {
handleViewChange('summary')
window.location.hash = 'summary'
}
const renderView = () => {
switch (activeView) {
case 'summary':
return <SummaryView onNavigate={handleNavigate} />
case 'consultations':
return <ConsultationsView />
case 'medications':
return <MedicationsView />
case 'problems':
return <ProblemsView onNavigate={handleNavigate} />
case 'investigations':
return <InvestigationsView />
case 'documents':
return <DocumentsView />
case 'referrals':
return <ReferralsView />
default:
return (
<div className="bg-white border border-gray-200 rounded p-6 shadow-pmr">
<h1 className="font-ui font-semibold text-lg text-gray-900 capitalize">
{activeView} View
</h1>
<p className="font-ui text-sm text-gray-500 mt-2">
Content for {activeView} will be implemented in a separate task.
</p>
</div>
)
}
}
const viewLabels: Record<ViewId, string> = {
summary: 'Summary',
consultations: 'Experience',
medications: 'Skills',
problems: 'Achievements',
investigations: 'Projects',
documents: 'Education',
referrals: 'Contact',
}
return (
<motion.div
className="flex h-screen overflow-hidden bg-pmr-content"
initial="hidden"
animate="visible"
>
{/* Fixed sidebar */}
{!isMobile && (
<motion.div variants={sidebarVariants} className="flex-shrink-0">
<ClinicalSidebar
activeView={activeView}
onViewChange={handleViewChange}
isTablet={isTablet}
/>
</motion.div>
)}
{/* Main content column: banner (fixed) + scrollable content */}
<div className="flex-1 flex flex-col min-w-0">
<motion.div variants={bannerVariants} className="flex-shrink-0">
<PatientBanner isMobile={isMobile} isTablet={isTablet} isCondensed={isCondensed} />
</motion.div>
<motion.main
ref={scrollContainerCallbackRef}
variants={contentVariants}
aria-label={`${viewLabels[activeView]} view`}
className={`
flex-1 overflow-y-auto p-4 md:p-6
${isMobile ? 'pb-20' : ''}
`}
>
{isMobile && (
<MobileSearchBar
query={mobileSearchQuery}
onChange={setMobileSearchQuery}
/>
)}
<div
ref={viewHeadingRef}
tabIndex={-1}
className="outline-none"
aria-label={viewLabels[activeView]}
>
<h1 className="sr-only">{viewLabels[activeView]}</h1>
</div>
{/* Breadcrumb (desktop/tablet only) */}
{!isMobile && (
<Breadcrumb
currentView={activeView}
expandedItem={
expandedItemId
? { name: expandedItemId, type: activeView }
: undefined
}
onNavigateToView={handleNavigate}
onCollapseItem={() => setExpandedItem(null)}
/>
)}
{/* Mobile back button (mobile only) */}
{isMobile && activeView !== 'summary' && (
<button
type="button"
onClick={handleBackToSummary}
className="flex items-center gap-1 text-pmr-nhsblue text-sm font-ui font-medium mb-4 hover:underline"
>
<ArrowLeft size={14} />
Back to Summary
</button>
)}
{children || renderView()}
</motion.main>
</div>
{isMobile && (
<motion.div variants={mobileNavVariants}>
<MobileBottomNav
activeView={activeView}
onViewChange={handleViewChange}
/>
</motion.div>
)}
</motion.div>
)
}
interface MobileSearchBarProps {
query: string
onChange: (query: string) => void
}
function MobileSearchBar({ query, onChange }: MobileSearchBarProps) {
return (
<div className="mb-4">
<div className="relative">
<Search
size={16}
className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 pointer-events-none"
/>
<input
type="search"
aria-label="Search record"
placeholder="Search record..."
value={query}
onChange={e => onChange(e.target.value)}
className="w-full h-10 pl-10 pr-10 bg-white border border-gray-200 rounded text-sm font-ui text-gray-900 placeholder-gray-400 focus:outline-none focus:border-pmr-nhsblue focus:ring-1 focus:ring-pmr-nhsblue/20 transition-colors"
/>
{query && (
<button
type="button"
onClick={() => onChange('')}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 transition-colors"
aria-label="Clear search"
>
<X size={16} />
</button>
)}
</div>
</div>
)
}
export function PMRInterface(props: PMRInterfaceProps) {
return <PMRContent {...props} />
}
-380
View File
@@ -1,380 +0,0 @@
import { Download, Mail, Linkedin, MoreHorizontal } from 'lucide-react'
import { useState, useRef, useEffect, useCallback } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { patient } from '@/data/patient'
interface PatientBannerProps {
isMobile?: boolean
isTablet?: boolean
isCondensed?: boolean
}
export function PatientBanner({ isMobile = false, isTablet = false, isCondensed = false }: PatientBannerProps) {
const prefersReducedMotion = typeof window !== 'undefined'
? window.matchMedia('(prefers-reduced-motion: reduce)').matches
: false
if (isMobile) {
return <MobileBanner />
}
const shouldCondense = isTablet || isCondensed
return (
<header
className={`
w-full z-40
bg-pmr-banner border-b border-slate-600
shadow-pmr-banner
transition-all duration-200 ease-out
${shouldCondense ? 'h-12' : 'h-20'}
`}
role="banner"
>
<AnimatePresence mode="wait" initial={false}>
{shouldCondense ? (
<motion.div
key="condensed"
initial={prefersReducedMotion ? false : { opacity: 0 }}
animate={{ opacity: 1 }}
exit={prefersReducedMotion ? undefined : { opacity: 0 }}
transition={{ duration: 0.15 }}
className="h-full"
>
<CondensedBanner />
</motion.div>
) : (
<motion.div
key="full"
initial={prefersReducedMotion ? false : { opacity: 0 }}
animate={{ opacity: 1 }}
exit={prefersReducedMotion ? undefined : { opacity: 0 }}
transition={{ duration: 0.15 }}
className="h-full"
>
<FullBanner />
</motion.div>
)}
</AnimatePresence>
</header>
)
}
function MobileBanner() {
const [showOverflow, setShowOverflow] = useState(false)
const menuRef = useRef<HTMLDivElement>(null)
const handleClickOutside = useCallback((e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
setShowOverflow(false)
}
}, [])
useEffect(() => {
if (showOverflow) {
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}
}, [showOverflow, handleClickOutside])
return (
<header
className="w-full z-40 h-12 bg-pmr-banner border-b border-slate-600 shadow-pmr-banner"
role="banner"
>
<div className="h-full px-3 flex items-center justify-between gap-2">
<div className="flex items-center gap-2 min-w-0 flex-1">
<h1 className="font-ui font-semibold text-white text-sm tracking-tight truncate">
CHARLWOOD, A (Mr)
</h1>
<span className="text-slate-500">|</span>
<span className="font-geist text-xs text-slate-300">
{patient.nhsNumber}
</span>
<StatusDot status={patient.status} />
</div>
<div className="relative" ref={menuRef}>
<button
type="button"
onClick={() => setShowOverflow(!showOverflow)}
className="p-2 text-white/70 hover:text-white transition-colors"
aria-label="Actions menu"
aria-expanded={showOverflow}
>
<MoreHorizontal size={18} />
</button>
<AnimatePresence>
{showOverflow && (
<motion.div
initial={{ opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -4 }}
transition={{ duration: 0.15 }}
className="absolute right-0 top-full mt-1 w-44 bg-white border border-pmr-border rounded shadow-pmr z-50 py-1"
>
<a
href="/cv.pdf"
className="flex items-center gap-2 px-3 py-2 text-sm font-ui text-pmr-text-primary hover:bg-gray-50 transition-colors"
onClick={() => setShowOverflow(false)}
>
<Download size={14} />
Download CV
</a>
<a
href={`mailto:${patient.email}`}
className="flex items-center gap-2 px-3 py-2 text-sm font-ui text-pmr-text-primary hover:bg-gray-50 transition-colors"
onClick={() => setShowOverflow(false)}
>
<Mail size={14} />
Email
</a>
<a
href={`https://${patient.linkedin}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 px-3 py-2 text-sm font-ui text-pmr-text-primary hover:bg-gray-50 transition-colors"
onClick={() => setShowOverflow(false)}
>
<Linkedin size={14} />
LinkedIn
</a>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
</header>
)
}
function FullBanner() {
return (
<div className="h-full px-4 lg:px-6 flex flex-col justify-center">
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
{/* Row 1: Name, status, badge */}
<div className="flex items-center gap-3 flex-wrap">
<h1 className="font-ui font-semibold text-white text-lg tracking-tight">
{patient.name}
</h1>
<div className="flex items-center gap-1.5">
<StatusDot status={patient.status} />
<span className="text-slate-400 text-sm font-ui">{patient.status}</span>
</div>
{patient.badge && <StatusBadge badge={patient.badge} />}
</div>
{/* Row 2: Demographics with pipe separators */}
<div className="flex items-center gap-4 mt-1 flex-wrap text-sm text-slate-300 font-ui">
<span>
<span className="text-slate-500">DOB:</span>{' '}
<span className="font-geist">{patient.dob}</span>
</span>
<span className="text-slate-500">|</span>
<span className="flex items-center gap-1">
<span className="text-slate-500">NHS No:</span>{' '}
<NHSNumberWithTooltip />
</span>
<span className="text-slate-500">|</span>
<span>{patient.address}</span>
</div>
{/* Row 3: Contact details */}
<div className="flex items-center gap-4 mt-1 flex-wrap text-sm text-slate-300 font-ui">
<a
href={`tel:${patient.phone}`}
className="hover:text-white transition-colors"
>
{patient.phone}
</a>
<span className="text-slate-500">|</span>
<a
href={`mailto:${patient.email}`}
className="hover:text-white transition-colors"
>
{patient.email}
</a>
</div>
</div>
{/* Action buttons */}
<div className="flex items-center gap-2 flex-shrink-0">
<ActionButton
icon={<Download size={14} />}
label="Download CV"
href="/cv.pdf"
/>
<ActionButton
icon={<Mail size={14} />}
label="Email"
href={`mailto:${patient.email}`}
/>
<ActionButton
icon={<Linkedin size={14} />}
label="LinkedIn"
href={`https://${patient.linkedin}`}
external
/>
</div>
</div>
</div>
)
}
function CondensedBanner() {
return (
<div className="h-full px-4 lg:px-6 flex items-center justify-between gap-4">
<div className="flex items-center gap-4 min-w-0">
<h1 className="font-ui font-semibold text-white text-sm tracking-tight truncate">
{patient.name}
</h1>
<span className="text-slate-500">|</span>
<span className="flex items-center gap-1 text-sm text-slate-300">
<span className="text-slate-500 font-ui">NHS No:</span>{' '}
<NHSNumberWithTooltip condensed />
</span>
<span className="text-slate-500">|</span>
<div className="flex items-center gap-1.5">
<StatusDot status={patient.status} />
<span className="text-slate-400 text-xs font-ui">{patient.status}</span>
</div>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<ActionButton
icon={<Download size={14} />}
label="Download CV"
href="/cv.pdf"
compact
/>
<ActionButton
icon={<Mail size={14} />}
label="Email"
href={`mailto:${patient.email}`}
compact
/>
</div>
</div>
)
}
/* --- Sub-components --- */
interface NHSNumberWithTooltipProps {
condensed?: boolean
}
function NHSNumberWithTooltip({ condensed = false }: NHSNumberWithTooltipProps) {
const [showTooltip, setShowTooltip] = useState(false)
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const handleMouseEnter = () => {
timeoutRef.current = setTimeout(() => setShowTooltip(true), 300)
}
const handleMouseLeave = () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
timeoutRef.current = null
}
setShowTooltip(false)
}
useEffect(() => {
return () => {
if (timeoutRef.current) clearTimeout(timeoutRef.current)
}
}, [])
return (
<span
className="relative inline-flex items-center"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onFocus={() => setShowTooltip(true)}
onBlur={() => setShowTooltip(false)}
>
<span
className={`font-geist cursor-help border-b border-dotted border-slate-500 ${condensed ? 'text-sm' : ''}`}
tabIndex={0}
role="button"
aria-describedby="nhs-tooltip"
>
{patient.nhsNumber}
</span>
<AnimatePresence>
{showTooltip && (
<motion.span
id="nhs-tooltip"
role="tooltip"
initial={{ opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 4 }}
transition={{ duration: 0.12 }}
className="absolute left-1/2 -translate-x-1/2 top-full mt-2 px-2.5 py-1 bg-slate-800 text-white text-xs font-ui rounded whitespace-nowrap z-50 shadow-lg pointer-events-none"
>
{patient.nhsNumberTooltip}
<span className="absolute left-1/2 -translate-x-1/2 -top-1 w-2 h-2 bg-slate-800 rotate-45" />
</motion.span>
)}
</AnimatePresence>
</span>
)
}
interface StatusDotProps {
status: string
}
function StatusDot({ status }: StatusDotProps) {
const colorClass = status === 'Active' ? 'bg-pmr-green' : 'bg-slate-400'
return (
<span
className={`w-2 h-2 rounded-full ${colorClass} flex-shrink-0`}
role="img"
aria-label={`Status: ${status}`}
/>
)
}
interface StatusBadgeProps {
badge: string
}
function StatusBadge({ badge }: StatusBadgeProps) {
return (
<span className="px-2.5 py-0.5 bg-pmr-nhsblue text-white text-xs font-ui font-medium rounded-full">
{badge}
</span>
)
}
interface ActionButtonProps {
icon: React.ReactNode
label: string
href: string
external?: boolean
compact?: boolean
}
function ActionButton({ icon, label, href, external, compact }: ActionButtonProps) {
return (
<a
href={href}
target={external ? '_blank' : undefined}
rel={external ? 'noopener noreferrer' : undefined}
className={`
inline-flex items-center gap-1.5
border border-pmr-nhsblue text-pmr-nhsblue
hover:bg-pmr-nhsblue hover:text-white
transition-colors duration-150
rounded
font-ui font-medium
focus:outline-none focus-visible:ring-2 focus-visible:ring-pmr-nhsblue/40 focus-visible:ring-offset-1 focus-visible:ring-offset-pmr-banner
${compact ? 'px-2 py-1 text-xs' : 'px-3 py-1.5 text-sm'}
`}
>
{icon}
<span>{label}</span>
</a>
)
}
-105
View File
@@ -1,105 +0,0 @@
import { motion } from 'framer-motion'
import { ExternalLink } from 'lucide-react'
import { useScrollReveal } from '@/hooks/useScrollReveal'
import type { Project as ProjectType } from '@/types'
const projectsData: ProjectType[] = [
{
title: 'PharMetrics',
description:
'Real-time medicines expenditure dashboard providing actionable analytics for NHS decision-makers.',
link: 'https://medicines.charlwood.xyz/',
},
{
title: 'Patient Pathway Analysis',
description:
'Data-driven analysis of patient pathways to identify optimisation opportunities and improve clinical outcomes.',
},
{
title: 'Blueteq Generator',
description:
'Automation tool reducing high-cost drug approval processing time by 70%, saving 200+ hours annually.',
},
{
title: 'NMS Video',
description:
'Educational video resource supporting New Medicine Service consultations, improving patient engagement.',
},
]
const ProjectCard = ({
project,
delay,
isVisible,
}: {
project: ProjectType
delay: number
isVisible: boolean
}) => {
return (
<motion.div
initial={{ opacity: 0, y: 24 }}
animate={isVisible ? { opacity: 1, y: 0 } : { opacity: 0, y: 24 }}
transition={{ duration: 0.5, delay, ease: 'easeOut' }}
className="group relative bg-white rounded-2xl p-6 shadow-sm overflow-hidden transition-all hover:shadow-md hover:-translate-y-0.5"
>
<div
className="absolute inset-0 rounded-2xl p-[2px] opacity-0 group-hover:opacity-100 transition-opacity duration-300 pointer-events-none"
style={{
background: 'linear-gradient(135deg, #00897B, #FF6B6B)',
WebkitMask:
'linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0)',
WebkitMaskComposite: 'xor',
maskComposite: 'exclude',
}}
/>
<h3 className="font-primary text-base font-semibold text-heading leading-tight">
{project.title}
</h3>
<p className="text-sm text-text leading-relaxed mt-2">
{project.description}
</p>
{project.link && (
<a
href={project.link}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 mt-3 px-4 py-1.5 bg-teal text-white rounded-full text-xs font-medium font-secondary transition-all hover:bg-[#00796B] hover:-translate-y-px"
>
Visit Project
<ExternalLink size={12} />
</a>
)}
</motion.div>
)
}
export function Projects() {
const [sectionRef, isVisible] = useScrollReveal<HTMLElement>({
threshold: 0.1,
})
return (
<section id="projects" ref={sectionRef} className="py-12 xs:py-16 md:py-20">
<motion.h2
initial={{ opacity: 0, y: 12 }}
animate={isVisible ? { opacity: 1, y: 0 } : { opacity: 0, y: 12 }}
transition={{ duration: 0.5 }}
className="font-primary text-2xl font-bold text-heading text-center mb-8"
>
Projects
</motion.h2>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5">
{projectsData.map((project, index) => (
<ProjectCard
key={project.title}
project={project}
delay={0.1 + index * 0.1}
isVisible={isVisible}
/>
))}
</div>
</section>
)
}
-193
View File
@@ -1,193 +0,0 @@
import { useRef, useState, useEffect } from 'react'
import { motion } from 'framer-motion'
import type { Skill } from '../types'
import { calculateSkillOffset } from '../lib/utils'
const GAUGE_RADIUS = 34
const GAUGE_CIRCUMFERENCE = 2 * Math.PI * GAUGE_RADIUS
interface SkillGaugeProps {
skill: Skill
delay: number
isVisible: boolean
}
function SkillGauge({ skill, delay, isVisible }: SkillGaugeProps) {
const [animated, setAnimated] = useState(false)
const strokeColor = skill.color === 'coral' ? '#FF6B6B' : '#00897B'
const hoverBg = skill.color === 'coral' ? 'hover:bg-coral-light' : 'hover:bg-teal-light'
const targetOffset = calculateSkillOffset(skill.level, GAUGE_RADIUS)
useEffect(() => {
if (isVisible && !animated) {
const timer = setTimeout(() => setAnimated(true), delay)
return () => clearTimeout(timer)
}
}, [isVisible, animated, delay])
return (
<motion.div
initial={{ opacity: 0, y: 24 }}
animate={isVisible ? { opacity: 1, y: 0 } : { opacity: 0, y: 24 }}
transition={{ duration: 0.5, delay: delay / 1000, ease: 'easeOut' }}
className={`flex flex-col items-center p-3 xs:p-4 rounded-2xl transition-colors duration-300 ${hoverBg}`}
>
<svg
className="skill-gauge block w-16 h-16 xs:w-20 xs:h-20"
viewBox="0 0 80 80"
>
<circle
cx="40"
cy="40"
r={GAUGE_RADIUS}
fill="none"
stroke="#E2E8F0"
strokeWidth="5"
/>
<circle
cx="40"
cy="40"
r={GAUGE_RADIUS}
fill="none"
stroke={strokeColor}
strokeWidth="5"
strokeLinecap="round"
transform="rotate(-90, 40, 40)"
style={{
strokeDasharray: GAUGE_CIRCUMFERENCE,
strokeDashoffset: animated ? targetOffset : GAUGE_CIRCUMFERENCE,
transition: animated ? 'stroke-dashoffset 1.2s ease-out' : 'none'
}}
/>
<text
x="40"
y="40"
textAnchor="middle"
dominantBaseline="central"
fontSize="14"
fontWeight="600"
fill="#0F172A"
fontFamily="'Inter Tight', system-ui, sans-serif"
>
{skill.level}%
</text>
</svg>
<span className="font-primary text-xs font-semibold text-heading mt-2 text-center leading-tight">
{skill.name}
</span>
<span className="font-secondary text-[10px] text-muted uppercase tracking-wide mt-0.5">
{skill.category}
</span>
</motion.div>
)
}
interface SkillCategoryProps {
label: string
skills: Skill[]
isVisible: boolean
baseDelay: number
}
function SkillCategory({ label, skills, isVisible, baseDelay }: SkillCategoryProps) {
return (
<div className="mb-10 last:mb-0">
<h3 className="font-secondary text-xs font-semibold uppercase tracking-widest text-muted mb-5 pl-1">
{label}
</h3>
<div className="grid grid-cols-2 xs:grid-cols-3 md:grid-cols-[repeat(auto-fit,minmax(140px,1fr))] gap-4 xs:gap-6">
{skills.map((skill, index) => (
<SkillGauge
key={skill.name}
skill={skill}
delay={baseDelay + index * 100}
isVisible={isVisible}
/>
))}
</div>
</div>
)
}
const skillsData: Skill[] = [
{ name: 'Python', level: 90, category: 'Technical', color: 'teal' },
{ name: 'SQL', level: 88, category: 'Technical', color: 'teal' },
{ name: 'Power BI', level: 92, category: 'Technical', color: 'teal' },
{ name: 'JS / TS', level: 70, category: 'Technical', color: 'teal' },
{ name: 'Data Analysis', level: 95, category: 'Technical', color: 'teal' },
{ name: 'Dashboard Dev', level: 88, category: 'Technical', color: 'teal' },
{ name: 'Algorithm Design', level: 82, category: 'Technical', color: 'teal' },
{ name: 'Data Pipelines', level: 80, category: 'Technical', color: 'teal' },
{ name: 'Medicines Optimisation', level: 95, category: 'Clinical', color: 'coral' },
{ name: 'Pop. Health Analytics', level: 90, category: 'Clinical', color: 'coral' },
{ name: 'NICE TA', level: 85, category: 'Clinical', color: 'coral' },
{ name: 'Health Economics', level: 80, category: 'Clinical', color: 'coral' },
{ name: 'Clinical Pathways', level: 82, category: 'Clinical', color: 'coral' },
{ name: 'CD Assurance', level: 88, category: 'Clinical', color: 'coral' },
{ name: 'Budget Mgmt', level: 90, category: 'Strategic', color: 'teal' },
{ name: 'Stakeholder Engagement', level: 88, category: 'Strategic', color: 'teal' },
{ name: 'Pharma Negotiation', level: 85, category: 'Strategic', color: 'teal' },
{ name: 'Team Development', level: 82, category: 'Strategic', color: 'teal' },
]
export function Skills() {
const sectionRef = useRef<HTMLElement>(null)
const [isVisible, setIsVisible] = useState(false)
useEffect(() => {
const element = sectionRef.current
if (!element) return
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setIsVisible(true)
observer.unobserve(element)
}
},
{ threshold: 0.15, rootMargin: '0px' }
)
observer.observe(element)
return () => observer.disconnect()
}, [])
const technicalSkills = skillsData.filter(s => s.category === 'Technical')
const clinicalSkills = skillsData.filter(s => s.category === 'Clinical')
const strategicSkills = skillsData.filter(s => s.category === 'Strategic')
return (
<section id="skills" ref={sectionRef} className="py-12 xs:py-16 md:py-20">
<motion.h2
initial={{ opacity: 0, y: 20 }}
animate={isVisible ? { opacity: 1, y: 0 } : { opacity: 0, y: 20 }}
transition={{ duration: 0.6, ease: 'easeOut' }}
className="font-primary text-2xl font-bold text-heading text-center mb-8"
>
Skills &amp; Expertise
</motion.h2>
<SkillCategory
label="Technical"
skills={technicalSkills}
isVisible={isVisible}
baseDelay={200}
/>
<SkillCategory
label="Clinical"
skills={clinicalSkills}
isVisible={isVisible}
baseDelay={200 + technicalSkills.length * 100 + 100}
/>
<SkillCategory
label="Strategic"
skills={strategicSkills}
isVisible={isVisible}
baseDelay={200 + technicalSkills.length * 100 + clinicalSkills.length * 100 + 200}
/>
</section>
)
}
-249
View File
@@ -1,249 +0,0 @@
import { useState } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { ChevronDown } from 'lucide-react'
import { consultations } from '@/data/consultations'
import type { Consultation, ViewId } from '@/types/pmr'
// ─── Props ──────────────────────────────────────────────────────────────────
interface ConsultationsViewProps {
onNavigate?: (view: ViewId, itemId?: string) => void
initialExpandedId?: string
}
export function ConsultationsView({ initialExpandedId }: ConsultationsViewProps) {
const [expandedId, setExpandedId] = useState<string | null>(initialExpandedId ?? null)
const prefersReducedMotion = typeof window !== 'undefined'
? window.matchMedia('(prefers-reduced-motion: reduce)').matches
: false
const handleToggle = (id: string) => {
setExpandedId(prev => prev === id ? null : id)
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h1 className="font-ui font-semibold text-[18px] text-gray-900">
Consultation Journal
</h1>
<span className="font-geist text-[12px] text-gray-500">
{consultations.length} entries
</span>
</div>
<div className="space-y-3">
{consultations.map(consultation => (
<ConsultationEntry
key={consultation.id}
consultation={consultation}
isExpanded={expandedId === consultation.id}
onToggle={() => handleToggle(consultation.id)}
prefersReducedMotion={prefersReducedMotion}
/>
))}
</div>
</div>
)
}
// ─── Consultation Entry ─────────────────────────────────────────────────────
interface ConsultationEntryProps {
consultation: Consultation
isExpanded: boolean
onToggle: () => void
prefersReducedMotion: boolean
}
function ConsultationEntry({
consultation,
isExpanded,
onToggle,
prefersReducedMotion,
}: ConsultationEntryProps) {
const keyCodedEntry = consultation.codedEntries[0]
return (
<article
className="bg-white border border-[#E5E7EB] rounded shadow-pmr overflow-hidden"
style={{ borderLeftWidth: '3px', borderLeftColor: consultation.orgColor }}
>
{/* Collapsed header — always visible */}
<button
type="button"
onClick={onToggle}
className="w-full px-4 py-3 flex items-start gap-3 text-left hover:bg-[#EFF6FF] transition-colors duration-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-pmr-nhsblue/40 focus-visible:ring-inset"
aria-expanded={isExpanded}
aria-label={`${consultation.role} at ${consultation.organization}, ${consultation.date}`}
>
<StatusDot isCurrent={consultation.isCurrent} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-geist text-[13px] text-gray-500">
{consultation.date}
</span>
<span className="text-gray-300">|</span>
<span
className="font-ui text-[13px]"
style={{ color: consultation.orgColor }}
>
{consultation.organization}
</span>
</div>
<h3 className="font-ui font-semibold text-[15px] text-gray-900 mt-1">
{consultation.role}
</h3>
{!isExpanded && keyCodedEntry && (
<p className="font-ui text-[13px] text-gray-500 mt-1 line-clamp-1">
<span className="font-medium text-gray-400">Key:</span>{' '}
<span className="font-geist text-[12px] text-gray-400">
[{keyCodedEntry.code}]
</span>{' '}
{keyCodedEntry.description}
</p>
)}
</div>
<motion.div
animate={{ rotate: isExpanded ? 180 : 0 }}
transition={{ duration: prefersReducedMotion ? 0 : 0.2 }}
className="flex-shrink-0 mt-1"
>
<ChevronDown size={18} className="text-gray-400" />
</motion.div>
</button>
{/* Expandable content — height-only animation, NO opacity fade */}
<AnimatePresence initial={false}>
{isExpanded && (
<motion.div
key="expanded"
initial={{ height: 0 }}
animate={{ height: 'auto' }}
exit={{ height: 0 }}
transition={{
duration: prefersReducedMotion ? 0 : 0.2,
ease: 'easeOut',
}}
className="overflow-hidden"
>
<ExpandedContent consultation={consultation} />
</motion.div>
)}
</AnimatePresence>
</article>
)
}
// ─── Status Dot ─────────────────────────────────────────────────────────────
interface StatusDotProps {
isCurrent: boolean
}
function StatusDot({ isCurrent }: StatusDotProps) {
return (
<span
className="flex-shrink-0 mt-1.5"
aria-label={isCurrent ? 'Current role' : 'Historical role'}
>
<span
className={`block w-2 h-2 rounded-full ${
isCurrent ? 'bg-green-500' : 'bg-gray-400'
}`}
/>
</span>
)
}
// ─── Expanded Content ───────────────────────────────────────────────────────
interface ExpandedContentProps {
consultation: Consultation
}
function ExpandedContent({ consultation }: ExpandedContentProps) {
return (
<div className="px-4 pb-4">
<div className="pl-5 border-l border-[#E5E7EB] ml-1">
{/* Duration */}
<div className="mb-4">
<span className="font-ui text-[13px] text-gray-500">Duration: </span>
<span className="font-geist text-[13px] text-gray-700">
{consultation.duration}
</span>
</div>
{/* HISTORY */}
<SectionHeader>HISTORY</SectionHeader>
<p className="font-ui text-[13px] text-gray-700 leading-relaxed mb-4">
{consultation.history}
</p>
{/* EXAMINATION */}
<SectionHeader>EXAMINATION</SectionHeader>
<ul className="space-y-1.5 mb-4">
{consultation.examination.map((item, index) => (
<li key={index} className="flex gap-2 text-[13px]">
<span className="text-gray-300 flex-shrink-0">-</span>
<span className="font-ui text-gray-700">{item}</span>
</li>
))}
</ul>
{/* PLAN */}
<SectionHeader>PLAN</SectionHeader>
<ul className="space-y-1.5 mb-4">
{consultation.plan.map((item, index) => (
<li key={index} className="flex gap-2 text-[13px]">
<span className="text-gray-300 flex-shrink-0">-</span>
<span className="font-ui text-gray-700">{item}</span>
</li>
))}
</ul>
{/* CODED ENTRIES */}
<SectionHeader>CODED ENTRIES</SectionHeader>
<div className="space-y-1">
{consultation.codedEntries.map(entry => (
<CodedEntry
key={entry.code}
code={entry.code}
description={entry.description}
/>
))}
</div>
</div>
</div>
)
}
// ─── Section Header ─────────────────────────────────────────────────────────
function SectionHeader({ children }: { children: React.ReactNode }) {
return (
<h4 className="font-ui font-semibold text-[12px] uppercase tracking-[0.05em] text-gray-400 mb-2">
{children}
</h4>
)
}
// ─── Coded Entry ────────────────────────────────────────────────────────────
interface CodedEntryProps {
code: string
description: string
}
function CodedEntry({ code, description }: CodedEntryProps) {
return (
<div className="font-geist text-[12px] text-gray-500">
[{code}] {description}
</div>
)
}
-344
View File
@@ -1,344 +0,0 @@
import { useState, useCallback } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { ChevronDown, FileText, Award, GraduationCap, FlaskConical } from 'lucide-react'
import { documents } from '@/data/documents'
import type { Document, DocumentType } from '@/types/pmr'
import { useBreakpoint } from '@/hooks/useBreakpoint'
import { useAccessibility } from '@/contexts/AccessibilityContext'
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
const documentIcons: Record<DocumentType, React.FC<{ className?: string }>> = {
Certificate: FileText,
Registration: Award,
Results: GraduationCap,
Research: FlaskConical,
}
function DocumentTypeIcon({ type }: { type: DocumentType }) {
const Icon = documentIcons[type]
return (
<div className="flex items-center justify-center">
<Icon className="w-4 h-4 text-gray-500" />
</div>
)
}
const documentBorderColors: Record<DocumentType, string> = {
Certificate: '#005EB8',
Registration: '#10B981',
Results: '#6366F1',
Research: '#8B5CF6',
}
interface TreeLineProps {
label: string
value: React.ReactNode
isLast?: boolean
}
function TreeLine({ label, value, isLast = false }: TreeLineProps) {
return (
<div className="flex">
<span className="text-gray-400 select-none">{isLast ? '└─ ' : '├─ '}</span>
<span className="text-gray-500 shrink-0 min-w-[160px]">{label}:</span>
<span className="ml-2 flex-1">{value}</span>
</div>
)
}
function DocumentRow({
document: doc,
isExpanded,
onToggle,
index,
}: {
document: Document
isExpanded: boolean
onToggle: () => void
index: number
}) {
const fields: Array<{ label: string; value: React.ReactNode }> = [
{ label: 'Type', value: doc.type },
{ label: 'Date Awarded', value: doc.date },
]
if (doc.institution) fields.push({ label: 'Institution', value: doc.institution })
if (doc.classification) fields.push({ label: 'Classification', value: doc.classification })
if (doc.duration) fields.push({ label: 'Duration', value: doc.duration })
if (doc.researchDetail) {
fields.push({
label: 'Research',
value: (
<>
{doc.researchDetail}
{doc.researchGrade && (
<>
<br />
<span className="text-gray-500">Grade: {doc.researchGrade}</span>
</>
)}
</>
),
})
}
if (doc.notes) fields.push({ label: 'Notes', value: <span className="text-gray-600">{doc.notes}</span> })
return (
<>
<tr
className={`cursor-pointer transition-colors h-[40px] ${
isExpanded ? 'bg-[#EFF6FF]' : index % 2 === 0 ? 'bg-white' : 'bg-[#F9FAFB]'
} hover:bg-[#EFF6FF]`}
onClick={onToggle}
tabIndex={0}
role="button"
aria-expanded={isExpanded}
aria-label={`${doc.title}${doc.type}, ${doc.date}`}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
onToggle()
}
}}
>
<td className="border-b border-r border-[#E5E7EB] px-3 py-2 w-12">
<DocumentTypeIcon type={doc.type} />
</td>
<td className="border-b border-r border-[#E5E7EB] px-3 py-2">
<div className="flex items-center gap-2">
<motion.div
animate={{ rotate: isExpanded ? 180 : 0 }}
transition={{ duration: prefersReducedMotion ? 0 : 0.2 }}
>
<ChevronDown className="w-4 h-4 text-gray-400" />
</motion.div>
<span className="font-ui text-[14px] text-gray-900">{doc.title}</span>
</div>
</td>
<td className="border-b border-r border-[#E5E7EB] px-3 py-2">
<span className="font-geist text-[13px] text-gray-500">{doc.date}</span>
</td>
<td className="border-b border-[#E5E7EB] px-3 py-2">
<span className="font-ui text-[13px] text-gray-700">{doc.source}</span>
</td>
</tr>
<AnimatePresence initial={false}>
{isExpanded && (
<motion.tr
initial={{ height: 0 }}
animate={{ height: 'auto' }}
exit={{ height: 0 }}
transition={{ duration: prefersReducedMotion ? 0 : 0.2, ease: 'easeOut' }}
>
<td colSpan={4} className="p-0 border-b border-[#E5E7EB]">
<motion.div
initial={{ height: 0 }}
animate={{ height: 'auto' }}
exit={{ height: 0 }}
transition={{ duration: prefersReducedMotion ? 0 : 0.2, ease: 'easeOut' }}
style={{ overflow: 'hidden' }}
>
<div
className="bg-[#F9FAFB] p-4 border-l-4"
style={{ borderLeftColor: documentBorderColors[doc.type] }}
>
<div className="font-geist text-[12px] text-gray-700 leading-relaxed space-y-0.5">
{fields.map((field, idx) => (
<TreeLine
key={field.label}
label={field.label}
value={field.value}
isLast={idx === fields.length - 1}
/>
))}
</div>
</div>
</motion.div>
</td>
</motion.tr>
)}
</AnimatePresence>
</>
)
}
function MobileDocumentCard({
document: doc,
isExpanded,
onToggle,
}: {
document: Document
isExpanded: boolean
onToggle: () => void
}) {
const fields: Array<{ label: string; value: React.ReactNode }> = [
{ label: 'Type', value: doc.type },
{ label: 'Date Awarded', value: doc.date },
]
if (doc.institution) fields.push({ label: 'Institution', value: doc.institution })
if (doc.classification) fields.push({ label: 'Classification', value: doc.classification })
if (doc.duration) fields.push({ label: 'Duration', value: doc.duration })
if (doc.researchDetail) {
fields.push({
label: 'Research',
value: (
<>
{doc.researchDetail}
{doc.researchGrade && (
<>
<br />
<span className="text-gray-500">Grade: {doc.researchGrade}</span>
</>
)}
</>
),
})
}
if (doc.notes) fields.push({ label: 'Notes', value: <span className="text-gray-600">{doc.notes}</span> })
return (
<div className="bg-white border border-[#E5E7EB] rounded shadow-pmr overflow-hidden">
<button
type="button"
onClick={onToggle}
className="w-full p-4 text-left focus-visible:ring-2 focus-visible:ring-[#005EB8]/40 focus-visible:ring-inset focus-visible:outline-none"
aria-expanded={isExpanded}
aria-label={`${doc.title}${doc.type}, ${doc.date}`}
>
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<DocumentTypeIcon type={doc.type} />
<span className="font-ui text-[12px] text-gray-500">{doc.type}</span>
</div>
<h3 className="font-ui font-medium text-[14px] text-gray-900">
{doc.title}
</h3>
<div className="flex items-center gap-2 mt-1.5">
<span className="font-geist text-[12px] text-gray-500">{doc.date}</span>
<span className="text-gray-300"></span>
<span className="font-ui text-[12px] text-gray-500">{doc.source}</span>
</div>
</div>
<motion.div
animate={{ rotate: isExpanded ? 180 : 0 }}
transition={{ duration: prefersReducedMotion ? 0 : 0.2 }}
className="flex-shrink-0 mt-1"
>
<ChevronDown size={16} className="text-gray-400" />
</motion.div>
</div>
</button>
<AnimatePresence initial={false}>
{isExpanded && (
<motion.div
initial={{ height: 0 }}
animate={{ height: 'auto' }}
exit={{ height: 0 }}
transition={{ duration: prefersReducedMotion ? 0 : 0.2, ease: 'easeOut' }}
style={{ overflow: 'hidden' }}
>
<div
className="px-4 pb-4 border-t border-[#E5E7EB] border-l-4"
style={{ borderLeftColor: documentBorderColors[doc.type] }}
>
<div className="pt-3 font-geist text-[12px] text-gray-700 leading-relaxed space-y-0.5">
{fields.map((field, idx) => (
<TreeLine
key={field.label}
label={field.label}
value={field.value}
isLast={idx === fields.length - 1}
/>
))}
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
)
}
export function DocumentsView() {
const [expandedId, setExpandedId] = useState<string | null>(null)
const { isMobile } = useBreakpoint()
const { setExpandedItem } = useAccessibility()
const handleToggle = useCallback((id: string, title: string) => {
const newId = expandedId === id ? null : id
setExpandedId(newId)
setExpandedItem(newId ? title : null)
}, [expandedId, setExpandedItem])
return (
<div className="bg-white border border-[#E5E7EB] rounded shadow-pmr overflow-hidden">
<div className="bg-[#F9FAFB] border-b border-[#E5E7EB] px-4 py-3">
<h2 className="font-ui font-semibold text-[13px] uppercase tracking-[0.05em] text-gray-500">
Attached Documents
</h2>
<p className="font-ui text-[12px] text-gray-400 mt-1">
{documents.length} document{documents.length !== 1 ? 's' : ''} attached. Click a row to view details.
</p>
</div>
{isMobile ? (
<div className="p-3 space-y-3 bg-[#F5F7FA]">
{documents.map((doc) => (
<MobileDocumentCard
key={doc.id}
document={doc}
isExpanded={expandedId === doc.id}
onToggle={() => handleToggle(doc.id, doc.title)}
/>
))}
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full border-collapse">
<thead>
<tr className="bg-[#F9FAFB]">
<th
scope="col"
className="border-b border-r border-[#E5E7EB] px-3 py-2 text-left font-ui font-semibold text-[13px] uppercase tracking-[0.05em] text-gray-400 w-12"
>
Type
</th>
<th
scope="col"
className="border-b border-r border-[#E5E7EB] px-3 py-2 text-left font-ui font-semibold text-[13px] uppercase tracking-[0.05em] text-gray-400"
>
Document
</th>
<th
scope="col"
className="border-b border-r border-[#E5E7EB] px-3 py-2 text-left font-ui font-semibold text-[13px] uppercase tracking-[0.05em] text-gray-400 w-20"
>
Date
</th>
<th
scope="col"
className="border-b border-[#E5E7EB] px-3 py-2 text-left font-ui font-semibold text-[13px] uppercase tracking-[0.05em] text-gray-400 w-32"
>
Source
</th>
</tr>
</thead>
<tbody>
{documents.map((doc, index) => (
<DocumentRow
key={doc.id}
document={doc}
isExpanded={expandedId === doc.id}
onToggle={() => handleToggle(doc.id, doc.title)}
index={index}
/>
))}
</tbody>
</table>
</div>
)}
</div>
)
}
-390
View File
@@ -1,390 +0,0 @@
import { useState, useCallback } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { ChevronDown, ExternalLink } from 'lucide-react'
import { investigations } from '@/data/investigations'
import type { Investigation } from '@/types/pmr'
import { useBreakpoint } from '@/hooks/useBreakpoint'
import { useAccessibility } from '@/contexts/AccessibilityContext'
type InvestigationStatus = 'Complete' | 'Ongoing' | 'Live'
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
function StatusBadge({ status }: { status: InvestigationStatus }) {
const styles: Record<InvestigationStatus, { badge: string; dot: string; label: string }> = {
Complete: {
badge: 'bg-emerald-100 text-emerald-800 border-emerald-200',
dot: 'bg-emerald-500',
label: 'Complete',
},
Ongoing: {
badge: 'bg-amber-100 text-amber-800 border-amber-200',
dot: 'bg-amber-500',
label: 'Ongoing',
},
Live: {
badge: 'bg-emerald-100 text-emerald-800 border-emerald-200',
dot: 'bg-emerald-500',
label: 'Live',
},
}
const { badge, dot, label } = styles[status]
return (
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded text-xs font-medium border ${badge}`}>
<span className="relative flex h-1.5 w-1.5">
{status === 'Live' && (
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75" />
)}
<span className={`relative inline-flex rounded-full h-1.5 w-1.5 ${dot}`} />
</span>
{label}
</span>
)
}
interface TreeLineProps {
label: string
value: React.ReactNode
isLast?: boolean
}
function TreeLine({ label, value, isLast = false }: TreeLineProps) {
return (
<div className="flex">
<span className="text-gray-400 select-none">{isLast ? '└─ ' : '├─ '}</span>
<span className="text-gray-500 shrink-0 min-w-[160px]">{label}:</span>
<span className="ml-2 flex-1">{value}</span>
</div>
)
}
function TreeBranch({ label, children, isLast = false }: { label: string; children: React.ReactNode; isLast?: boolean }) {
return (
<div>
<div className="flex">
<span className="text-gray-400 select-none">{isLast ? '└─ ' : '├─ '}</span>
<span className="text-gray-500 shrink-0 min-w-[160px]">{label}:</span>
</div>
<div className="ml-[18px]">
{children}
</div>
</div>
)
}
function InvestigationRow({
investigation,
isExpanded,
onToggle,
index,
}: {
investigation: Investigation
isExpanded: boolean
onToggle: () => void
index: number
}) {
const statusBorderColor: Record<InvestigationStatus, string> = {
Complete: '#10B981',
Ongoing: '#F59E0B',
Live: '#10B981',
}
return (
<>
<tr
className={`cursor-pointer transition-colors h-[40px] ${
isExpanded ? 'bg-[#EFF6FF]' : index % 2 === 0 ? 'bg-white' : 'bg-[#F9FAFB]'
} hover:bg-[#EFF6FF]`}
onClick={onToggle}
tabIndex={0}
role="button"
aria-expanded={isExpanded}
aria-label={`${investigation.name}${investigation.status}`}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
onToggle()
}
}}
>
<td className="border-b border-r border-[#E5E7EB] px-3 py-2">
<div className="flex items-center gap-2">
<motion.div
animate={{ rotate: isExpanded ? 180 : 0 }}
transition={{ duration: prefersReducedMotion ? 0 : 0.2 }}
>
<ChevronDown className="w-4 h-4 text-gray-400" />
</motion.div>
<span className="font-ui text-[14px] text-gray-900">{investigation.name}</span>
</div>
</td>
<td className="border-b border-r border-[#E5E7EB] px-3 py-2">
<span className="font-geist text-[13px] text-gray-500">{investigation.requestedYear}</span>
</td>
<td className="border-b border-r border-[#E5E7EB] px-3 py-2">
<StatusBadge status={investigation.status} />
</td>
<td className="border-b border-[#E5E7EB] px-3 py-2">
<span className="font-ui text-[13px] text-gray-700">{investigation.resultSummary}</span>
</td>
</tr>
<AnimatePresence initial={false}>
{isExpanded && (
<motion.tr
initial={{ height: 0 }}
animate={{ height: 'auto' }}
exit={{ height: 0 }}
transition={{ duration: prefersReducedMotion ? 0 : 0.2, ease: 'easeOut' }}
>
<td colSpan={4} className="p-0 border-b border-[#E5E7EB]">
<motion.div
initial={{ height: 0 }}
animate={{ height: 'auto' }}
exit={{ height: 0 }}
transition={{ duration: prefersReducedMotion ? 0 : 0.2, ease: 'easeOut' }}
style={{ overflow: 'hidden' }}
>
<div
className="bg-[#F9FAFB] p-4 border-l-4"
style={{ borderLeftColor: statusBorderColor[investigation.status] }}
>
<div className="font-geist text-[12px] text-gray-700 leading-relaxed space-y-0.5">
<TreeLine label="Date Requested" value={String(investigation.requestedYear)} />
<TreeLine label="Date Reported" value={investigation.reportedYear ? String(investigation.reportedYear) : 'Pending'} />
<TreeLine
label="Status"
value={
<>
{investigation.status}
{investigation.status === 'Live' && investigation.externalUrl && (
<> Live at {investigation.externalUrl.replace('https://', '')}</>
)}
</>
}
/>
<TreeLine label="Requesting Clinician" value={investigation.requestingClinician} />
<TreeLine label="Methodology" value={investigation.methodology} />
<TreeBranch label="Results">
{investigation.results.map((result, idx) => (
<div key={idx} className="flex">
<span className="text-gray-400 select-none">{idx === investigation.results.length - 1 ? '└─ ' : '├─ '}</span>
<span>{result}</span>
</div>
))}
</TreeBranch>
<TreeLine label="Tech Stack" value={investigation.techStack.join(', ')} isLast={!investigation.externalUrl} />
{investigation.externalUrl && (
<div className="flex items-center pt-2">
<span className="text-gray-400 select-none"> </span>
<a
href={investigation.externalUrl}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="inline-flex items-center gap-2 px-4 py-1.5 bg-[#005EB8] text-white text-[12px] font-medium rounded hover:bg-[#004D9F] transition-colors focus-visible:ring-2 focus-visible:ring-[#005EB8]/40 focus-visible:outline-none"
>
View Results
<ExternalLink className="w-3.5 h-3.5" />
</a>
</div>
)}
</div>
</div>
</motion.div>
</td>
</motion.tr>
)}
</AnimatePresence>
</>
)
}
function MobileInvestigationCard({
investigation,
isExpanded,
onToggle,
}: {
investigation: Investigation
isExpanded: boolean
onToggle: () => void
}) {
const statusBorderColor: Record<InvestigationStatus, string> = {
Complete: '#10B981',
Ongoing: '#F59E0B',
Live: '#10B981',
}
return (
<div className="bg-white border border-[#E5E7EB] rounded shadow-pmr overflow-hidden">
<button
type="button"
onClick={onToggle}
className="w-full p-4 text-left focus-visible:ring-2 focus-visible:ring-[#005EB8]/40 focus-visible:ring-inset focus-visible:outline-none"
aria-expanded={isExpanded}
aria-label={`${investigation.name}${investigation.status}`}
>
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<h3 className="font-ui font-medium text-[14px] text-gray-900">
{investigation.name}
</h3>
<div className="flex items-center gap-3 mt-1.5">
<span className="font-geist text-[12px] text-gray-500">{investigation.requestedYear}</span>
<StatusBadge status={investigation.status} />
</div>
<p className="font-ui text-[12px] text-gray-700 mt-2 line-clamp-2">
{investigation.resultSummary}
</p>
</div>
<motion.div
animate={{ rotate: isExpanded ? 180 : 0 }}
transition={{ duration: prefersReducedMotion ? 0 : 0.2 }}
className="flex-shrink-0 mt-1"
>
<ChevronDown size={16} className="text-gray-400" />
</motion.div>
</div>
</button>
<AnimatePresence initial={false}>
{isExpanded && (
<motion.div
initial={{ height: 0 }}
animate={{ height: 'auto' }}
exit={{ height: 0 }}
transition={{ duration: prefersReducedMotion ? 0 : 0.2, ease: 'easeOut' }}
style={{ overflow: 'hidden' }}
>
<div
className="px-4 pb-4 border-t border-[#E5E7EB] border-l-4"
style={{ borderLeftColor: statusBorderColor[investigation.status] }}
>
<div className="pt-3 font-geist text-[12px] text-gray-700 leading-relaxed space-y-0.5">
<TreeLine label="Date Requested" value={String(investigation.requestedYear)} />
<TreeLine label="Date Reported" value={investigation.reportedYear ? String(investigation.reportedYear) : 'Pending'} />
<TreeLine
label="Status"
value={
<>
{investigation.status}
{investigation.status === 'Live' && investigation.externalUrl && (
<> Live at {investigation.externalUrl.replace('https://', '')}</>
)}
</>
}
/>
<TreeLine label="Clinician" value={investigation.requestingClinician} />
<TreeLine label="Methodology" value={investigation.methodology} />
<TreeBranch label="Results">
{investigation.results.map((result, idx) => (
<div key={idx} className="flex">
<span className="text-gray-400 select-none">{idx === investigation.results.length - 1 ? '└─ ' : '├─ '}</span>
<span>{result}</span>
</div>
))}
</TreeBranch>
<TreeLine label="Tech Stack" value={investigation.techStack.join(', ')} isLast={!investigation.externalUrl} />
</div>
{investigation.externalUrl && (
<div className="mt-3">
<a
href={investigation.externalUrl}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="inline-flex items-center gap-2 px-4 py-1.5 bg-[#005EB8] text-white text-[12px] font-medium rounded hover:bg-[#004D9F] transition-colors focus-visible:ring-2 focus-visible:ring-[#005EB8]/40 focus-visible:outline-none"
>
View Results
<ExternalLink className="w-3 h-3" />
</a>
</div>
)}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
)
}
export function InvestigationsView() {
const [expandedId, setExpandedId] = useState<string | null>(null)
const { isMobile } = useBreakpoint()
const { setExpandedItem } = useAccessibility()
const handleToggle = useCallback((id: string, name: string) => {
const newId = expandedId === id ? null : id
setExpandedId(newId)
setExpandedItem(newId ? name : null)
}, [expandedId, setExpandedItem])
return (
<div className="bg-white border border-[#E5E7EB] rounded shadow-pmr overflow-hidden">
<div className="bg-[#F9FAFB] border-b border-[#E5E7EB] px-4 py-3">
<h2 className="font-ui font-semibold text-[13px] uppercase tracking-[0.05em] text-gray-500">
Investigation Results
</h2>
<p className="font-ui text-[12px] text-gray-400 mt-1">
{investigations.length} investigation{investigations.length !== 1 ? 's' : ''} on record. Click a row to view full results.
</p>
</div>
{isMobile ? (
<div className="p-3 space-y-3 bg-[#F5F7FA]">
{investigations.map((investigation) => (
<MobileInvestigationCard
key={investigation.id}
investigation={investigation}
isExpanded={expandedId === investigation.id}
onToggle={() => handleToggle(investigation.id, investigation.name)}
/>
))}
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full border-collapse">
<thead>
<tr className="bg-[#F9FAFB]">
<th
scope="col"
className="border-b border-r border-[#E5E7EB] px-3 py-2 text-left font-ui font-semibold text-[13px] uppercase tracking-[0.05em] text-gray-400"
>
Test Name
</th>
<th
scope="col"
className="border-b border-r border-[#E5E7EB] px-3 py-2 text-left font-ui font-semibold text-[13px] uppercase tracking-[0.05em] text-gray-400 w-24"
>
Requested
</th>
<th
scope="col"
className="border-b border-r border-[#E5E7EB] px-3 py-2 text-left font-ui font-semibold text-[13px] uppercase tracking-[0.05em] text-gray-400 w-28"
>
Status
</th>
<th
scope="col"
className="border-b border-[#E5E7EB] px-3 py-2 text-left font-ui font-semibold text-[13px] uppercase tracking-[0.05em] text-gray-400"
>
Result
</th>
</tr>
</thead>
<tbody>
{investigations.map((investigation, index) => (
<InvestigationRow
key={investigation.id}
investigation={investigation}
isExpanded={expandedId === investigation.id}
onToggle={() => handleToggle(investigation.id, investigation.name)}
index={index}
/>
))}
</tbody>
</table>
</div>
)}
</div>
)
}
-433
View File
@@ -1,433 +0,0 @@
import { useState, useMemo } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { ChevronDown, ChevronUp, ChevronsUpDown } from 'lucide-react'
import { medications } from '@/data/medications'
import type { Medication } from '@/types/pmr'
import { useBreakpoint } from '@/hooks/useBreakpoint'
import { useAccessibility } from '@/contexts/AccessibilityContext'
type SortField = 'name' | 'dose' | 'frequency' | 'startYear' | 'status'
type SortDirection = 'asc' | 'desc' | null
interface SortState {
field: SortField
direction: SortDirection
}
type CategoryId = 'Active' | 'Clinical' | 'PRN'
const categoryTabs: { id: CategoryId; label: string; shortLabel: string }[] = [
{ id: 'Active', label: 'Active Medications', shortLabel: 'Active' },
{ id: 'Clinical', label: 'Clinical Medications', shortLabel: 'Clinical' },
{ id: 'PRN', label: 'PRN (As Required)', shortLabel: 'PRN' },
]
const categoryCounts: Record<CategoryId, number> = {
Active: medications.filter(m => m.category === 'Active').length,
Clinical: medications.filter(m => m.category === 'Clinical').length,
PRN: medications.filter(m => m.category === 'PRN').length,
}
const prefersReducedMotion = typeof window !== 'undefined'
? window.matchMedia('(prefers-reduced-motion: reduce)').matches
: false
export function MedicationsView() {
const [activeTab, setActiveTab] = useState<CategoryId>('Active')
const [expandedRow, setExpandedRow] = useState<string | null>(null)
const [sort, setSort] = useState<SortState>({ field: 'name', direction: null })
const { isMobile } = useBreakpoint()
const { setExpandedItem } = useAccessibility()
const filteredMedications = useMemo(() => {
return medications.filter(med => med.category === activeTab)
}, [activeTab])
const sortedMedications = useMemo(() => {
if (!sort.direction) return filteredMedications
return [...filteredMedications].sort((a, b) => {
let comparison = 0
switch (sort.field) {
case 'name':
comparison = a.name.localeCompare(b.name)
break
case 'dose':
comparison = a.dose - b.dose
break
case 'frequency': {
const freqOrder: Record<string, number> = { 'Daily': 0, 'Weekly': 1, 'Monthly': 2, 'As needed': 3 }
comparison = (freqOrder[a.frequency] ?? 4) - (freqOrder[b.frequency] ?? 4)
break
}
case 'startYear':
comparison = a.startYear - b.startYear
break
case 'status':
comparison = a.status.localeCompare(b.status)
break
}
return sort.direction === 'asc' ? comparison : -comparison
})
}, [filteredMedications, sort])
const handleSort = (field: SortField) => {
if (sort.field === field) {
if (sort.direction === 'asc') {
setSort({ field, direction: 'desc' })
} else if (sort.direction === 'desc') {
setSort({ field, direction: null })
} else {
setSort({ field, direction: 'asc' })
}
} else {
setSort({ field, direction: 'asc' })
}
}
const toggleRow = (id: string, name: string) => {
const nextExpanded = expandedRow === id ? null : id
setExpandedRow(nextExpanded)
setExpandedItem(nextExpanded ? name : null)
}
const SortIndicator = ({ field }: { field: SortField }) => {
if (sort.field !== field || !sort.direction) {
return <ChevronsUpDown className="w-3.5 h-3.5 text-gray-400" />
}
return sort.direction === 'asc'
? <ChevronUp className="w-3.5 h-3.5 text-[#005EB8]" />
: <ChevronDown className="w-3.5 h-3.5 text-[#005EB8]" />
}
return (
<div className="space-y-6">
<div className="bg-white border border-[#E5E7EB] rounded shadow-pmr overflow-hidden">
{/* Header */}
<div className="px-5 py-3 border-b border-[#E5E7EB] bg-[#F9FAFB]">
<h1 className="font-ui font-semibold text-[15px] text-gray-900">
Current Medications
</h1>
<p className="font-ui text-[13px] text-gray-500 mt-0.5">
Skills mapped as active medications proficiency shown as dosage
</p>
</div>
{/* Category Tabs */}
<div className="border-b border-[#E5E7EB]">
<nav className="flex" role="tablist" aria-label="Medication categories">
{categoryTabs.map((tab) => (
<button
key={tab.id}
id={`tab-${tab.id}`}
role="tab"
aria-selected={activeTab === tab.id}
aria-controls={`panel-${tab.id}`}
onClick={() => {
setActiveTab(tab.id)
setExpandedRow(null)
setExpandedItem(null)
}}
className={`
flex-1 px-4 py-2.5 transition-colors duration-100 text-left
border-b-2
${activeTab === tab.id
? 'bg-white border-[#005EB8]'
: 'bg-[#F9FAFB] border-transparent text-gray-600 hover:bg-white'}
`}
>
<span className="flex items-center gap-2">
<span className={`font-ui font-medium text-[14px] ${activeTab === tab.id ? 'text-[#005EB8]' : 'text-gray-600'}`}>
{isMobile ? tab.shortLabel : tab.label}
</span>
<span
className={`
inline-flex items-center justify-center min-w-[20px] h-5 px-1.5 rounded-full text-[11px] font-ui font-medium
${activeTab === tab.id
? 'bg-[#005EB8]/10 text-[#005EB8]'
: 'bg-gray-200 text-gray-500'}
`}
>
{categoryCounts[tab.id]}
</span>
</span>
</button>
))}
</nav>
</div>
{/* Tab Panel */}
<div
id={`panel-${activeTab}`}
role="tabpanel"
aria-labelledby={`tab-${activeTab}`}
>
{isMobile ? (
<MobileMedicationList
medications={sortedMedications}
expandedRow={expandedRow}
onToggle={toggleRow}
/>
) : (
<div className="overflow-x-auto">
<table className="w-full" role="grid">
<thead>
<tr className="border-b border-[#E5E7EB] bg-[#F9FAFB]">
{(['name', 'dose', 'frequency', 'startYear', 'status'] as SortField[]).map((field) => {
const labels: Record<SortField, string> = {
name: 'Drug Name',
dose: 'Dose',
frequency: 'Frequency',
startYear: 'Start',
status: 'Status',
}
return (
<th key={field} scope="col" className="text-left border-r border-[#E5E7EB] last:border-r-0">
<button
type="button"
onClick={() => handleSort(field)}
className="w-full px-4 h-[40px] flex items-center gap-2 hover:bg-[#EFF6FF] transition-colors duration-100"
>
<span className="font-ui font-semibold text-[13px] uppercase tracking-[0.03em] text-gray-400">
{labels[field]}
</span>
<SortIndicator field={field} />
</button>
</th>
)
})}
</tr>
</thead>
<tbody>
{sortedMedications.map((med, index) => (
<MedicationRow
key={med.id}
medication={med}
isExpanded={expandedRow === med.id}
isEven={index % 2 === 1}
onToggle={() => toggleRow(med.id, med.name)}
/>
))}
</tbody>
</table>
</div>
)}
</div>
{/* Footer */}
<div className="px-5 py-3 border-t border-[#E5E7EB] bg-[#F9FAFB]">
<p className="font-ui text-[12px] text-gray-500">
{sortedMedications.length} medications in this category. {isMobile ? 'Tap' : 'Click'} a row to view prescribing history.
</p>
</div>
</div>
</div>
)
}
/* ─── Mobile Card Layout ───────────────────────────────────────────── */
interface MobileMedicationListProps {
medications: Medication[]
expandedRow: string | null
onToggle: (id: string, name: string) => void
}
function MobileMedicationList({ medications, expandedRow, onToggle }: MobileMedicationListProps) {
return (
<div className="divide-y divide-[#E5E7EB]">
{medications.map((med) => {
const isExpanded = expandedRow === med.id
return (
<div key={med.id} className="bg-white">
<button
type="button"
onClick={() => onToggle(med.id, med.name)}
className="w-full p-4 text-left hover:bg-[#EFF6FF] transition-colors duration-100 focus-visible:ring-2 focus-visible:ring-[#005EB8]/40 focus-visible:ring-inset"
aria-expanded={isExpanded}
aria-label={`${med.name}, ${med.dose}% proficiency, ${med.frequency}, since ${med.startYear}`}
>
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<h3 className="font-ui font-medium text-[14px] text-gray-900">
{med.name}
</h3>
<div className="flex items-center gap-3 mt-1.5 font-ui text-[12px] text-gray-500">
<span className="font-geist">{med.dose}%</span>
<span className="text-gray-300">·</span>
<span>{med.frequency}</span>
<span className="text-gray-300">·</span>
<span className="font-geist">Since {med.startYear}</span>
</div>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<StatusDot status={med.status} />
<motion.div
animate={{ rotate: isExpanded ? 180 : 0 }}
transition={{ duration: prefersReducedMotion ? 0 : 0.2 }}
>
<ChevronDown size={16} className="text-gray-400" />
</motion.div>
</div>
</div>
</button>
<AnimatePresence initial={false}>
{isExpanded && (
<motion.div
initial={{ height: 0 }}
animate={{ height: 'auto' }}
exit={{ height: 0 }}
transition={{ duration: prefersReducedMotion ? 0 : 0.2, ease: [0.4, 0, 0.2, 1] }}
className="overflow-hidden"
>
<div className="px-4 pb-4">
<PrescribingHistory history={med.prescribingHistory} />
</div>
</motion.div>
)}
</AnimatePresence>
</div>
)
})}
</div>
)
}
/* ─── Desktop Table Row ────────────────────────────────────────────── */
interface MedicationRowProps {
medication: Medication
isExpanded: boolean
isEven: boolean
onToggle: () => void
}
function MedicationRow({ medication, isExpanded, isEven, onToggle }: MedicationRowProps) {
return (
<>
<tr
className={`
h-[40px] border-b border-[#E5E7EB] cursor-pointer transition-colors duration-100
${isEven ? 'bg-[#F9FAFB]' : 'bg-white'}
hover:bg-[#EFF6FF]
`}
onClick={onToggle}
role="row"
aria-expanded={isExpanded}
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
onToggle()
}
}}
>
<td className="px-4 py-2 border-r border-[#E5E7EB]">
<div className="flex items-center gap-2">
<motion.div
animate={{ rotate: isExpanded ? 180 : 0 }}
transition={{ duration: prefersReducedMotion ? 0 : 0.2 }}
className="flex-shrink-0"
>
<ChevronDown size={14} className="text-gray-400" />
</motion.div>
<span className="font-ui font-medium text-[14px] text-gray-900">
{medication.name}
</span>
</div>
</td>
<td className="px-4 py-2 border-r border-[#E5E7EB]">
<span className="font-geist text-[13px] text-gray-700">
{medication.dose}%
</span>
</td>
<td className="px-4 py-2 border-r border-[#E5E7EB]">
<span className="font-ui text-[13px] text-gray-700">
{medication.frequency}
</span>
</td>
<td className="px-4 py-2 border-r border-[#E5E7EB]">
<span className="font-geist text-[13px] text-gray-700">
{medication.startYear}
</span>
</td>
<td className="px-4 py-2">
<StatusDot status={medication.status} />
</td>
</tr>
<AnimatePresence initial={false}>
{isExpanded && (
<motion.tr
initial={{ height: 0 }}
animate={{ height: 'auto' }}
exit={{ height: 0 }}
transition={{ duration: prefersReducedMotion ? 0 : 0.2, ease: [0.4, 0, 0.2, 1] }}
className="overflow-hidden"
>
<td colSpan={5} className="p-0">
<motion.div
initial={{ height: 0 }}
animate={{ height: 'auto' }}
exit={{ height: 0 }}
transition={{ duration: prefersReducedMotion ? 0 : 0.2, ease: [0.4, 0, 0.2, 1] }}
className="overflow-hidden"
>
<div className="px-6 py-4 bg-[#F9FAFB] border-b border-[#E5E7EB]">
<PrescribingHistory history={medication.prescribingHistory} />
</div>
</motion.div>
</td>
</motion.tr>
)}
</AnimatePresence>
</>
)
}
/* ─── Status Dot ───────────────────────────────────────────────────── */
function StatusDot({ status }: { status: 'Active' | 'Historical' }) {
const color = status === 'Active' ? 'bg-[#22C55E]' : 'bg-gray-400'
return (
<div className="flex items-center gap-2">
<span className={`w-1.5 h-1.5 rounded-full ${color}`} aria-hidden="true" />
<span className="font-ui text-[13px] text-gray-700">{status}</span>
</div>
)
}
/* ─── Prescribing History (shared) ─────────────────────────────────── */
interface PrescribingHistoryProps {
history: { year: number; description: string }[]
}
function PrescribingHistory({ history }: PrescribingHistoryProps) {
return (
<div className="pl-6">
<p className="font-ui font-semibold text-[12px] uppercase tracking-[0.05em] text-gray-400 mb-3">
Prescribing History
</p>
<div className="relative">
{/* Vertical timeline line */}
<div className="absolute left-[18px] top-1 bottom-1 w-px bg-[#E5E7EB]" aria-hidden="true" />
<div className="space-y-2">
{history.map((entry, index) => (
<div key={index} className="flex gap-4 relative">
{/* Timeline dot */}
<div className="relative z-10 flex-shrink-0 mt-1.5">
<span className="block w-2 h-2 rounded-full bg-[#005EB8] ring-2 ring-white" aria-hidden="true" />
</div>
<span className="font-geist font-semibold text-[12px] text-gray-600 w-10 flex-shrink-0 pt-[1px]">
{entry.year}
</span>
<span className="font-geist text-[12px] text-gray-500 pt-[1px]">
{entry.description}
</span>
</div>
))}
</div>
</div>
</div>
)
}
-448
View File
@@ -1,448 +0,0 @@
import { useState, useCallback } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { ChevronDown, ExternalLink } from 'lucide-react'
import { problems } from '@/data/problems'
import { consultations } from '@/data/consultations'
import type { Problem, Consultation } from '@/types/pmr'
import { useBreakpoint } from '@/hooks/useBreakpoint'
import { useAccessibility } from '@/contexts/AccessibilityContext'
interface ProblemsViewProps {
onNavigate?: (view: 'consultations', itemId?: string) => void
}
type ProblemStatus = 'Active' | 'In Progress' | 'Resolved'
function TrafficLight({ status }: { status: ProblemStatus }) {
const colorMap: Record<ProblemStatus, { bg: string; label: string }> = {
Active: { bg: 'bg-green-500', label: 'Active' },
'In Progress': { bg: 'bg-amber-500', label: 'In Progress' },
Resolved: { bg: 'bg-green-500', label: 'Resolved' },
}
const { bg, label } = colorMap[status]
return (
<div className="flex items-center gap-2">
<span
className={`w-2 h-2 rounded-full ${bg}`}
aria-hidden="true"
/>
<span className="font-ui text-xs text-gray-600">{label}</span>
</div>
)
}
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
function ProblemRow({
problem,
isExpanded,
onToggle,
onNavigate,
showOutcome,
}: {
problem: Problem
isExpanded: boolean
onToggle: () => void
onNavigate?: (view: 'consultations', itemId?: string) => void
showOutcome: boolean
}) {
const linkedConsultations = (problem.linkedConsultations ?? [])
.map((id) => consultations.find((c) => c.id === id))
.filter((c): c is Consultation => c !== undefined)
const handleLinkedClick = (consultationId: string) => {
if (onNavigate) {
onNavigate('consultations', consultationId)
}
}
return (
<>
<motion.tr
className={`cursor-pointer hover:bg-[#EFF6FF] transition-colors ${
isExpanded ? 'bg-[#EFF6FF]' : ''
}`}
onClick={onToggle}
aria-expanded={isExpanded}
initial={false}
>
<td className="border border-gray-200 px-3 py-2.5">
<TrafficLight status={problem.status} />
</td>
<td className="border border-gray-200 px-3 py-2.5">
<span className="font-geist text-xs text-gray-500">[{problem.code}]</span>
</td>
<td className="border border-gray-200 px-3 py-2.5">
<span className="font-ui text-[14px] text-gray-900">{problem.description}</span>
</td>
<td className="border border-gray-200 px-3 py-2.5">
<span className="font-geist text-xs text-gray-500">
{problem.resolved || problem.since}
</span>
</td>
{showOutcome && (
<td className="border border-gray-200 px-3 py-2.5">
{problem.outcome && (
<span className="font-ui text-[13px] text-gray-700">{problem.outcome}</span>
)}
</td>
)}
<td className="border border-gray-200 px-3 py-2.5 w-10">
<motion.div
animate={{ rotate: isExpanded ? 180 : 0 }}
transition={{ duration: prefersReducedMotion ? 0 : 0.2 }}
className="inline-block"
>
<button
className="p-1 hover:bg-gray-100 rounded transition-colors"
aria-label={isExpanded ? 'Collapse' : 'Expand'}
>
<ChevronDown className="w-4 h-4 text-gray-400" />
</button>
</motion.div>
</td>
</motion.tr>
<AnimatePresence initial={false}>
{isExpanded && (
<motion.tr
key={`${problem.id}-expanded`}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: prefersReducedMotion ? 0 : 0.2, ease: 'easeOut' }}
>
<td colSpan={showOutcome ? 6 : 5} className="p-0 border border-gray-200">
<motion.div
initial={{ height: 0 }}
animate={{ height: 'auto' }}
exit={{ height: 0 }}
transition={{ duration: prefersReducedMotion ? 0 : 0.2, ease: 'easeOut' }}
style={{ overflow: 'hidden' }}
>
<div className="bg-gray-50 p-4">
<div className="font-ui text-[14px] text-gray-700 leading-relaxed mb-4">
{problem.narrative}
</div>
{linkedConsultations.length > 0 && (
<div>
<span className="font-ui text-xs font-semibold text-gray-400 uppercase tracking-wider">
Linked Consultations:
</span>
<div className="mt-2 flex flex-wrap gap-2">
{linkedConsultations.map((consultation) => (
<button
key={consultation.id}
onClick={(e) => {
e.stopPropagation()
handleLinkedClick(consultation.id)
}}
className="inline-flex items-center gap-1 text-xs text-pmr-nhsblue hover:underline focus-visible:ring-2 focus-visible:ring-pmr-nhsblue/40"
>
<ExternalLink className="w-3 h-3" />
{consultation.organization} {consultation.role}
</button>
))}
</div>
</div>
)}
</div>
</motion.div>
</td>
</motion.tr>
)}
</AnimatePresence>
</>
)
}
function MobileProblemCard({
problem,
isExpanded,
onToggle,
onNavigate,
showOutcome,
}: {
problem: Problem
isExpanded: boolean
onToggle: () => void
onNavigate?: (view: 'consultations', itemId?: string) => void
showOutcome: boolean
}) {
const linkedConsultations = (problem.linkedConsultations ?? [])
.map((id) => consultations.find((c) => c.id === id))
.filter((c): c is Consultation => c !== undefined)
const handleLinkedClick = (consultationId: string) => {
if (onNavigate) {
onNavigate('consultations', consultationId)
}
}
return (
<div className="bg-white border border-gray-200 rounded shadow-pmr">
<button
type="button"
onClick={onToggle}
className="w-full p-4 text-left focus-visible:ring-2 focus-visible:ring-pmr-nhsblue/40"
aria-expanded={isExpanded}
>
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<TrafficLight status={problem.status} />
<span className="font-geist text-xs text-gray-500">[{problem.code}]</span>
</div>
<h3 className="font-ui font-medium text-[14px] text-gray-900">
{problem.description}
</h3>
<div className="flex items-center gap-2 mt-1.5 text-xs text-gray-500 font-ui">
<span>{showOutcome ? 'Resolved' : 'Since'}: {problem.resolved || problem.since}</span>
{showOutcome && problem.outcome && (
<>
<span></span>
<span className="text-gray-700">{problem.outcome}</span>
</>
)}
</div>
</div>
<motion.div
animate={{ rotate: isExpanded ? 180 : 0 }}
transition={{ duration: prefersReducedMotion ? 0 : 0.2 }}
className="flex-shrink-0 mt-1"
>
<ChevronDown size={16} className="text-gray-400" />
</motion.div>
</div>
</button>
<AnimatePresence initial={false}>
{isExpanded && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: prefersReducedMotion ? 0 : 0.2, ease: 'easeOut' }}
style={{ overflow: 'hidden' }}
className="border-t border-gray-100"
>
<div className="px-4 pb-4">
<div className="pt-3 font-ui text-[14px] text-gray-700 leading-relaxed">
{problem.narrative}
</div>
{linkedConsultations.length > 0 && (
<div className="mt-3">
<span className="font-ui text-xs font-semibold text-gray-400 uppercase tracking-wider">
Linked Consultations:
</span>
<div className="mt-2 flex flex-wrap gap-2">
{linkedConsultations.map((consultation) => (
<button
key={consultation.id}
onClick={(e) => {
e.stopPropagation()
handleLinkedClick(consultation.id)
}}
className="inline-flex items-center gap-1 text-xs text-pmr-nhsblue hover:underline focus-visible:ring-2 focus-visible:ring-pmr-nhsblue/40"
>
<ExternalLink className="w-3 h-3" />
{consultation.organization} {consultation.role}
</button>
))}
</div>
</div>
)}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
)
}
export function ProblemsView({ onNavigate }: ProblemsViewProps) {
const [expandedId, setExpandedId] = useState<string | null>(null)
const { isMobile } = useBreakpoint()
const { setExpandedItem } = useAccessibility()
const activeProblems = problems.filter(
(p) => p.status === 'Active' || p.status === 'In Progress'
)
const resolvedProblems = problems.filter((p) => p.status === 'Resolved')
const handleToggle = useCallback(
(id: string) => {
const newExpandedId = expandedId === id ? null : id
setExpandedId(newExpandedId)
// Update breadcrumb context - pass the problem description as the expanded item ID
if (newExpandedId) {
const problem = problems.find((p) => p.id === newExpandedId)
if (problem) {
setExpandedItem(problem.description)
}
} else {
setExpandedItem(null)
}
},
[expandedId, setExpandedItem]
)
return (
<div className="space-y-6">
<div className="bg-white border border-gray-200 rounded overflow-hidden shadow-pmr">
<div className="bg-gray-50 border-b border-gray-200 px-4 py-3">
<h2 className="font-ui font-semibold text-[13px] uppercase tracking-wider text-gray-500">
Active Problems
</h2>
</div>
{isMobile ? (
<div className="p-3 space-y-3 bg-pmr-content">
{activeProblems.map((problem) => (
<MobileProblemCard
key={problem.id}
problem={problem}
isExpanded={expandedId === problem.id}
onToggle={() => handleToggle(problem.id)}
onNavigate={onNavigate}
showOutcome={false}
/>
))}
</div>
) : (
<table className="w-full border-collapse">
<thead>
<tr className="bg-gray-50">
<th
scope="col"
className="border border-gray-200 px-3 py-2 text-left font-ui font-semibold text-[13px] uppercase tracking-wider text-gray-400 w-28"
>
Status
</th>
<th
scope="col"
className="border border-gray-200 px-3 py-2 text-left font-ui font-semibold text-[13px] uppercase tracking-wider text-gray-400 w-28"
>
Code
</th>
<th
scope="col"
className="border border-gray-200 px-3 py-2 text-left font-ui font-semibold text-[13px] uppercase tracking-wider text-gray-400"
>
Problem
</th>
<th
scope="col"
className="border border-gray-200 px-3 py-2 text-left font-ui font-semibold text-[13px] uppercase tracking-wider text-gray-400 w-28"
>
Since
</th>
<th
scope="col"
className="border border-gray-200 px-3 py-2 text-left font-ui font-semibold text-[13px] uppercase tracking-wider text-gray-400 w-10"
>
<span className="sr-only">Expand</span>
</th>
</tr>
</thead>
<tbody>
{activeProblems.map((problem) => (
<ProblemRow
key={problem.id}
problem={problem}
isExpanded={expandedId === problem.id}
onToggle={() => handleToggle(problem.id)}
onNavigate={onNavigate}
showOutcome={false}
/>
))}
</tbody>
</table>
)}
{activeProblems.length === 0 && (
<div className="p-4 font-ui text-[14px] text-gray-500 text-center">No active problems</div>
)}
</div>
<div className="bg-white border border-gray-200 rounded overflow-hidden shadow-pmr">
<div className="bg-gray-50 border-b border-gray-200 px-4 py-3">
<h2 className="font-ui font-semibold text-[13px] uppercase tracking-wider text-gray-500">
Resolved Problems
</h2>
</div>
{isMobile ? (
<div className="p-3 space-y-3 bg-pmr-content">
{resolvedProblems.map((problem) => (
<MobileProblemCard
key={problem.id}
problem={problem}
isExpanded={expandedId === problem.id}
onToggle={() => handleToggle(problem.id)}
onNavigate={onNavigate}
showOutcome={true}
/>
))}
</div>
) : (
<table className="w-full border-collapse">
<thead>
<tr className="bg-gray-50">
<th
scope="col"
className="border border-gray-200 px-3 py-2 text-left font-ui font-semibold text-[13px] uppercase tracking-wider text-gray-400 w-28"
>
Status
</th>
<th
scope="col"
className="border border-gray-200 px-3 py-2 text-left font-ui font-semibold text-[13px] uppercase tracking-wider text-gray-400 w-28"
>
Code
</th>
<th
scope="col"
className="border border-gray-200 px-3 py-2 text-left font-ui font-semibold text-[13px] uppercase tracking-wider text-gray-400"
>
Problem
</th>
<th
scope="col"
className="border border-gray-200 px-3 py-2 text-left font-ui font-semibold text-[13px] uppercase tracking-wider text-gray-400 w-28"
>
Resolved
</th>
<th
scope="col"
className="border border-gray-200 px-3 py-2 text-left font-ui font-semibold text-[13px] uppercase tracking-wider text-gray-400"
>
Outcome
</th>
<th
scope="col"
className="border border-gray-200 px-3 py-2 text-left font-ui font-semibold text-[13px] uppercase tracking-wider text-gray-400 w-10"
>
<span className="sr-only">Expand</span>
</th>
</tr>
</thead>
<tbody>
{resolvedProblems.map((problem) => (
<ProblemRow
key={problem.id}
problem={problem}
isExpanded={expandedId === problem.id}
onToggle={() => handleToggle(problem.id)}
onNavigate={onNavigate}
showOutcome={true}
/>
))}
</tbody>
</table>
)}
{resolvedProblems.length === 0 && (
<div className="p-4 font-ui text-[14px] text-gray-500 text-center">No resolved problems</div>
)}
</div>
</div>
)
}
-487
View File
@@ -1,487 +0,0 @@
import { useState } from 'react'
import { Send, Mail, Phone, MapPin, ExternalLink, Loader2, CheckCircle } from 'lucide-react'
import { patient } from '@/data/patient'
type Priority = 'urgent' | 'routine' | 'two-week-wait'
type ContactMethod = 'email' | 'phone' | 'linkedin'
interface FormData {
priority: Priority
referrerName: string
referrerEmail: string
referrerOrg: string
reason: string
contactMethod: ContactMethod
}
interface FormErrors {
referrerName?: string
referrerEmail?: string
}
const prefersReducedMotion =
typeof window !== 'undefined'
? window.matchMedia('(prefers-reduced-motion: reduce)').matches
: false
function generateRefNumber(): string {
const now = new Date()
const year = now.getFullYear()
const month = String(now.getMonth() + 1).padStart(2, '0')
const day = String(now.getDate()).padStart(2, '0')
const seq = String(Math.floor(Math.random() * 999) + 1).padStart(3, '0')
return `REF-${year}-${month}${day}-${seq}`
}
function PriorityOption({
value,
label,
selected,
tooltip,
onSelect,
}: {
value: Priority
label: string
selected: boolean
tooltip: string
onSelect: () => void
}) {
const dotColors: Record<Priority, string> = {
urgent: 'bg-red-500',
routine: 'bg-pmr-nhsblue',
'two-week-wait': 'bg-amber-500',
}
const labelColors: Record<Priority, string> = {
urgent: 'text-red-600',
routine: 'text-pmr-nhsblue',
'two-week-wait': 'text-amber-600',
}
return (
<label className="flex items-center gap-2 cursor-pointer group relative">
<input
type="radio"
name="priority"
value={value}
checked={selected}
onChange={onSelect}
className="sr-only"
/>
<span
className={`w-4 h-4 rounded-full border-2 flex items-center justify-center transition-colors ${
selected ? 'border-current' : 'border-gray-300'
}`}
>
{selected && <span className={`w-2 h-2 rounded-full ${dotColors[value]}`} />}
</span>
<span className={`font-ui text-sm font-medium ${labelColors[value]}`}>{label}</span>
<span
className="absolute left-0 bottom-full mb-2 px-2 py-1 bg-gray-900 text-white text-xs font-ui rounded opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none whitespace-nowrap z-10"
role="tooltip"
>
{tooltip}
</span>
</label>
)
}
function ContactMethodOption({
value,
label,
selected,
onSelect,
}: {
value: ContactMethod
label: string
selected: boolean
onSelect: () => void
}) {
return (
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="contactMethod"
value={value}
checked={selected}
onChange={onSelect}
className="sr-only"
/>
<span
className={`w-4 h-4 rounded-full border-2 flex items-center justify-center transition-colors ${
selected ? 'border-pmr-nhsblue' : 'border-gray-300'
}`}
>
{selected && <span className="w-2 h-2 rounded-full bg-pmr-nhsblue" />}
</span>
<span className="font-ui text-sm text-gray-700">{label}</span>
</label>
)
}
function FormField({
label,
id,
required,
error,
children,
}: {
label: string
id: string
required?: boolean
error?: string
children: React.ReactNode
}) {
return (
<div className="space-y-1">
<label htmlFor={id} className="block font-ui font-medium text-[13px] text-gray-600">
{label}
{required && <span className="text-red-500 ml-0.5">*</span>}
</label>
{children}
{error && <p className="font-ui text-xs text-red-600 mt-1">{error}</p>}
</div>
)
}
function DirectContactTable() {
const contactMethods = [
{
label: 'Email',
value: patient.email,
href: `mailto:${patient.email}`,
action: 'Send Email',
icon: Mail,
},
{
label: 'Phone',
value: patient.phone,
href: `tel:${patient.phone}`,
action: 'Call',
icon: Phone,
},
{
label: 'LinkedIn',
value: patient.linkedin,
href: `https://${patient.linkedin}`,
action: 'View Profile',
icon: ExternalLink,
external: true,
},
{
label: 'Location',
value: 'Norwich, UK',
href: null,
action: null,
icon: MapPin,
},
]
return (
<div className="bg-white border border-[#E5E7EB] rounded shadow-pmr">
<div className="bg-[#F9FAFB] border-b border-[#E5E7EB] px-4 py-3">
<h3 className="font-ui font-semibold text-sm uppercase tracking-wider text-gray-500">
Direct Contact
</h3>
</div>
<div className="divide-y divide-[#E5E7EB]">
{contactMethods.map((method) => (
<div key={method.label} className="flex items-center justify-between px-4 py-3 hover:bg-[#EFF6FF] transition-colors">
<div className="flex items-center gap-3">
<method.icon className="w-4 h-4 text-gray-400" />
<span className="font-ui text-sm text-gray-500 w-20">{method.label}</span>
{method.href ? (
<a
href={method.href}
target={method.external ? '_blank' : undefined}
rel={method.external ? 'noopener noreferrer' : undefined}
className="font-geist text-sm text-pmr-nhsblue hover:underline focus-visible:ring-2 focus-visible:ring-pmr-nhsblue/40 focus-visible:outline-none rounded"
>
{method.value}
</a>
) : (
<span className="font-geist text-sm text-gray-900">{method.value}</span>
)}
</div>
{method.href && (
<a
href={method.href}
target={method.external ? '_blank' : undefined}
rel={method.external ? 'noopener noreferrer' : undefined}
className="font-ui text-xs text-pmr-nhsblue hover:underline flex items-center gap-1 focus-visible:ring-2 focus-visible:ring-pmr-nhsblue/40 focus-visible:outline-none rounded"
>
{method.action}
{method.external && <ExternalLink className="w-3 h-3" />}
</a>
)}
</div>
))}
</div>
</div>
)
}
export function ReferralsView() {
const [formData, setFormData] = useState<FormData>({
priority: 'routine',
referrerName: '',
referrerEmail: '',
referrerOrg: '',
reason: '',
contactMethod: 'email',
})
const [errors, setErrors] = useState<FormErrors>({})
const [isSubmitting, setIsSubmitting] = useState(false)
const [isSuccess, setIsSuccess] = useState(false)
const [refNumber, setRefNumber] = useState('')
const validateForm = (): boolean => {
const newErrors: FormErrors = {}
if (!formData.referrerName.trim()) {
newErrors.referrerName = 'Referrer name is required'
}
if (!formData.referrerEmail.trim()) {
newErrors.referrerEmail = 'Referrer email is required'
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.referrerEmail)) {
newErrors.referrerEmail = 'Please enter a valid email address'
}
setErrors(newErrors)
return Object.keys(newErrors).length === 0
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!validateForm()) return
setIsSubmitting(true)
await new Promise((resolve) => setTimeout(resolve, 1000))
setRefNumber(generateRefNumber())
setIsSubmitting(false)
setIsSuccess(true)
}
const handleReset = () => {
setFormData({
priority: 'routine',
referrerName: '',
referrerEmail: '',
referrerOrg: '',
reason: '',
contactMethod: 'email',
})
setErrors({})
setIsSuccess(false)
setRefNumber('')
}
if (isSuccess) {
return (
<div className="space-y-6">
<div className="bg-white border border-[#E5E7EB] rounded shadow-pmr">
<div className="bg-[#F9FAFB] border-b border-[#E5E7EB] px-4 py-3">
<h2 className="font-ui font-semibold text-sm uppercase tracking-wider text-gray-500">
New Referral
</h2>
</div>
<div className="p-8 text-center">
<div
className={`inline-flex items-center justify-center w-16 h-16 rounded-full bg-green-100 mb-4 ${
prefersReducedMotion ? '' : 'animate-[fadeIn_200ms_ease-out]'
}`}
>
<CheckCircle className="w-8 h-8 text-green-600" />
</div>
<h3 className="font-ui font-semibold text-lg text-gray-900 mb-2">
Referral sent successfully
</h3>
<p className="font-geist text-sm text-gray-500 mb-1">Reference: {refNumber}</p>
<p className="font-ui text-sm text-gray-500 mb-6">
Expected response time: 24-48 hours
</p>
<button
onClick={handleReset}
className="font-ui font-medium text-sm px-4 py-2 bg-pmr-nhsblue text-white rounded hover:bg-[#004D9F] transition-colors focus-visible:ring-2 focus-visible:ring-pmr-nhsblue/40 focus-visible:outline-none"
>
Send Another Referral
</button>
</div>
</div>
<DirectContactTable />
</div>
)
}
return (
<div className="space-y-6">
<div className="bg-white border border-[#E5E7EB] rounded shadow-pmr">
<div className="bg-[#F9FAFB] border-b border-[#E5E7EB] px-4 py-3">
<h2 className="font-ui font-semibold text-sm uppercase tracking-wider text-gray-500">
New Referral
</h2>
<p className="font-ui text-xs text-gray-400 mt-1">
Contact Andy using a clinical referral form format.
</p>
</div>
<form onSubmit={handleSubmit} className="p-4 space-y-6">
<div className="grid grid-cols-2 gap-6">
<div className="space-y-1">
<span className="block font-ui font-medium text-[13px] text-gray-600">
Referring to
</span>
<span className="font-ui text-sm text-gray-900">{patient.name}</span>
</div>
<div className="space-y-1">
<span className="block font-ui font-medium text-[13px] text-gray-600">
NHS Number
</span>
<span className="font-geist text-sm text-gray-900">{patient.nhsNumber}</span>
</div>
</div>
<div className="space-y-2">
<span className="block font-ui font-medium text-[13px] text-gray-600">
Priority
</span>
<div className="flex gap-6">
<PriorityOption
value="urgent"
label="Urgent"
selected={formData.priority === 'urgent'}
tooltip="All enquiries are welcome, urgent or not."
onSelect={() => setFormData({ ...formData, priority: 'urgent' })}
/>
<PriorityOption
value="routine"
label="Routine"
selected={formData.priority === 'routine'}
tooltip="Standard response timeframe."
onSelect={() => setFormData({ ...formData, priority: 'routine' })}
/>
<PriorityOption
value="two-week-wait"
label="Two-Week Wait"
selected={formData.priority === 'two-week-wait'}
tooltip="NHS cancer referral pathway — this isn't that, but the spirit of promptness applies."
onSelect={() => setFormData({ ...formData, priority: 'two-week-wait' })}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<FormField
label="Referrer Name"
id="referrerName"
required
error={errors.referrerName}
>
<input
type="text"
id="referrerName"
value={formData.referrerName}
onChange={(e) => setFormData({ ...formData, referrerName: e.target.value })}
className="w-full border border-[#D1D5DB] rounded px-3 py-2 text-sm font-ui text-gray-900 placeholder-gray-400 focus:border-pmr-nhsblue focus:ring-2 focus:ring-pmr-nhsblue/15 focus:outline-none transition-all duration-200"
placeholder="Your name"
/>
</FormField>
<FormField
label="Referrer Email"
id="referrerEmail"
required
error={errors.referrerEmail}
>
<input
type="email"
id="referrerEmail"
value={formData.referrerEmail}
onChange={(e) => setFormData({ ...formData, referrerEmail: e.target.value })}
className="w-full border border-[#D1D5DB] rounded px-3 py-2 text-sm font-ui text-gray-900 placeholder-gray-400 focus:border-pmr-nhsblue focus:ring-2 focus:ring-pmr-nhsblue/15 focus:outline-none transition-all duration-200"
placeholder="your.email@example.com"
/>
</FormField>
</div>
<FormField label="Referrer Organisation" id="referrerOrg">
<input
type="text"
id="referrerOrg"
value={formData.referrerOrg}
onChange={(e) => setFormData({ ...formData, referrerOrg: e.target.value })}
className="w-full border border-[#D1D5DB] rounded px-3 py-2 text-sm font-ui text-gray-900 placeholder-gray-400 focus:border-pmr-nhsblue focus:ring-2 focus:ring-pmr-nhsblue/15 focus:outline-none transition-all duration-200"
placeholder="Organisation name (optional)"
/>
</FormField>
<FormField label="Reason for Referral" id="reason">
<textarea
id="reason"
value={formData.reason}
onChange={(e) => setFormData({ ...formData, reason: e.target.value })}
rows={4}
className="w-full border border-[#D1D5DB] rounded px-3 py-2 text-sm font-ui text-gray-900 placeholder-gray-400 focus:border-pmr-nhsblue focus:ring-2 focus:ring-pmr-nhsblue/15 focus:outline-none transition-all duration-200 resize-y"
placeholder="Describe the opportunity or reason for contact..."
/>
</FormField>
<div className="space-y-2">
<span className="block font-ui font-medium text-[13px] text-gray-600">
Contact Method
</span>
<div className="flex gap-6">
<ContactMethodOption
value="email"
label="Email"
selected={formData.contactMethod === 'email'}
onSelect={() => setFormData({ ...formData, contactMethod: 'email' })}
/>
<ContactMethodOption
value="phone"
label="Phone"
selected={formData.contactMethod === 'phone'}
onSelect={() => setFormData({ ...formData, contactMethod: 'phone' })}
/>
<ContactMethodOption
value="linkedin"
label="LinkedIn"
selected={formData.contactMethod === 'linkedin'}
onSelect={() => setFormData({ ...formData, contactMethod: 'linkedin' })}
/>
</div>
</div>
<div className="flex justify-end gap-3 pt-4 border-t border-[#E5E7EB]">
<button
type="button"
onClick={handleReset}
className="font-ui font-medium text-sm px-4 py-2 border border-[#D1D5DB] text-gray-700 rounded hover:bg-gray-50 transition-colors focus-visible:ring-2 focus-visible:ring-pmr-nhsblue/40 focus-visible:outline-none"
>
Cancel
</button>
<button
type="submit"
disabled={isSubmitting}
className="font-ui font-medium text-sm px-6 py-2 bg-pmr-nhsblue text-white rounded hover:bg-[#004D9F] transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2 focus-visible:ring-2 focus-visible:ring-pmr-nhsblue/40 focus-visible:outline-none"
>
{isSubmitting ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Sending...
</>
) : (
<>
<Send className="w-4 h-4" />
Send Referral
</>
)}
</button>
</div>
</form>
</div>
<DirectContactTable />
</div>
)
}
-462
View File
@@ -1,462 +0,0 @@
import { useState, useCallback } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { AlertTriangle, CheckCircle, ChevronRight } from 'lucide-react'
import { patient } from '@/data/patient'
import { consultations } from '@/data/consultations'
import { problems } from '@/data/problems'
import { medications } from '@/data/medications'
import type { ViewId, Problem, Medication, Consultation } from '@/types/pmr'
// ─── Alert state machine ────────────────────────────────────────────────────
type AlertState = 'visible' | 'acknowledging' | 'dismissed'
// ─── Props ──────────────────────────────────────────────────────────────────
interface SummaryViewProps {
onNavigate?: (view: ViewId, itemId?: string) => void
}
export function SummaryView({ onNavigate }: SummaryViewProps) {
const [alertState, setAlertState] = useState<AlertState>('visible')
const prefersReducedMotion = typeof window !== 'undefined'
? window.matchMedia('(prefers-reduced-motion: reduce)').matches
: false
const handleAcknowledge = useCallback(() => {
if (prefersReducedMotion) {
setAlertState('dismissed')
return
}
setAlertState('acknowledging')
// Icon crossfade (200ms) + hold beat (200ms) = 400ms before collapse
const timer = setTimeout(() => {
setAlertState('dismissed')
}, 400)
return () => clearTimeout(timer)
}, [prefersReducedMotion])
const activeProblems = problems.filter(
(p) => p.status === 'Active' || p.status === 'In Progress'
)
const topMedications = medications
.filter((m) => m.category === 'Active')
.slice(0, 5)
const lastConsultation = consultations[0]
return (
<div className="space-y-6">
{/* Clinical Alert */}
<AnimatePresence>
{alertState !== 'dismissed' && (
<ClinicalAlert
state={alertState}
onAcknowledge={handleAcknowledge}
prefersReducedMotion={prefersReducedMotion}
/>
)}
</AnimatePresence>
{/* Summary cards grid */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Card 1: Demographics — full width */}
<DemographicsCard />
{/* Card 2: Active Problems — left column */}
<ActiveProblemsCard
problems={activeProblems}
onNavigate={onNavigate}
/>
{/* Card 3: Current Medications Quick View — right column */}
<QuickMedsCard
medications={topMedications}
onNavigate={onNavigate}
/>
{/* Card 4: Last Consultation — full width */}
<LastConsultationCard
consultation={lastConsultation}
onNavigate={onNavigate}
/>
</div>
</div>
)
}
// ─── Clinical Alert ─────────────────────────────────────────────────────────
interface ClinicalAlertProps {
state: AlertState
onAcknowledge: () => void
prefersReducedMotion: boolean
}
function ClinicalAlert({
state,
onAcknowledge,
prefersReducedMotion,
}: ClinicalAlertProps) {
const isAcknowledging = state === 'acknowledging'
return (
<motion.div
role="alert"
aria-live="assertive"
initial={
prefersReducedMotion
? { y: 0, opacity: 1 }
: { y: '-100%', opacity: 0 }
}
animate={{ y: 0, opacity: 1 }}
exit={
prefersReducedMotion
? { opacity: 0 }
: { height: 0, opacity: 0, marginBottom: 0 }
}
transition={
prefersReducedMotion
? { duration: 0 }
: state === 'acknowledging'
? { duration: 0.2, ease: 'easeOut' }
: { type: 'spring', stiffness: 300, damping: 25 }
}
className="overflow-hidden"
>
<div
className="flex items-start gap-3 p-4 rounded border-l-4"
style={{
backgroundColor: '#FEF3C7',
borderLeftColor: '#F59E0B',
}}
>
{/* Icon area — crossfade between AlertTriangle and CheckCircle */}
<div className="flex-shrink-0 mt-0.5 relative w-5 h-5">
<AnimatePresence mode="wait">
{isAcknowledging ? (
<motion.span
key="check"
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.2 }}
className="absolute inset-0 flex items-center justify-center"
>
<CheckCircle size={20} className="text-green-600" />
</motion.span>
) : (
<motion.span
key="warning"
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
className="absolute inset-0 flex items-center justify-center"
>
<AlertTriangle size={20} className="text-amber-600" />
</motion.span>
)}
</AnimatePresence>
</div>
{/* Message */}
<div className="flex-1 min-w-0">
<p className="font-ui font-medium text-sm" style={{ color: '#92400E' }}>
<span className="font-semibold">ALERT:</span> This patient has
identified{' '}
<span className="font-semibold">£14.6M</span> in prescribing
efficiency savings across Norfolk &amp; Waveney ICS.
</p>
</div>
{/* Acknowledge button */}
<button
type="button"
onClick={onAcknowledge}
disabled={isAcknowledging}
aria-label="Acknowledge clinical alert"
className="flex-shrink-0 px-3 py-1.5 text-xs font-ui font-medium border rounded transition-colors duration-100 hover:bg-[#F59E0B] hover:text-white disabled:opacity-50"
style={{
borderColor: '#F59E0B',
color: isAcknowledging ? '#16A34A' : '#92400E',
}}
>
{isAcknowledging ? 'Acknowledged' : 'Acknowledge'}
</button>
</div>
</motion.div>
)
}
// ─── Shared Card Components ─────────────────────────────────────────────────
function CardHeader({ title }: { title: string }) {
return (
<div className="bg-[#F9FAFB] border-b border-[#E5E7EB] px-4 py-3">
<h2 className="font-ui font-semibold text-sm uppercase tracking-wide text-gray-500">
{title}
</h2>
</div>
)
}
// ─── Demographics Card ──────────────────────────────────────────────────────
function DemographicsCard() {
return (
<div className="lg:col-span-2 bg-white border border-[#E5E7EB] rounded shadow-pmr">
<CardHeader title="Patient Demographics" />
<div className="p-4 md:p-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-12 gap-y-2">
<DemographicsRow label="Name" value={patient.displayName} />
<DemographicsRow
label="Status"
value={
<span className="flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-green-500" />
<span>{patient.status}</span>
</span>
}
/>
<DemographicsRow label="DOB" value={patient.dob} mono />
<DemographicsRow label="Location" value={patient.address} />
<DemographicsRow
label="Registration"
value={
<span>
<span className="text-gray-500">GPhC</span>{' '}
<span className="font-geist text-[13px]">
{patient.nhsNumber.replace(/ /g, '')}
</span>
</span>
}
/>
<DemographicsRow label="Since" value={patient.registrationYear} mono />
<DemographicsRow
label="Qualification"
value={patient.qualification}
/>
<DemographicsRow label="University" value={patient.university} />
</div>
</div>
</div>
)
}
interface DemographicsRowProps {
label: string
value: React.ReactNode
mono?: boolean
}
function DemographicsRow({ label, value, mono }: DemographicsRowProps) {
return (
<div className="flex items-start gap-4 py-1">
<span className="font-ui font-medium text-[13px] text-gray-500 min-w-[100px] text-right flex-shrink-0">
{label}:
</span>
<span
className={`text-sm text-gray-900 ${mono ? 'font-geist' : 'font-ui'}`}
>
{value}
</span>
</div>
)
}
// ─── Active Problems Card ───────────────────────────────────────────────────
interface ActiveProblemsCardProps {
problems: Problem[]
onNavigate?: (view: ViewId, itemId?: string) => void
}
function ActiveProblemsCard({ problems, onNavigate }: ActiveProblemsCardProps) {
return (
<div className="bg-white border border-[#E5E7EB] rounded shadow-pmr">
<CardHeader title="Active Problems" />
<div className="divide-y divide-gray-100">
{problems.map((problem) => (
<button
key={problem.id}
type="button"
onClick={() => onNavigate?.('problems', problem.id)}
className="w-full px-4 py-3 flex items-start gap-3 text-left hover:bg-[#EFF6FF] transition-colors duration-100"
>
<TrafficLight status={problem.status} />
<div className="flex-1 min-w-0">
<p className="font-ui font-medium text-sm text-gray-900 line-clamp-2">
{problem.description}
</p>
{problem.since && (
<p className="font-geist text-xs text-gray-500 mt-1">
{problem.since}
</p>
)}
</div>
</button>
))}
</div>
</div>
)
}
// ─── Traffic Light (always with text label — guardrail) ─────────────────────
interface TrafficLightProps {
status: 'Active' | 'In Progress' | 'Resolved'
}
function TrafficLight({ status }: TrafficLightProps) {
const config: Record<
TrafficLightProps['status'],
{ dotClass: string; label: string }
> = {
Active: { dotClass: 'bg-green-500', label: 'Active' },
'In Progress': { dotClass: 'bg-amber-500', label: 'In Progress' },
Resolved: { dotClass: 'bg-green-500', label: 'Resolved' },
}
const { dotClass, label } = config[status]
return (
<span className="flex items-center gap-1.5 flex-shrink-0 mt-0.5">
<span
className={`w-2 h-2 rounded-full ${dotClass}`}
aria-hidden="true"
/>
<span className="font-ui text-xs text-gray-500">{label}</span>
</span>
)
}
// ─── Quick Medications Card ─────────────────────────────────────────────────
interface QuickMedsCardProps {
medications: Medication[]
onNavigate?: (view: ViewId) => void
}
function QuickMedsCard({ medications, onNavigate }: QuickMedsCardProps) {
return (
<div className="bg-white border border-[#E5E7EB] rounded shadow-pmr">
<CardHeader title="Current Medications (Quick View)" />
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-[#E5E7EB]">
<th
scope="col"
className="px-4 py-2 text-left font-ui font-semibold text-xs uppercase tracking-wider text-gray-400"
>
Drug
</th>
<th
scope="col"
className="px-4 py-2 text-left font-ui font-semibold text-xs uppercase tracking-wider text-gray-400"
>
Dose
</th>
<th
scope="col"
className="px-4 py-2 text-left font-ui font-semibold text-xs uppercase tracking-wider text-gray-400"
>
Freq
</th>
<th
scope="col"
className="px-4 py-2 text-left font-ui font-semibold text-xs uppercase tracking-wider text-gray-400"
>
Status
</th>
</tr>
</thead>
<tbody>
{medications.map((med, index) => (
<tr
key={med.id}
className={`${
index % 2 === 0 ? 'bg-white' : 'bg-[#F9FAFB]'
} hover:bg-[#EFF6FF] transition-colors duration-100`}
style={{ height: '40px' }}
>
<td className="px-4 py-2 font-ui text-sm text-gray-900">
{med.name}
</td>
<td className="px-4 py-2 font-geist text-[13px] text-gray-700">
{med.dose}%
</td>
<td className="px-4 py-2 font-ui text-sm text-gray-700">
{med.frequency}
</td>
<td className="px-4 py-2">
<span className="flex items-center gap-1.5">
<span
className="w-1.5 h-1.5 rounded-full bg-green-500"
aria-hidden="true"
/>
<span className="font-ui text-xs text-gray-600">
{med.status}
</span>
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="px-4 py-2 border-t border-[#E5E7EB]">
<button
type="button"
onClick={() => onNavigate?.('medications')}
className="flex items-center gap-1 font-ui text-sm text-pmr-nhsblue hover:underline"
>
View Full List
<ChevronRight size={14} />
</button>
</div>
</div>
)
}
// ─── Last Consultation Card ─────────────────────────────────────────────────
interface LastConsultationCardProps {
consultation: Consultation
onNavigate?: (view: ViewId, itemId?: string) => void
}
function LastConsultationCard({
consultation,
onNavigate,
}: LastConsultationCardProps) {
return (
<div className="lg:col-span-2 bg-white border border-[#E5E7EB] rounded shadow-pmr">
<CardHeader title="Last Consultation" />
<div className="p-4 md:p-6">
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-3 text-sm text-gray-500 mb-2">
<span className="font-geist text-[12px]">
{consultation.date}
</span>
<span className="text-gray-300">|</span>
<span className="font-ui text-pmr-nhsblue">
{consultation.organization}
</span>
</div>
<h3 className="font-ui font-semibold text-[15px] text-gray-900 mb-2">
{consultation.role}
</h3>
<p className="font-ui text-sm text-gray-600 leading-relaxed line-clamp-3">
{consultation.history}
</p>
</div>
<button
type="button"
onClick={() => onNavigate?.('consultations', consultation.id)}
className="flex-shrink-0 flex items-center gap-1 font-ui text-sm text-pmr-nhsblue hover:underline"
>
View Full Record
<ChevronRight size={14} />
</button>
</div>
</div>
</div>
)
}