feat: Implement responsive design for tablet and mobile breakpoints
- Add useBreakpoint hook for responsive breakpoint detection - Add MobileBottomNav component for mobile navigation - Update ClinicalSidebar with tablet icon-only mode and tooltips - Update PatientBanner with mobile minimal mode and overflow menu - Update PMRInterface to handle responsive layouts and mobile search - Add mobile card layouts to MedicationsView, ProblemsView, InvestigationsView, and DocumentsView - Desktop: 220px sidebar, full banner, tables - Tablet: 56px icon sidebar, condensed banner, scrollable tables - Mobile: Bottom nav, minimal banner, card layouts, search bar
This commit is contained in:
@@ -22,6 +22,7 @@ interface NavItem {
|
||||
interface ClinicalSidebarProps {
|
||||
activeView: ViewId
|
||||
onViewChange: (view: ViewId) => void
|
||||
isTablet?: boolean
|
||||
}
|
||||
|
||||
const navItems: NavItem[] = [
|
||||
@@ -42,11 +43,12 @@ function getCurrentTime(): string {
|
||||
})
|
||||
}
|
||||
|
||||
export function ClinicalSidebar({ activeView, onViewChange }: ClinicalSidebarProps) {
|
||||
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 } = useAccessibility()
|
||||
|
||||
@@ -161,6 +163,69 @@ export function ClinicalSidebar({ activeView, onViewChange }: ClinicalSidebarPro
|
||||
)
|
||||
}, [searchQuery])
|
||||
|
||||
if (isTablet) {
|
||||
return (
|
||||
<aside
|
||||
role="navigation"
|
||||
aria-label="Clinical record navigation"
|
||||
className="hidden md:flex lg:hidden flex-col w-14 h-screen sticky top-0 bg-pmr-sidebar text-white"
|
||||
>
|
||||
<div className="p-2 border-b border-white/10">
|
||||
<div className="font-inter font-medium text-[10px] text-white/50 text-center leading-tight">
|
||||
PMR
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav 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 relative
|
||||
${activeView === item.id
|
||||
? 'text-white bg-white/12 border-l-[3px] border-pmr-nhsblue'
|
||||
: 'text-white/70 hover:text-white hover:bg-white/8'}
|
||||
`}
|
||||
>
|
||||
<span className={activeView === item.id ? 'text-white' : 'text-white/60'}>
|
||||
{item.icon}
|
||||
</span>
|
||||
{hoveredItem === item.id && (
|
||||
<div className="absolute left-full ml-2 px-2 py-1 bg-gray-900 text-white text-xs rounded whitespace-nowrap z-50 font-inter">
|
||||
{item.label}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<div className="p-2 border-t border-white/10">
|
||||
<div className="font-inter text-[9px] text-slate-400 text-center leading-relaxed">
|
||||
<div>A.C</div>
|
||||
<div>{currentTime}</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<aside
|
||||
role="navigation"
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
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: 'Consultations', shortLabel: 'Consult', icon: <FileText size={20} /> },
|
||||
{ id: 'medications', label: 'Medications', shortLabel: 'Meds', icon: <Pill size={20} /> },
|
||||
{ id: 'problems', label: 'Problems', shortLabel: 'Issues', icon: <AlertTriangle size={20} /> },
|
||||
{ id: 'investigations', label: 'Investigations', shortLabel: 'Tests', icon: <FlaskConical size={20} /> },
|
||||
{ id: 'documents', label: 'Documents', shortLabel: 'Docs', icon: <FolderOpen size={20} /> },
|
||||
{ id: 'referrals', label: 'Referrals', shortLabel: 'Refer', 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-inter font-medium">
|
||||
{item.shortLabel}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
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 { SummaryView } from './views/SummaryView'
|
||||
import { ConsultationsView } from './views/ConsultationsView'
|
||||
import { MedicationsView } from './views/MedicationsView'
|
||||
@@ -10,6 +12,7 @@ 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'
|
||||
|
||||
interface PMRInterfaceProps {
|
||||
children?: React.ReactNode
|
||||
@@ -30,8 +33,11 @@ function PMRContent({ children }: PMRInterfaceProps) {
|
||||
return validViews.includes(hash) ? hash : 'summary'
|
||||
})
|
||||
|
||||
const [mobileSearchQuery, setMobileSearchQuery] = useState('')
|
||||
|
||||
const viewHeadingRef = useRef<HTMLDivElement>(null)
|
||||
const { requestFocusAfterViewChange, expandedItemId, setExpandedItem } = useAccessibility()
|
||||
const { isMobile, isTablet } = useBreakpoint()
|
||||
|
||||
useEffect(() => {
|
||||
requestFocusAfterViewChange()
|
||||
@@ -55,6 +61,11 @@ function PMRContent({ children }: PMRInterfaceProps) {
|
||||
}
|
||||
}
|
||||
|
||||
const handleBackToSummary = () => {
|
||||
handleViewChange('summary')
|
||||
window.location.hash = 'summary'
|
||||
}
|
||||
|
||||
const renderView = () => {
|
||||
switch (activeView) {
|
||||
case 'summary':
|
||||
@@ -97,14 +108,31 @@ function PMRContent({ children }: PMRInterfaceProps) {
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-pmr-content">
|
||||
<PatientBanner />
|
||||
<PatientBanner isMobile={isMobile} isTablet={isTablet} />
|
||||
<div className="flex">
|
||||
<ClinicalSidebar activeView={activeView} onViewChange={handleViewChange} />
|
||||
{!isMobile && (
|
||||
<ClinicalSidebar
|
||||
activeView={activeView}
|
||||
onViewChange={handleViewChange}
|
||||
isTablet={isTablet}
|
||||
/>
|
||||
)}
|
||||
<main
|
||||
role="main"
|
||||
aria-label={`${activeView} view`}
|
||||
className="flex-1 min-h-[calc(100vh-80px)] p-6"
|
||||
className={`
|
||||
flex-1 p-4 md:p-6
|
||||
${isMobile ? 'pb-20' : ''}
|
||||
${isTablet ? 'min-h-[calc(100vh-48px)]' : 'min-h-[calc(100vh-80px)]'}
|
||||
`}
|
||||
>
|
||||
{isMobile && (
|
||||
<MobileSearchBar
|
||||
query={mobileSearchQuery}
|
||||
onChange={setMobileSearchQuery}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div
|
||||
ref={viewHeadingRef}
|
||||
tabIndex={-1}
|
||||
@@ -113,9 +141,63 @@ function PMRContent({ children }: PMRInterfaceProps) {
|
||||
>
|
||||
<h1 className="sr-only">{viewLabels[activeView]}</h1>
|
||||
</div>
|
||||
|
||||
{isMobile && activeView !== 'summary' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleBackToSummary}
|
||||
className="flex items-center gap-1 text-pmr-nhsblue text-sm font-inter font-medium mb-4 hover:underline"
|
||||
>
|
||||
<ArrowLeft size={14} />
|
||||
Back to Summary
|
||||
</button>
|
||||
)}
|
||||
|
||||
{children || renderView()}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{isMobile && (
|
||||
<MobileBottomNav
|
||||
activeView={activeView}
|
||||
onViewChange={handleViewChange}
|
||||
/>
|
||||
)}
|
||||
</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="text"
|
||||
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-inter 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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,10 +1,31 @@
|
||||
import { Download, Mail, Linkedin } from 'lucide-react'
|
||||
import { Download, Mail, Linkedin, MoreHorizontal } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { patient } from '@/data/patient'
|
||||
import { useScrollCondensation } from '@/hooks/useScrollCondensation'
|
||||
|
||||
export function PatientBanner() {
|
||||
interface PatientBannerProps {
|
||||
isMobile?: boolean
|
||||
isTablet?: boolean
|
||||
}
|
||||
|
||||
export function PatientBanner({ isMobile = false, isTablet = false }: PatientBannerProps) {
|
||||
const { isCondensed, sentinelRef } = useScrollCondensation({ threshold: 100 })
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
ref={sentinelRef}
|
||||
className="h-0 w-full absolute top-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<MobileBanner />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const shouldCondense = isTablet || isCondensed
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
@@ -17,11 +38,11 @@ export function PatientBanner() {
|
||||
sticky top-0 z-40 w-full
|
||||
bg-pmr-banner border-b border-slate-600
|
||||
transition-all duration-200 ease-out
|
||||
${isCondensed ? 'h-12' : 'h-20'}
|
||||
${shouldCondense ? 'h-12' : 'h-20'}
|
||||
`}
|
||||
role="banner"
|
||||
>
|
||||
{isCondensed ? (
|
||||
{shouldCondense ? (
|
||||
<CondensedBanner />
|
||||
) : (
|
||||
<FullBanner />
|
||||
@@ -31,6 +52,78 @@ export function PatientBanner() {
|
||||
)
|
||||
}
|
||||
|
||||
function MobileBanner() {
|
||||
const [showOverflow, setShowOverflow] = useState(false)
|
||||
|
||||
return (
|
||||
<header
|
||||
className="sticky top-0 z-40 w-full h-12 bg-pmr-banner border-b border-slate-600"
|
||||
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-inter 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">
|
||||
221 181 0
|
||||
</span>
|
||||
<StatusDot status="Active" />
|
||||
</div>
|
||||
<div className="relative">
|
||||
<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>
|
||||
{showOverflow && (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 z-40"
|
||||
onClick={() => setShowOverflow(false)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div className="absolute right-0 top-full mt-1 w-40 bg-white border border-gray-200 rounded shadow-lg z-50 py-1">
|
||||
<a
|
||||
href="/cv.pdf"
|
||||
className="flex items-center gap-2 px-3 py-2 text-sm text-gray-700 hover:bg-gray-50"
|
||||
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 text-gray-700 hover:bg-gray-50"
|
||||
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 text-gray-700 hover:bg-gray-50"
|
||||
onClick={() => setShowOverflow(false)}
|
||||
>
|
||||
<Linkedin size={14} />
|
||||
LinkedIn
|
||||
</a>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
function FullBanner() {
|
||||
return (
|
||||
<div className="h-full px-4 lg:px-6 flex flex-col justify-center">
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState, useEffect, useRef } from 'react'
|
||||
import { ChevronDown, ChevronUp, FileText, Award, GraduationCap, FlaskConical } from 'lucide-react'
|
||||
import { documents } from '@/data/documents'
|
||||
import type { Document, DocumentType } from '@/types/pmr'
|
||||
import { useBreakpoint } from '@/hooks/useBreakpoint'
|
||||
|
||||
function DocumentTypeIcon({ type }: { type: DocumentType }) {
|
||||
const iconMap: Record<DocumentType, React.ReactNode> = {
|
||||
@@ -136,15 +137,110 @@ function DocumentRow({
|
||||
)
|
||||
}
|
||||
|
||||
function MobileDocumentCard({
|
||||
document,
|
||||
isExpanded,
|
||||
onToggle,
|
||||
}: {
|
||||
document: Document
|
||||
isExpanded: boolean
|
||||
onToggle: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="bg-white border border-gray-200 rounded">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
className="w-full p-4 text-left"
|
||||
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">
|
||||
<DocumentTypeIcon type={document.type} />
|
||||
<span className="text-xs text-gray-500">{document.type}</span>
|
||||
</div>
|
||||
<h3 className="font-inter font-medium text-sm text-gray-900">
|
||||
{document.title}
|
||||
</h3>
|
||||
<div className="flex items-center gap-2 mt-1.5 text-xs text-gray-500">
|
||||
<span className="font-geist">{document.date}</span>
|
||||
<span>•</span>
|
||||
<span>{document.source}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-shrink-0 mt-1">
|
||||
{isExpanded ? (
|
||||
<ChevronUp size={16} className="text-gray-400" />
|
||||
) : (
|
||||
<ChevronDown size={16} className="text-gray-400" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
{isExpanded && (
|
||||
<div className="px-4 pb-4 border-t border-gray-100">
|
||||
<div className="pt-3 font-mono text-xs text-gray-700 leading-relaxed space-y-2">
|
||||
<div className="flex">
|
||||
<span className="text-gray-400 w-28 shrink-0">Type:</span>
|
||||
<span>{document.type}</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="text-gray-400 w-28 shrink-0">Date Awarded:</span>
|
||||
<span>{document.date}</span>
|
||||
</div>
|
||||
{document.institution && (
|
||||
<div className="flex">
|
||||
<span className="text-gray-400 w-28 shrink-0">Institution:</span>
|
||||
<span>{document.institution}</span>
|
||||
</div>
|
||||
)}
|
||||
{document.classification && (
|
||||
<div className="flex">
|
||||
<span className="text-gray-400 w-28 shrink-0">Classification:</span>
|
||||
<span>{document.classification}</span>
|
||||
</div>
|
||||
)}
|
||||
{document.duration && (
|
||||
<div className="flex">
|
||||
<span className="text-gray-400 w-28 shrink-0">Duration:</span>
|
||||
<span>{document.duration}</span>
|
||||
</div>
|
||||
)}
|
||||
{document.researchDetail && (
|
||||
<div className="flex flex-col">
|
||||
<span className="text-gray-400 w-28 shrink-0">Research:</span>
|
||||
<span className="mt-1">
|
||||
{document.researchDetail}
|
||||
{document.researchGrade && (
|
||||
<><br />Grade: {document.researchGrade}</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{document.notes && (
|
||||
<div className="flex flex-col">
|
||||
<span className="text-gray-400 w-28 shrink-0">Notes:</span>
|
||||
<span className="mt-1 text-gray-600">{document.notes}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function DocumentsView() {
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null)
|
||||
const { isMobile } = useBreakpoint()
|
||||
|
||||
const handleToggle = (id: string) => {
|
||||
setExpandedId(expandedId === id ? null : id)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white border border-gray-200 rounded">
|
||||
<div className="bg-white border border-gray-200 rounded overflow-hidden">
|
||||
<div className="bg-gray-50 border-b border-gray-200 px-4 py-3">
|
||||
<h2 className="font-inter font-semibold text-sm uppercase tracking-wider text-gray-500">
|
||||
Attached Documents
|
||||
@@ -153,54 +249,67 @@ export function DocumentsView() {
|
||||
Education and certifications presented as attached documents in the patient record.
|
||||
</p>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<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-inter font-semibold text-xs uppercase tracking-wider text-gray-400 w-12"
|
||||
>
|
||||
Type
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="border border-gray-200 px-3 py-2 text-left font-inter font-semibold text-xs uppercase tracking-wider text-gray-400"
|
||||
>
|
||||
Document
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="border border-gray-200 px-3 py-2 text-left font-inter font-semibold text-xs uppercase tracking-wider text-gray-400 w-20"
|
||||
>
|
||||
Date
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="border border-gray-200 px-3 py-2 text-left font-inter font-semibold text-xs uppercase tracking-wider text-gray-400 w-32"
|
||||
>
|
||||
Source
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="border border-gray-200 px-3 py-2 text-left font-inter font-semibold text-xs uppercase tracking-wider text-gray-400 w-10"
|
||||
>
|
||||
<span className="sr-only">Expand</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{documents.map((document) => (
|
||||
<DocumentRow
|
||||
key={document.id}
|
||||
document={document}
|
||||
isExpanded={expandedId === document.id}
|
||||
onToggle={() => handleToggle(document.id)}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{isMobile ? (
|
||||
<div className="p-3 space-y-3 bg-pmr-content">
|
||||
{documents.map((document) => (
|
||||
<MobileDocumentCard
|
||||
key={document.id}
|
||||
document={document}
|
||||
isExpanded={expandedId === document.id}
|
||||
onToggle={() => handleToggle(document.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<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-inter font-semibold text-xs uppercase tracking-wider text-gray-400 w-12"
|
||||
>
|
||||
Type
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="border border-gray-200 px-3 py-2 text-left font-inter font-semibold text-xs uppercase tracking-wider text-gray-400"
|
||||
>
|
||||
Document
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="border border-gray-200 px-3 py-2 text-left font-inter font-semibold text-xs uppercase tracking-wider text-gray-400 w-20"
|
||||
>
|
||||
Date
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="border border-gray-200 px-3 py-2 text-left font-inter font-semibold text-xs uppercase tracking-wider text-gray-400 w-32"
|
||||
>
|
||||
Source
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="border border-gray-200 px-3 py-2 text-left font-inter font-semibold text-xs uppercase tracking-wider text-gray-400 w-10"
|
||||
>
|
||||
<span className="sr-only">Expand</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{documents.map((document) => (
|
||||
<DocumentRow
|
||||
key={document.id}
|
||||
document={document}
|
||||
isExpanded={expandedId === document.id}
|
||||
onToggle={() => handleToggle(document.id)}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
{documents.length === 0 && (
|
||||
<div className="p-4 text-sm text-gray-500 text-center">No documents attached</div>
|
||||
)}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState, useEffect, useRef } from 'react'
|
||||
import { ChevronDown, ChevronUp, ExternalLink, Circle } from 'lucide-react'
|
||||
import { investigations } from '@/data/investigations'
|
||||
import type { Investigation } from '@/types/pmr'
|
||||
import { useBreakpoint } from '@/hooks/useBreakpoint'
|
||||
|
||||
type InvestigationStatus = 'Complete' | 'Ongoing' | 'Live'
|
||||
|
||||
@@ -151,7 +152,7 @@ function InvestigationRow({
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 bg-nhsblue text-white text-sm font-medium rounded hover:bg-blue-700 transition-colors"
|
||||
className="inline-flex items-center gap-2 px-4 py-2 bg-pmr-nhsblue text-white text-sm font-medium rounded hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
View Results
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
@@ -166,15 +167,120 @@ function InvestigationRow({
|
||||
)
|
||||
}
|
||||
|
||||
function MobileInvestigationCard({
|
||||
investigation,
|
||||
isExpanded,
|
||||
onToggle,
|
||||
}: {
|
||||
investigation: Investigation
|
||||
isExpanded: boolean
|
||||
onToggle: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="bg-white border border-gray-200 rounded">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
className="w-full p-4 text-left"
|
||||
aria-expanded={isExpanded}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-inter font-medium text-sm text-gray-900">
|
||||
{investigation.name}
|
||||
</h3>
|
||||
<div className="flex items-center gap-3 mt-1.5 text-xs text-gray-500">
|
||||
<span className="font-geist">{investigation.requestedYear}</span>
|
||||
<span>•</span>
|
||||
<StatusBadge status={investigation.status} />
|
||||
</div>
|
||||
<p className="text-xs text-gray-700 mt-2 line-clamp-2">
|
||||
{investigation.resultSummary}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex-shrink-0 mt-1">
|
||||
{isExpanded ? (
|
||||
<ChevronUp size={16} className="text-gray-400" />
|
||||
) : (
|
||||
<ChevronDown size={16} className="text-gray-400" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
{isExpanded && (
|
||||
<div className="px-4 pb-4 border-t border-gray-100">
|
||||
<div className="pt-3 font-mono text-xs text-gray-700 leading-relaxed space-y-2">
|
||||
<div className="flex">
|
||||
<span className="text-gray-400 w-28 shrink-0">Date Requested:</span>
|
||||
<span>{investigation.requestedYear}</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="text-gray-400 w-28 shrink-0">Date Reported:</span>
|
||||
<span>{investigation.reportedYear ?? 'Pending'}</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="text-gray-400 w-28 shrink-0">Status:</span>
|
||||
<span>
|
||||
{investigation.status}
|
||||
{investigation.status === 'Live' && investigation.externalUrl && (
|
||||
<> — Live at {investigation.externalUrl.replace('https://', '')}</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="text-gray-400 w-28 shrink-0">Clinician:</span>
|
||||
<span>{investigation.requestingClinician}</span>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-gray-400 w-28 shrink-0">Methodology:</span>
|
||||
<span className="mt-1">{investigation.methodology}</span>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-gray-400 w-28 shrink-0">Results:</span>
|
||||
<ul className="mt-1 space-y-0.5">
|
||||
{investigation.results.map((result, idx) => (
|
||||
<li key={idx} className="flex items-start gap-2">
|
||||
<span className="text-gray-300 mt-0.5">-</span>
|
||||
<span>{result}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="text-gray-400 w-28 shrink-0">Tech Stack:</span>
|
||||
<span>{investigation.techStack.join(', ')}</span>
|
||||
</div>
|
||||
</div>
|
||||
{investigation.externalUrl && (
|
||||
<div className="mt-4">
|
||||
<a
|
||||
href={investigation.externalUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 bg-pmr-nhsblue text-white text-xs font-medium rounded hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
View Results
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function InvestigationsView() {
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null)
|
||||
const { isMobile } = useBreakpoint()
|
||||
|
||||
const handleToggle = (id: string) => {
|
||||
setExpandedId(expandedId === id ? null : id)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white border border-gray-200 rounded">
|
||||
<div className="bg-white border border-gray-200 rounded overflow-hidden">
|
||||
<div className="bg-gray-50 border-b border-gray-200 px-4 py-3">
|
||||
<h2 className="font-inter font-semibold text-sm uppercase tracking-wider text-gray-500">
|
||||
Investigation Results
|
||||
@@ -183,54 +289,67 @@ export function InvestigationsView() {
|
||||
Projects presented as diagnostic investigations — tests that were ordered, performed, and returned results.
|
||||
</p>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<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-inter font-semibold text-xs uppercase tracking-wider text-gray-400"
|
||||
>
|
||||
Test Name
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="border border-gray-200 px-3 py-2 text-left font-inter font-semibold text-xs uppercase tracking-wider text-gray-400 w-24"
|
||||
>
|
||||
Requested
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="border border-gray-200 px-3 py-2 text-left font-inter font-semibold text-xs 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-inter font-semibold text-xs uppercase tracking-wider text-gray-400"
|
||||
>
|
||||
Result
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="border border-gray-200 px-3 py-2 text-left font-inter font-semibold text-xs uppercase tracking-wider text-gray-400 w-10"
|
||||
>
|
||||
<span className="sr-only">Expand</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{investigations.map((investigation) => (
|
||||
<InvestigationRow
|
||||
key={investigation.id}
|
||||
investigation={investigation}
|
||||
isExpanded={expandedId === investigation.id}
|
||||
onToggle={() => handleToggle(investigation.id)}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{isMobile ? (
|
||||
<div className="p-3 space-y-3 bg-pmr-content">
|
||||
{investigations.map((investigation) => (
|
||||
<MobileInvestigationCard
|
||||
key={investigation.id}
|
||||
investigation={investigation}
|
||||
isExpanded={expandedId === investigation.id}
|
||||
onToggle={() => handleToggle(investigation.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<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-inter font-semibold text-xs uppercase tracking-wider text-gray-400"
|
||||
>
|
||||
Test Name
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="border border-gray-200 px-3 py-2 text-left font-inter font-semibold text-xs uppercase tracking-wider text-gray-400 w-24"
|
||||
>
|
||||
Requested
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="border border-gray-200 px-3 py-2 text-left font-inter font-semibold text-xs 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-inter font-semibold text-xs uppercase tracking-wider text-gray-400"
|
||||
>
|
||||
Result
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="border border-gray-200 px-3 py-2 text-left font-inter font-semibold text-xs uppercase tracking-wider text-gray-400 w-10"
|
||||
>
|
||||
<span className="sr-only">Expand</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{investigations.map((investigation) => (
|
||||
<InvestigationRow
|
||||
key={investigation.id}
|
||||
investigation={investigation}
|
||||
isExpanded={expandedId === investigation.id}
|
||||
onToggle={() => handleToggle(investigation.id)}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
{investigations.length === 0 && (
|
||||
<div className="p-4 text-sm text-gray-500 text-center">No investigation results</div>
|
||||
)}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState, useMemo } from 'react'
|
||||
import { ChevronDown, ChevronUp, ArrowUpDown, ArrowUp, ArrowDown } from 'lucide-react'
|
||||
import { medications } from '@/data/medications'
|
||||
import type { Medication } from '@/types/pmr'
|
||||
import { useBreakpoint } from '@/hooks/useBreakpoint'
|
||||
|
||||
type SortField = 'name' | 'dose' | 'frequency' | 'startYear' | 'status'
|
||||
type SortDirection = 'asc' | 'desc' | null
|
||||
@@ -12,15 +13,16 @@ interface SortState {
|
||||
}
|
||||
|
||||
const categoryTabs = [
|
||||
{ id: 'Active', label: 'Active Medications', description: 'Technical skills (daily use)' },
|
||||
{ id: 'Clinical', label: 'Clinical Medications', description: 'Healthcare domain skills' },
|
||||
{ id: 'PRN', label: 'PRN (As Required)', description: 'Strategic & leadership skills' },
|
||||
{ id: 'Active', label: 'Active Medications', shortLabel: 'Active', description: 'Technical skills (daily use)' },
|
||||
{ id: 'Clinical', label: 'Clinical Medications', shortLabel: 'Clinical', description: 'Healthcare domain skills' },
|
||||
{ id: 'PRN', label: 'PRN (As Required)', shortLabel: 'PRN', description: 'Strategic & leadership skills' },
|
||||
] as const
|
||||
|
||||
export function MedicationsView() {
|
||||
const [activeTab, setActiveTab] = useState<'Active' | 'Clinical' | 'PRN'>('Active')
|
||||
const [expandedRow, setExpandedRow] = useState<string | null>(null)
|
||||
const [sort, setSort] = useState<SortState>({ field: 'name', direction: null })
|
||||
const { isMobile } = useBreakpoint()
|
||||
|
||||
const prefersReducedMotion = typeof window !== 'undefined'
|
||||
? window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
@@ -117,101 +119,112 @@ export function MedicationsView() {
|
||||
`}
|
||||
>
|
||||
<span className={`font-inter font-medium text-sm ${activeTab === tab.id ? 'text-gray-900' : 'text-gray-600'}`}>
|
||||
{tab.label}
|
||||
</span>
|
||||
<span className="block font-inter text-xs text-gray-500 mt-0.5">
|
||||
{tab.description}
|
||||
{isMobile ? tab.shortLabel : tab.label}
|
||||
</span>
|
||||
{!isMobile && (
|
||||
<span className="block font-inter text-xs text-gray-500 mt-0.5">
|
||||
{tab.description}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full" role="grid">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 bg-gray-50">
|
||||
<th scope="col" className="w-8"></th>
|
||||
<th scope="col" className="text-left">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSort('name')}
|
||||
className="w-full px-4 py-3 flex items-center gap-2 hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
<span className="font-inter font-semibold text-xs uppercase tracking-wide text-gray-400">
|
||||
Drug Name
|
||||
</span>
|
||||
{getSortIcon('name')}
|
||||
</button>
|
||||
</th>
|
||||
<th scope="col" className="text-left">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSort('dose')}
|
||||
className="w-full px-4 py-3 flex items-center gap-2 hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
<span className="font-inter font-semibold text-xs uppercase tracking-wide text-gray-400">
|
||||
Dose
|
||||
</span>
|
||||
{getSortIcon('dose')}
|
||||
</button>
|
||||
</th>
|
||||
<th scope="col" className="text-left">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSort('frequency')}
|
||||
className="w-full px-4 py-3 flex items-center gap-2 hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
<span className="font-inter font-semibold text-xs uppercase tracking-wide text-gray-400">
|
||||
Frequency
|
||||
</span>
|
||||
{getSortIcon('frequency')}
|
||||
</button>
|
||||
</th>
|
||||
<th scope="col" className="text-left">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSort('startYear')}
|
||||
className="w-full px-4 py-3 flex items-center gap-2 hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
<span className="font-inter font-semibold text-xs uppercase tracking-wide text-gray-400">
|
||||
Start
|
||||
</span>
|
||||
{getSortIcon('startYear')}
|
||||
</button>
|
||||
</th>
|
||||
<th scope="col" className="text-left">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSort('status')}
|
||||
className="w-full px-4 py-3 flex items-center gap-2 hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
<span className="font-inter font-semibold text-xs uppercase tracking-wide text-gray-400">
|
||||
Status
|
||||
</span>
|
||||
{getSortIcon('status')}
|
||||
</button>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sortedMedications.map((med, index) => (
|
||||
<MedicationRow
|
||||
key={med.id}
|
||||
medication={med}
|
||||
isExpanded={expandedRow === med.id}
|
||||
isAlternating={index % 2 === 1}
|
||||
onToggle={() => toggleRow(med.id)}
|
||||
prefersReducedMotion={prefersReducedMotion}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{isMobile ? (
|
||||
<MobileMedicationList
|
||||
medications={sortedMedications}
|
||||
expandedRow={expandedRow}
|
||||
onToggle={toggleRow}
|
||||
prefersReducedMotion={prefersReducedMotion}
|
||||
/>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full" role="grid">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 bg-gray-50">
|
||||
<th scope="col" className="w-8"></th>
|
||||
<th scope="col" className="text-left">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSort('name')}
|
||||
className="w-full px-4 py-3 flex items-center gap-2 hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
<span className="font-inter font-semibold text-xs uppercase tracking-wide text-gray-400">
|
||||
Drug Name
|
||||
</span>
|
||||
{getSortIcon('name')}
|
||||
</button>
|
||||
</th>
|
||||
<th scope="col" className="text-left">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSort('dose')}
|
||||
className="w-full px-4 py-3 flex items-center gap-2 hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
<span className="font-inter font-semibold text-xs uppercase tracking-wide text-gray-400">
|
||||
Dose
|
||||
</span>
|
||||
{getSortIcon('dose')}
|
||||
</button>
|
||||
</th>
|
||||
<th scope="col" className="text-left">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSort('frequency')}
|
||||
className="w-full px-4 py-3 flex items-center gap-2 hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
<span className="font-inter font-semibold text-xs uppercase tracking-wide text-gray-400">
|
||||
Frequency
|
||||
</span>
|
||||
{getSortIcon('frequency')}
|
||||
</button>
|
||||
</th>
|
||||
<th scope="col" className="text-left">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSort('startYear')}
|
||||
className="w-full px-4 py-3 flex items-center gap-2 hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
<span className="font-inter font-semibold text-xs uppercase tracking-wide text-gray-400">
|
||||
Start
|
||||
</span>
|
||||
{getSortIcon('startYear')}
|
||||
</button>
|
||||
</th>
|
||||
<th scope="col" className="text-left">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSort('status')}
|
||||
className="w-full px-4 py-3 flex items-center gap-2 hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
<span className="font-inter font-semibold text-xs uppercase tracking-wide text-gray-400">
|
||||
Status
|
||||
</span>
|
||||
{getSortIcon('status')}
|
||||
</button>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sortedMedications.map((med, index) => (
|
||||
<MedicationRow
|
||||
key={med.id}
|
||||
medication={med}
|
||||
isExpanded={expandedRow === med.id}
|
||||
isAlternating={index % 2 === 1}
|
||||
onToggle={() => toggleRow(med.id)}
|
||||
prefersReducedMotion={prefersReducedMotion}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="px-4 py-3 border-t border-gray-200 bg-gray-50">
|
||||
<p className="font-inter text-xs text-gray-500">
|
||||
{sortedMedications.length} medications in this category. Click a row to view prescribing history.
|
||||
{sortedMedications.length} medications in this category. {isMobile ? 'Tap' : 'Click'} a row to view prescribing history.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -219,6 +232,80 @@ export function MedicationsView() {
|
||||
)
|
||||
}
|
||||
|
||||
interface MobileMedicationListProps {
|
||||
medications: Medication[]
|
||||
expandedRow: string | null
|
||||
onToggle: (id: string) => void
|
||||
prefersReducedMotion: boolean
|
||||
}
|
||||
|
||||
function MobileMedicationList({ medications, expandedRow, onToggle, prefersReducedMotion }: MobileMedicationListProps) {
|
||||
const statusColors = {
|
||||
'Active': 'bg-green-500',
|
||||
'Historical': 'bg-gray-400',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="divide-y divide-gray-200">
|
||||
{medications.map((med) => (
|
||||
<div key={med.id} className="bg-white">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onToggle(med.id)}
|
||||
className="w-full p-4 text-left"
|
||||
aria-expanded={expandedRow === med.id}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-inter font-medium text-sm text-gray-900">
|
||||
{med.name}
|
||||
</h3>
|
||||
<div className="flex items-center gap-3 mt-1.5 text-xs text-gray-500">
|
||||
<span className="font-geist">{med.dose}%</span>
|
||||
<span>•</span>
|
||||
<span>{med.frequency}</span>
|
||||
<span>•</span>
|
||||
<span className="font-geist">Since {med.startYear}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<span className={`w-2 h-2 rounded-full ${statusColors[med.status]}`} />
|
||||
<span className="text-xs text-gray-600">{med.status}</span>
|
||||
{expandedRow === med.id ? (
|
||||
<ChevronUp size={16} className="text-gray-400" />
|
||||
) : (
|
||||
<ChevronDown size={16} className="text-gray-400" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
{expandedRow === med.id && (
|
||||
<div className={`px-4 pb-4 ${prefersReducedMotion ? '' : 'animate-fadeIn'}`}>
|
||||
<div className="bg-gray-50 rounded p-3">
|
||||
<p className="font-inter font-medium text-xs uppercase tracking-wide text-gray-400 mb-2">
|
||||
Prescribing History
|
||||
</p>
|
||||
<div className="space-y-1.5">
|
||||
{med.prescribingHistory.map((entry, index) => (
|
||||
<div key={index} className="flex gap-3">
|
||||
<span className="font-geist font-medium text-xs text-gray-500 w-10 flex-shrink-0">
|
||||
{entry.year}
|
||||
</span>
|
||||
<span className="font-geist text-xs text-gray-600">
|
||||
{entry.description}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface MedicationRowProps {
|
||||
medication: Medication
|
||||
isExpanded: boolean
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ChevronDown, ChevronUp, 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'
|
||||
|
||||
interface ProblemsViewProps {
|
||||
onNavigate?: (view: 'consultations', itemId?: string) => void
|
||||
@@ -135,7 +136,7 @@ function ProblemRow({
|
||||
e.stopPropagation()
|
||||
handleLinkedClick(consultation.id)
|
||||
}}
|
||||
className="inline-flex items-center gap-1 text-xs text-nhsblue hover:underline"
|
||||
className="inline-flex items-center gap-1 text-xs text-pmr-nhsblue hover:underline"
|
||||
>
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
{consultation.organization} — {consultation.role}
|
||||
@@ -152,8 +153,101 @@ function ProblemRow({
|
||||
)
|
||||
}
|
||||
|
||||
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">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
className="w-full p-4 text-left"
|
||||
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-mono text-xs text-gray-500">[{problem.code}]</span>
|
||||
</div>
|
||||
<h3 className="font-inter font-medium text-sm text-gray-900">
|
||||
{problem.description}
|
||||
</h3>
|
||||
<div className="flex items-center gap-2 mt-1.5 text-xs text-gray-500">
|
||||
<span>{showOutcome ? 'Resolved' : 'Since'}: {problem.resolved || problem.since}</span>
|
||||
{showOutcome && problem.outcome && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<span className="text-gray-700">{problem.outcome}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-shrink-0 mt-1">
|
||||
{isExpanded ? (
|
||||
<ChevronUp size={16} className="text-gray-400" />
|
||||
) : (
|
||||
<ChevronDown size={16} className="text-gray-400" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
{isExpanded && (
|
||||
<div className="px-4 pb-4 border-t border-gray-100">
|
||||
<div className="pt-3 text-sm text-gray-700 leading-relaxed">
|
||||
{problem.narrative}
|
||||
</div>
|
||||
{linkedConsultations.length > 0 && (
|
||||
<div className="mt-3">
|
||||
<span className="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"
|
||||
>
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
{consultation.organization}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ProblemsView({ onNavigate }: ProblemsViewProps) {
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null)
|
||||
const { isMobile } = useBreakpoint()
|
||||
|
||||
const activeProblems = problems.filter(
|
||||
(p) => p.status === 'Active' || p.status === 'In Progress'
|
||||
@@ -166,50 +260,16 @@ export function ProblemsView({ onNavigate }: ProblemsViewProps) {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-white border border-gray-200 rounded">
|
||||
<div className="bg-white border border-gray-200 rounded overflow-hidden">
|
||||
<div className="bg-gray-50 border-b border-gray-200 px-4 py-3">
|
||||
<h2 className="font-inter font-semibold text-sm uppercase tracking-wider text-gray-500">
|
||||
Active Problems
|
||||
</h2>
|
||||
</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-inter font-semibold text-xs 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-inter font-semibold text-xs 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-inter font-semibold text-xs uppercase tracking-wider text-gray-400"
|
||||
>
|
||||
Problem
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="border border-gray-200 px-3 py-2 text-left font-inter font-semibold text-xs 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-inter font-semibold text-xs uppercase tracking-wider text-gray-400 w-10"
|
||||
>
|
||||
<span className="sr-only">Expand</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{isMobile ? (
|
||||
<div className="p-3 space-y-3 bg-pmr-content">
|
||||
{activeProblems.map((problem) => (
|
||||
<ProblemRow
|
||||
<MobileProblemCard
|
||||
key={problem.id}
|
||||
problem={problem}
|
||||
isExpanded={expandedId === problem.id}
|
||||
@@ -218,63 +278,72 @@ export function ProblemsView({ onNavigate }: ProblemsViewProps) {
|
||||
showOutcome={false}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</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-inter font-semibold text-xs 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-inter font-semibold text-xs 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-inter font-semibold text-xs uppercase tracking-wider text-gray-400"
|
||||
>
|
||||
Problem
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="border border-gray-200 px-3 py-2 text-left font-inter font-semibold text-xs 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-inter font-semibold text-xs 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 text-sm text-gray-500 text-center">No active problems</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-white border border-gray-200 rounded">
|
||||
<div className="bg-white border border-gray-200 rounded overflow-hidden">
|
||||
<div className="bg-gray-50 border-b border-gray-200 px-4 py-3">
|
||||
<h2 className="font-inter font-semibold text-sm uppercase tracking-wider text-gray-500">
|
||||
Resolved Problems
|
||||
</h2>
|
||||
</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-inter font-semibold text-xs 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-inter font-semibold text-xs 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-inter font-semibold text-xs uppercase tracking-wider text-gray-400"
|
||||
>
|
||||
Problem
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="border border-gray-200 px-3 py-2 text-left font-inter font-semibold text-xs 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-inter font-semibold text-xs uppercase tracking-wider text-gray-400"
|
||||
>
|
||||
Outcome
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="border border-gray-200 px-3 py-2 text-left font-inter font-semibold text-xs uppercase tracking-wider text-gray-400 w-10"
|
||||
>
|
||||
<span className="sr-only">Expand</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{isMobile ? (
|
||||
<div className="p-3 space-y-3 bg-pmr-content">
|
||||
{resolvedProblems.map((problem) => (
|
||||
<ProblemRow
|
||||
<MobileProblemCard
|
||||
key={problem.id}
|
||||
problem={problem}
|
||||
isExpanded={expandedId === problem.id}
|
||||
@@ -283,8 +352,63 @@ export function ProblemsView({ onNavigate }: ProblemsViewProps) {
|
||||
showOutcome={true}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</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-inter font-semibold text-xs 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-inter font-semibold text-xs 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-inter font-semibold text-xs uppercase tracking-wider text-gray-400"
|
||||
>
|
||||
Problem
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="border border-gray-200 px-3 py-2 text-left font-inter font-semibold text-xs 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-inter font-semibold text-xs uppercase tracking-wider text-gray-400"
|
||||
>
|
||||
Outcome
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="border border-gray-200 px-3 py-2 text-left font-inter font-semibold text-xs 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 text-sm text-gray-500 text-center">No resolved problems</div>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
type Breakpoint = 'mobile' | 'tablet' | 'desktop'
|
||||
|
||||
interface BreakpointState {
|
||||
breakpoint: Breakpoint
|
||||
isMobile: boolean
|
||||
isTablet: boolean
|
||||
isDesktop: boolean
|
||||
}
|
||||
|
||||
export function useBreakpoint(): BreakpointState {
|
||||
const [state, setState] = useState<BreakpointState>(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return { breakpoint: 'desktop', isMobile: false, isTablet: false, isDesktop: true }
|
||||
}
|
||||
const width = window.innerWidth
|
||||
if (width < 768) {
|
||||
return { breakpoint: 'mobile', isMobile: true, isTablet: false, isDesktop: false }
|
||||
}
|
||||
if (width < 1024) {
|
||||
return { breakpoint: 'tablet', isMobile: false, isTablet: true, isDesktop: false }
|
||||
}
|
||||
return { breakpoint: 'desktop', isMobile: false, isTablet: false, isDesktop: true }
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
const width = window.innerWidth
|
||||
let breakpoint: Breakpoint
|
||||
let isMobile: boolean
|
||||
let isTablet: boolean
|
||||
let isDesktop: boolean
|
||||
|
||||
if (width < 768) {
|
||||
breakpoint = 'mobile'
|
||||
isMobile = true
|
||||
isTablet = false
|
||||
isDesktop = false
|
||||
} else if (width < 1024) {
|
||||
breakpoint = 'tablet'
|
||||
isMobile = false
|
||||
isTablet = true
|
||||
isDesktop = false
|
||||
} else {
|
||||
breakpoint = 'desktop'
|
||||
isMobile = false
|
||||
isTablet = false
|
||||
isDesktop = true
|
||||
}
|
||||
|
||||
setState({ breakpoint, isMobile, isTablet, isDesktop })
|
||||
}
|
||||
|
||||
handleResize()
|
||||
window.addEventListener('resize', handleResize)
|
||||
return () => window.removeEventListener('resize', handleResize)
|
||||
}, [])
|
||||
|
||||
return state
|
||||
}
|
||||
Reference in New Issue
Block a user