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