Task 7: Build ConsultationsView with History/Examination/Plan structure

- Create ConsultationsView with 5 expandable consultation entries
- Each entry has color-coded left border by employer (NHS blue vs Teal)
- Collapsed state shows date, org, role, key coded entry
- Expanded state shows Duration, HISTORY, EXAMINATION, PLAN, CODED ENTRIES
- Accordion behavior: only one entry expanded at a time
- Expand animation 200ms ease-out, respects reduced motion
- Section headers in uppercase with letter-spacing
- Coded entries in [XXX000] format with Geist Mono font
This commit is contained in:
2026-02-11 01:40:56 +00:00
parent 4bf4d1171f
commit 4272ca4dfe
4 changed files with 270 additions and 1 deletions
+1 -1
View File
@@ -131,7 +131,7 @@ src/
Create `src/components/views/SummaryView.tsx`. Grid layout with cards: Patient Demographics (full width, two-column key-value table), Active Problems (left column, green/amber dots with dates), Current Medications Quick View (right column, 4-column table showing top 5 skills), Last Consultation preview (full width, truncated to 2-3 lines with "View Full Record" link). Clinical Alert banner: amber background (#FEF3C7), amber left border, warning icon, text "ALERT: This patient has identified £14.6M in prescribing efficiency savings...", Acknowledge button. Alert slides down with spring animation (250ms) after view loads. Clicking Acknowledge: icon changes to green checkmark (200ms), then alert collapses upward (200ms).
- [ ] **Task 7: Build ConsultationsView with History/Examination/Plan structure**
- [x] **Task 7: Build ConsultationsView with History/Examination/Plan structure**
Create `src/components/views/ConsultationsView.tsx`. Reverse-chronological journal of 5 roles. Each entry: collapsed state shows date, organization (NHS blue), role title, key coded entry, expand chevron. Click to expand: shows Duration, HISTORY section (context/background), EXAMINATION section (bullet list of analysis/findings), PLAN section (bullet list of outcomes), CODED ENTRIES (SNOMED-style codes like [EFF001], [ALG001]). Section headers styled as clinical consultation dividers (uppercase, letter-spacing). Only one entry expanded at a time. Color-coded left border: NHS blue for NHS N&W ICB, Teal (#00897B) for Tesco PLC. Expand animation: height 0→auto (200ms, ease-out).
+26
View File
@@ -218,3 +218,29 @@ This is a complete redesign of the CV presentation, moving from the ECG animatio
- Clinical Alert text uses amber-800 (#92400E) for contrast against amber-100 background
- Grid layout: demographics full width, problems/medications side-by-side, last consultation full width
- `line-clamp-2` and `line-clamp-3` utilities work well for truncating text in cards
### Iteration 7 — Task 7: Build ConsultationsView with History/Examination/Plan structure
- **Completed**: Task 7 - Created ConsultationsView with expandable consultation entries
- **Files created**:
- `src/components/views/ConsultationsView.tsx` - Full Consultations view with 5 expandable entries
- **Files modified**:
- `src/components/PMRInterface.tsx` - Added ConsultationsView to renderView switch
- **Design decisions**:
- Each entry has 3px left border color-coded by employer: NHS blue (#005EB8) for ICB, Teal (#00897B) for Tesco
- Collapsed state shows: status dot, date, organization (colored), role title, key coded entry summary
- Status dot: green for current roles, gray for historical
- Expanded state shows: Duration, HISTORY (paragraph), EXAMINATION (bullets), PLAN (bullets), CODED ENTRIES
- Section headers styled in Inter 600, 12px, uppercase, tracking-wider, gray-400
- Coded entries use [XXX000] format in Geist Mono, gray-400
- Only one entry expanded at a time (accordion behavior)
- Expand animation: height 0→auto (200ms, ease-out)
- Chevron icon rotates 180° when expanded
- **Accessibility**:
- `aria-expanded` on toggle buttons
- Status dots have `aria-label` describing current vs historical
- Respects `prefers-reduced-motion`: expand is instant
- **Quality checks**: `npm run typecheck` ✓, `npm run lint` ✓, `npm run build` ✓
- **Learnings**:
- Height animation uses `height: auto` which requires setting height to undefined after animation
- Content inside expanded area uses separate opacity transition for smooth appearance
- Border-left styling with explicit width/color in style prop for dynamic org colors
+3
View File
@@ -3,6 +3,7 @@ import type { ViewId } from '../types/pmr'
import { ClinicalSidebar } from './ClinicalSidebar'
import { PatientBanner } from './PatientBanner'
import { SummaryView } from './views/SummaryView'
import { ConsultationsView } from './views/ConsultationsView'
interface PMRInterfaceProps {
children?: React.ReactNode
@@ -37,6 +38,8 @@ export function PMRInterface({ children }: PMRInterfaceProps) {
switch (activeView) {
case 'summary':
return <SummaryView onNavigate={handleNavigate} />
case 'consultations':
return <ConsultationsView />
default:
return (
<div className="bg-white border border-gray-200 rounded p-6">
+240
View File
@@ -0,0 +1,240 @@
import { useState, useRef, useEffect } from 'react'
import { ChevronDown } from 'lucide-react'
import { consultations } from '@/data/consultations'
import type { Consultation, ViewId } from '@/types/pmr'
interface ConsultationsViewProps {
onNavigate?: (view: ViewId, itemId?: string) => void
initialExpandedId?: string
}
export function ConsultationsView({ initialExpandedId }: ConsultationsViewProps) {
const [expandedId, setExpandedId] = useState<string | null>(initialExpandedId ?? null)
const prefersReducedMotion = typeof window !== 'undefined'
? window.matchMedia('(prefers-reduced-motion: reduce)').matches
: false
const handleToggle = (id: string) => {
setExpandedId(prev => prev === id ? null : id)
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h1 className="font-inter font-semibold text-lg text-gray-900">
Consultation History
</h1>
<span className="font-geist text-xs text-gray-500">
{consultations.length} entries
</span>
</div>
<div className="space-y-3">
{consultations.map(consultation => (
<ConsultationEntry
key={consultation.id}
consultation={consultation}
isExpanded={expandedId === consultation.id}
onToggle={() => handleToggle(consultation.id)}
prefersReducedMotion={prefersReducedMotion}
/>
))}
</div>
</div>
)
}
interface ConsultationEntryProps {
consultation: Consultation
isExpanded: boolean
onToggle: () => void
prefersReducedMotion: boolean
}
function ConsultationEntry({
consultation,
isExpanded,
onToggle,
prefersReducedMotion,
}: ConsultationEntryProps) {
const contentRef = useRef<HTMLDivElement>(null)
const [height, setHeight] = useState<number | undefined>(isExpanded ? undefined : 0)
useEffect(() => {
if (prefersReducedMotion) {
setHeight(isExpanded ? undefined : 0)
return
}
if (isExpanded) {
const timer = setTimeout(() => {
setHeight(undefined)
}, 200)
return () => clearTimeout(timer)
}
setHeight(0)
}, [isExpanded, prefersReducedMotion])
const keyCodedEntry = consultation.codedEntries[0]
return (
<div
className="bg-white border border-gray-200 rounded overflow-hidden"
style={{ borderLeftWidth: '3px', borderLeftColor: consultation.orgColor }}
>
<button
type="button"
onClick={onToggle}
className="w-full px-4 py-3 flex items-start gap-3 text-left hover:bg-gray-50 transition-colors duration-100"
aria-expanded={isExpanded}
>
<StatusDot isCurrent={consultation.isCurrent} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-geist text-sm text-gray-500">{consultation.date}</span>
<span className="text-gray-300">|</span>
<span
className="font-inter text-sm"
style={{ color: consultation.orgColor }}
>
{consultation.organization}
</span>
</div>
<h3 className="font-inter font-semibold text-base text-gray-900 mt-1">
{consultation.role}
</h3>
{!isExpanded && keyCodedEntry && (
<p className="font-inter text-sm text-gray-500 mt-1 line-clamp-1">
<span className="text-gray-400">Key:</span>{' '}
<span className="font-geist text-xs text-gray-400">
[{keyCodedEntry.code}]
</span>{' '}
{keyCodedEntry.description}
</p>
)}
</div>
<ChevronDown
size={18}
className={`
flex-shrink-0 text-gray-400 transition-transform duration-200 mt-1
${isExpanded ? 'rotate-180' : ''}
`}
/>
</button>
<div
ref={contentRef}
style={{
height: height !== undefined ? `${height}px` : 'auto',
transition: prefersReducedMotion ? 'none' : 'height 200ms ease-out',
overflow: 'hidden',
}}
>
{isExpanded && (
<ExpandedContent
consultation={consultation}
prefersReducedMotion={prefersReducedMotion}
/>
)}
</div>
</div>
)
}
interface StatusDotProps {
isCurrent: boolean
}
function StatusDot({ isCurrent }: StatusDotProps) {
return (
<span className="flex-shrink-0 mt-1.5">
<span
className={`
block w-2 h-2 rounded-full
${isCurrent ? 'bg-green-500' : 'bg-gray-400'}
`}
aria-label={isCurrent ? 'Current role' : 'Historical role'}
/>
</span>
)
}
interface ExpandedContentProps {
consultation: Consultation
prefersReducedMotion: boolean
}
function ExpandedContent({ consultation, prefersReducedMotion }: ExpandedContentProps) {
const opacity = prefersReducedMotion ? 1 : undefined
const transition = prefersReducedMotion ? 'none' : 'opacity 150ms ease-out'
return (
<div
className="px-4 pb-4"
style={{ opacity, transition }}
>
<div className="pl-5 border-l border-gray-200 ml-1">
<div className="mb-4">
<span className="font-inter text-sm text-gray-500">Duration: </span>
<span className="font-geist text-sm text-gray-700">{consultation.duration}</span>
</div>
<SectionHeader>HISTORY</SectionHeader>
<p className="font-inter text-sm text-gray-700 leading-relaxed mb-4">
{consultation.history}
</p>
<SectionHeader>EXAMINATION</SectionHeader>
<ul className="space-y-1.5 mb-4">
{consultation.examination.map((item, index) => (
<li key={index} className="flex gap-2 text-sm">
<span className="text-gray-300 flex-shrink-0">-</span>
<span className="font-inter text-gray-700">{item}</span>
</li>
))}
</ul>
<SectionHeader>PLAN</SectionHeader>
<ul className="space-y-1.5 mb-4">
{consultation.plan.map((item, index) => (
<li key={index} className="flex gap-2 text-sm">
<span className="text-gray-300 flex-shrink-0">-</span>
<span className="font-inter text-gray-700">{item}</span>
</li>
))}
</ul>
<SectionHeader>CODED ENTRIES</SectionHeader>
<div className="space-y-1">
{consultation.codedEntries.map(entry => (
<CodedEntry key={entry.code} code={entry.code} description={entry.description} />
))}
</div>
</div>
</div>
)
}
function SectionHeader({ children }: { children: React.ReactNode }) {
return (
<h4 className="font-inter font-semibold text-xs uppercase tracking-wider text-gray-400 mb-2">
{children}
</h4>
)
}
interface CodedEntryProps {
code: string
description: string
}
function CodedEntry({ code, description }: CodedEntryProps) {
return (
<div className="flex items-start gap-2 text-sm">
<span className="font-geist text-xs text-gray-400 flex-shrink-0">
[{code}]
</span>
<span className="font-inter text-gray-600">{description}</span>
</div>
)
}