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:
2026-02-11 03:07:25 +00:00
parent a7df2d0037
commit 4ec108484e
9 changed files with 1092 additions and 283 deletions
+66 -1
View File
@@ -22,6 +22,7 @@ interface NavItem {
interface ClinicalSidebarProps { interface ClinicalSidebarProps {
activeView: ViewId activeView: ViewId
onViewChange: (view: ViewId) => void onViewChange: (view: ViewId) => void
isTablet?: boolean
} }
const navItems: NavItem[] = [ 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 [currentTime, setCurrentTime] = useState(getCurrentTime)
const [searchQuery, setSearchQuery] = useState('') const [searchQuery, setSearchQuery] = useState('')
const [isSearchFocused, setIsSearchFocused] = useState(false) const [isSearchFocused, setIsSearchFocused] = useState(false)
const [focusedIndex, setFocusedIndex] = useState<number | null>(null) const [focusedIndex, setFocusedIndex] = useState<number | null>(null)
const [hoveredItem, setHoveredItem] = useState<ViewId | null>(null)
const navButtonRefs = useRef<(HTMLButtonElement | null)[]>([]) const navButtonRefs = useRef<(HTMLButtonElement | null)[]>([])
const { focusAfterLoginRef } = useAccessibility() const { focusAfterLoginRef } = useAccessibility()
@@ -161,6 +163,69 @@ export function ClinicalSidebar({ activeView, onViewChange }: ClinicalSidebarPro
) )
}, [searchQuery]) }, [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 ( return (
<aside <aside
role="navigation" role="navigation"
+69
View File
@@ -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>
)
}
+85 -3
View File
@@ -1,7 +1,9 @@
import { useState, useEffect, useRef } from 'react' import { useState, useEffect, useRef } from 'react'
import { Search, X, ArrowLeft } from 'lucide-react'
import type { ViewId } from '../types/pmr' import type { ViewId } from '../types/pmr'
import { ClinicalSidebar } from './ClinicalSidebar' import { ClinicalSidebar } from './ClinicalSidebar'
import { PatientBanner } from './PatientBanner' import { PatientBanner } from './PatientBanner'
import { MobileBottomNav } from './MobileBottomNav'
import { SummaryView } from './views/SummaryView' import { SummaryView } from './views/SummaryView'
import { ConsultationsView } from './views/ConsultationsView' import { ConsultationsView } from './views/ConsultationsView'
import { MedicationsView } from './views/MedicationsView' import { MedicationsView } from './views/MedicationsView'
@@ -10,6 +12,7 @@ import { InvestigationsView } from './views/InvestigationsView'
import { DocumentsView } from './views/DocumentsView' import { DocumentsView } from './views/DocumentsView'
import { ReferralsView } from './views/ReferralsView' import { ReferralsView } from './views/ReferralsView'
import { useAccessibility } from '../contexts/AccessibilityContext' import { useAccessibility } from '../contexts/AccessibilityContext'
import { useBreakpoint } from '../hooks/useBreakpoint'
interface PMRInterfaceProps { interface PMRInterfaceProps {
children?: React.ReactNode children?: React.ReactNode
@@ -30,8 +33,11 @@ function PMRContent({ children }: PMRInterfaceProps) {
return validViews.includes(hash) ? hash : 'summary' return validViews.includes(hash) ? hash : 'summary'
}) })
const [mobileSearchQuery, setMobileSearchQuery] = useState('')
const viewHeadingRef = useRef<HTMLDivElement>(null) const viewHeadingRef = useRef<HTMLDivElement>(null)
const { requestFocusAfterViewChange, expandedItemId, setExpandedItem } = useAccessibility() const { requestFocusAfterViewChange, expandedItemId, setExpandedItem } = useAccessibility()
const { isMobile, isTablet } = useBreakpoint()
useEffect(() => { useEffect(() => {
requestFocusAfterViewChange() requestFocusAfterViewChange()
@@ -55,6 +61,11 @@ function PMRContent({ children }: PMRInterfaceProps) {
} }
} }
const handleBackToSummary = () => {
handleViewChange('summary')
window.location.hash = 'summary'
}
const renderView = () => { const renderView = () => {
switch (activeView) { switch (activeView) {
case 'summary': case 'summary':
@@ -97,14 +108,31 @@ function PMRContent({ children }: PMRInterfaceProps) {
return ( return (
<div className="min-h-screen bg-pmr-content"> <div className="min-h-screen bg-pmr-content">
<PatientBanner /> <PatientBanner isMobile={isMobile} isTablet={isTablet} />
<div className="flex"> <div className="flex">
<ClinicalSidebar activeView={activeView} onViewChange={handleViewChange} /> {!isMobile && (
<ClinicalSidebar
activeView={activeView}
onViewChange={handleViewChange}
isTablet={isTablet}
/>
)}
<main <main
role="main" role="main"
aria-label={`${activeView} view`} 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 <div
ref={viewHeadingRef} ref={viewHeadingRef}
tabIndex={-1} tabIndex={-1}
@@ -113,9 +141,63 @@ function PMRContent({ children }: PMRInterfaceProps) {
> >
<h1 className="sr-only">{viewLabels[activeView]}</h1> <h1 className="sr-only">{viewLabels[activeView]}</h1>
</div> </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()} {children || renderView()}
</main> </main>
</div> </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> </div>
) )
} }
+97 -4
View File
@@ -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 { patient } from '@/data/patient'
import { useScrollCondensation } from '@/hooks/useScrollCondensation' 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 }) 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 ( return (
<> <>
<div <div
@@ -17,11 +38,11 @@ export function PatientBanner() {
sticky top-0 z-40 w-full sticky top-0 z-40 w-full
bg-pmr-banner border-b border-slate-600 bg-pmr-banner border-b border-slate-600
transition-all duration-200 ease-out transition-all duration-200 ease-out
${isCondensed ? 'h-12' : 'h-20'} ${shouldCondense ? 'h-12' : 'h-20'}
`} `}
role="banner" role="banner"
> >
{isCondensed ? ( {shouldCondense ? (
<CondensedBanner /> <CondensedBanner />
) : ( ) : (
<FullBanner /> <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() { function FullBanner() {
return ( return (
<div className="h-full px-4 lg:px-6 flex flex-col justify-center"> <div className="h-full px-4 lg:px-6 flex flex-col justify-center">
+110 -1
View File
@@ -2,6 +2,7 @@ import { useState, useEffect, useRef } from 'react'
import { ChevronDown, ChevronUp, FileText, Award, GraduationCap, FlaskConical } from 'lucide-react' import { ChevronDown, ChevronUp, FileText, Award, GraduationCap, FlaskConical } from 'lucide-react'
import { documents } from '@/data/documents' import { documents } from '@/data/documents'
import type { Document, DocumentType } from '@/types/pmr' import type { Document, DocumentType } from '@/types/pmr'
import { useBreakpoint } from '@/hooks/useBreakpoint'
function DocumentTypeIcon({ type }: { type: DocumentType }) { function DocumentTypeIcon({ type }: { type: DocumentType }) {
const iconMap: Record<DocumentType, React.ReactNode> = { 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() { export function DocumentsView() {
const [expandedId, setExpandedId] = useState<string | null>(null) const [expandedId, setExpandedId] = useState<string | null>(null)
const { isMobile } = useBreakpoint()
const handleToggle = (id: string) => { const handleToggle = (id: string) => {
setExpandedId(expandedId === id ? null : id) setExpandedId(expandedId === id ? null : id)
} }
return ( 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"> <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"> <h2 className="font-inter font-semibold text-sm uppercase tracking-wider text-gray-500">
Attached Documents Attached Documents
@@ -153,6 +249,18 @@ export function DocumentsView() {
Education and certifications presented as attached documents in the patient record. Education and certifications presented as attached documents in the patient record.
</p> </p>
</div> </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"> <div className="overflow-x-auto">
<table className="w-full border-collapse"> <table className="w-full border-collapse">
<thead> <thead>
@@ -201,6 +309,7 @@ export function DocumentsView() {
</tbody> </tbody>
</table> </table>
</div> </div>
)}
{documents.length === 0 && ( {documents.length === 0 && (
<div className="p-4 text-sm text-gray-500 text-center">No documents attached</div> <div className="p-4 text-sm text-gray-500 text-center">No documents attached</div>
)} )}
+121 -2
View File
@@ -2,6 +2,7 @@ import { useState, useEffect, useRef } from 'react'
import { ChevronDown, ChevronUp, ExternalLink, Circle } from 'lucide-react' import { ChevronDown, ChevronUp, ExternalLink, Circle } from 'lucide-react'
import { investigations } from '@/data/investigations' import { investigations } from '@/data/investigations'
import type { Investigation } from '@/types/pmr' import type { Investigation } from '@/types/pmr'
import { useBreakpoint } from '@/hooks/useBreakpoint'
type InvestigationStatus = 'Complete' | 'Ongoing' | 'Live' type InvestigationStatus = 'Complete' | 'Ongoing' | 'Live'
@@ -151,7 +152,7 @@ function InvestigationRow({
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()} 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 View Results
<ExternalLink className="w-4 h-4" /> <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() { export function InvestigationsView() {
const [expandedId, setExpandedId] = useState<string | null>(null) const [expandedId, setExpandedId] = useState<string | null>(null)
const { isMobile } = useBreakpoint()
const handleToggle = (id: string) => { const handleToggle = (id: string) => {
setExpandedId(expandedId === id ? null : id) setExpandedId(expandedId === id ? null : id)
} }
return ( 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"> <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"> <h2 className="font-inter font-semibold text-sm uppercase tracking-wider text-gray-500">
Investigation Results Investigation Results
@@ -183,6 +289,18 @@ export function InvestigationsView() {
Projects presented as diagnostic investigations tests that were ordered, performed, and returned results. Projects presented as diagnostic investigations tests that were ordered, performed, and returned results.
</p> </p>
</div> </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"> <div className="overflow-x-auto">
<table className="w-full border-collapse"> <table className="w-full border-collapse">
<thead> <thead>
@@ -231,6 +349,7 @@ export function InvestigationsView() {
</tbody> </tbody>
</table> </table>
</div> </div>
)}
{investigations.length === 0 && ( {investigations.length === 0 && (
<div className="p-4 text-sm text-gray-500 text-center">No investigation results</div> <div className="p-4 text-sm text-gray-500 text-center">No investigation results</div>
)} )}
+92 -5
View File
@@ -2,6 +2,7 @@ import { useState, useMemo } from 'react'
import { ChevronDown, ChevronUp, ArrowUpDown, ArrowUp, ArrowDown } from 'lucide-react' import { ChevronDown, ChevronUp, ArrowUpDown, ArrowUp, ArrowDown } from 'lucide-react'
import { medications } from '@/data/medications' import { medications } from '@/data/medications'
import type { Medication } from '@/types/pmr' import type { Medication } from '@/types/pmr'
import { useBreakpoint } from '@/hooks/useBreakpoint'
type SortField = 'name' | 'dose' | 'frequency' | 'startYear' | 'status' type SortField = 'name' | 'dose' | 'frequency' | 'startYear' | 'status'
type SortDirection = 'asc' | 'desc' | null type SortDirection = 'asc' | 'desc' | null
@@ -12,15 +13,16 @@ interface SortState {
} }
const categoryTabs = [ const categoryTabs = [
{ id: 'Active', label: 'Active Medications', description: 'Technical skills (daily use)' }, { id: 'Active', label: 'Active Medications', shortLabel: 'Active', description: 'Technical skills (daily use)' },
{ id: 'Clinical', label: 'Clinical Medications', description: 'Healthcare domain skills' }, { id: 'Clinical', label: 'Clinical Medications', shortLabel: 'Clinical', description: 'Healthcare domain skills' },
{ id: 'PRN', label: 'PRN (As Required)', description: 'Strategic & leadership skills' }, { id: 'PRN', label: 'PRN (As Required)', shortLabel: 'PRN', description: 'Strategic & leadership skills' },
] as const ] as const
export function MedicationsView() { export function MedicationsView() {
const [activeTab, setActiveTab] = useState<'Active' | 'Clinical' | 'PRN'>('Active') const [activeTab, setActiveTab] = useState<'Active' | 'Clinical' | 'PRN'>('Active')
const [expandedRow, setExpandedRow] = useState<string | null>(null) const [expandedRow, setExpandedRow] = useState<string | null>(null)
const [sort, setSort] = useState<SortState>({ field: 'name', direction: null }) const [sort, setSort] = useState<SortState>({ field: 'name', direction: null })
const { isMobile } = useBreakpoint()
const prefersReducedMotion = typeof window !== 'undefined' const prefersReducedMotion = typeof window !== 'undefined'
? window.matchMedia('(prefers-reduced-motion: reduce)').matches ? window.matchMedia('(prefers-reduced-motion: reduce)').matches
@@ -117,16 +119,26 @@ export function MedicationsView() {
`} `}
> >
<span className={`font-inter font-medium text-sm ${activeTab === tab.id ? 'text-gray-900' : 'text-gray-600'}`}> <span className={`font-inter font-medium text-sm ${activeTab === tab.id ? 'text-gray-900' : 'text-gray-600'}`}>
{tab.label} {isMobile ? tab.shortLabel : tab.label}
</span> </span>
{!isMobile && (
<span className="block font-inter text-xs text-gray-500 mt-0.5"> <span className="block font-inter text-xs text-gray-500 mt-0.5">
{tab.description} {tab.description}
</span> </span>
)}
</button> </button>
))} ))}
</nav> </nav>
</div> </div>
{isMobile ? (
<MobileMedicationList
medications={sortedMedications}
expandedRow={expandedRow}
onToggle={toggleRow}
prefersReducedMotion={prefersReducedMotion}
/>
) : (
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full" role="grid"> <table className="w-full" role="grid">
<thead> <thead>
@@ -208,10 +220,11 @@ export function MedicationsView() {
</tbody> </tbody>
</table> </table>
</div> </div>
)}
<div className="px-4 py-3 border-t border-gray-200 bg-gray-50"> <div className="px-4 py-3 border-t border-gray-200 bg-gray-50">
<p className="font-inter text-xs text-gray-500"> <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> </p>
</div> </div>
</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 { interface MedicationRowProps {
medication: Medication medication: Medication
isExpanded: boolean isExpanded: boolean
+127 -3
View File
@@ -3,6 +3,7 @@ import { ChevronDown, ChevronUp, ExternalLink } from 'lucide-react'
import { problems } from '@/data/problems' import { problems } from '@/data/problems'
import { consultations } from '@/data/consultations' import { consultations } from '@/data/consultations'
import type { Problem, Consultation } from '@/types/pmr' import type { Problem, Consultation } from '@/types/pmr'
import { useBreakpoint } from '@/hooks/useBreakpoint'
interface ProblemsViewProps { interface ProblemsViewProps {
onNavigate?: (view: 'consultations', itemId?: string) => void onNavigate?: (view: 'consultations', itemId?: string) => void
@@ -135,7 +136,7 @@ function ProblemRow({
e.stopPropagation() e.stopPropagation()
handleLinkedClick(consultation.id) 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" /> <ExternalLink className="w-3 h-3" />
{consultation.organization} {consultation.role} {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) { export function ProblemsView({ onNavigate }: ProblemsViewProps) {
const [expandedId, setExpandedId] = useState<string | null>(null) const [expandedId, setExpandedId] = useState<string | null>(null)
const { isMobile } = useBreakpoint()
const activeProblems = problems.filter( const activeProblems = problems.filter(
(p) => p.status === 'Active' || p.status === 'In Progress' (p) => p.status === 'Active' || p.status === 'In Progress'
@@ -166,12 +260,26 @@ export function ProblemsView({ onNavigate }: ProblemsViewProps) {
return ( return (
<div className="space-y-6"> <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"> <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"> <h2 className="font-inter font-semibold text-sm uppercase tracking-wider text-gray-500">
Active Problems Active Problems
</h2> </h2>
</div> </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"> <table className="w-full border-collapse">
<thead> <thead>
<tr className="bg-gray-50"> <tr className="bg-gray-50">
@@ -220,17 +328,32 @@ export function ProblemsView({ onNavigate }: ProblemsViewProps) {
))} ))}
</tbody> </tbody>
</table> </table>
)}
{activeProblems.length === 0 && ( {activeProblems.length === 0 && (
<div className="p-4 text-sm text-gray-500 text-center">No active problems</div> <div className="p-4 text-sm text-gray-500 text-center">No active problems</div>
)} )}
</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"> <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"> <h2 className="font-inter font-semibold text-sm uppercase tracking-wider text-gray-500">
Resolved Problems Resolved Problems
</h2> </h2>
</div> </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"> <table className="w-full border-collapse">
<thead> <thead>
<tr className="bg-gray-50"> <tr className="bg-gray-50">
@@ -285,6 +408,7 @@ export function ProblemsView({ onNavigate }: ProblemsViewProps) {
))} ))}
</tbody> </tbody>
</table> </table>
)}
{resolvedProblems.length === 0 && ( {resolvedProblems.length === 0 && (
<div className="p-4 text-sm text-gray-500 text-center">No resolved problems</div> <div className="p-4 text-sm text-gray-500 text-center">No resolved problems</div>
)} )}
+61
View File
@@ -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
}