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"
This commit is contained in:
261
components/swift/blocks/patient-context-card.tsx
Normal file
261
components/swift/blocks/patient-context-card.tsx
Normal file
@@ -0,0 +1,261 @@
|
||||
'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'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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user