Files
triqura-ecd/components/cortex/blocks/patient-dashboard-block.tsx
colinislit 96848ecc81 chore(strip): verwijder behandelplan-module
AI-generatie negeerde de geregistreerde diagnose en ernst — elk plan was
generiek (reviewbevinding). Module komt terug bij de rebuild, gekoppeld
aan het nieuwe datamodel.

- app/epd/patients/[id]/behandelplan/ en /api/behandelplan/ verwijderd
- components/behandelplan/ (view, list, forms) verwijderd
- lib/ai/behandelplan-prompt.ts, lib/ai/intervention-mapping.ts,
  lib/types/behandelplan.ts verwijderd
- behandelplan-sectie uit patientdashboard, dashboard-API en
  Cortex patient-dashboard-block
- 'Doorzetten naar behandelplan' uit behandeladvies-formulier

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 22:47:58 +02:00

329 lines
12 KiB
TypeScript

'use client';
/**
* Patient Dashboard Block
*
* Swift artifact that shows patient properties and dashboard summary.
*/
import { useEffect, useMemo, useState } from 'react';
import { format } from 'date-fns';
import { nl } from 'date-fns/locale';
import {
AlertCircle,
Calendar,
ClipboardList,
Clock,
FileText,
Loader2,
User,
} from 'lucide-react';
import { BlockContainer } from './block-container';
import { safeFetch, getErrorInfo } from '@/lib/cortex/error-handler';
import { useToast } from '@/hooks/use-toast';
import { BLOCK_CONFIGS } from '@/lib/cortex/types';
import type { BlockPrefillData } from '@/stores/cortex-store';
import { useCortexStore } from '@/stores/cortex-store';
import type { FHIRPatient } from '@/lib/fhir';
import type { Intake } from '@/lib/types/intake';
import { cn } from '@/lib/utils';
import { formatPatientName } from '@/lib/fhir/patient-mapper';
interface PatientDashboardBlockProps {
prefill?: BlockPrefillData;
}
interface EncounterSummary {
id: string;
period_start: string;
period_end?: string | null;
type_display?: string | null;
status: string;
}
interface PatientDashboardResponse {
patient: FHIRPatient;
intakes: Intake[];
encounters: EncounterSummary[];
}
const STATUS_LABELS: Record<string, string> = {
planned: 'Screening',
active: 'Actief',
finished: 'Afgerond',
cancelled: 'Afgemeld',
};
const GENDER_LABELS: Record<string, string> = {
male: 'Man',
female: 'Vrouw',
other: 'Anders',
unknown: 'Onbekend',
};
function extractEpisodeStatus(patient?: FHIRPatient): string | null {
if (!patient) return null;
const statusExtension = (patient as any)?.extension?.find(
(ext: any) => ext.url === 'http://mini-epd.local/fhir/StructureDefinition/episode-status'
);
return statusExtension?.valueCode || null;
}
function getPatientName(patient?: FHIRPatient): string {
const name = patient?.name?.[0];
if (!name) return 'Onbekende patiënt';
return [
...(name.prefix || []),
...(name.given || []),
name.family,
]
.filter(Boolean)
.join(' ');
}
function getPatientBsn(patient?: FHIRPatient): string | null {
if (!patient?.identifier) return null;
return (
patient.identifier.find(
(id) => id.system === 'http://fhir.nl/fhir/NamingSystem/bsn'
)?.value || null
);
}
export function PatientDashboardBlock({ prefill }: PatientDashboardBlockProps) {
const config = BLOCK_CONFIGS['patient-dashboard'];
const { activePatient } = useCortexStore();
const { toast } = useToast();
// E3.S2: Use activePatient as fallback for patientId
const patientId = prefill?.patientId || activePatient?.id;
const patientNameFromPrefill = prefill?.patientName || (activePatient ? formatPatientName(activePatient) : undefined);
const [data, setData] = useState<PatientDashboardResponse | null>(null);
const [isLoading, setIsLoading] = useState(Boolean(patientId));
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!patientId) {
setError('Geen patiënt geselecteerd');
setIsLoading(false);
return;
}
const fetchDashboard = async () => {
setIsLoading(true);
setError(null);
try {
const response = await safeFetch(
`/api/patients/${patientId}/dashboard`,
undefined,
{ operation: 'Patiëntdashboard laden' }
);
const result = (await response.json()) as PatientDashboardResponse;
setData(result);
} catch (err) {
const statusCode = (err as any)?.statusCode;
const errorInfo = getErrorInfo(err, {
operation: 'Patiëntdashboard laden',
statusCode,
});
setError(errorInfo.description);
toast({
variant: 'destructive',
title: errorInfo.title,
description: errorInfo.description,
});
} finally {
setIsLoading(false);
}
};
fetchDashboard();
}, [patientId, toast]);
const patient = data?.patient;
const patientName = useMemo(() => getPatientName(patient), [patient]);
const patientStatus = extractEpisodeStatus(patient);
const patientStatusLabel = patientStatus ? STATUS_LABELS[patientStatus] || patientStatus : null;
const patientBirthDate = patient?.birthDate
? format(new Date(patient.birthDate), 'd MMM yyyy', { locale: nl })
: 'Onbekend';
const patientGender = patient?.gender ? GENDER_LABELS[patient.gender] || patient.gender : 'Onbekend';
const patientBsn = getPatientBsn(patient) || 'Onbekend';
const recentIntakes = data?.intakes?.slice(0, 3) || [];
const encounters = data?.encounters || [];
const encounterGroups = useMemo(() => {
const now = new Date();
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const upcoming = encounters.filter((e) => new Date(e.period_start) >= todayStart);
const recent = encounters.filter((e) => new Date(e.period_start) < todayStart);
const displayEncounters = [...upcoming, ...recent].slice(0, 5);
return displayEncounters;
}, [encounters]);
// E3.S2: Use patientNameFromPrefill for title
const title = patientNameFromPrefill
? `${config.title} - ${patientNameFromPrefill}`
: config.title;
return (
<BlockContainer title={title} size={config.size}>
{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-500">Dashboard laden...</span>
</div>
) : error ? (
<div className="text-center py-8">
<AlertCircle className="h-8 w-8 text-red-500 mx-auto mb-2" />
<p className="text-sm text-red-700">{error}</p>
</div>
) : data ? (
<div className="space-y-6">
{/* Basisgegevens */}
<section className="bg-white rounded-lg border border-slate-200 p-4">
<div className="flex items-center gap-2 mb-3">
<User className="h-4 w-4 text-blue-600" />
<h3 className="text-sm font-medium text-slate-700">Basisgegevens</h3>
{patientStatusLabel && (
<span className="text-xs px-2 py-0.5 rounded-full bg-slate-100 text-slate-700 border border-slate-200">
{patientStatusLabel}
</span>
)}
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 text-sm">
<div>
<p className="text-xs text-slate-500">Naam</p>
<p className="text-slate-900 font-medium">{patientName}</p>
</div>
<div>
<p className="text-xs text-slate-500">Geboortedatum</p>
<p className="text-slate-900">{patientBirthDate}</p>
</div>
<div>
<p className="text-xs text-slate-500">BSN</p>
<p className="text-slate-900">{patientBsn}</p>
</div>
<div>
<p className="text-xs text-slate-500">Geslacht</p>
<p className="text-slate-900">{patientGender}</p>
</div>
</div>
</section>
{/* Recente intakes */}
<section className="bg-white rounded-lg border border-slate-200 p-4">
<div className="flex items-center gap-2 mb-3">
<FileText className="h-4 w-4 text-teal-600" />
<h3 className="text-sm font-medium text-slate-700">Recente intakes</h3>
<span className="text-xs text-slate-500">({data.intakes.length})</span>
</div>
{recentIntakes.length === 0 ? (
<p className="text-sm text-slate-500">Geen intakes gevonden</p>
) : (
<div className="space-y-2">
{recentIntakes.map((intake) => (
<div
key={intake.id}
className="flex items-center justify-between p-3 rounded-lg bg-slate-50 border border-slate-200"
>
<div>
<p className="text-sm font-medium text-slate-900">{intake.title}</p>
<div className="flex items-center gap-2 text-xs text-slate-500 mt-0.5">
<span>{intake.department}</span>
<span></span>
<span>
{format(new Date(intake.start_date), 'd MMM yyyy', { locale: nl })}
</span>
</div>
</div>
<span className={cn(
'px-2 py-0.5 rounded-full text-xs font-medium',
intake.status === 'Open'
? 'bg-blue-50 text-blue-700'
: 'bg-green-50 text-green-700'
)}>
{intake.status}
</span>
</div>
))}
</div>
)}
</section>
{/* Agenda afspraken */}
<section className="bg-white rounded-lg border border-slate-200 p-4">
<div className="flex items-center gap-2 mb-3">
<Calendar className="h-4 w-4 text-amber-600" />
<h3 className="text-sm font-medium text-slate-700">Agenda afspraken</h3>
<span className="text-xs text-slate-500">({encounters.length})</span>
</div>
{encounterGroups.length === 0 ? (
<p className="text-sm text-slate-500">Geen afspraken gevonden</p>
) : (
<div className="space-y-2">
{encounterGroups.map((encounter) => {
const encounterDate = new Date(encounter.period_start);
const isPast = encounterDate < new Date();
return (
<div
key={encounter.id}
className="flex items-center justify-between p-3 rounded-lg bg-slate-50 border border-slate-200"
>
<div>
<p className="text-sm font-medium text-slate-900">
{encounter.type_display || 'Afspraak'}
</p>
<div className="flex items-center gap-2 text-xs text-slate-500 mt-0.5">
<Clock className="h-3 w-3" />
<span>
{format(encounterDate, 'd MMM yyyy HH:mm', { locale: nl })}
</span>
{encounter.period_end && (
<>
<span></span>
<span>
{format(new Date(encounter.period_end), 'HH:mm', { locale: nl })}
</span>
</>
)}
</div>
</div>
<span className={cn(
'px-2 py-0.5 rounded-full text-xs font-medium',
encounter.status === 'planned' || encounter.status === 'arrived'
? 'bg-blue-50 text-blue-700'
: encounter.status === 'finished'
? 'bg-green-50 text-green-700'
: isPast
? 'bg-slate-100 text-slate-700'
: 'bg-slate-50 text-slate-700'
)}>
{encounter.status === 'planned'
? 'Gepland'
: encounter.status === 'arrived'
? 'Aangekomen'
: encounter.status === 'finished'
? 'Afgerond'
: encounter.status}
</span>
</div>
);
})}
</div>
)}
</section>
</div>
) : (
<div className="text-sm text-slate-500">Geen gegevens beschikbaar</div>
)}
</BlockContainer>
);
}