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:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 — MPharm, GPhC Registered Pharmacist
|
||||
</p>
|
||||
</motion.footer>
|
||||
)
|
||||
}
|
||||
|
||||
export { Footer }
|
||||
@@ -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 & 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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} />
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 & 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 & 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>
|
||||
)
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
import { useEffect, useState, useRef, useCallback } from 'react'
|
||||
|
||||
const SECTION_IDS = ['about', 'skills', 'experience', 'education', 'projects', 'contact'] as const
|
||||
|
||||
type SectionId = typeof SECTION_IDS[number]
|
||||
|
||||
export function useActiveSection(): SectionId {
|
||||
const [activeSection, setActiveSection] = useState<SectionId>('about')
|
||||
const observerRef = useRef<IntersectionObserver | null>(null)
|
||||
const visibleSectionsRef = useRef<Map<string, number>>(new Map())
|
||||
|
||||
const handleIntersect = useCallback((entries: IntersectionObserverEntry[]) => {
|
||||
entries.forEach((entry) => {
|
||||
const sectionId = entry.target.id
|
||||
if (SECTION_IDS.includes(sectionId as SectionId)) {
|
||||
if (entry.isIntersecting) {
|
||||
visibleSectionsRef.current.set(sectionId, entry.intersectionRatio)
|
||||
} else {
|
||||
visibleSectionsRef.current.delete(sectionId)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const visibleEntries = Array.from(visibleSectionsRef.current.entries())
|
||||
if (visibleEntries.length > 0) {
|
||||
visibleEntries.sort((a, b) => {
|
||||
const indexA = SECTION_IDS.indexOf(a[0] as SectionId)
|
||||
const indexB = SECTION_IDS.indexOf(b[0] as SectionId)
|
||||
return indexA - indexB
|
||||
})
|
||||
|
||||
const topSection = visibleEntries[0][0] as SectionId
|
||||
setActiveSection(topSection)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
observerRef.current = new IntersectionObserver(handleIntersect, {
|
||||
rootMargin: '-20% 0px -70% 0px',
|
||||
threshold: [0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1],
|
||||
})
|
||||
|
||||
SECTION_IDS.forEach((id) => {
|
||||
const element = document.getElementById(id)
|
||||
if (element && observerRef.current) {
|
||||
observerRef.current.observe(element)
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
if (observerRef.current) {
|
||||
observerRef.current.disconnect()
|
||||
}
|
||||
}
|
||||
}, [handleIntersect])
|
||||
|
||||
return activeSection
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
|
||||
interface UseScrollCondensationOptions {
|
||||
threshold?: number
|
||||
scrollContainer?: HTMLElement | null
|
||||
}
|
||||
|
||||
export function useScrollCondensation(options: UseScrollCondensationOptions = {}) {
|
||||
const { threshold = 100, scrollContainer } = options
|
||||
const [isCondensed, setIsCondensed] = useState(false)
|
||||
|
||||
const handleScroll = useCallback(() => {
|
||||
if (!scrollContainer) return
|
||||
setIsCondensed(scrollContainer.scrollTop >= threshold)
|
||||
}, [scrollContainer, threshold])
|
||||
|
||||
useEffect(() => {
|
||||
if (!scrollContainer) return
|
||||
|
||||
scrollContainer.addEventListener('scroll', handleScroll, { passive: true })
|
||||
// Check initial state
|
||||
handleScroll()
|
||||
|
||||
return () => {
|
||||
scrollContainer.removeEventListener('scroll', handleScroll)
|
||||
}
|
||||
}, [scrollContainer, handleScroll])
|
||||
|
||||
return { isCondensed }
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
import { useEffect, useRef, useState, type RefObject } from 'react'
|
||||
|
||||
interface UseScrollRevealOptions {
|
||||
threshold?: number
|
||||
rootMargin?: string
|
||||
triggerOnce?: boolean
|
||||
}
|
||||
|
||||
export function useScrollReveal<T extends HTMLElement>(
|
||||
options: UseScrollRevealOptions = {}
|
||||
): [RefObject<T>, boolean] {
|
||||
const { threshold = 0.15, rootMargin = '0px', triggerOnce = true } = options
|
||||
const ref = useRef<T>(null)
|
||||
const [isVisible, setIsVisible] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const element = ref.current
|
||||
if (!element) return
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry.isIntersecting) {
|
||||
setIsVisible(true)
|
||||
if (triggerOnce) {
|
||||
observer.unobserve(element)
|
||||
}
|
||||
} else if (!triggerOnce) {
|
||||
setIsVisible(false)
|
||||
}
|
||||
},
|
||||
{ threshold, rootMargin }
|
||||
)
|
||||
|
||||
observer.observe(element)
|
||||
|
||||
return () => observer.disconnect()
|
||||
}, [threshold, rootMargin, triggerOnce])
|
||||
|
||||
return [ref, isVisible]
|
||||
}
|
||||
Reference in New Issue
Block a user