Task 11: Rebuild InvestigationsView + DocumentsView (Projects + Education)
- Replace CSS height transitions with Framer Motion AnimatePresence - Add tree-indented monospace content with box-drawing characters - Add StatusBadge pills (Complete/Ongoing/Live with pulse) - Replace font-inter with font-ui, font-mono with font-geist - Add multi-layered shadows (shadow-pmr), proper borders - Add document type icons (FileText, Award, GraduationCap, FlaskConical) - Color-coded left borders on expanded panels by status/type - Alternating row backgrounds, hover:bg-[#EFF6FF] - AccessibilityContext integration for breadcrumb updates - Framer Motion chevron rotation, keyboard navigation - Mobile card layouts with same animations - prefers-reduced-motion support throughout Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,144 +1,170 @@
|
|||||||
import { useState, useEffect, useRef } from 'react'
|
import { useState, useCallback } from 'react'
|
||||||
import { ChevronDown, ChevronUp, FileText, Award, GraduationCap, FlaskConical } from 'lucide-react'
|
import { motion, AnimatePresence } from 'framer-motion'
|
||||||
|
import { ChevronDown, 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'
|
import { useBreakpoint } from '@/hooks/useBreakpoint'
|
||||||
|
import { useAccessibility } from '@/contexts/AccessibilityContext'
|
||||||
|
|
||||||
|
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||||
|
|
||||||
|
const documentIcons: Record<DocumentType, React.FC<{ className?: string }>> = {
|
||||||
|
Certificate: FileText,
|
||||||
|
Registration: Award,
|
||||||
|
Results: GraduationCap,
|
||||||
|
Research: FlaskConical,
|
||||||
|
}
|
||||||
|
|
||||||
function DocumentTypeIcon({ type }: { type: DocumentType }) {
|
function DocumentTypeIcon({ type }: { type: DocumentType }) {
|
||||||
const iconMap: Record<DocumentType, React.ReactNode> = {
|
const Icon = documentIcons[type]
|
||||||
Certificate: <FileText className="w-4 h-4 text-gray-500" />,
|
|
||||||
Registration: <Award className="w-4 h-4 text-gray-500" />,
|
|
||||||
Results: <GraduationCap className="w-4 h-4 text-gray-500" />,
|
|
||||||
Research: <FlaskConical className="w-4 h-4 text-gray-500" />,
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center">
|
<div className="flex items-center justify-center">
|
||||||
{iconMap[type]}
|
<Icon className="w-4 h-4 text-gray-500" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const documentBorderColors: Record<DocumentType, string> = {
|
||||||
|
Certificate: '#005EB8',
|
||||||
|
Registration: '#10B981',
|
||||||
|
Results: '#6366F1',
|
||||||
|
Research: '#8B5CF6',
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TreeLineProps {
|
||||||
|
label: string
|
||||||
|
value: React.ReactNode
|
||||||
|
isLast?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function TreeLine({ label, value, isLast = false }: TreeLineProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex">
|
||||||
|
<span className="text-gray-400 select-none">{isLast ? '└─ ' : '├─ '}</span>
|
||||||
|
<span className="text-gray-500 shrink-0 min-w-[160px]">{label}:</span>
|
||||||
|
<span className="ml-2 flex-1">{value}</span>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function DocumentRow({
|
function DocumentRow({
|
||||||
document,
|
document: doc,
|
||||||
isExpanded,
|
isExpanded,
|
||||||
onToggle,
|
onToggle,
|
||||||
|
index,
|
||||||
}: {
|
}: {
|
||||||
document: Document
|
document: Document
|
||||||
isExpanded: boolean
|
isExpanded: boolean
|
||||||
onToggle: () => void
|
onToggle: () => void
|
||||||
|
index: number
|
||||||
}) {
|
}) {
|
||||||
const contentRef = useRef<HTMLDivElement>(null)
|
const fields: Array<{ label: string; value: React.ReactNode }> = [
|
||||||
const [contentHeight, setContentHeight] = useState<number | undefined>(undefined)
|
{ label: 'Type', value: doc.type },
|
||||||
const prefersReducedMotion = useRef(
|
{ label: 'Date Awarded', value: doc.date },
|
||||||
window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
]
|
||||||
).current
|
|
||||||
|
|
||||||
useEffect(() => {
|
if (doc.institution) fields.push({ label: 'Institution', value: doc.institution })
|
||||||
if (contentRef.current) {
|
if (doc.classification) fields.push({ label: 'Classification', value: doc.classification })
|
||||||
setContentHeight(contentRef.current.scrollHeight)
|
if (doc.duration) fields.push({ label: 'Duration', value: doc.duration })
|
||||||
}
|
if (doc.researchDetail) {
|
||||||
}, [isExpanded])
|
fields.push({
|
||||||
|
label: 'Research',
|
||||||
|
value: (
|
||||||
|
<>
|
||||||
|
{doc.researchDetail}
|
||||||
|
{doc.researchGrade && (
|
||||||
|
<>
|
||||||
|
<br />
|
||||||
|
<span className="text-gray-500">Grade: {doc.researchGrade}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (doc.notes) fields.push({ label: 'Notes', value: <span className="text-gray-600">{doc.notes}</span> })
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<tr
|
<tr
|
||||||
className={`cursor-pointer hover:bg-blue-50 transition-colors ${
|
className={`cursor-pointer transition-colors h-[40px] ${
|
||||||
isExpanded ? 'bg-blue-50' : ''
|
isExpanded ? 'bg-[#EFF6FF]' : index % 2 === 0 ? 'bg-white' : 'bg-[#F9FAFB]'
|
||||||
}`}
|
} hover:bg-[#EFF6FF]`}
|
||||||
onClick={onToggle}
|
onClick={onToggle}
|
||||||
|
tabIndex={0}
|
||||||
|
role="button"
|
||||||
aria-expanded={isExpanded}
|
aria-expanded={isExpanded}
|
||||||
|
aria-label={`${doc.title} — ${doc.type}, ${doc.date}`}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault()
|
||||||
|
onToggle()
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<td className="border border-gray-200 px-3 py-2.5 w-12">
|
<td className="border-b border-r border-[#E5E7EB] px-3 py-2 w-12">
|
||||||
<DocumentTypeIcon type={document.type} />
|
<DocumentTypeIcon type={doc.type} />
|
||||||
</td>
|
</td>
|
||||||
<td className="border border-gray-200 px-3 py-2.5">
|
<td className="border-b border-r border-[#E5E7EB] px-3 py-2">
|
||||||
<span className="text-sm text-gray-900">{document.title}</span>
|
<div className="flex items-center gap-2">
|
||||||
</td>
|
<motion.div
|
||||||
<td className="border border-gray-200 px-3 py-2.5">
|
animate={{ rotate: isExpanded ? 180 : 0 }}
|
||||||
<span className="font-mono text-xs text-gray-500">{document.date}</span>
|
transition={{ duration: prefersReducedMotion ? 0 : 0.2 }}
|
||||||
</td>
|
>
|
||||||
<td className="border border-gray-200 px-3 py-2.5">
|
|
||||||
<span className="text-sm text-gray-700">{document.source}</span>
|
|
||||||
</td>
|
|
||||||
<td className="border border-gray-200 px-3 py-2.5 w-10">
|
|
||||||
<button
|
|
||||||
className="p-1 hover:bg-gray-100 rounded transition-colors"
|
|
||||||
aria-label={isExpanded ? 'Collapse' : 'Expand'}
|
|
||||||
>
|
|
||||||
{isExpanded ? (
|
|
||||||
<ChevronUp className="w-4 h-4 text-gray-400" />
|
|
||||||
) : (
|
|
||||||
<ChevronDown className="w-4 h-4 text-gray-400" />
|
<ChevronDown className="w-4 h-4 text-gray-400" />
|
||||||
)}
|
</motion.div>
|
||||||
</button>
|
<span className="font-ui text-[14px] text-gray-900">{doc.title}</span>
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td colSpan={5} className="p-0 border border-gray-200">
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
height: isExpanded ? contentHeight : 0,
|
|
||||||
overflow: 'hidden',
|
|
||||||
transition: prefersReducedMotion ? 'none' : 'height 200ms ease-out',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div ref={contentRef} className="bg-gray-50 p-4">
|
|
||||||
<div className="font-mono text-sm text-gray-700 leading-relaxed space-y-1">
|
|
||||||
<div className="flex">
|
|
||||||
<span className="text-gray-400 w-40 shrink-0">Type:</span>
|
|
||||||
<span>{document.type}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex">
|
|
||||||
<span className="text-gray-400 w-40 shrink-0">Date Awarded:</span>
|
|
||||||
<span>{document.date}</span>
|
|
||||||
</div>
|
|
||||||
{document.institution && (
|
|
||||||
<div className="flex">
|
|
||||||
<span className="text-gray-400 w-40 shrink-0">Institution:</span>
|
|
||||||
<span>{document.institution}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{document.classification && (
|
|
||||||
<div className="flex">
|
|
||||||
<span className="text-gray-400 w-40 shrink-0">Classification:</span>
|
|
||||||
<span>{document.classification}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{document.duration && (
|
|
||||||
<div className="flex">
|
|
||||||
<span className="text-gray-400 w-40 shrink-0">Duration:</span>
|
|
||||||
<span>{document.duration}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{document.researchDetail && (
|
|
||||||
<div className="flex">
|
|
||||||
<span className="text-gray-400 w-40 shrink-0">Research:</span>
|
|
||||||
<span className="flex-1">
|
|
||||||
{document.researchDetail}
|
|
||||||
{document.researchGrade && (
|
|
||||||
<><br />Grade: {document.researchGrade}</>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{document.notes && (
|
|
||||||
<div className="flex">
|
|
||||||
<span className="text-gray-400 w-40 shrink-0">Notes:</span>
|
|
||||||
<span className="flex-1 text-gray-600">{document.notes}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
<td className="border-b border-r border-[#E5E7EB] px-3 py-2">
|
||||||
|
<span className="font-geist text-[13px] text-gray-500">{doc.date}</span>
|
||||||
|
</td>
|
||||||
|
<td className="border-b border-[#E5E7EB] px-3 py-2">
|
||||||
|
<span className="font-ui text-[13px] text-gray-700">{doc.source}</span>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
<AnimatePresence initial={false}>
|
||||||
|
{isExpanded && (
|
||||||
|
<motion.tr
|
||||||
|
initial={{ height: 0 }}
|
||||||
|
animate={{ height: 'auto' }}
|
||||||
|
exit={{ height: 0 }}
|
||||||
|
transition={{ duration: prefersReducedMotion ? 0 : 0.2, ease: 'easeOut' }}
|
||||||
|
>
|
||||||
|
<td colSpan={4} className="p-0 border-b border-[#E5E7EB]">
|
||||||
|
<motion.div
|
||||||
|
initial={{ height: 0 }}
|
||||||
|
animate={{ height: 'auto' }}
|
||||||
|
exit={{ height: 0 }}
|
||||||
|
transition={{ duration: prefersReducedMotion ? 0 : 0.2, ease: 'easeOut' }}
|
||||||
|
style={{ overflow: 'hidden' }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="bg-[#F9FAFB] p-4 border-l-4"
|
||||||
|
style={{ borderLeftColor: documentBorderColors[doc.type] }}
|
||||||
|
>
|
||||||
|
<div className="font-geist text-[12px] text-gray-700 leading-relaxed space-y-0.5">
|
||||||
|
{fields.map((field, idx) => (
|
||||||
|
<TreeLine
|
||||||
|
key={field.label}
|
||||||
|
label={field.label}
|
||||||
|
value={field.value}
|
||||||
|
isLast={idx === fields.length - 1}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</td>
|
||||||
|
</motion.tr>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function MobileDocumentCard({
|
function MobileDocumentCard({
|
||||||
document,
|
document: doc,
|
||||||
isExpanded,
|
isExpanded,
|
||||||
onToggle,
|
onToggle,
|
||||||
}: {
|
}: {
|
||||||
@@ -146,87 +172,92 @@ function MobileDocumentCard({
|
|||||||
isExpanded: boolean
|
isExpanded: boolean
|
||||||
onToggle: () => void
|
onToggle: () => void
|
||||||
}) {
|
}) {
|
||||||
|
const fields: Array<{ label: string; value: React.ReactNode }> = [
|
||||||
|
{ label: 'Type', value: doc.type },
|
||||||
|
{ label: 'Date Awarded', value: doc.date },
|
||||||
|
]
|
||||||
|
|
||||||
|
if (doc.institution) fields.push({ label: 'Institution', value: doc.institution })
|
||||||
|
if (doc.classification) fields.push({ label: 'Classification', value: doc.classification })
|
||||||
|
if (doc.duration) fields.push({ label: 'Duration', value: doc.duration })
|
||||||
|
if (doc.researchDetail) {
|
||||||
|
fields.push({
|
||||||
|
label: 'Research',
|
||||||
|
value: (
|
||||||
|
<>
|
||||||
|
{doc.researchDetail}
|
||||||
|
{doc.researchGrade && (
|
||||||
|
<>
|
||||||
|
<br />
|
||||||
|
<span className="text-gray-500">Grade: {doc.researchGrade}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (doc.notes) fields.push({ label: 'Notes', value: <span className="text-gray-600">{doc.notes}</span> })
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-white border border-gray-200 rounded">
|
<div className="bg-white border border-[#E5E7EB] rounded shadow-pmr overflow-hidden">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onToggle}
|
onClick={onToggle}
|
||||||
className="w-full p-4 text-left"
|
className="w-full p-4 text-left focus-visible:ring-2 focus-visible:ring-[#005EB8]/40 focus-visible:ring-inset focus-visible:outline-none"
|
||||||
aria-expanded={isExpanded}
|
aria-expanded={isExpanded}
|
||||||
|
aria-label={`${doc.title} — ${doc.type}, ${doc.date}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
<DocumentTypeIcon type={document.type} />
|
<DocumentTypeIcon type={doc.type} />
|
||||||
<span className="text-xs text-gray-500">{document.type}</span>
|
<span className="font-ui text-[12px] text-gray-500">{doc.type}</span>
|
||||||
</div>
|
</div>
|
||||||
<h3 className="font-inter font-medium text-sm text-gray-900">
|
<h3 className="font-ui font-medium text-[14px] text-gray-900">
|
||||||
{document.title}
|
{doc.title}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="flex items-center gap-2 mt-1.5 text-xs text-gray-500">
|
<div className="flex items-center gap-2 mt-1.5">
|
||||||
<span className="font-geist">{document.date}</span>
|
<span className="font-geist text-[12px] text-gray-500">{doc.date}</span>
|
||||||
<span>•</span>
|
<span className="text-gray-300">•</span>
|
||||||
<span>{document.source}</span>
|
<span className="font-ui text-[12px] text-gray-500">{doc.source}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-shrink-0 mt-1">
|
<motion.div
|
||||||
{isExpanded ? (
|
animate={{ rotate: isExpanded ? 180 : 0 }}
|
||||||
<ChevronUp size={16} className="text-gray-400" />
|
transition={{ duration: prefersReducedMotion ? 0 : 0.2 }}
|
||||||
) : (
|
className="flex-shrink-0 mt-1"
|
||||||
<ChevronDown size={16} className="text-gray-400" />
|
>
|
||||||
)}
|
<ChevronDown size={16} className="text-gray-400" />
|
||||||
</div>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
{isExpanded && (
|
<AnimatePresence initial={false}>
|
||||||
<div className="px-4 pb-4 border-t border-gray-100">
|
{isExpanded && (
|
||||||
<div className="pt-3 font-mono text-xs text-gray-700 leading-relaxed space-y-2">
|
<motion.div
|
||||||
<div className="flex">
|
initial={{ height: 0 }}
|
||||||
<span className="text-gray-400 w-28 shrink-0">Type:</span>
|
animate={{ height: 'auto' }}
|
||||||
<span>{document.type}</span>
|
exit={{ height: 0 }}
|
||||||
|
transition={{ duration: prefersReducedMotion ? 0 : 0.2, ease: 'easeOut' }}
|
||||||
|
style={{ overflow: 'hidden' }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="px-4 pb-4 border-t border-[#E5E7EB] border-l-4"
|
||||||
|
style={{ borderLeftColor: documentBorderColors[doc.type] }}
|
||||||
|
>
|
||||||
|
<div className="pt-3 font-geist text-[12px] text-gray-700 leading-relaxed space-y-0.5">
|
||||||
|
{fields.map((field, idx) => (
|
||||||
|
<TreeLine
|
||||||
|
key={field.label}
|
||||||
|
label={field.label}
|
||||||
|
value={field.value}
|
||||||
|
isLast={idx === fields.length - 1}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex">
|
</motion.div>
|
||||||
<span className="text-gray-400 w-28 shrink-0">Date Awarded:</span>
|
)}
|
||||||
<span>{document.date}</span>
|
</AnimatePresence>
|
||||||
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -234,29 +265,32 @@ function MobileDocumentCard({
|
|||||||
export function DocumentsView() {
|
export function DocumentsView() {
|
||||||
const [expandedId, setExpandedId] = useState<string | null>(null)
|
const [expandedId, setExpandedId] = useState<string | null>(null)
|
||||||
const { isMobile } = useBreakpoint()
|
const { isMobile } = useBreakpoint()
|
||||||
|
const { setExpandedItem } = useAccessibility()
|
||||||
|
|
||||||
const handleToggle = (id: string) => {
|
const handleToggle = useCallback((id: string, title: string) => {
|
||||||
setExpandedId(expandedId === id ? null : id)
|
const newId = expandedId === id ? null : id
|
||||||
}
|
setExpandedId(newId)
|
||||||
|
setExpandedItem(newId ? title : null)
|
||||||
|
}, [expandedId, setExpandedItem])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-white border border-gray-200 rounded overflow-hidden">
|
<div className="bg-white border border-[#E5E7EB] rounded shadow-pmr overflow-hidden">
|
||||||
<div className="bg-gray-50 border-b border-gray-200 px-4 py-3">
|
<div className="bg-[#F9FAFB] border-b border-[#E5E7EB] px-4 py-3">
|
||||||
<h2 className="font-inter font-semibold text-sm uppercase tracking-wider text-gray-500">
|
<h2 className="font-ui font-semibold text-[13px] uppercase tracking-[0.05em] text-gray-500">
|
||||||
Attached Documents
|
Attached Documents
|
||||||
</h2>
|
</h2>
|
||||||
<p className="font-inter text-xs text-gray-400 mt-1">
|
<p className="font-ui text-[12px] text-gray-400 mt-1">
|
||||||
Education and certifications presented as attached documents in the patient record.
|
{documents.length} document{documents.length !== 1 ? 's' : ''} attached. Click a row to view details.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{isMobile ? (
|
{isMobile ? (
|
||||||
<div className="p-3 space-y-3 bg-pmr-content">
|
<div className="p-3 space-y-3 bg-[#F5F7FA]">
|
||||||
{documents.map((document) => (
|
{documents.map((doc) => (
|
||||||
<MobileDocumentCard
|
<MobileDocumentCard
|
||||||
key={document.id}
|
key={doc.id}
|
||||||
document={document}
|
document={doc}
|
||||||
isExpanded={expandedId === document.id}
|
isExpanded={expandedId === doc.id}
|
||||||
onToggle={() => handleToggle(document.id)}
|
onToggle={() => handleToggle(doc.id, doc.title)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -264,55 +298,47 @@ export function DocumentsView() {
|
|||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full border-collapse">
|
<table className="w-full border-collapse">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="bg-gray-50">
|
<tr className="bg-[#F9FAFB]">
|
||||||
<th
|
<th
|
||||||
scope="col"
|
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"
|
className="border-b border-r border-[#E5E7EB] px-3 py-2 text-left font-ui font-semibold text-[13px] uppercase tracking-[0.05em] text-gray-400 w-12"
|
||||||
>
|
>
|
||||||
Type
|
Type
|
||||||
</th>
|
</th>
|
||||||
<th
|
<th
|
||||||
scope="col"
|
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"
|
className="border-b border-r border-[#E5E7EB] px-3 py-2 text-left font-ui font-semibold text-[13px] uppercase tracking-[0.05em] text-gray-400"
|
||||||
>
|
>
|
||||||
Document
|
Document
|
||||||
</th>
|
</th>
|
||||||
<th
|
<th
|
||||||
scope="col"
|
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"
|
className="border-b border-r border-[#E5E7EB] px-3 py-2 text-left font-ui font-semibold text-[13px] uppercase tracking-[0.05em] text-gray-400 w-20"
|
||||||
>
|
>
|
||||||
Date
|
Date
|
||||||
</th>
|
</th>
|
||||||
<th
|
<th
|
||||||
scope="col"
|
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"
|
className="border-b border-[#E5E7EB] px-3 py-2 text-left font-ui font-semibold text-[13px] uppercase tracking-[0.05em] text-gray-400 w-32"
|
||||||
>
|
>
|
||||||
Source
|
Source
|
||||||
</th>
|
</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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{documents.map((document) => (
|
{documents.map((doc, index) => (
|
||||||
<DocumentRow
|
<DocumentRow
|
||||||
key={document.id}
|
key={doc.id}
|
||||||
document={document}
|
document={doc}
|
||||||
isExpanded={expandedId === document.id}
|
isExpanded={expandedId === doc.id}
|
||||||
onToggle={() => handleToggle(document.id)}
|
onToggle={() => handleToggle(doc.id, doc.title)}
|
||||||
|
index={index}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{documents.length === 0 && (
|
|
||||||
<div className="p-4 text-sm text-gray-500 text-center">No documents attached</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,39 +1,75 @@
|
|||||||
import { useState, useEffect, useRef } from 'react'
|
import { useState, useCallback } from 'react'
|
||||||
import { ChevronDown, ChevronUp, ExternalLink, Circle } from 'lucide-react'
|
import { motion, AnimatePresence } from 'framer-motion'
|
||||||
|
import { ChevronDown, ExternalLink } 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'
|
import { useBreakpoint } from '@/hooks/useBreakpoint'
|
||||||
|
import { useAccessibility } from '@/contexts/AccessibilityContext'
|
||||||
|
|
||||||
type InvestigationStatus = 'Complete' | 'Ongoing' | 'Live'
|
type InvestigationStatus = 'Complete' | 'Ongoing' | 'Live'
|
||||||
|
|
||||||
|
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||||
|
|
||||||
function StatusBadge({ status }: { status: InvestigationStatus }) {
|
function StatusBadge({ status }: { status: InvestigationStatus }) {
|
||||||
if (status === 'Live') {
|
const styles: Record<InvestigationStatus, { badge: string; dot: string; label: string }> = {
|
||||||
return (
|
Complete: {
|
||||||
<div className="flex items-center gap-2">
|
badge: 'bg-emerald-100 text-emerald-800 border-emerald-200',
|
||||||
<span className="relative flex h-2 w-2">
|
dot: 'bg-emerald-500',
|
||||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"></span>
|
label: 'Complete',
|
||||||
<Circle className="relative inline-flex rounded-full h-2 w-2 bg-green-500 fill-green-500" />
|
},
|
||||||
</span>
|
Ongoing: {
|
||||||
<span className="text-xs text-gray-600">Live</span>
|
badge: 'bg-amber-100 text-amber-800 border-amber-200',
|
||||||
</div>
|
dot: 'bg-amber-500',
|
||||||
)
|
label: 'Ongoing',
|
||||||
|
},
|
||||||
|
Live: {
|
||||||
|
badge: 'bg-emerald-100 text-emerald-800 border-emerald-200',
|
||||||
|
dot: 'bg-emerald-500',
|
||||||
|
label: 'Live',
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
const colorMap: Record<Exclude<InvestigationStatus, 'Live'>, { bg: string; label: string }> = {
|
const { badge, dot, label } = styles[status]
|
||||||
Complete: { bg: 'bg-green-500', label: 'Complete' },
|
|
||||||
Ongoing: { bg: 'bg-amber-500', label: 'Ongoing' },
|
|
||||||
}
|
|
||||||
|
|
||||||
const { bg, label } = colorMap[status as Exclude<InvestigationStatus, 'Live'>]
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded text-xs font-medium border ${badge}`}>
|
||||||
<span
|
<span className="relative flex h-1.5 w-1.5">
|
||||||
className={`w-2 h-2 rounded-full ${bg}`}
|
{status === 'Live' && (
|
||||||
aria-label={`Status: ${status}`}
|
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75" />
|
||||||
role="img"
|
)}
|
||||||
/>
|
<span className={`relative inline-flex rounded-full h-1.5 w-1.5 ${dot}`} />
|
||||||
<span className="text-xs text-gray-600">{label}</span>
|
</span>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TreeLineProps {
|
||||||
|
label: string
|
||||||
|
value: React.ReactNode
|
||||||
|
isLast?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function TreeLine({ label, value, isLast = false }: TreeLineProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex">
|
||||||
|
<span className="text-gray-400 select-none">{isLast ? '└─ ' : '├─ '}</span>
|
||||||
|
<span className="text-gray-500 shrink-0 min-w-[160px]">{label}:</span>
|
||||||
|
<span className="ml-2 flex-1">{value}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TreeBranch({ label, children, isLast = false }: { label: string; children: React.ReactNode; isLast?: boolean }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex">
|
||||||
|
<span className="text-gray-400 select-none">{isLast ? '└─ ' : '├─ '}</span>
|
||||||
|
<span className="text-gray-500 shrink-0 min-w-[160px]">{label}:</span>
|
||||||
|
</div>
|
||||||
|
<div className="ml-[18px]">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -42,127 +78,125 @@ function InvestigationRow({
|
|||||||
investigation,
|
investigation,
|
||||||
isExpanded,
|
isExpanded,
|
||||||
onToggle,
|
onToggle,
|
||||||
|
index,
|
||||||
}: {
|
}: {
|
||||||
investigation: Investigation
|
investigation: Investigation
|
||||||
isExpanded: boolean
|
isExpanded: boolean
|
||||||
onToggle: () => void
|
onToggle: () => void
|
||||||
|
index: number
|
||||||
}) {
|
}) {
|
||||||
const contentRef = useRef<HTMLDivElement>(null)
|
const statusBorderColor: Record<InvestigationStatus, string> = {
|
||||||
const [contentHeight, setContentHeight] = useState<number | undefined>(undefined)
|
Complete: '#10B981',
|
||||||
const prefersReducedMotion = useRef(
|
Ongoing: '#F59E0B',
|
||||||
window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
Live: '#10B981',
|
||||||
).current
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (contentRef.current) {
|
|
||||||
setContentHeight(contentRef.current.scrollHeight)
|
|
||||||
}
|
|
||||||
}, [isExpanded])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<tr
|
<tr
|
||||||
className={`cursor-pointer hover:bg-blue-50 transition-colors ${
|
className={`cursor-pointer transition-colors h-[40px] ${
|
||||||
isExpanded ? 'bg-blue-50' : ''
|
isExpanded ? 'bg-[#EFF6FF]' : index % 2 === 0 ? 'bg-white' : 'bg-[#F9FAFB]'
|
||||||
}`}
|
} hover:bg-[#EFF6FF]`}
|
||||||
onClick={onToggle}
|
onClick={onToggle}
|
||||||
|
tabIndex={0}
|
||||||
|
role="button"
|
||||||
aria-expanded={isExpanded}
|
aria-expanded={isExpanded}
|
||||||
|
aria-label={`${investigation.name} — ${investigation.status}`}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault()
|
||||||
|
onToggle()
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<td className="border border-gray-200 px-3 py-2.5">
|
<td className="border-b border-r border-[#E5E7EB] px-3 py-2">
|
||||||
<span className="text-sm text-gray-900">{investigation.name}</span>
|
<div className="flex items-center gap-2">
|
||||||
</td>
|
<motion.div
|
||||||
<td className="border border-gray-200 px-3 py-2.5">
|
animate={{ rotate: isExpanded ? 180 : 0 }}
|
||||||
<span className="font-mono text-xs text-gray-500">{investigation.requestedYear}</span>
|
transition={{ duration: prefersReducedMotion ? 0 : 0.2 }}
|
||||||
</td>
|
>
|
||||||
<td className="border border-gray-200 px-3 py-2.5">
|
|
||||||
<StatusBadge status={investigation.status} />
|
|
||||||
</td>
|
|
||||||
<td className="border border-gray-200 px-3 py-2.5">
|
|
||||||
<span className="text-sm text-gray-700">{investigation.resultSummary}</span>
|
|
||||||
</td>
|
|
||||||
<td className="border border-gray-200 px-3 py-2.5 w-10">
|
|
||||||
<button
|
|
||||||
className="p-1 hover:bg-gray-100 rounded transition-colors"
|
|
||||||
aria-label={isExpanded ? 'Collapse' : 'Expand'}
|
|
||||||
>
|
|
||||||
{isExpanded ? (
|
|
||||||
<ChevronUp className="w-4 h-4 text-gray-400" />
|
|
||||||
) : (
|
|
||||||
<ChevronDown className="w-4 h-4 text-gray-400" />
|
<ChevronDown className="w-4 h-4 text-gray-400" />
|
||||||
)}
|
</motion.div>
|
||||||
</button>
|
<span className="font-ui text-[14px] text-gray-900">{investigation.name}</span>
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td colSpan={5} className="p-0 border border-gray-200">
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
height: isExpanded ? contentHeight : 0,
|
|
||||||
overflow: 'hidden',
|
|
||||||
transition: prefersReducedMotion ? 'none' : 'height 200ms ease-out',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div ref={contentRef} className="bg-gray-50 p-4">
|
|
||||||
<div className="font-mono text-sm text-gray-700 leading-relaxed space-y-1">
|
|
||||||
<div className="flex">
|
|
||||||
<span className="text-gray-400 w-40 shrink-0">Date Requested:</span>
|
|
||||||
<span>{investigation.requestedYear}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex">
|
|
||||||
<span className="text-gray-400 w-40 shrink-0">Date Reported:</span>
|
|
||||||
<span>{investigation.reportedYear ?? 'Pending'}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex">
|
|
||||||
<span className="text-gray-400 w-40 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-40 shrink-0">Requesting Clinician:</span>
|
|
||||||
<span>{investigation.requestingClinician}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex">
|
|
||||||
<span className="text-gray-400 w-40 shrink-0">Methodology:</span>
|
|
||||||
<span className="flex-1">{investigation.methodology}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex">
|
|
||||||
<span className="text-gray-400 w-40 shrink-0">Results:</span>
|
|
||||||
<ul className="flex-1 space-y-1">
|
|
||||||
{investigation.results.map((result, idx) => (
|
|
||||||
<li key={idx} className="flex items-start gap-2">
|
|
||||||
<span className="text-gray-300 mt-1">-</span>
|
|
||||||
<span>{result}</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<div className="flex">
|
|
||||||
<span className="text-gray-400 w-40 shrink-0">Tech Stack:</span>
|
|
||||||
<span>{investigation.techStack.join(', ')}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{investigation.externalUrl && (
|
|
||||||
<div className="mt-4 pt-4 border-t border-gray-200">
|
|
||||||
<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-sm font-medium rounded hover:bg-blue-700 transition-colors"
|
|
||||||
>
|
|
||||||
View Results
|
|
||||||
<ExternalLink className="w-4 h-4" />
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
<td className="border-b border-r border-[#E5E7EB] px-3 py-2">
|
||||||
|
<span className="font-geist text-[13px] text-gray-500">{investigation.requestedYear}</span>
|
||||||
|
</td>
|
||||||
|
<td className="border-b border-r border-[#E5E7EB] px-3 py-2">
|
||||||
|
<StatusBadge status={investigation.status} />
|
||||||
|
</td>
|
||||||
|
<td className="border-b border-[#E5E7EB] px-3 py-2">
|
||||||
|
<span className="font-ui text-[13px] text-gray-700">{investigation.resultSummary}</span>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
<AnimatePresence initial={false}>
|
||||||
|
{isExpanded && (
|
||||||
|
<motion.tr
|
||||||
|
initial={{ height: 0 }}
|
||||||
|
animate={{ height: 'auto' }}
|
||||||
|
exit={{ height: 0 }}
|
||||||
|
transition={{ duration: prefersReducedMotion ? 0 : 0.2, ease: 'easeOut' }}
|
||||||
|
>
|
||||||
|
<td colSpan={4} className="p-0 border-b border-[#E5E7EB]">
|
||||||
|
<motion.div
|
||||||
|
initial={{ height: 0 }}
|
||||||
|
animate={{ height: 'auto' }}
|
||||||
|
exit={{ height: 0 }}
|
||||||
|
transition={{ duration: prefersReducedMotion ? 0 : 0.2, ease: 'easeOut' }}
|
||||||
|
style={{ overflow: 'hidden' }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="bg-[#F9FAFB] p-4 border-l-4"
|
||||||
|
style={{ borderLeftColor: statusBorderColor[investigation.status] }}
|
||||||
|
>
|
||||||
|
<div className="font-geist text-[12px] text-gray-700 leading-relaxed space-y-0.5">
|
||||||
|
<TreeLine label="Date Requested" value={String(investigation.requestedYear)} />
|
||||||
|
<TreeLine label="Date Reported" value={investigation.reportedYear ? String(investigation.reportedYear) : 'Pending'} />
|
||||||
|
<TreeLine
|
||||||
|
label="Status"
|
||||||
|
value={
|
||||||
|
<>
|
||||||
|
{investigation.status}
|
||||||
|
{investigation.status === 'Live' && investigation.externalUrl && (
|
||||||
|
<> — Live at {investigation.externalUrl.replace('https://', '')}</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<TreeLine label="Requesting Clinician" value={investigation.requestingClinician} />
|
||||||
|
<TreeLine label="Methodology" value={investigation.methodology} />
|
||||||
|
<TreeBranch label="Results">
|
||||||
|
{investigation.results.map((result, idx) => (
|
||||||
|
<div key={idx} className="flex">
|
||||||
|
<span className="text-gray-400 select-none">{idx === investigation.results.length - 1 ? '└─ ' : '├─ '}</span>
|
||||||
|
<span>{result}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</TreeBranch>
|
||||||
|
<TreeLine label="Tech Stack" value={investigation.techStack.join(', ')} isLast={!investigation.externalUrl} />
|
||||||
|
{investigation.externalUrl && (
|
||||||
|
<div className="flex items-center pt-2">
|
||||||
|
<span className="text-gray-400 select-none">└─ </span>
|
||||||
|
<a
|
||||||
|
href={investigation.externalUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="inline-flex items-center gap-2 px-4 py-1.5 bg-[#005EB8] text-white text-[12px] font-medium rounded hover:bg-[#004D9F] transition-colors focus-visible:ring-2 focus-visible:ring-[#005EB8]/40 focus-visible:outline-none"
|
||||||
|
>
|
||||||
|
View Results
|
||||||
|
<ExternalLink className="w-3.5 h-3.5" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</td>
|
||||||
|
</motion.tr>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -176,97 +210,100 @@ function MobileInvestigationCard({
|
|||||||
isExpanded: boolean
|
isExpanded: boolean
|
||||||
onToggle: () => void
|
onToggle: () => void
|
||||||
}) {
|
}) {
|
||||||
|
const statusBorderColor: Record<InvestigationStatus, string> = {
|
||||||
|
Complete: '#10B981',
|
||||||
|
Ongoing: '#F59E0B',
|
||||||
|
Live: '#10B981',
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-white border border-gray-200 rounded">
|
<div className="bg-white border border-[#E5E7EB] rounded shadow-pmr overflow-hidden">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onToggle}
|
onClick={onToggle}
|
||||||
className="w-full p-4 text-left"
|
className="w-full p-4 text-left focus-visible:ring-2 focus-visible:ring-[#005EB8]/40 focus-visible:ring-inset focus-visible:outline-none"
|
||||||
aria-expanded={isExpanded}
|
aria-expanded={isExpanded}
|
||||||
|
aria-label={`${investigation.name} — ${investigation.status}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<h3 className="font-inter font-medium text-sm text-gray-900">
|
<h3 className="font-ui font-medium text-[14px] text-gray-900">
|
||||||
{investigation.name}
|
{investigation.name}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="flex items-center gap-3 mt-1.5 text-xs text-gray-500">
|
<div className="flex items-center gap-3 mt-1.5">
|
||||||
<span className="font-geist">{investigation.requestedYear}</span>
|
<span className="font-geist text-[12px] text-gray-500">{investigation.requestedYear}</span>
|
||||||
<span>•</span>
|
|
||||||
<StatusBadge status={investigation.status} />
|
<StatusBadge status={investigation.status} />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-gray-700 mt-2 line-clamp-2">
|
<p className="font-ui text-[12px] text-gray-700 mt-2 line-clamp-2">
|
||||||
{investigation.resultSummary}
|
{investigation.resultSummary}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-shrink-0 mt-1">
|
<motion.div
|
||||||
{isExpanded ? (
|
animate={{ rotate: isExpanded ? 180 : 0 }}
|
||||||
<ChevronUp size={16} className="text-gray-400" />
|
transition={{ duration: prefersReducedMotion ? 0 : 0.2 }}
|
||||||
) : (
|
className="flex-shrink-0 mt-1"
|
||||||
<ChevronDown size={16} className="text-gray-400" />
|
>
|
||||||
)}
|
<ChevronDown size={16} className="text-gray-400" />
|
||||||
</div>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
{isExpanded && (
|
<AnimatePresence initial={false}>
|
||||||
<div className="px-4 pb-4 border-t border-gray-100">
|
{isExpanded && (
|
||||||
<div className="pt-3 font-mono text-xs text-gray-700 leading-relaxed space-y-2">
|
<motion.div
|
||||||
<div className="flex">
|
initial={{ height: 0 }}
|
||||||
<span className="text-gray-400 w-28 shrink-0">Date Requested:</span>
|
animate={{ height: 'auto' }}
|
||||||
<span>{investigation.requestedYear}</span>
|
exit={{ height: 0 }}
|
||||||
|
transition={{ duration: prefersReducedMotion ? 0 : 0.2, ease: 'easeOut' }}
|
||||||
|
style={{ overflow: 'hidden' }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="px-4 pb-4 border-t border-[#E5E7EB] border-l-4"
|
||||||
|
style={{ borderLeftColor: statusBorderColor[investigation.status] }}
|
||||||
|
>
|
||||||
|
<div className="pt-3 font-geist text-[12px] text-gray-700 leading-relaxed space-y-0.5">
|
||||||
|
<TreeLine label="Date Requested" value={String(investigation.requestedYear)} />
|
||||||
|
<TreeLine label="Date Reported" value={investigation.reportedYear ? String(investigation.reportedYear) : 'Pending'} />
|
||||||
|
<TreeLine
|
||||||
|
label="Status"
|
||||||
|
value={
|
||||||
|
<>
|
||||||
|
{investigation.status}
|
||||||
|
{investigation.status === 'Live' && investigation.externalUrl && (
|
||||||
|
<> — Live at {investigation.externalUrl.replace('https://', '')}</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<TreeLine label="Clinician" value={investigation.requestingClinician} />
|
||||||
|
<TreeLine label="Methodology" value={investigation.methodology} />
|
||||||
|
<TreeBranch label="Results">
|
||||||
|
{investigation.results.map((result, idx) => (
|
||||||
|
<div key={idx} className="flex">
|
||||||
|
<span className="text-gray-400 select-none">{idx === investigation.results.length - 1 ? '└─ ' : '├─ '}</span>
|
||||||
|
<span>{result}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</TreeBranch>
|
||||||
|
<TreeLine label="Tech Stack" value={investigation.techStack.join(', ')} isLast={!investigation.externalUrl} />
|
||||||
|
</div>
|
||||||
|
{investigation.externalUrl && (
|
||||||
|
<div className="mt-3">
|
||||||
|
<a
|
||||||
|
href={investigation.externalUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="inline-flex items-center gap-2 px-4 py-1.5 bg-[#005EB8] text-white text-[12px] font-medium rounded hover:bg-[#004D9F] transition-colors focus-visible:ring-2 focus-visible:ring-[#005EB8]/40 focus-visible:outline-none"
|
||||||
|
>
|
||||||
|
View Results
|
||||||
|
<ExternalLink className="w-3 h-3" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex">
|
</motion.div>
|
||||||
<span className="text-gray-400 w-28 shrink-0">Date Reported:</span>
|
)}
|
||||||
<span>{investigation.reportedYear ?? 'Pending'}</span>
|
</AnimatePresence>
|
||||||
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -274,29 +311,32 @@ function MobileInvestigationCard({
|
|||||||
export function InvestigationsView() {
|
export function InvestigationsView() {
|
||||||
const [expandedId, setExpandedId] = useState<string | null>(null)
|
const [expandedId, setExpandedId] = useState<string | null>(null)
|
||||||
const { isMobile } = useBreakpoint()
|
const { isMobile } = useBreakpoint()
|
||||||
|
const { setExpandedItem } = useAccessibility()
|
||||||
|
|
||||||
const handleToggle = (id: string) => {
|
const handleToggle = useCallback((id: string, name: string) => {
|
||||||
setExpandedId(expandedId === id ? null : id)
|
const newId = expandedId === id ? null : id
|
||||||
}
|
setExpandedId(newId)
|
||||||
|
setExpandedItem(newId ? name : null)
|
||||||
|
}, [expandedId, setExpandedItem])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-white border border-gray-200 rounded overflow-hidden">
|
<div className="bg-white border border-[#E5E7EB] rounded shadow-pmr overflow-hidden">
|
||||||
<div className="bg-gray-50 border-b border-gray-200 px-4 py-3">
|
<div className="bg-[#F9FAFB] border-b border-[#E5E7EB] px-4 py-3">
|
||||||
<h2 className="font-inter font-semibold text-sm uppercase tracking-wider text-gray-500">
|
<h2 className="font-ui font-semibold text-[13px] uppercase tracking-[0.05em] text-gray-500">
|
||||||
Investigation Results
|
Investigation Results
|
||||||
</h2>
|
</h2>
|
||||||
<p className="font-inter text-xs text-gray-400 mt-1">
|
<p className="font-ui text-[12px] text-gray-400 mt-1">
|
||||||
Projects presented as diagnostic investigations — tests that were ordered, performed, and returned results.
|
{investigations.length} investigation{investigations.length !== 1 ? 's' : ''} on record. Click a row to view full results.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{isMobile ? (
|
{isMobile ? (
|
||||||
<div className="p-3 space-y-3 bg-pmr-content">
|
<div className="p-3 space-y-3 bg-[#F5F7FA]">
|
||||||
{investigations.map((investigation) => (
|
{investigations.map((investigation) => (
|
||||||
<MobileInvestigationCard
|
<MobileInvestigationCard
|
||||||
key={investigation.id}
|
key={investigation.id}
|
||||||
investigation={investigation}
|
investigation={investigation}
|
||||||
isExpanded={expandedId === investigation.id}
|
isExpanded={expandedId === investigation.id}
|
||||||
onToggle={() => handleToggle(investigation.id)}
|
onToggle={() => handleToggle(investigation.id, investigation.name)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -304,55 +344,47 @@ export function InvestigationsView() {
|
|||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full border-collapse">
|
<table className="w-full border-collapse">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="bg-gray-50">
|
<tr className="bg-[#F9FAFB]">
|
||||||
<th
|
<th
|
||||||
scope="col"
|
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"
|
className="border-b border-r border-[#E5E7EB] px-3 py-2 text-left font-ui font-semibold text-[13px] uppercase tracking-[0.05em] text-gray-400"
|
||||||
>
|
>
|
||||||
Test Name
|
Test Name
|
||||||
</th>
|
</th>
|
||||||
<th
|
<th
|
||||||
scope="col"
|
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"
|
className="border-b border-r border-[#E5E7EB] px-3 py-2 text-left font-ui font-semibold text-[13px] uppercase tracking-[0.05em] text-gray-400 w-24"
|
||||||
>
|
>
|
||||||
Requested
|
Requested
|
||||||
</th>
|
</th>
|
||||||
<th
|
<th
|
||||||
scope="col"
|
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"
|
className="border-b border-r border-[#E5E7EB] px-3 py-2 text-left font-ui font-semibold text-[13px] uppercase tracking-[0.05em] text-gray-400 w-28"
|
||||||
>
|
>
|
||||||
Status
|
Status
|
||||||
</th>
|
</th>
|
||||||
<th
|
<th
|
||||||
scope="col"
|
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"
|
className="border-b border-[#E5E7EB] px-3 py-2 text-left font-ui font-semibold text-[13px] uppercase tracking-[0.05em] text-gray-400"
|
||||||
>
|
>
|
||||||
Result
|
Result
|
||||||
</th>
|
</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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{investigations.map((investigation) => (
|
{investigations.map((investigation, index) => (
|
||||||
<InvestigationRow
|
<InvestigationRow
|
||||||
key={investigation.id}
|
key={investigation.id}
|
||||||
investigation={investigation}
|
investigation={investigation}
|
||||||
isExpanded={expandedId === investigation.id}
|
isExpanded={expandedId === investigation.id}
|
||||||
onToggle={() => handleToggle(investigation.id)}
|
onToggle={() => handleToggle(investigation.id, investigation.name)}
|
||||||
|
index={index}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{investigations.length === 0 && (
|
|
||||||
<div className="p-4 text-sm text-gray-500 text-center">No investigation results</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user