Files
triqura-ecd/components/swift/blocks/patient-context-card.tsx
ikbenlit 2a67a0a2d9 git commit -m "feat(swift): implement Epic 3 - P1 Blocks (E3.S0-S6)" -m "Epic 3 compleet: Alle P1 blocks geïmplementeerd met volledige functionaliteit." -m "E3.S0 - CanvasArea block rendering" -m "- Switch/case voor block types met prefill data" -m "- AnimatePresence voor smooth transitions" -m "- PatientContextCard auto-display wanneer activePatient is gezet" -m "" -m "E3.S1 - Block Container animaties" -m "- Framer Motion animaties voor container, content en close button" -m "- Stagger effecten voor content children" -m "- Hover/tap animaties voor close button" -m "" -m "E3.S2 - DagnotatieBlock" -m "- Patient search met debounced FHIR API integratie" -m "- Category selector (medicatie, adl, gedrag, incident, observatie)" -m "- Textarea met character counter (max 500)" -m "- Opslaan naar /api/reports met validatie en error handling" -m "" -m "E3.S3 - Patient search API" -m "- GET /api/patients/search?q= met fuzzy search" -m "- Match score berekening voor resultaten" -m "- Auth check en error handling" -m "" -m "E3.S4 - ZoekenBlock" -m "- Debounced patient search met dropdown" -m "- Patient selectie → setActivePatient in store" -m "- Auto-close na selectie + recent action" -m "" -m "E3.S5 - PatientContextCard" -m "- Auto-open na patient selectie" -m "- Notities, vitals, diagnoses en risico's secties" -m "- API integratie met /api/overdracht/[patientId]" -m "" -m "E3.S6 - OverdrachtBlock" -m "- Lijst van patiënten met activiteit" -m "- Period selector (1d, 3d, 7d, 14d)" -m "- AI samenvatting generatie per patiënt" -m "- Aandachtspunten en actiepunten weergave" -m "" -m "Technische verbeteringen:" -m "- Technische debt opgelost: BlockContainer animaties, CanvasArea rendering" -m "- PatientContextCard toegevoegd aan blocks" -m "- Alle blocks gebruiken BlockContainer voor consistente styling" -m "" -m "Voortgang: 56 SP / 72 SP (78%) - Epic 3 compleet"
2025-12-24 09:06:39 +01:00

262 lines
9.7 KiB
TypeScript

'use client';
/**
* Patient Context Card
*
* Toont patiënt context na selectie: notities, vitals, diagnoses.
* E3.S5: Volledige implementatie met auto-open na patient selectie.
*/
import { useEffect, useState } from 'react';
import { useSwiftStore } from '@/stores/swift-store';
import { BlockContainer } from './block-container';
import { BLOCK_CONFIGS } from '@/lib/swift/types';
import type { PatientDetail, Report, VitalSign, Condition, RiskAssessment } from '@/lib/types/overdracht';
import { Loader2, FileText, Activity, Stethoscope, AlertTriangle, Calendar } from 'lucide-react';
import { format } from 'date-fns';
import { nl } from 'date-fns/locale/nl';
import { cn } from '@/lib/utils';
export function PatientContextCard() {
const { activePatient, closeBlock } = useSwiftStore();
const [data, setData] = useState<PatientDetail | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!activePatient?.id) {
return;
}
const fetchContext = async () => {
setIsLoading(true);
setError(null);
try {
const response = await fetch(`/api/overdracht/${activePatient.id}`);
if (!response.ok) {
throw new Error('Kon patiënt context niet laden');
}
const result = await response.json();
setData(result);
} catch (err) {
console.error('Failed to fetch patient context:', err);
setError(err instanceof Error ? err.message : 'Onbekende fout');
} finally {
setIsLoading(false);
}
};
fetchContext();
}, [activePatient?.id]);
if (!activePatient) {
return null;
}
const patientName = `${activePatient.name_given?.join(' ') || ''} ${activePatient.name_family || ''}`.trim();
return (
<BlockContainer title={`Patiënt: ${patientName}`} size="lg">
{isLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-6 w-6 animate-spin text-slate-400 mr-2" />
<span className="text-sm text-slate-400">Context laden...</span>
</div>
) : error ? (
<div className="text-center py-8">
<AlertTriangle className="h-8 w-8 text-red-400 mx-auto mb-2" />
<p className="text-sm text-red-600">{error}</p>
</div>
) : data ? (
<div className="space-y-6">
{/* Notities (Reports) */}
{data.reports && data.reports.length > 0 && (
<section>
<div className="flex items-center gap-2 mb-3">
<FileText className="h-4 w-4 text-slate-400" />
<h3 className="text-sm font-medium text-slate-300">Recente notities</h3>
<span className="text-xs text-slate-500">({data.reports.length})</span>
</div>
<div className="space-y-2">
{data.reports.slice(0, 5).map((report) => (
<ReportItem key={report.id} report={report} />
))}
{data.reports.length > 5 && (
<p className="text-xs text-slate-500 text-center pt-2">
+ {data.reports.length - 5} meer notities
</p>
)}
</div>
</section>
)}
{/* Vitals */}
{data.vitals && data.vitals.length > 0 && (
<section>
<div className="flex items-center gap-2 mb-3">
<Activity className="h-4 w-4 text-slate-400" />
<h3 className="text-sm font-medium text-slate-300">Vitale functies (vandaag)</h3>
<span className="text-xs text-slate-500">({data.vitals.length})</span>
</div>
<div className="grid grid-cols-2 gap-2">
{data.vitals.slice(0, 6).map((vital) => (
<VitalItem key={vital.id} vital={vital} />
))}
</div>
</section>
)}
{/* Diagnoses */}
{data.conditions && data.conditions.length > 0 && (
<section>
<div className="flex items-center gap-2 mb-3">
<Stethoscope className="h-4 w-4 text-slate-400" />
<h3 className="text-sm font-medium text-slate-300">Actieve diagnoses</h3>
<span className="text-xs text-slate-500">({data.conditions.length})</span>
</div>
<div className="space-y-2">
{data.conditions.map((condition) => (
<ConditionItem key={condition.id} condition={condition} />
))}
</div>
</section>
)}
{/* Risico's */}
{data.risks && data.risks.length > 0 && (
<section>
<div className="flex items-center gap-2 mb-3">
<AlertTriangle className="h-4 w-4 text-amber-400" />
<h3 className="text-sm font-medium text-slate-300">Risico&apos;s</h3>
<span className="text-xs text-slate-500">({data.risks.length})</span>
</div>
<div className="flex flex-wrap gap-2">
{data.risks.map((risk) => (
<RiskBadge key={risk.id} risk={risk} />
))}
</div>
</section>
)}
{/* Empty state */}
{(!data.reports || data.reports.length === 0) &&
(!data.vitals || data.vitals.length === 0) &&
(!data.conditions || data.conditions.length === 0) &&
(!data.risks || data.risks.length === 0) && (
<div className="text-center py-8 text-slate-500">
<p className="text-sm">Geen context beschikbaar voor deze patiënt</p>
</div>
)}
</div>
) : null}
</BlockContainer>
);
}
function ReportItem({ report }: { report: Report }) {
const date = report.created_at ? new Date(report.created_at) : null;
const formattedDate = date ? format(date, 'd MMM HH:mm', { locale: nl }) : 'Onbekend';
return (
<div className="p-3 rounded-lg bg-slate-800/50 border border-slate-700">
<div className="flex items-start justify-between gap-2 mb-1">
<div className="flex items-center gap-2 flex-1 min-w-0">
<span className="text-xs font-medium text-slate-400 uppercase">{report.type}</span>
{report.include_in_handover && (
<span className="text-xs px-1.5 py-0.5 rounded bg-blue-900/50 text-blue-300">
Overdracht
</span>
)}
</div>
<div className="flex items-center gap-1 text-xs text-slate-500">
<Calendar className="h-3 w-3" />
{formattedDate}
</div>
</div>
<p className="text-sm text-slate-300 line-clamp-2">{report.content}</p>
</div>
);
}
function VitalItem({ vital }: { vital: VitalSign }) {
const isAbnormal = vital.interpretation_code === 'H' || vital.interpretation_code === 'L';
const date = vital.effective_datetime ? new Date(vital.effective_datetime) : null;
const formattedTime = date ? format(date, 'HH:mm', { locale: nl }) : '';
return (
<div
className={cn(
'p-2 rounded-lg border',
isAbnormal
? 'bg-amber-900/20 border-amber-700 text-amber-200'
: 'bg-slate-800/50 border-slate-700 text-slate-300'
)}
>
<div className="text-xs font-medium mb-0.5">{vital.code_display}</div>
<div className="text-sm font-semibold">
{vital.value_quantity_value}
{vital.value_quantity_unit && <span className="text-xs ml-1">{vital.value_quantity_unit}</span>}
</div>
{formattedTime && <div className="text-xs text-slate-500 mt-0.5">{formattedTime}</div>}
</div>
);
}
function ConditionItem({ condition }: { condition: Condition }) {
const date = condition.onset_datetime ? new Date(condition.onset_datetime) : null;
const formattedDate = date ? format(date, 'd MMM yyyy', { locale: nl }) : null;
return (
<div className="flex items-center gap-3 p-2 rounded-lg bg-slate-800/50 border border-slate-700">
<div className="w-1.5 h-1.5 rounded-full bg-blue-400" />
<div className="flex-1 min-w-0">
<p className="text-sm text-slate-300">{condition.code_display}</p>
{formattedDate && (
<p className="text-xs text-slate-500">Sinds {formattedDate}</p>
)}
</div>
</div>
);
}
function RiskBadge({ risk }: { risk: RiskAssessment }) {
const RISK_TYPE_LABELS: Record<string, string> = {
suiciderisico: 'Suicide',
agressie: 'Agressie',
terugval: 'Terugval',
automutilatie: 'Automutilatie',
verwaarlozing: 'Verwaarlozing',
weglopen: 'Weglopen',
};
const RISK_LEVEL_STYLES: Record<string, { bg: string; text: string; dot: string }> = {
zeer_hoog: { bg: 'bg-red-900/30', text: 'text-red-300', dot: 'bg-red-500' },
hoog: { bg: 'bg-red-900/20', text: 'text-red-400', dot: 'bg-red-500' },
gemiddeld: { bg: 'bg-amber-900/20', text: 'text-amber-300', dot: 'bg-amber-500' },
laag: { bg: 'bg-green-900/20', text: 'text-green-300', dot: 'bg-green-500' },
};
const styles = RISK_LEVEL_STYLES[risk.risk_level] || RISK_LEVEL_STYLES.laag;
const label = RISK_TYPE_LABELS[risk.risk_type] || risk.risk_type;
const levelLabel =
risk.risk_level === 'zeer_hoog'
? 'Zeer hoog'
: risk.risk_level.charAt(0).toUpperCase() + risk.risk_level.slice(1);
return (
<span
className={cn(
'inline-flex items-center gap-1.5 px-2 py-1 rounded-full text-xs font-medium border',
styles.bg,
styles.text,
styles.dot === 'bg-red-500' ? 'border-red-700' : styles.dot === 'bg-amber-500' ? 'border-amber-700' : 'border-green-700'
)}
>
<span className={cn('w-1.5 h-1.5 rounded-full', styles.dot)} />
{label}: {levelLabel}
</span>
);
}