mobile banner v1

This commit is contained in:
2026-02-18 02:55:49 +00:00
parent 134e41f4f9
commit 8b79f7b273
17 changed files with 2000 additions and 233 deletions
+2 -37
View File
@@ -11,6 +11,7 @@ import { TimelineInterventionsSubsection } from './TimelineInterventionsSubsecti
import { RepeatMedicationsSubsection } from './RepeatMedicationsSubsection'
import { LastConsultationCard } from './LastConsultationCard'
import { ChatWidget } from './ChatWidget'
import { MobilePatientBanner } from './MobilePatientBanner'
import { useActiveSection } from '@/hooks/useActiveSection'
import { useIsMobileNav } from '@/hooks/useIsMobileNav'
import { useDetailPanel } from '@/contexts/DetailPanelContext'
@@ -299,43 +300,7 @@ export function DashboardLayout() {
paddingBottom: isMobileNav ? 'calc(56px + env(safe-area-inset-bottom) + 16px)' : undefined,
}}
>
{isMobileNav && (
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '10px 16px',
background: 'var(--accent)',
borderRadius: 'var(--radius-sm)',
marginBottom: '12px',
}}
>
<div>
<div
style={{
fontSize: '14px',
fontWeight: 700,
color: '#FFFFFF',
letterSpacing: '0.04em',
fontFamily: 'var(--font-ui)',
}}
>
CHARLWOOD, Andrew
</div>
<div
style={{
fontSize: '11px',
color: 'rgba(255,255,255,0.75)',
fontFamily: 'var(--font-geist-mono)',
letterSpacing: '0.02em',
}}
>
Informatics Pharmacist · NHS Norfolk & Waveney ICB
</div>
</div>
</div>
)}
{isMobileNav && <MobilePatientBanner />}
<div className="dashboard-grid">
{/* PatientSummaryTile — full width (includes Latest Results subsection) */}
<div ref={patientSummaryRef}>
+42 -2
View File
@@ -75,7 +75,7 @@ export function LastConsultationCard({ highlightedRoleId, focusRelatedIds }: Las
opacity: isDimmed ? 0.25 : 1,
}}
>
<CardHeader dotColor="green" title="LAST CONSULTATION" rightText="Current role" />
<CardHeader dotColor="green" title="LATEST CONSULTATION" rightText="Current role" />
<div
role="button"
@@ -126,12 +126,52 @@ export function LastConsultationCard({ highlightedRoleId, focusRelatedIds }: Las
fontSize: '15px',
fontWeight: 600,
color: consultation.orgColor ?? 'var(--accent)',
marginBottom: '4px',
marginBottom: '12px',
}}
>
{consultation.role}
</div>
<ul
style={{
listStyle: 'none',
padding: 0,
margin: 0,
display: 'flex',
flexDirection: 'column',
gap: '7px',
marginBottom: '0px',
}}
>
{consultation.examination.map((bullet, index) => (
<li
key={index}
style={{
fontSize: '14px',
color: 'var(--text-primary)',
paddingLeft: '16px',
lineHeight: '1.5',
position: 'relative',
}}
>
<span
aria-hidden="true"
style={{
position: 'absolute',
left: '0',
top: '8px',
width: '5px',
height: '5px',
borderRadius: '50%',
backgroundColor: consultation.orgColor ?? 'var(--accent)',
opacity: 0.5,
}}
/>
{bullet}
</li>
))}
</ul>
<button
onClick={handleOpenPanel}
style={{
+225
View File
@@ -0,0 +1,225 @@
import { useState, useEffect, useRef, useCallback } from 'react'
import type { ReactNode } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { ChevronDown } from 'lucide-react'
import { patient } from '@/data/patient'
import { getSidebarCopy } from '@/lib/profile-content'
import { PhoneCaptcha } from './PhoneCaptcha'
function DataRow({ label, children }: { label: string; children: ReactNode }) {
return (
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
fontSize: '13px',
padding: '2px 0',
}}
>
<span style={{ color: 'var(--text-tertiary)', fontWeight: 400 }}>{label}</span>
{children}
</div>
)
}
export function MobilePatientBanner() {
const sidebarCopy = getSidebarCopy()
const [expanded, setExpanded] = useState(true)
const expandedByClickRef = useRef(false)
const clickExpandScrollRef = useRef(0)
useEffect(() => {
const scrollContainer = document.querySelector('.dashboard-main')
if (!scrollContainer) return
let prevScrollTop = scrollContainer.scrollTop
const handleScroll = () => {
const currentScroll = scrollContainer.scrollTop
const delta = currentScroll - prevScrollTop
prevScrollTop = currentScroll
if (delta <= 0) return
if (expandedByClickRef.current) {
// After click-expand, collapse once user scrolls 20px from where they expanded
const scrollSinceExpand = currentScroll - clickExpandScrollRef.current
if (scrollSinceExpand > 20) {
setExpanded(false)
expandedByClickRef.current = false
}
} else if (currentScroll > 40) {
// Initial collapse after scrolling 40px from top
setExpanded(false)
}
}
scrollContainer.addEventListener('scroll', handleScroll, { passive: true })
return () => scrollContainer.removeEventListener('scroll', handleScroll)
}, [])
const handleToggle = useCallback(() => {
setExpanded((prev) => {
if (!prev) {
expandedByClickRef.current = true
const container = document.querySelector('.dashboard-main')
if (container) clickExpandScrollRef.current = container.scrollTop
return true
}
return prev
})
}, [])
return (
<div
className="-mx-3 xs:-mx-5 -mt-3 xs:-mt-5"
style={{
position: 'sticky',
top: 0,
zIndex: 20,
marginBottom: '12px',
overflow: 'hidden',
boxShadow: expanded ? 'none' : '0 2px 8px rgba(0,0,0,0.1)',
transition: 'box-shadow 0.25s ease',
}}
>
{/* Green header — always visible */}
<button
type="button"
onClick={handleToggle}
aria-expanded={expanded}
aria-label={expanded ? 'Patient summary expanded' : 'Tap to view patient details'}
style={{
width: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '10px 16px',
background: 'var(--accent)',
border: 'none',
cursor: expanded ? 'default' : 'pointer',
textAlign: 'left',
color: '#FFFFFF',
}}
>
<div>
<div
style={{
fontSize: '14px',
fontWeight: 700,
letterSpacing: '0.04em',
fontFamily: 'var(--font-ui)',
}}
>
CHARLWOOD, Andrew
</div>
<div
style={{
fontSize: '11px',
opacity: 0.75,
fontFamily: 'var(--font-geist-mono)',
letterSpacing: '0.02em',
}}
>
Informatics Pharmacist · NHS Norfolk & Waveney ICB
</div>
</div>
<motion.div
animate={
expanded
? { rotate: 180, opacity: 0.3 }
: { rotate: 0, opacity: 0.65, y: [0, 2, 0] }
}
transition={
expanded
? { duration: 0.2 }
: {
rotate: { duration: 0.2 },
opacity: { duration: 0.2 },
y: { duration: 1.2, repeat: 2, ease: 'easeInOut', delay: 0.3 },
}
}
style={{ flexShrink: 0, marginLeft: '8px', display: 'flex' }}
>
<ChevronDown size={16} />
</motion.div>
</button>
{/* Expandable patient data panel */}
<AnimatePresence initial={false}>
{expanded && (
<motion.div
key="patient-data-panel"
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.25, ease: [0.4, 0, 0.2, 1] }}
style={{ overflow: 'hidden' }}
>
<div
style={{
background: 'var(--surface)',
borderTop: '1px solid var(--border-light)',
padding: '10px 16px 12px',
display: 'grid',
gap: '4px',
}}
>
<DataRow label={sidebarCopy.gphcLabel}>
<span
style={{
color: 'var(--text-primary)',
fontWeight: 500,
fontFamily: 'Geist Mono, monospace',
fontSize: '12px',
letterSpacing: '0.12em',
}}
>
{patient.nhsNumber.replace(/\s/g, '')}
</span>
</DataRow>
<DataRow label={sidebarCopy.educationLabel}>
<span style={{ color: 'var(--text-primary)', fontWeight: 500 }}>
{patient.qualification}
</span>
</DataRow>
<DataRow label={sidebarCopy.locationLabel}>
<span style={{ color: 'var(--text-primary)', fontWeight: 500 }}>
{patient.address}
</span>
</DataRow>
<DataRow label={sidebarCopy.phoneLabel}>
<PhoneCaptcha phone={patient.phone} />
</DataRow>
<DataRow label={sidebarCopy.emailLabel}>
<a
href={`mailto:${patient.email}`}
style={{
color: 'var(--accent)',
fontWeight: 500,
textDecoration: 'none',
textAlign: 'right',
}}
>
{patient.email}
</a>
</DataRow>
<DataRow label={sidebarCopy.registeredLabel}>
<span style={{ color: 'var(--text-primary)', fontWeight: 500, textAlign: 'right' }}>
{patient.registrationYear}
</span>
</DataRow>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
)
}
+431
View File
@@ -0,0 +1,431 @@
import { useState } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { X, Send } from 'lucide-react'
interface ReferralFormModalProps {
isOpen: boolean
onClose: () => void
}
interface FormData {
referringClinician: string
organisationFrom: string
presentingComplaint: string
clinicalDetails: string
contactEmail: string
}
const INITIAL_FORM: FormData = {
referringClinician: '',
organisationFrom: '',
presentingComplaint: '',
clinicalDetails: '',
contactEmail: '',
}
type SubmitStatus = 'idle' | 'submitting' | 'success' | 'error'
export function ReferralFormModal({ isOpen, onClose }: ReferralFormModalProps) {
const [form, setForm] = useState<FormData>(INITIAL_FORM)
const [status, setStatus] = useState<SubmitStatus>('idle')
const [errorMessage, setErrorMessage] = useState('')
const updateField = (field: keyof FormData, value: string) => {
setForm(prev => ({ ...prev, [field]: value }))
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setStatus('submitting')
setErrorMessage('')
try {
const res = await fetch('/api/contact', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: form.referringClinician,
organisation: form.organisationFrom,
subject: form.presentingComplaint,
message: form.clinicalDetails,
email: form.contactEmail,
}),
})
const data = await res.json()
if (!res.ok) {
throw new Error(data.message || 'Failed to send referral')
}
setStatus('success')
setTimeout(() => {
setForm(INITIAL_FORM)
setStatus('idle')
onClose()
}, 2000)
} catch (err) {
setStatus('error')
setErrorMessage(err instanceof Error ? err.message : 'Failed to send referral. Please try again.')
}
}
const labelStyle: React.CSSProperties = {
display: 'block',
fontFamily: 'var(--font-geist-mono)',
fontSize: '11px',
fontWeight: 500,
color: 'var(--text-tertiary, #8DA8A5)',
textTransform: 'uppercase',
letterSpacing: '0.06em',
marginBottom: '6px',
}
const inputStyle: React.CSSProperties = {
width: '100%',
padding: '10px 12px',
fontFamily: 'var(--font-ui)',
fontSize: '14px',
color: 'var(--text-primary, #1A2B2A)',
backgroundColor: 'var(--surface, #FFFFFF)',
border: '1px solid var(--border, #D1DDD9)',
borderRadius: 'var(--radius-sm, 6px)',
outline: 'none',
transition: 'border-color 150ms ease',
}
const readOnlyStyle: React.CSSProperties = {
...inputStyle,
backgroundColor: 'var(--bg-dashboard, #F0F5F4)',
fontStyle: 'italic',
color: 'var(--text-secondary, #5B7A78)',
cursor: 'default',
}
return (
<AnimatePresence>
{isOpen && (
<motion.div
key="referral-overlay"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
style={{
position: 'fixed',
inset: 0,
backgroundColor: 'rgba(26, 43, 42, 0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1100,
padding: '16px',
}}
onClick={(e) => {
if (e.target === e.currentTarget) onClose()
}}
>
<motion.div
key="referral-modal"
initial={{ opacity: 0, y: 24 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 24 }}
transition={{ duration: 0.25, ease: 'easeOut' }}
style={{
width: '100%',
maxWidth: '540px',
maxHeight: 'calc(100vh - 32px)',
overflowY: 'auto',
backgroundColor: 'var(--surface, #FFFFFF)',
borderRadius: 'var(--radius-card, 8px)',
boxShadow: 'var(--shadow-lg, 0 8px 32px rgba(26,43,42,0.12))',
border: '1px solid var(--border-light, #E4EDEB)',
}}
role="dialog"
aria-modal="true"
aria-labelledby="referral-form-title"
>
{/* Header */}
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '16px 24px',
borderBottom: '2px solid var(--accent, #0D6E6E)',
backgroundColor: 'var(--bg-dashboard, #F0F5F4)',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
<div
style={{
width: '8px',
height: '8px',
borderRadius: '50%',
backgroundColor: 'var(--accent, #0D6E6E)',
flexShrink: 0,
}}
aria-hidden="true"
/>
<h2
id="referral-form-title"
style={{
fontFamily: 'var(--font-geist-mono)',
fontSize: '13px',
fontWeight: 600,
color: 'var(--accent, #0D6E6E)',
letterSpacing: '0.08em',
textTransform: 'uppercase',
margin: 0,
}}
>
Patient Referral Form
</h2>
</div>
<button
onClick={onClose}
aria-label="Close referral form"
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '32px',
height: '32px',
border: 'none',
background: 'transparent',
borderRadius: 'var(--radius-sm, 6px)',
cursor: 'pointer',
color: 'var(--text-secondary, #5B7A78)',
transition: 'background-color 150ms, color 150ms',
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = 'var(--accent-light, #E0F2F1)'
e.currentTarget.style.color = 'var(--accent, #0D6E6E)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = 'transparent'
e.currentTarget.style.color = 'var(--text-secondary, #5B7A78)'
}}
>
<X size={18} />
</button>
</div>
{/* Form body */}
<form
onSubmit={handleSubmit}
style={{ padding: '24px', display: 'flex', flexDirection: 'column', gap: '18px' }}
>
{/* Referring Clinician */}
<div>
<label style={labelStyle} htmlFor="referringClinician">
Referring Clinician
</label>
<input
id="referringClinician"
type="text"
required
value={form.referringClinician}
onChange={(e) => updateField('referringClinician', e.target.value)}
placeholder="Your name"
style={inputStyle}
onFocus={(e) => { e.currentTarget.style.borderColor = 'var(--accent, #0D6E6E)' }}
onBlur={(e) => { e.currentTarget.style.borderColor = 'var(--border, #D1DDD9)' }}
/>
</div>
{/* Organisation Referred From */}
<div>
<label style={labelStyle} htmlFor="organisationFrom">
Organisation Referred From
</label>
<input
id="organisationFrom"
type="text"
value={form.organisationFrom}
onChange={(e) => updateField('organisationFrom', e.target.value)}
placeholder="Your organisation"
style={inputStyle}
onFocus={(e) => { e.currentTarget.style.borderColor = 'var(--accent, #0D6E6E)' }}
onBlur={(e) => { e.currentTarget.style.borderColor = 'var(--border, #D1DDD9)' }}
/>
</div>
{/* Organisation Referred To (read-only) */}
<div>
<label style={labelStyle} htmlFor="organisationTo">
Organisation Referred To
</label>
<input
id="organisationTo"
type="text"
readOnly
value="A. Charlwood"
style={readOnlyStyle}
tabIndex={-1}
/>
</div>
{/* Receiving Clinician (read-only) */}
<div>
<label style={labelStyle} htmlFor="receivingClinician">
Receiving Clinician
</label>
<input
id="receivingClinician"
type="text"
readOnly
value="Mr A. Charlwood"
style={readOnlyStyle}
tabIndex={-1}
/>
</div>
{/* Presenting Complaint */}
<div>
<label style={labelStyle} htmlFor="presentingComplaint">
Presenting Complaint
</label>
<input
id="presentingComplaint"
type="text"
required
value={form.presentingComplaint}
onChange={(e) => updateField('presentingComplaint', e.target.value)}
placeholder="Subject / reason for referral"
style={inputStyle}
onFocus={(e) => { e.currentTarget.style.borderColor = 'var(--accent, #0D6E6E)' }}
onBlur={(e) => { e.currentTarget.style.borderColor = 'var(--border, #D1DDD9)' }}
/>
</div>
{/* Clinical Details */}
<div>
<label style={labelStyle} htmlFor="clinicalDetails">
Clinical Details
</label>
<textarea
id="clinicalDetails"
required
value={form.clinicalDetails}
onChange={(e) => updateField('clinicalDetails', e.target.value)}
placeholder="Your message..."
rows={5}
style={{
...inputStyle,
resize: 'vertical',
minHeight: '100px',
}}
onFocus={(e) => { e.currentTarget.style.borderColor = 'var(--accent, #0D6E6E)' }}
onBlur={(e) => { e.currentTarget.style.borderColor = 'var(--border, #D1DDD9)' }}
/>
</div>
{/* Contact Email */}
<div>
<label style={labelStyle} htmlFor="contactEmail">
Contact Email
</label>
<input
id="contactEmail"
type="email"
required
value={form.contactEmail}
onChange={(e) => updateField('contactEmail', e.target.value)}
placeholder="your.email@example.com"
style={inputStyle}
onFocus={(e) => { e.currentTarget.style.borderColor = 'var(--accent, #0D6E6E)' }}
onBlur={(e) => { e.currentTarget.style.borderColor = 'var(--border, #D1DDD9)' }}
/>
</div>
{/* Success message */}
{status === 'success' && (
<div
style={{
padding: '12px 16px',
backgroundColor: 'rgba(5, 150, 105, 0.08)',
border: '1px solid rgba(5, 150, 105, 0.2)',
borderRadius: 'var(--radius-sm, 6px)',
fontFamily: 'var(--font-ui)',
fontSize: '13px',
color: 'var(--success, #059669)',
textAlign: 'center',
}}
>
Referral sent successfully!
</div>
)}
{/* Error message */}
{status === 'error' && (
<div
style={{
padding: '12px 16px',
backgroundColor: 'rgba(220, 38, 38, 0.08)',
border: '1px solid rgba(220, 38, 38, 0.2)',
borderRadius: 'var(--radius-sm, 6px)',
fontFamily: 'var(--font-ui)',
fontSize: '13px',
color: 'var(--alert, #DC2626)',
textAlign: 'center',
}}
>
{errorMessage}
</div>
)}
{/* Submit button */}
<button
type="submit"
disabled={status === 'submitting' || status === 'success'}
style={{
width: '100%',
padding: '12px 16px',
fontFamily: 'var(--font-ui)',
fontSize: '14px',
fontWeight: 600,
color: '#FFFFFF',
backgroundColor: status === 'submitting' || status === 'success'
? 'var(--accent-hover, #0A8080)'
: 'var(--accent, #0D6E6E)',
border: 'none',
borderRadius: 'var(--radius-sm, 6px)',
cursor: status === 'submitting' || status === 'success' ? 'default' : 'pointer',
opacity: status === 'submitting' ? 0.8 : 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '8px',
transition: 'background-color 150ms, opacity 150ms',
}}
onMouseEnter={(e) => {
if (status === 'idle' || status === 'error') {
e.currentTarget.style.backgroundColor = 'var(--accent-hover, #0A8080)'
}
}}
onMouseLeave={(e) => {
if (status === 'idle' || status === 'error') {
e.currentTarget.style.backgroundColor = 'var(--accent, #0D6E6E)'
}
}}
>
{status === 'submitting' ? (
'Sending referral...'
) : status === 'success' ? (
'Referral sent!'
) : (
<>
<Send size={16} />
Submit Referral
</>
)}
</button>
</form>
</motion.div>
</motion.div>
)}
</AnimatePresence>
)
}
+122 -64
View File
@@ -1,11 +1,13 @@
import { useEffect, useState } from 'react'
import type { CSSProperties, ReactNode } from 'react'
import {
AlertCircle,
AlertTriangle,
Download,
Github,
Linkedin,
type LucideIcon,
Menu,
Search,
Send,
UserRound,
Workflow,
Wrench,
@@ -14,11 +16,11 @@ import {
import { useIsMobileNav } from '@/hooks/useIsMobileNav'
import { CvmisLogo } from './CvmisLogo'
import { PhoneCaptcha } from './PhoneCaptcha'
import { ReferralFormModal } from './ReferralFormModal'
import { patient } from '@/data/patient'
import { tags } from '@/data/tags'
import { alerts } from '@/data/alerts'
import { getSidebarCopy } from '@/lib/profile-content'
import type { Tag, Alert } from '@/types/pmr'
import type { Tag } from '@/types/pmr'
interface SidebarProps {
activeSection: string
@@ -110,62 +112,12 @@ function TagPill({ tag }: TagPillProps) {
)
}
interface AlertFlagProps {
alert: Alert
}
function AlertFlag({ alert }: AlertFlagProps) {
const Icon = alert.icon === 'AlertTriangle' ? AlertTriangle : AlertCircle
const styles: Record<Alert['severity'], CSSProperties> = {
alert: {
background: 'var(--alert-light)',
color: 'var(--alert)',
border: '1px solid var(--alert-border)',
},
amber: {
background: 'var(--amber-light)',
color: 'var(--amber)',
border: '1px solid var(--amber-border)',
},
}
return (
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
fontSize: '13px',
fontWeight: 700,
padding: '8px 12px',
borderRadius: 'var(--radius-sm)',
letterSpacing: '0.02em',
...styles[alert.severity],
}}
>
<div
style={{
width: '18px',
height: '18px',
flexShrink: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Icon size={16} strokeWidth={2.5} />
</div>
<span>{alert.message}</span>
</div>
)
}
export default function Sidebar({ activeSection, onNavigate, onSearchClick }: SidebarProps) {
const sidebarCopy = getSidebarCopy()
const [isDesktop, setIsDesktop] = useState(() => window.matchMedia('(min-width: 1024px)').matches)
const isMobileNav = useIsMobileNav()
const [isMobileExpanded, setIsMobileExpanded] = useState(false)
const [showReferralForm, setShowReferralForm] = useState(false)
useEffect(() => {
const mediaQuery = window.matchMedia('(min-width: 1024px)')
@@ -481,6 +433,35 @@ export default function Sidebar({ activeSection, onNavigate, onSearchClick }: Si
</span>
</div>
</div>
<a
href="/References/CV_v4.md"
target="_blank"
rel="noopener noreferrer"
style={{
width: '100%',
minHeight: '40px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '8px',
border: '1px solid var(--accent-border)',
background: 'var(--surface)',
color: 'var(--accent)',
borderRadius: 'var(--radius-sm)',
cursor: 'pointer',
fontSize: '13px',
fontWeight: 600,
//fontFamily: 'var(--font-geist-mono)',
letterSpacing: '0.03em',
transition: 'border-color 150ms, color 150ms',
}}
onMouseEnter={(e) => (e.currentTarget.style.textDecoration = 'underline')}
onMouseLeave={(e) => (e.currentTarget.style.textDecoration = 'none')}
>
<Download size={14} />
Download CV
</a>
</section>
)}
@@ -530,6 +511,91 @@ export default function Sidebar({ activeSection, onNavigate, onSearchClick }: Si
{isExpanded && (
<>
<section style={{ paddingTop: '4px' }}>
<SectionTitle>Contact</SectionTitle>
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
<button
type="button"
onClick={() => setShowReferralForm(true)}
className="sidebar-control"
style={{
width: '100%',
minHeight: '40px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '8px',
border: '1px solid var(--accent-border)',
background: 'var(--surface)',
color: 'var(--accent)',
borderRadius: 'var(--radius-sm)',
cursor: 'pointer',
fontSize: '13px',
fontWeight: 600,
//fontFamily: 'var(--font-geist-mono)',
letterSpacing: '0.03em',
transition: 'border-color 150ms, color 150ms',
}}
>
<Send size={14} />
Refer Patient
</button>
<div style={{ display: 'flex', gap: '6px' }}>
<a
href="https://linkedin.com/in/andycharlwood"
target="_blank"
rel="noopener noreferrer"
className="sidebar-control"
style={{
flex: 1,
minHeight: '36px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '6px',
border: '1px solid var(--border-light)',
background: 'var(--surface)',
color: 'var(--text-secondary)',
borderRadius: 'var(--radius-sm)',
fontSize: '12px',
fontWeight: 500,
textDecoration: 'none',
transition: 'border-color 150ms, color 150ms',
}}
>
<Linkedin size={14} />
LinkedIn
</a>
<a
href="https://github.com/andycharlwood"
target="_blank"
rel="noopener noreferrer"
className="sidebar-control"
style={{
flex: 1,
minHeight: '36px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '6px',
border: '1px solid var(--border-light)',
background: 'var(--surface)',
color: 'var(--text-secondary)',
borderRadius: 'var(--radius-sm)',
fontSize: '12px',
fontWeight: 500,
textDecoration: 'none',
transition: 'border-color 150ms, color 150ms',
}}
>
<Github size={14} />
GitHub
</a>
</div>
</div>
</section>
<section style={{ paddingTop: '8px' }}>
<SectionTitle>{sidebarCopy.tagsTitle}</SectionTitle>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '5px' }}>
@@ -538,18 +604,10 @@ export default function Sidebar({ activeSection, onNavigate, onSearchClick }: Si
))}
</div>
</section>
<section style={{ padding: '8px 0 4px' }}>
<SectionTitle>{sidebarCopy.alertsTitle}</SectionTitle>
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
{alerts.map((alert, index) => (
<AlertFlag key={index} alert={alert} />
))}
</div>
</section>
</>
)}
</aside>
<ReferralFormModal isOpen={showReferralForm} onClose={() => setShowReferralForm(false)} />
</>
)
}
+3 -82
View File
@@ -1,13 +1,12 @@
import React from 'react'
import { FileText, ChevronRight, Mail, Linkedin, Github, Download } from 'lucide-react'
import { FileText, ChevronRight } from 'lucide-react'
import { CardHeader } from '../Card'
import { ParentSection } from '../ParentSection'
import { kpis } from '@/data/kpis'
import type { KPI } from '@/types/pmr'
import { useDetailPanel } from '@/contexts/DetailPanelContext'
import { getLatestResultsCopy, getProfileSectionTitle, getStructuredProfile } from '@/lib/profile-content'
import { getLatestResultsCopy, getProfileSectionTitle, getPatientSummaryNarrative } from '@/lib/profile-content'
import { KPI_COLORS } from '@/lib/theme-colors'
import { useIsMobileNav } from '@/hooks/useIsMobileNav'
import { ProjectsCarousel } from './ProjectsTile'
interface MetricCardProps {
@@ -108,37 +107,9 @@ function MetricCard({ kpi }: MetricCardProps) {
)
}
const ACTION_LINKS = [
{ label: 'Email', href: 'mailto:andy@charlwood.xyz', icon: Mail, external: false },
{ label: 'LinkedIn', href: 'https://linkedin.com/in/andycharlwood', icon: Linkedin, external: true },
{ label: 'GitHub', href: 'https://github.com/andycharlwood', icon: Github, external: true },
{ label: 'Download CV', href: '/References/CV_v4.md', icon: Download, external: true },
] as const
const actionButtonStyles: React.CSSProperties = {
display: 'inline-flex',
alignItems: 'center',
gap: '6px',
padding: '6px 12px',
fontSize: '12px',
fontWeight: 600,
fontFamily: 'var(--font-geist-mono)',
letterSpacing: '0.03em',
textTransform: 'uppercase',
borderRadius: 'var(--radius-sm)',
border: '1px solid var(--border)',
background: 'var(--surface)',
color: 'var(--accent)',
cursor: 'pointer',
transition: 'background-color 150ms, border-color 150ms',
textDecoration: 'none',
}
export function PatientSummaryTile() {
const structuredProfile = getStructuredProfile()
const latestResultsCopy = getLatestResultsCopy()
const sectionTitle = getProfileSectionTitle()
const isMobile = useIsMobileNav()
const profileTextStyles: React.CSSProperties = {
fontSize: '15px',
@@ -146,30 +117,6 @@ export function PatientSummaryTile() {
color: 'var(--text-primary)',
}
const fieldsGridStyles: React.CSSProperties = {
display: 'grid',
gridTemplateColumns: isMobile ? '1fr' : 'auto 1fr',
gap: isMobile ? '2px 0' : '6px 16px',
borderTop: '1px solid var(--border-light)',
paddingTop: '14px',
marginTop: '14px',
}
const fieldLabelStyles: React.CSSProperties = {
fontSize: '12px',
textTransform: 'uppercase',
letterSpacing: '0.06em',
color: 'var(--text-tertiary)',
fontFamily: 'var(--font-geist-mono)',
}
const fieldValueStyles: React.CSSProperties = {
fontSize: '13px',
fontWeight: 600,
color: 'var(--text-primary)',
marginBottom: isMobile ? '8px' : undefined,
}
const kpiGridStyles: React.CSSProperties = {
display: 'grid',
gap: '10px',
@@ -177,33 +124,7 @@ export function PatientSummaryTile() {
return (
<ParentSection title={sectionTitle} tileId="patient-summary">
{/* Presenting complaint */}
<div style={profileTextStyles}>{structuredProfile.presentingComplaint}</div>
{/* Structured profile fields */}
<div style={fieldsGridStyles}>
{structuredProfile.fields.map((field) => (
<React.Fragment key={field.label}>
<span style={fieldLabelStyles}>{field.label}</span>
<span style={fieldValueStyles}>{field.value}</span>
</React.Fragment>
))}
</div>
{/* Contact / CTA action bar */}
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap', marginTop: '16px' }}>
{ACTION_LINKS.map((action) => (
<a
key={action.label}
href={action.href}
{...(action.external ? { target: '_blank', rel: 'noopener noreferrer' } : {})}
style={actionButtonStyles}
>
<action.icon size={13} aria-hidden="true" />
{action.label}
</a>
))}
</div>
<div style={profileTextStyles}>{getPatientSummaryNarrative()}</div>
{/* Latest Results subsection */}
<div style={{ marginTop: '28px' }}>
+94
View File
@@ -23,6 +23,7 @@ function ProjectItem({
}: ProjectItemProps) {
const dotColor = PROJECT_STATUS_COLORS[project.status]
const livePillLabel = project.demoUrl ? 'Live Demo' : project.externalUrl ? 'Live' : null
const [isHovered, setIsHovered] = useState(false)
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
@@ -34,11 +35,14 @@ function ProjectItem({
[onClick],
)
const maxVisibleResults = 4
return (
<div
style={{
flex: `0 0 ${slideWidth}`,
minWidth: 0,
containerType: 'inline-size',
}}
>
<div
@@ -47,6 +51,7 @@ function ProjectItem({
onClick={onClick}
onKeyDown={handleKeyDown}
style={{
position: 'relative',
display: 'flex',
flexDirection: 'column',
gap: '10px',
@@ -59,12 +64,15 @@ function ProjectItem({
color: 'var(--text-primary)',
transition: 'border-color 0.15s, box-shadow 0.15s',
cursor: 'pointer',
overflow: 'hidden',
}}
onMouseEnter={(e) => {
setIsHovered(true)
e.currentTarget.style.borderColor = 'var(--accent-border)'
e.currentTarget.style.boxShadow = '0 2px 8px rgba(26,43,42,0.08)'
}}
onMouseLeave={(e) => {
setIsHovered(false)
e.currentTarget.style.borderColor = 'var(--border-light)'
e.currentTarget.style.boxShadow = 'none'
}}
@@ -77,6 +85,92 @@ function ProjectItem({
e.currentTarget.style.boxShadow = 'none'
}}
>
{/* Results hover overlay */}
{project.results && project.results.length > 0 && (
<div
style={{
position: 'absolute',
inset: 0,
zIndex: 1,
background: 'rgba(20, 40, 38, 0.96)',
borderRadius: 'inherit',
display: 'flex',
flexDirection: 'column',
padding: 'clamp(10px, 4cqi, 18px) clamp(12px, 5cqi, 20px)',
opacity: isHovered ? 1 : 0,
transition: 'opacity 0.25s ease',
pointerEvents: isHovered ? 'auto' : 'none',
}}
>
<div
style={{
fontSize: 'clamp(9px, 3.5cqi, 13px)',
fontFamily: 'var(--font-geist-mono)',
fontWeight: 600,
letterSpacing: '0.1em',
textTransform: 'uppercase',
color: 'rgba(255, 255, 255, 0.45)',
marginBottom: 'clamp(6px, 3cqi, 12px)',
}}
>
Intervention Outcomes
</div>
<ul
style={{
listStyle: 'none',
margin: 0,
padding: 0,
display: 'flex',
flexDirection: 'column',
gap: 'clamp(5px, 2.5cqi, 12px)',
flex: 1,
overflow: 'hidden',
}}
>
{project.results.slice(0, maxVisibleResults).map((result, i) => (
<li
key={i}
style={{
display: 'flex',
gap: 'clamp(6px, 2.5cqi, 10px)',
fontSize: 'clamp(11px, 4.5cqi, 16px)',
lineHeight: 1.4,
color: 'rgba(255, 255, 255, 0.85)',
}}
>
<span
style={{
flexShrink: 0,
width: 'clamp(4px, 1.5cqi, 7px)',
height: 'clamp(4px, 1.5cqi, 7px)',
borderRadius: '50%',
background: 'var(--accent-primary, #00897B)',
marginTop: 'clamp(4px, 2cqi, 7px)',
}}
/>
<span>{result}</span>
</li>
))}
</ul>
<div
style={{
marginTop: 'auto',
paddingTop: 'clamp(6px, 3cqi, 14px)',
fontSize: 'clamp(10px, 4cqi, 14px)',
fontFamily: 'var(--font-geist-mono)',
fontWeight: 500,
letterSpacing: '0.02em',
color: 'var(--accent-primary, #00897B)',
display: 'flex',
alignItems: 'center',
gap: 'clamp(3px, 1.5cqi, 6px)',
}}
>
Click to view more
<span style={{ fontSize: 'clamp(12px, 4.5cqi, 16px)', lineHeight: 1 }}>&#8594;</span>
</div>
</div>
)}
<div
style={{
aspectRatio: '16 / 9',
+1 -11
View File
@@ -4,18 +4,8 @@ export const profileContent: DeepReadonly<ProfileContent> = {
profile: {
sectionTitle: 'Patient Summary',
patientSummaryNarrative: 'Healthcare leader combining clinical pharmacy expertise with proficiency in Python, SQL, and data analytics, self-taught over the past decade through a drive to find root causes in data and build the most efficient solutions to complex problems. Currently leading population health analytics for NHS Norfolk & Waveney ICB, serving a population of 1.2 million. Experienced in working with messy, real-world prescribing data at scale to deliver actionable insights — from financial scenario modelling and pharmaceutical rebate negotiation to algorithm design and population-level pathway development. Proven track record of identifying and prioritising efficiency programmes worth £14.6M+ through automated, data-driven analysis. Skilled at translating complex clinical, financial, and analytical requirements into clear recommendations for executive stakeholders.',
structuredProfile: {
presentingComplaint: 'Healthcare leader combining clinical pharmacy expertise with proficiency in Python, SQL, and data analytics. Currently leading population health analytics for NHS Norfolk & Waveney ICB, serving 1.2 million.',
fields: [
{ label: 'Specialisation', value: 'Population Health Analytics & Medicines Optimisation' },
{ label: 'Current System', value: 'NHS Norfolk & Waveney ICB' },
{ label: 'Population', value: '1.2 million' },
{ label: 'Focus Areas', value: 'Prescribing analytics, financial modelling, algorithm design, data pipelines' },
{ label: 'Key Achievement', value: '£14.6M+ efficiency programmes identified' },
],
},
latestResults: {
title: 'KEY METRICS',
title: 'LATEST RESULTS',
rightText: 'Updated February 2026',
helperText: 'Select a metric to inspect methodology, impact, and outcomes.',
evidenceCta: 'Click to view evidence',
+4 -4
View File
@@ -8,13 +8,16 @@ import type {
QuickActionCopyEntry,
SidebarCopy,
SkillsUICopy,
StructuredProfile,
} from '@/types/profile-content'
export function getProfileSectionTitle(): string {
return profileContent.profile.sectionTitle
}
export function getPatientSummaryNarrative(): string {
return profileContent.profile.patientSummaryNarrative
}
export function getLatestResultsCopy(): DeepReadonly<LatestResultsCopy> {
return profileContent.profile.latestResults
}
@@ -43,6 +46,3 @@ export function getEducationEntries(): ReadonlyArray<EducationCopyEntry> {
return profileContent.experienceEducation.educationEntries
}
export function getStructuredProfile(): DeepReadonly<StructuredProfile> {
return profileContent.profile.structuredProfile
}
-11
View File
@@ -80,21 +80,10 @@ export interface SkillsUICopy {
readonly categories: ReadonlyArray<SkillsCategoryCopyEntry>
}
export interface StructuredProfileField {
readonly label: string
readonly value: string
}
export interface StructuredProfile {
readonly presentingComplaint: string
readonly fields: ReadonlyArray<StructuredProfileField>
}
export interface ProfileContent {
readonly profile: {
readonly sectionTitle: string
readonly patientSummaryNarrative: string
readonly structuredProfile: StructuredProfile
readonly latestResults: LatestResultsCopy
readonly sidebar: SidebarCopy
}