feat(diagnose+agenda): Diagnose module met ICD-10 en agenda uitbreidingen

Diagnose Module:
- Diagnose overzicht pagina met alle patiënt diagnoses
- Diagnosis manager met ICD-10 combobox zoekfunctie
- Diagnose kaarten met hoofddiagnose markering
- Modal voor nieuwe/bewerkte diagnoses
- ICD-10 GGZ codes dataset (lib/data/)
- Zod schemas voor diagnose validatie
- TypeScript types voor ICD-10 (lib/types/icd10.ts)
- Complete documentatie (PRD, FO, TO, Bouwplan)

Agenda Uitbreidingen:
- Patient context card in afspraak modal
- Rapportage composer direct in afspraak modal
- Rapportage bewerken vanuit gekoppelde rapportages
- Verbeterde focus styling voor inputs

Behandelplan:
- Flat componenten structuur (behandeldoel-card, form, planning)
- Context header component
- Uitgebreide types (lib/types/behandelplan.ts)
- Actions voor behandelplan beheer

UI Componenten:
- Command component (shadcn/ui) voor combobox
- Popover component (shadcn/ui)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
colinislit
2025-12-12 13:29:59 +01:00
parent 593bef4dcf
commit 265d3a971f
36 changed files with 6663 additions and 236 deletions

View File

@@ -6,7 +6,7 @@
* Client-side wrapper managing calendar state, view switching, and interactions. * Client-side wrapper managing calendar state, view switching, and interactions.
*/ */
import { useState, useCallback, useRef, useTransition, useEffect } from 'react'; import React, { useState, useCallback, useRef, useTransition, useEffect } from 'react';
import { startOfWeek, endOfWeek, addDays, format } from 'date-fns'; import { startOfWeek, endOfWeek, addDays, format } from 'date-fns';
import { toast } from '@/hooks/use-toast'; import { toast } from '@/hooks/use-toast';

View File

@@ -6,11 +6,14 @@
* Modal for creating and editing appointments (encounters). * Modal for creating and editing appointments (encounters).
*/ */
import { useState, useEffect, useCallback } from 'react'; import React, { useState, useEffect, useCallback } from 'react';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { nl } from 'date-fns/locale'; import { nl } from 'date-fns/locale';
import { Calendar, Clock, User, MapPin, FileText, Search, X, Trash2, PenLine } from 'lucide-react'; import { Calendar, Clock, User, MapPin, FileText, Search, X, Trash2, PenLine, ExternalLink } from 'lucide-react';
import Link from 'next/link'; import Link from 'next/link';
import { QuickActions } from '@/app/epd/patients/[id]/rapportage/components/quick-actions';
import { RichTextEditor } from '@/components/rich-text-editor';
import type { ReportType } from '@/lib/types/report';
import { import {
Dialog, Dialog,
@@ -24,6 +27,7 @@ import { toast } from '@/hooks/use-toast';
import { createEncounter, updateEncounter, cancelEncounter, getEncounterReports } from '../actions'; import { createEncounter, updateEncounter, cancelEncounter, getEncounterReports } from '../actions';
import { CancelDialog } from './cancel-dialog'; import { CancelDialog } from './cancel-dialog';
import { PatientContextCard } from './patient-context-card';
import { import {
APPOINTMENT_TYPES, APPOINTMENT_TYPES,
LOCATION_CLASSES, LOCATION_CLASSES,
@@ -58,8 +62,8 @@ interface AppointmentModalProps {
onSuccess?: () => void; onSuccess?: () => void;
} }
const inputClassName = "w-full min-w-0 px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-teal-500 focus:border-transparent text-sm box-border"; const inputClassName = "w-full min-w-0 px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-teal-500 focus:ring-offset-0 focus:border-transparent text-sm box-border";
const selectClassName = "w-full min-w-0 px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-teal-500 focus:border-transparent text-sm bg-white box-border"; const selectClassName = "w-full min-w-0 px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-teal-500 focus:ring-offset-0 focus:border-transparent text-sm bg-white box-border";
const labelClassName = "block text-sm font-medium text-slate-700 mb-1"; const labelClassName = "block text-sm font-medium text-slate-700 mb-1";
export function AppointmentModal({ export function AppointmentModal({
@@ -90,6 +94,17 @@ export function AppointmentModal({
const [linkedReports, setLinkedReports] = useState<LinkedReport[]>([]); const [linkedReports, setLinkedReports] = useState<LinkedReport[]>([]);
const [isLoadingReports, setIsLoadingReports] = useState(false); const [isLoadingReports, setIsLoadingReports] = useState(false);
// Report composer state
const [showReportComposer, setShowReportComposer] = useState(false);
const [selectedReportType, setSelectedReportType] = useState<ReportType>('vrije_notitie');
const [reportContent, setReportContent] = useState('');
const [isSavingReport, setIsSavingReport] = useState(false);
// Report edit state
const [editingReport, setEditingReport] = useState<LinkedReport | null>(null);
const [editReportContent, setEditReportContent] = useState('');
const [isUpdatingReport, setIsUpdatingReport] = useState(false);
// Form state // Form state
const [date, setDate] = useState<string>( const [date, setDate] = useState<string>(
initialDate ? format(initialDate, 'yyyy-MM-dd') : format(new Date(), 'yyyy-MM-dd') initialDate ? format(initialDate, 'yyyy-MM-dd') : format(new Date(), 'yyyy-MM-dd')
@@ -153,6 +168,11 @@ export function AppointmentModal({
setTypeCode('behandeling'); setTypeCode('behandeling');
setClassCode('AMB'); setClassCode('AMB');
setLinkedReports([]); setLinkedReports([]);
setShowReportComposer(false);
setReportContent('');
setSelectedReportType('vrije_notitie');
setEditingReport(null);
setEditReportContent('');
} }
}, [open, initialDate, initialStartTime, initialEndTime, editingEvent]); }, [open, initialDate, initialStartTime, initialEndTime, editingEvent]);
@@ -298,16 +318,16 @@ export function AppointmentModal({
periodStart, periodStart,
periodEnd, periodEnd,
typeCode, typeCode,
typeDisplay: APPOINTMENT_TYPES[typeCode], typeDisplay: APPOINTMENT_TYPES[typeCode as AppointmentTypeCode],
classCode, classCode,
classDisplay: LOCATION_CLASSES[classCode], classDisplay: LOCATION_CLASSES[classCode as LocationClassCode],
notes: notes || '', notes: notes || '',
}); });
if (result.success) { if (result.success) {
toast({ toast({
title: 'Afspraak bijgewerkt', title: 'Afspraak bijgewerkt',
description: `${APPOINTMENT_TYPES[typeCode]} is aangepast.`, description: `${APPOINTMENT_TYPES[typeCode as AppointmentTypeCode]} is aangepast.`,
}); });
onOpenChange(false); onOpenChange(false);
onSuccess?.(); onSuccess?.();
@@ -326,16 +346,16 @@ export function AppointmentModal({
periodStart, periodStart,
periodEnd: periodEnd || undefined, periodEnd: periodEnd || undefined,
typeCode, typeCode,
typeDisplay: APPOINTMENT_TYPES[typeCode], typeDisplay: APPOINTMENT_TYPES[typeCode as AppointmentTypeCode],
classCode, classCode,
classDisplay: LOCATION_CLASSES[classCode], classDisplay: LOCATION_CLASSES[classCode as LocationClassCode],
notes: notes || undefined, notes: notes || undefined,
}); });
if (result.success) { if (result.success) {
toast({ toast({
title: 'Afspraak aangemaakt', title: 'Afspraak aangemaakt',
description: `${APPOINTMENT_TYPES[typeCode]} met ${selectedPatient!.name_given?.[0] || ''} ${selectedPatient!.name_family || ''}`.trim(), description: `${APPOINTMENT_TYPES[typeCode as AppointmentTypeCode]} met ${selectedPatient!.name_given?.[0] || ''} ${selectedPatient!.name_family || ''}`.trim(),
}); });
onOpenChange(false); onOpenChange(false);
onSuccess?.(); onSuccess?.();
@@ -392,6 +412,61 @@ export function AppointmentModal({
} }
}; };
// Handle save report
const handleSaveReport = async () => {
if (!editingEvent?.extendedProps.patient || !editingEvent.id) return;
const textContent = reportContent.replace(/<[^>]*>/g, '').trim();
if (textContent.length < 20) {
toast({
variant: 'destructive',
title: 'Verslag te kort',
description: 'Een verslag moet minimaal 20 karakters bevatten.',
});
return;
}
if (textContent.length > 5000) {
toast({
variant: 'destructive',
title: 'Verslag te lang',
description: 'Een verslag mag maximaal 5000 karakters bevatten.',
});
return;
}
setIsSavingReport(true);
try {
await handleCreateReport(
editingEvent.extendedProps.patient.id,
selectedReportType,
textContent,
editingEvent.id
);
toast({
title: 'Verslag opgeslagen',
description: 'Het verslag is gekoppeld aan deze afspraak.',
});
// Reset composer
setReportContent('');
setShowReportComposer(false);
// Refresh linked reports
const reports = await getEncounterReports(editingEvent.id);
setLinkedReports(reports);
} catch (error) {
toast({
variant: 'destructive',
title: 'Opslaan mislukt',
description: error instanceof Error ? error.message : 'Probeer het opnieuw.',
});
} finally {
setIsSavingReport(false);
}
};
const formatPatientName = (patient: Patient) => { const formatPatientName = (patient: Patient) => {
const name = `${patient.name_given?.[0] || ''} ${patient.name_family || ''}`.trim(); const name = `${patient.name_given?.[0] || ''} ${patient.name_family || ''}`.trim();
const birthDate = patient.birth_date const birthDate = patient.birth_date
@@ -406,17 +481,118 @@ export function AppointmentModal({
return { name, birthDate, identifier }; return { name, birthDate, identifier };
}; };
// Client-side createReport function
const handleCreateReport = async (patientId: string, type: ReportType, content: string, encounterId: string) => {
const response = await fetch('/api/reports', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
patient_id: patientId,
type,
content,
encounter_id: encounterId,
}),
});
if (!response.ok) {
const error = await response.json().catch(() => ({}));
throw new Error(error.error || 'Opslaan mislukt');
}
return response.json();
};
// Client-side updateReport function
const handleUpdateReport = async (reportId: string, content: string) => {
const response = await fetch(`/api/reports/${reportId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
content,
}),
});
if (!response.ok) {
const error = await response.json().catch(() => ({}));
throw new Error(error.error || 'Bijwerken mislukt');
}
return response.json();
};
// Handle edit report
const handleEditReport = (report: LinkedReport) => {
setEditingReport(report);
setEditReportContent(report.content);
setShowReportComposer(false); // Close composer if open
};
// Handle save edited report
const handleSaveEditedReport = async () => {
if (!editingReport || !editingEvent?.id) return;
const textContent = editReportContent.replace(/<[^>]*>/g, '').trim();
if (textContent.length < 20) {
toast({
variant: 'destructive',
title: 'Verslag te kort',
description: 'Een verslag moet minimaal 20 karakters bevatten.',
});
return;
}
if (textContent.length > 5000) {
toast({
variant: 'destructive',
title: 'Verslag te lang',
description: 'Een verslag mag maximaal 5000 karakters bevatten.',
});
return;
}
setIsUpdatingReport(true);
try {
await handleUpdateReport(editingReport.id, textContent);
toast({
title: 'Verslag bijgewerkt',
description: 'Het verslag is succesvol bijgewerkt.',
});
// Reset edit state
setEditingReport(null);
setEditReportContent('');
// Refresh linked reports
const reports = await getEncounterReports(editingEvent.id);
setLinkedReports(reports);
} catch (error) {
toast({
variant: 'destructive',
title: 'Bijwerken mislukt',
description: error instanceof Error ? error.message : 'Probeer het opnieuw.',
});
} finally {
setIsUpdatingReport(false);
}
};
const wrappedOnOpenChange = React.useCallback((newOpen: boolean) => {
onOpenChange(newOpen);
}, [onOpenChange]);
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={wrappedOnOpenChange}>
<DialogContent className="sm:max-w-[500px]"> <DialogContent className="sm:max-w-[800px] max-h-[90vh] !grid !grid-rows-[auto_1fr_auto] !gap-0 p-0 overflow-hidden">
<DialogHeader> <DialogHeader className="flex-shrink-0 px-6 pt-6 pb-4">
<DialogTitle className="flex items-center gap-2"> <DialogTitle className="flex items-center gap-2">
<Calendar className="h-5 w-5 text-teal-600" /> <Calendar className="h-5 w-5 text-teal-600" />
{editingEvent ? 'Afspraak bewerken' : 'Nieuwe Afspraak'} {editingEvent ? 'Afspraak bewerken' : 'Nieuwe Afspraak'}
</DialogTitle> </DialogTitle>
</DialogHeader> </DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4 mt-2 overflow-hidden"> <form onSubmit={handleSubmit} className="flex flex-col min-h-0 overflow-hidden">
<div className="flex-1 overflow-y-auto space-y-4 px-6 min-h-0">
{/* Patient Search */} {/* Patient Search */}
<div className="relative"> <div className="relative">
<label className={labelClassName}> <label className={labelClassName}>
@@ -428,7 +604,7 @@ export function AppointmentModal({
<input <input
type="text" type="text"
value={patientSearch} value={patientSearch}
onChange={(e) => { onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
setPatientSearch(e.target.value); setPatientSearch(e.target.value);
if (selectedPatient) { if (selectedPatient) {
setSelectedPatient(null); setSelectedPatient(null);
@@ -459,8 +635,8 @@ export function AppointmentModal({
{/* Patient Dropdown */} {/* Patient Dropdown */}
{showPatientDropdown && patients.length > 0 && ( {showPatientDropdown && patients.length > 0 && (
<div className="absolute z-50 w-full mt-1 bg-white border border-slate-200 rounded-lg shadow-lg max-h-48 overflow-auto"> <div className="absolute z-[100] w-full mt-1 bg-white border border-slate-200 rounded-lg shadow-lg max-h-40 overflow-auto">
{patients.map((patient) => { {patients.map((patient: Patient) => {
const { name, birthDate, identifier } = formatPatientName(patient); const { name, birthDate, identifier } = formatPatientName(patient);
return ( return (
<button <button
@@ -483,24 +659,24 @@ export function AppointmentModal({
)} )}
{isSearching && ( {isSearching && (
<div className="absolute z-50 w-full mt-1 bg-white border border-slate-200 rounded-lg shadow-lg p-3 text-center text-sm text-slate-500"> <div className="absolute z-[100] w-full mt-1 bg-white border border-slate-200 rounded-lg shadow-lg p-3 text-center text-sm text-slate-500">
Zoeken... Zoeken...
</div> </div>
)} )}
{showPatientDropdown && patients.length === 0 && patientSearch.length >= 2 && !isSearching && ( {showPatientDropdown && patients.length === 0 && patientSearch.length >= 2 && !isSearching && (
<div className="absolute z-50 w-full mt-1 bg-white border border-slate-200 rounded-lg shadow-lg p-3 text-center text-sm text-slate-500"> <div className="absolute z-[100] w-full mt-1 bg-white border border-slate-200 rounded-lg shadow-lg p-3 text-center text-sm text-slate-500">
Geen patiënten gevonden Geen patiënten gevonden
</div> </div>
)} )}
{/* Recent Patients Dropdown - shown when focused but no search query */} {/* Recent Patients Dropdown - shown when focused but no search query */}
{isInputFocused && !selectedPatient && patientSearch.length < 2 && recentPatients.length > 0 && !isEditMode && ( {isInputFocused && !selectedPatient && patientSearch.length < 2 && recentPatients.length > 0 && !isEditMode && (
<div className="absolute z-50 w-full mt-1 bg-white border border-slate-200 rounded-lg shadow-lg max-h-48 overflow-auto"> <div className="absolute z-[100] w-full mt-1 bg-white border border-slate-200 rounded-lg shadow-lg max-h-40 overflow-auto">
<div className="px-3 py-2 border-b border-slate-100 text-xs font-medium text-slate-500 uppercase tracking-wide"> <div className="px-3 py-2 border-b border-slate-100 text-xs font-medium text-slate-500 uppercase tracking-wide">
Recente patiënten Recente patiënten
</div> </div>
{recentPatients.map((patient) => { {recentPatients.map((patient: Patient) => {
const { name, birthDate, identifier } = formatPatientName(patient); const { name, birthDate, identifier } = formatPatientName(patient);
return ( return (
<button <button
@@ -527,10 +703,19 @@ export function AppointmentModal({
{selectedPatient && ( {selectedPatient && (
<div className="mt-2 p-3 bg-teal-50 rounded-lg border border-teal-100"> <div className="mt-2 p-3 bg-teal-50 rounded-lg border border-teal-100">
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<div> <div className="flex-1">
<div className="flex items-center gap-2">
<div className="font-medium text-slate-900"> <div className="font-medium text-slate-900">
{selectedPatient.name_given?.[0]} {selectedPatient.name_family} {selectedPatient.name_given?.[0]} {selectedPatient.name_family}
</div> </div>
<Link
href={`/epd/patients/${selectedPatient.id}`}
className="text-teal-600 hover:text-teal-700 transition-colors"
title="Open patiëntendossier"
>
<ExternalLink className="h-4 w-4" />
</Link>
</div>
<div className="text-sm text-slate-600 mt-0.5"> <div className="text-sm text-slate-600 mt-0.5">
Geb. {selectedPatient.birth_date Geb. {selectedPatient.birth_date
? format(new Date(selectedPatient.birth_date), 'd MMMM yyyy', { locale: nl }) ? format(new Date(selectedPatient.birth_date), 'd MMMM yyyy', { locale: nl })
@@ -558,6 +743,8 @@ export function AppointmentModal({
<X className="h-4 w-4" /> <X className="h-4 w-4" />
</button> </button>
</div> </div>
{/* Patient Medical Context */}
<PatientContextCard patientId={selectedPatient.id} />
</div> </div>
)} )}
@@ -571,7 +758,7 @@ export function AppointmentModal({
<input <input
type="date" type="date"
value={date} value={date}
onChange={(e) => setDate(e.target.value)} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setDate(e.target.value)}
className={inputClassName} className={inputClassName}
required required
/> />
@@ -584,7 +771,7 @@ export function AppointmentModal({
<input <input
type="time" type="time"
value={startTime} value={startTime}
onChange={(e) => setStartTime(e.target.value)} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setStartTime(e.target.value)}
className={inputClassName} className={inputClassName}
required required
/> />
@@ -597,7 +784,7 @@ export function AppointmentModal({
<input <input
type="time" type="time"
value={endTime} value={endTime}
onChange={(e) => setEndTime(e.target.value)} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setEndTime(e.target.value)}
className={inputClassName} className={inputClassName}
/> />
</div> </div>
@@ -609,7 +796,7 @@ export function AppointmentModal({
<label className={labelClassName}>Type afspraak *</label> <label className={labelClassName}>Type afspraak *</label>
<select <select
value={typeCode} value={typeCode}
onChange={(e) => setTypeCode(e.target.value as AppointmentTypeCode)} onChange={(e: React.ChangeEvent<HTMLSelectElement>) => setTypeCode(e.target.value as AppointmentTypeCode)}
className={selectClassName} className={selectClassName}
required required
> >
@@ -627,7 +814,7 @@ export function AppointmentModal({
</label> </label>
<select <select
value={classCode} value={classCode}
onChange={(e) => setClassCode(e.target.value as LocationClassCode)} onChange={(e: React.ChangeEvent<HTMLSelectElement>) => setClassCode(e.target.value as LocationClassCode)}
className={selectClassName} className={selectClassName}
> >
{Object.entries(LOCATION_CLASSES).map(([code, label]) => ( {Object.entries(LOCATION_CLASSES).map(([code, label]) => (
@@ -647,15 +834,90 @@ export function AppointmentModal({
</label> </label>
<textarea <textarea
value={notes} value={notes}
onChange={(e) => setNotes(e.target.value)} onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setNotes(e.target.value)}
placeholder="Optionele notities voor deze afspraak..." placeholder="Optionele notities voor deze afspraak..."
rows={3} rows={3}
className={`${inputClassName} resize-none`} className={`${inputClassName} resize-none`}
/> />
</div> </div>
{/* Report Composer Section - only shown in edit mode when toggled */}
{isEditMode && showReportComposer && editingEvent?.extendedProps.patient && (
<div className="border-t border-slate-200 pt-4">
<div className="flex items-center justify-between mb-4">
<label className={labelClassName}>
<FileText className="h-4 w-4 inline mr-1" />
Nieuw verslag
</label>
<button
type="button"
onClick={() => setShowReportComposer(false)}
className="text-slate-400 hover:text-slate-600"
>
<X className="h-4 w-4" />
</button>
</div>
{/* Quick Actions voor type selectie */}
<div className="mb-4">
<QuickActions
onSelectType={setSelectedReportType}
selectedType={selectedReportType}
disabled={isSavingReport}
/>
</div>
{/* Rich Text Editor */}
<div className="mb-3">
<RichTextEditor
value={reportContent}
onChange={setReportContent}
placeholder="Begin met typen..."
minHeight="150px"
/>
</div>
{/* Character count */}
<div className="flex justify-between text-xs text-slate-500 mb-4">
<span>
{reportContent.replace(/<[^>]*>/g, '').trim().length} / 5000 karakters
</span>
{reportContent.replace(/<[^>]*>/g, '').trim().length > 0 &&
reportContent.replace(/<[^>]*>/g, '').trim().length < 20 && (
<span className="text-amber-600">Minimaal 20 karakters</span>
)}
</div>
{/* Save button */}
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={() => {
setShowReportComposer(false);
setReportContent('');
}}
disabled={isSavingReport}
>
Annuleren
</Button>
<Button
type="button"
onClick={handleSaveReport}
disabled={
isSavingReport ||
reportContent.replace(/<[^>]*>/g, '').trim().length < 20 ||
reportContent.replace(/<[^>]*>/g, '').trim().length > 5000
}
>
{isSavingReport ? 'Opslaan...' : 'Verslag opslaan'}
</Button>
</div>
</div>
)}
{/* Linked Reports Section - only shown in edit mode */} {/* Linked Reports Section - only shown in edit mode */}
{isEditMode && ( {isEditMode && !editingReport && (
<div className="border-t border-slate-200 pt-4"> <div className="border-t border-slate-200 pt-4">
<label className={labelClassName}> <label className={labelClassName}>
<FileText className="h-4 w-4 inline mr-1" /> <FileText className="h-4 w-4 inline mr-1" />
@@ -669,7 +931,7 @@ export function AppointmentModal({
</div> </div>
) : ( ) : (
<div className="space-y-2 mt-2"> <div className="space-y-2 mt-2">
{linkedReports.map((report) => { {linkedReports.map((report: LinkedReport) => {
const reportDate = new Date(report.created_at); const reportDate = new Date(report.created_at);
const TYPE_LABELS: Record<string, string> = { const TYPE_LABELS: Record<string, string> = {
behandeladvies: 'Behandeladvies', behandeladvies: 'Behandeladvies',
@@ -680,10 +942,11 @@ export function AppointmentModal({
contact: 'Contactnotitie', contact: 'Contactnotitie',
}; };
return ( return (
<Link <button
key={report.id} key={report.id}
href={`/epd/patients/${editingEvent?.extendedProps.patient?.id}/rapportage?reportId=${report.id}`} type="button"
className="block p-3 bg-slate-50 hover:bg-slate-100 rounded-lg border border-slate-200 transition-colors" onClick={() => handleEditReport(report)}
className="w-full text-left p-3 bg-slate-50 hover:bg-slate-100 rounded-lg border border-slate-200 transition-colors"
> >
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-sm font-medium text-slate-700"> <span className="text-sm font-medium text-slate-700">
@@ -697,7 +960,7 @@ export function AppointmentModal({
{report.content.substring(0, 100)} {report.content.substring(0, 100)}
{report.content.length > 100 ? '...' : ''} {report.content.length > 100 ? '...' : ''}
</p> </p>
</Link> </button>
); );
})} })}
</div> </div>
@@ -705,7 +968,77 @@ export function AppointmentModal({
</div> </div>
)} )}
<DialogFooter className="flex justify-between sm:justify-between gap-2"> {/* Report Edit Section - shown when editing a report */}
{isEditMode && editingReport && (
<div className="border-t border-slate-200 pt-4">
<div className="flex items-center justify-between mb-4">
<label className={labelClassName}>
<FileText className="h-4 w-4 inline mr-1" />
Verslag bewerken
</label>
<button
type="button"
onClick={() => {
setEditingReport(null);
setEditReportContent('');
}}
className="text-slate-400 hover:text-slate-600"
>
<X className="h-4 w-4" />
</button>
</div>
{/* Rich Text Editor */}
<div className="mb-3">
<RichTextEditor
value={editReportContent}
onChange={setEditReportContent}
placeholder="Begin met typen..."
minHeight="150px"
/>
</div>
{/* Character count */}
<div className="flex justify-between text-xs text-slate-500 mb-4">
<span>
{editReportContent.replace(/<[^>]*>/g, '').trim().length} / 5000 karakters
</span>
{editReportContent.replace(/<[^>]*>/g, '').trim().length > 0 &&
editReportContent.replace(/<[^>]*>/g, '').trim().length < 20 && (
<span className="text-amber-600">Minimaal 20 karakters</span>
)}
</div>
{/* Save button */}
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={() => {
setEditingReport(null);
setEditReportContent('');
}}
disabled={isUpdatingReport}
>
Annuleren
</Button>
<Button
type="button"
onClick={handleSaveEditedReport}
disabled={
isUpdatingReport ||
editReportContent.replace(/<[^>]*>/g, '').trim().length < 20 ||
editReportContent.replace(/<[^>]*>/g, '').trim().length > 5000
}
>
{isUpdatingReport ? 'Opslaan...' : 'Wijzigingen opslaan'}
</Button>
</div>
</div>
)}
</div>
<DialogFooter className="flex-shrink-0 flex justify-between sm:justify-between gap-2 pt-4 px-6 pb-6 border-t border-slate-200 bg-white">
{isEditMode && editingEvent?.extendedProps.patient && ( {isEditMode && editingEvent?.extendedProps.patient && (
<div className="flex gap-2 mr-auto"> <div className="flex gap-2 mr-auto">
<Button <Button
@@ -720,14 +1053,11 @@ export function AppointmentModal({
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
asChild onClick={() => setShowReportComposer(!showReportComposer)}
> disabled={isSubmitting}
<Link
href={`/epd/patients/${editingEvent.extendedProps.patient.id}/rapportage?encounterId=${editingEvent.id}`}
> >
<PenLine className="h-4 w-4 mr-1" /> <PenLine className="h-4 w-4 mr-1" />
Maak verslag {showReportComposer ? 'Verberg verslag' : 'Maak verslag'}
</Link>
</Button> </Button>
</div> </div>
)} )}
@@ -753,7 +1083,7 @@ export function AppointmentModal({
Sluiten Sluiten
</Button> </Button>
<Button type="submit" disabled={isSubmitting || (!selectedPatient && !isEditMode)}> <Button type="submit" disabled={isSubmitting || (!selectedPatient && !isEditMode)}>
{isSubmitting ? 'Opslaan...' : isEditMode ? 'Wijzigingen opslaan' : 'Afspraak maken'} {isSubmitting ? 'Opslaan...' : isEditMode ? 'Opslaan' : 'Afspraak maken'}
</Button> </Button>
</div> </div>
</DialogFooter> </DialogFooter>

View File

@@ -0,0 +1,228 @@
'use client';
/**
* Patient Context Card Component
*
* Displays medical context (conditions, risks, vitals) for a patient
* in the appointment modal. Collapsible by default.
*/
import React, { useEffect, useState } from 'react';
import { AlertTriangle, Activity, Stethoscope, Loader2, ChevronDown, ChevronRight } from 'lucide-react';
import type { PatientDetail, Condition, RiskAssessment, VitalSign } from '@/lib/types/overdracht';
interface PatientContextCardProps {
patientId: string;
}
// Risk type labels in Dutch
const RISK_TYPE_LABELS: Record<string, string> = {
suiciderisico: 'Suicide',
agressie: 'Agressie',
terugval: 'Terugval',
automutilatie: 'Automutilatie',
verwaarlozing: 'Verwaarlozing',
weglopen: 'Weglopen',
};
// Risk level colors
const RISK_LEVEL_STYLES: Record<string, { bg: string; text: string; dot: string }> = {
zeer_hoog: { bg: 'bg-red-100', text: 'text-red-800', dot: 'bg-red-500' },
hoog: { bg: 'bg-red-100', text: 'text-red-700', dot: 'bg-red-500' },
gemiddeld: { bg: 'bg-amber-100', text: 'text-amber-800', dot: 'bg-amber-500' },
laag: { bg: 'bg-green-100', text: 'text-green-800', dot: 'bg-green-500' },
};
export function PatientContextCard({ patientId }: PatientContextCardProps) {
const [data, setData] = useState<PatientDetail | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [isExpanded, setIsExpanded] = useState(false);
useEffect(() => {
const fetchContext = async () => {
setIsLoading(true);
setError(null);
try {
const response = await fetch(`/api/overdracht/${patientId}`);
if (!response.ok) {
throw new Error('Kon patient context niet laden');
}
const result = await response.json();
setData(result);
} catch (err) {
setError(err instanceof Error ? err.message : 'Onbekende fout');
} finally {
setIsLoading(false);
}
};
fetchContext();
}, [patientId]);
// Loading state
if (isLoading) {
return (
<div className="mt-3 p-3 bg-slate-50 rounded-lg border border-slate-200 animate-pulse">
<div className="flex items-center gap-2 text-slate-500 text-sm">
<Loader2 className="h-4 w-4 animate-spin" />
Medische context laden...
</div>
</div>
);
}
// Error state
if (error) {
return null; // Silently fail - don't block the modal
}
// No data or all arrays empty
if (!data) {
return null;
}
const hasConditions = data.conditions.length > 0;
const hasRisks = data.risks.length > 0;
const hasVitals = data.vitals.length > 0;
// Nothing to show
if (!hasConditions && !hasRisks && !hasVitals) {
return null;
}
// Sort risks by level (highest first)
const sortedRisks = [...data.risks].sort((a, b) => {
const order = ['zeer_hoog', 'hoog', 'gemiddeld', 'laag'];
return order.indexOf(a.risk_level) - order.indexOf(b.risk_level);
});
// Filter to significant risks only (gemiddeld and up)
const significantRisks = sortedRisks.filter(
(r) => r.risk_level === 'zeer_hoog' || r.risk_level === 'hoog' || r.risk_level === 'gemiddeld'
);
// Build summary for collapsed state
const summaryParts: string[] = [];
if (hasConditions) summaryParts.push(`${data.conditions.length} diagnose${data.conditions.length > 1 ? 's' : ''}`);
if (significantRisks.length > 0) summaryParts.push(`${significantRisks.length} risico${significantRisks.length > 1 ? "'s" : ''}`);
if (hasVitals) summaryParts.push(`${data.vitals.length} vital${data.vitals.length > 1 ? 's' : ''}`);
return (
<div className="mt-3 bg-slate-50 rounded-lg border border-slate-200">
{/* Collapsible header */}
<button
type="button"
onClick={() => setIsExpanded(!isExpanded)}
className="w-full px-3 py-2 flex items-center justify-between text-left hover:bg-slate-100 transition-colors rounded-lg"
>
<div className="flex items-center gap-2">
{isExpanded ? (
<ChevronDown className="h-4 w-4 text-slate-400" />
) : (
<ChevronRight className="h-4 w-4 text-slate-400" />
)}
<Stethoscope className="h-3.5 w-3.5 text-slate-500" />
<span className="text-xs font-medium text-slate-600">Medische context</span>
</div>
{!isExpanded && summaryParts.length > 0 && (
<span className="text-xs text-slate-500">{summaryParts.join(' · ')}</span>
)}
</button>
{/* Expanded content */}
{isExpanded && (
<div className="px-3 pb-3 space-y-3 border-t border-slate-200 pt-3">
{/* Risks - Always show first if present (most important) */}
{significantRisks.length > 0 && (
<div>
<div className="flex items-center gap-1.5 text-xs font-medium text-slate-600 mb-1.5">
<AlertTriangle className="h-3.5 w-3.5 text-amber-600" />
Risico&apos;s
</div>
<div className="flex flex-wrap gap-1.5">
{significantRisks.map((risk) => (
<RiskBadge key={risk.id} risk={risk} />
))}
</div>
</div>
)}
{/* Conditions */}
{hasConditions && (
<div>
<div className="text-xs font-medium text-slate-600 mb-1.5">Diagnoses</div>
<div className="space-y-1">
{data.conditions.slice(0, 3).map((condition) => (
<ConditionItem key={condition.id} condition={condition} />
))}
{data.conditions.length > 3 && (
<div className="text-xs text-slate-500">
+ {data.conditions.length - 3} meer
</div>
)}
</div>
</div>
)}
{/* Vitals (today only) */}
{hasVitals && (
<div>
<div className="flex items-center gap-1.5 text-xs font-medium text-slate-600 mb-1.5">
<Activity className="h-3.5 w-3.5 text-teal-600" />
Vitals vandaag
</div>
<div className="flex flex-wrap gap-2">
{data.vitals.slice(0, 4).map((vital) => (
<VitalItem key={vital.id} vital={vital} />
))}
</div>
</div>
)}
</div>
)}
</div>
);
}
function RiskBadge({ risk }: { risk: RiskAssessment }) {
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={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium ${styles.bg} ${styles.text}`}
>
<span className={`w-1.5 h-1.5 rounded-full ${styles.dot}`} />
{label}: {levelLabel}
</span>
);
}
function ConditionItem({ condition }: { condition: Condition }) {
return (
<div className="flex items-center gap-2 text-sm text-slate-700">
<span className="w-1.5 h-1.5 rounded-full bg-slate-400" />
<span className="line-clamp-1">{condition.code_display}</span>
</div>
);
}
function VitalItem({ vital }: { vital: VitalSign }) {
const isAbnormal = vital.interpretation_code === 'H' || vital.interpretation_code === 'L';
return (
<div
className={`text-xs px-2 py-1 rounded ${
isAbnormal ? 'bg-amber-100 text-amber-800' : 'bg-white text-slate-600 border border-slate-200'
}`}
>
<span className="font-medium">{vital.code_display}:</span>{' '}
{vital.value_quantity_value}
{vital.value_quantity_unit && ` ${vital.value_quantity_unit}`}
</div>
);
}

View File

@@ -2,7 +2,8 @@
import { createClient } from '@/lib/auth/server'; import { createClient } from '@/lib/auth/server';
import { revalidatePath } from 'next/cache'; import { revalidatePath } from 'next/cache';
import type { GeneratedPlan, SmartGoal, Intervention, Behandelstructuur } from '@/lib/types/behandelplan'; import type { GeneratedPlan, SmartGoal, Intervention, Behandelstructuur, Behandeldoel } from '@/lib/types/behandelplan';
import { transformFromFlat } from '@/lib/types/behandelplan';
import type { LifeDomainScore } from '@/lib/types/leefgebieden'; import type { LifeDomainScore } from '@/lib/types/leefgebieden';
import type { Json } from '@/lib/supabase/database.types'; import type { Json } from '@/lib/supabase/database.types';
@@ -518,3 +519,132 @@ export async function deleteIntervention(
revalidatePath(`/epd/patients/${patientId}/behandelplan`); revalidatePath(`/epd/patients/${patientId}/behandelplan`);
} }
// =============================================================================
// BEHANDELDOEL (FLAT STRUCTURE - doel met embedded interventies)
// =============================================================================
/**
* Save a behandeldoel (creates or updates)
* Transforms flat structure to goals + activities for backwards compatibility
*/
export async function saveBehandeldoel(
carePlanId: string,
patientId: string,
behandeldoel: Behandeldoel
) {
const supabase = await createClient();
// Get current plan
const { data: plan } = await supabase
.from('care_plans')
.select('goals, activities')
.eq('id', carePlanId)
.single();
const currentGoals = (plan?.goals as unknown as SmartGoal[]) || [];
const currentActivities = (plan?.activities as unknown as Intervention[]) || [];
// Check if this is an update or a new goal
const existingGoalIndex = currentGoals.findIndex((g) => g.id === behandeldoel.id);
// Convert behandeldoel to SmartGoal
const smartGoal: SmartGoal = {
id: behandeldoel.id,
title: behandeldoel.title,
description: '', // Not used in flat structure
clientVersion: behandeldoel.clientVersion,
lifeDomain: behandeldoel.lifeDomain,
priority: 'middel', // Default
measurability: '', // Not used in flat structure
timelineWeeks: behandeldoel.endWeek,
status: behandeldoel.status,
progress: behandeldoel.progress,
};
// Update goals array
let updatedGoals: SmartGoal[];
if (existingGoalIndex >= 0) {
updatedGoals = currentGoals.map((g, i) =>
i === existingGoalIndex ? smartGoal : g
);
} else {
updatedGoals = [...currentGoals, smartGoal];
}
// Handle interventions: remove old ones for this goal and add new ones
const otherActivities = currentActivities.filter(
(a) => !a.linkedGoalIds.includes(behandeldoel.id)
);
const newActivities: Intervention[] = behandeldoel.interventies.map((int) => ({
id: int.id,
name: int.name,
description: int.description,
rationale: '', // Not used in flat structure
linkedGoalIds: [behandeldoel.id],
}));
const updatedActivities = [...otherActivities, ...newActivities];
// Save to database
const { error } = await supabase
.from('care_plans')
.update({
goals: updatedGoals as unknown as Json,
activities: updatedActivities as unknown as Json,
})
.eq('id', carePlanId);
if (error) {
console.error('Error saving behandeldoel:', error);
throw new Error('Kon behandeldoel niet opslaan');
}
revalidatePath(`/epd/patients/${patientId}/behandelplan`);
}
/**
* Delete a behandeldoel and its linked interventions
*/
export async function deleteBehandeldoel(
carePlanId: string,
patientId: string,
doelId: string
) {
const supabase = await createClient();
// Get current plan
const { data: plan } = await supabase
.from('care_plans')
.select('goals, activities')
.eq('id', carePlanId)
.single();
const currentGoals = (plan?.goals as unknown as SmartGoal[]) || [];
const currentActivities = (plan?.activities as unknown as Intervention[]) || [];
// Remove the goal
const updatedGoals = currentGoals.filter((g) => g.id !== doelId);
// Remove interventions linked to this goal
const updatedActivities = currentActivities.filter(
(a) => !a.linkedGoalIds.includes(doelId)
);
// Save to database
const { error } = await supabase
.from('care_plans')
.update({
goals: updatedGoals as unknown as Json,
activities: updatedActivities as unknown as Json,
})
.eq('id', carePlanId);
if (error) {
console.error('Error deleting behandeldoel:', error);
throw new Error('Kon behandeldoel niet verwijderen');
}
revalidatePath(`/epd/patients/${patientId}/behandelplan`);
}

View File

@@ -3,6 +3,7 @@
import { useState, useCallback, useMemo } from 'react'; import { useState, useCallback, useMemo } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { BehandelplanView, BehandelplanList } from '@/components/behandelplan'; import { BehandelplanView, BehandelplanList } from '@/components/behandelplan';
import { BehandelplanFlat } from '@/components/behandelplan/flat';
import { import {
createCarePlan, createCarePlan,
updateCarePlanStatus, updateCarePlanStatus,
@@ -14,10 +15,14 @@ import {
addIntervention, addIntervention,
updateIntervention, updateIntervention,
deleteIntervention, deleteIntervention,
saveBehandeldoel,
deleteBehandeldoel,
} from './actions'; } from './actions';
import type { GeneratedPlan, SmartGoal, Intervention, Sessie, Evaluatiemoment, Veiligheidsplan, Behandelstructuur } from '@/lib/types/behandelplan'; import type { GeneratedPlan, SmartGoal, Intervention, Sessie, Evaluatiemoment, Veiligheidsplan, Behandelstructuur, Behandeldoel } from '@/lib/types/behandelplan';
import type { LifeDomainScore } from '@/lib/types/leefgebieden'; import type { LifeDomainScore } from '@/lib/types/leefgebieden';
import type { Json } from '@/lib/supabase/database.types'; import type { Json } from '@/lib/supabase/database.types';
import { Button } from '@/components/ui/button';
import { LayoutGrid, List } from 'lucide-react';
// Database row types (what we get from Supabase) // Database row types (what we get from Supabase)
interface DbCarePlan { interface DbCarePlan {
@@ -124,6 +129,9 @@ export function BehandelplanPageClient({
}: BehandelplanPageClientProps) { }: BehandelplanPageClientProps) {
const router = useRouter(); const router = useRouter();
// View mode toggle: 'flat' = nieuwe platte UI, 'detailed' = oude gedetailleerde UI
const [viewMode, setViewMode] = useState<'flat' | 'detailed'>('flat');
// State voor alle plannen en selectie // State voor alle plannen en selectie
const [plans, setPlans] = useState<DbCarePlan[]>(initialPlans); const [plans, setPlans] = useState<DbCarePlan[]>(initialPlans);
const [selectedPlanId, setSelectedPlanId] = useState<string | null>( const [selectedPlanId, setSelectedPlanId] = useState<string | null>(
@@ -380,9 +388,53 @@ export function BehandelplanPageClient({
[selectedPlan, patientId, router] [selectedPlan, patientId, router]
); );
// =============================================================================
// FLAT VIEW HANDLERS (Behandeldoel met embedded interventies)
// =============================================================================
const handleSaveBehandeldoel = useCallback(
async (doel: Behandeldoel) => {
if (!selectedPlan) return;
await saveBehandeldoel(selectedPlan.id, patientId, doel);
// Update local state - we need to update both goals and activities
// For now, just refresh the page to get fresh data
router.refresh();
},
[selectedPlan, patientId, router]
);
const handleDeleteBehandeldoel = useCallback(
async (doelId: string) => {
if (!selectedPlan) return;
await deleteBehandeldoel(selectedPlan.id, patientId, doelId);
router.refresh();
},
[selectedPlan, patientId, router]
);
// Get hulpvraag from first intake notes (first line/sentence)
const hulpvraag = useMemo(() => {
const firstIntake = intakes[0];
if (!firstIntake?.notes) return null;
// Get first sentence or first 150 chars
const notes = firstIntake.notes;
const firstSentence = notes.split(/[.!?]/)[0];
return firstSentence.length > 150 ? firstSentence.slice(0, 150) + '...' : firstSentence;
}, [intakes]);
// Get life domain scores from first intake
const lifeDomainScores = useMemo(() => {
const firstIntake = intakes[0];
return firstIntake?.life_domains as LifeDomainScore[] | null;
}, [intakes]);
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Plannen overzicht */} {/* Header met view toggle */}
<div className="flex items-center justify-between">
<BehandelplanList <BehandelplanList
plans={plans.map(p => ({ plans={plans.map(p => ({
id: p.id, id: p.id,
@@ -398,7 +450,52 @@ export function BehandelplanPageClient({
isCreating={isCreatingNew} isCreating={isCreatingNew}
/> />
{/* Geselecteerd plan of create view */} {/* View mode toggle */}
<div className="flex items-center gap-1 border rounded-lg p-1 bg-slate-50">
<Button
variant={viewMode === 'flat' ? 'default' : 'ghost'}
size="sm"
onClick={() => setViewMode('flat')}
className="h-8 px-3"
>
<LayoutGrid className="h-4 w-4 mr-1.5" />
Compact
</Button>
<Button
variant={viewMode === 'detailed' ? 'default' : 'ghost'}
size="sm"
onClick={() => setViewMode('detailed')}
className="h-8 px-3"
>
<List className="h-4 w-4 mr-1.5" />
Uitgebreid
</Button>
</div>
</div>
{/* Geselecteerd plan - Flat view */}
{viewMode === 'flat' && (
<BehandelplanFlat
patientId={patientId}
carePlan={showCreateView ? null : selectedPlan}
condition={conditions[0] || null}
hulpvraag={hulpvraag}
lifeDomainScores={lifeDomainScores}
onGenerate={async () => {
const intakeId = intakes[0]?.id;
if (intakeId) await handleGenerate(intakeId);
}}
onCreateManual={async () => {
await handleCreateManual(intakes[0]?.id);
}}
onStatusChange={handleStatusChange}
onSaveBehandeldoel={handleSaveBehandeldoel}
onDeleteBehandeldoel={handleDeleteBehandeldoel}
/>
)}
{/* Geselecteerd plan - Detailed view (oude UI) */}
{viewMode === 'detailed' && (
<BehandelplanView <BehandelplanView
patientId={patientId} patientId={patientId}
carePlan={showCreateView ? null : selectedPlan} carePlan={showCreateView ? null : selectedPlan}
@@ -415,6 +512,7 @@ export function BehandelplanPageClient({
onUpdateIntervention={handleUpdateIntervention} onUpdateIntervention={handleUpdateIntervention}
onDeleteIntervention={handleDeleteIntervention} onDeleteIntervention={handleDeleteIntervention}
/> />
)}
</div> </div>
); );
} }

View File

@@ -0,0 +1,71 @@
'use server';
import { createClient } from '@/lib/auth/server';
import type { Database } from '@/lib/supabase/database.types';
export type Condition = Database['public']['Tables']['conditions']['Row'];
export type IntakeInfo = {
id: string;
title: string | null;
department: string | null;
start_date: string | null;
};
export type DiagnosisWithIntake = Condition & {
intake?: IntakeInfo | null;
};
/**
* Haal alle diagnoses op voor een patiënt (uit alle intakes)
*/
export async function getPatientDiagnoses(patientId: string): Promise<DiagnosisWithIntake[]> {
const supabase = await createClient();
// Haal eerst alle diagnoses op
const { data: conditions, error: conditionsError } = await supabase
.from('conditions')
.select('*')
.eq('patient_id', patientId)
.order('recorded_date', { ascending: false });
if (conditionsError) {
console.error('getPatientDiagnoses error', conditionsError);
throw new Error('Kon diagnoses niet ophalen');
}
if (!conditions || conditions.length === 0) {
return [];
}
// Haal de intake IDs op
const intakeIds = [...new Set(conditions.map((c) => c.encounter_id).filter((id): id is string => id !== null))];
if (intakeIds.length === 0) {
return conditions.map((c) => ({ ...c, intake: null }));
}
// Haal intake informatie op
const { data: intakes, error: intakesError } = await supabase
.from('intakes')
.select('id, title, department, start_date')
.in('id', intakeIds);
if (intakesError) {
console.error('getPatientDiagnoses intakes error', intakesError);
// Return conditions zonder intake info als de query faalt
return conditions.map((c) => ({ ...c, intake: null }));
}
// Maak lookup map
const intakeMap = new Map<string, IntakeInfo>();
intakes?.forEach((intake) => {
intakeMap.set(intake.id, intake);
});
// Combineer data
return conditions.map((condition) => ({
...condition,
intake: condition.encounter_id ? intakeMap.get(condition.encounter_id) || null : null,
}));
}

View File

@@ -0,0 +1,182 @@
'use client';
/**
* Diagnosis Overview Card Component
*
* Read-only weergave van een diagnose voor het patiënt-breed overzicht.
* Toont ook de gekoppelde intake informatie.
*/
import { useState } from 'react';
import Link from 'next/link';
import { format } from 'date-fns';
import { nl } from 'date-fns/locale';
import { ChevronDown, ChevronUp, ExternalLink } from 'lucide-react';
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import type { DiagnosisWithIntake } from '../actions';
interface DiagnosisOverviewCardProps {
diagnosis: DiagnosisWithIntake;
patientId: string;
}
// Status badge configuratie
const STATUS_CONFIG: Record<string, { label: string; color: string; bgColor: string }> = {
active: {
label: 'Actief',
color: 'text-green-700',
bgColor: 'bg-green-100 border-green-300',
},
remission: {
label: 'In remissie',
color: 'text-blue-700',
bgColor: 'bg-blue-100 border-blue-300',
},
resolved: {
label: 'Opgelost',
color: 'text-slate-700',
bgColor: 'bg-slate-100 border-slate-300',
},
inactive: {
label: 'Inactief',
color: 'text-amber-700',
bgColor: 'bg-amber-100 border-amber-300',
},
};
// Severity labels
const SEVERITY_LABELS: Record<string, string> = {
licht: 'Licht',
matig: 'Matig',
ernstig: 'Ernstig',
};
export function DiagnosisOverviewCard({ diagnosis, patientId }: DiagnosisOverviewCardProps) {
const [isNotesExpanded, setIsNotesExpanded] = useState(false);
const code = diagnosis.code_code || '';
const description = diagnosis.code_display || '';
const severity = diagnosis.severity_display || '';
const status = diagnosis.clinical_status || 'active';
const notes = diagnosis.note || '';
const recordedDate = diagnosis.recorded_date ? new Date(diagnosis.recorded_date) : null;
const isPrimary = diagnosis.category === 'primary-diagnosis';
const statusConfig = STATUS_CONFIG[status] || STATUS_CONFIG.active;
const hasNotes = notes.trim().length > 0;
// Intake informatie
const intake = diagnosis.intake;
const intakeUrl = intake
? `/epd/patients/${patientId}/intakes/${intake.id}/diagnosis`
: null;
return (
<Card className="transition-all hover:border-teal-300 hover:shadow-sm">
<CardHeader className="p-4 pb-3">
<div className="flex items-start justify-between gap-3">
{/* Code + beschrijving */}
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-slate-900">
{code && description ? (
<>
<span className="font-mono">{code}</span>
{' — '}
<span>{description}</span>
</>
) : (
code || description || 'Geen diagnose code'
)}
</h3>
</div>
{/* Badges */}
<div className="flex items-center gap-2 shrink-0">
{/* HOOFD badge */}
{isPrimary && (
<Badge className="bg-green-600 text-white border-green-700 hover:bg-green-600">
HOOFD
</Badge>
)}
{/* Status badge */}
<Badge
variant="outline"
className={`text-xs ${statusConfig.color} ${statusConfig.bgColor}`}
>
{statusConfig.label}
</Badge>
</div>
</div>
</CardHeader>
<CardContent className="p-4 pt-0 space-y-3">
{/* Meta informatie */}
<div className="flex flex-wrap items-center gap-4 text-sm text-slate-600">
{severity && (
<div>
<span className="font-medium">Ernst:</span>{' '}
<span>{SEVERITY_LABELS[severity] || severity}</span>
</div>
)}
{recordedDate && (
<div>
<span className="font-medium">Datum:</span>{' '}
<span>{format(recordedDate, 'd MMM yyyy', { locale: nl })}</span>
</div>
)}
</div>
{/* Intake link */}
{intake && intakeUrl && (
<div className="flex items-center gap-2 text-sm">
<span className="text-slate-500">Intake:</span>
<Link
href={intakeUrl}
className="text-teal-600 hover:text-teal-700 hover:underline inline-flex items-center gap-1"
>
{intake.title || intake.department || 'Intake'}
<ExternalLink className="h-3 w-3" />
</Link>
</div>
)}
{/* Onderbouwing (expand/collapse) */}
{hasNotes && (
<div className="border-t border-slate-100 pt-3">
<button
onClick={() => setIsNotesExpanded(!isNotesExpanded)}
className="flex items-center gap-2 w-full text-left text-sm font-medium text-slate-700 hover:text-slate-900 transition-colors"
>
{isNotesExpanded ? (
<ChevronUp className="h-4 w-4 text-slate-500" />
) : (
<ChevronDown className="h-4 w-4 text-slate-500" />
)}
<span>Onderbouwing</span>
</button>
{isNotesExpanded && (
<div className="mt-2 pl-6 text-sm text-slate-600 whitespace-pre-line">
{notes}
</div>
)}
</div>
)}
{/* Bewerk link naar intake */}
{intakeUrl && (
<div className="pt-2">
<Button variant="outline" size="sm" asChild>
<Link href={intakeUrl}>
Bewerken in intake
<ExternalLink className="ml-2 h-3 w-3" />
</Link>
</Button>
</div>
)}
</CardContent>
</Card>
);
}

View File

@@ -1,40 +1,145 @@
/** /**
* Diagnose Page * Diagnose Overzicht Pagina
* E2.S3: Placeholder for diagnose functionality (to be implemented in Epic 6) *
* Toont alle diagnoses van een patiënt (uit alle intakes).
* Diagnoses kunnen worden bewerkt via de gekoppelde intake.
*/ */
import { Stethoscope } from 'lucide-react'; import { Stethoscope, Plus } from 'lucide-react';
import Link from 'next/link';
import { Button } from '@/components/ui/button';
import { getPatientDiagnoses } from './actions';
import { DiagnosisOverviewCard } from './components/diagnosis-overview-card';
import { createClient } from '@/lib/auth/server';
export default async function DiagnosePage({ export default async function DiagnosePage({
params, params,
}: { }: {
params: Promise<{ id: string }>; params: Promise<{ id: string }>;
}) { }) {
const { id } = await params; const { id: patientId } = await params;
// Haal alle diagnoses op
const diagnoses = await getPatientDiagnoses(patientId);
// Sorteer: hoofddiagnoses eerst, dan actieve, dan op datum
const sortedDiagnoses = [...diagnoses].sort((a, b) => {
// Hoofddiagnoses eerst
const aIsPrimary = a.category === 'primary-diagnosis';
const bIsPrimary = b.category === 'primary-diagnosis';
if (aIsPrimary && !bIsPrimary) return -1;
if (!aIsPrimary && bIsPrimary) return 1;
// Actieve diagnoses eerst
const aIsActive = a.clinical_status === 'active';
const bIsActive = b.clinical_status === 'active';
if (aIsActive && !bIsActive) return -1;
if (!aIsActive && bIsActive) return 1;
// Dan op datum (nieuwste eerst)
const aDate = a.recorded_date ? new Date(a.recorded_date).getTime() : 0;
const bDate = b.recorded_date ? new Date(b.recorded_date).getTime() : 0;
return bDate - aDate;
});
// Tel actieve diagnoses
const activeDiagnoses = diagnoses.filter((d) => d.clinical_status === 'active');
const primaryDiagnosis = diagnoses.find((d) => d.category === 'primary-diagnosis');
// Haal meest recente intake op voor "Nieuwe diagnose" link
const supabase = await createClient();
const { data: recentIntake } = await supabase
.from('intakes')
.select('id')
.eq('patient_id', patientId)
.order('start_date', { ascending: false })
.limit(1)
.maybeSingle();
const newDiagnosisUrl = recentIntake
? `/epd/patients/${patientId}/intakes/${recentIntake.id}/diagnosis`
: null;
return ( return (
<div className="p-6"> <div className="p-6 space-y-6">
{/* Page Header */} {/* Page Header */}
<div className="mb-6"> <div className="flex items-start justify-between">
<h2 className="text-lg font-semibold text-slate-900">Diagnose</h2> <div>
<h2 className="text-lg font-semibold text-slate-900">Diagnoses</h2>
<p className="text-sm text-slate-600 mt-1"> <p className="text-sm text-slate-600 mt-1">
DSM-5 diagnoses en behandeladvies Overzicht van alle diagnoses (ICD-10) voor deze patiënt.
</p> </p>
</div> </div>
{/* Placeholder */} {newDiagnosisUrl && (
<Button asChild>
<Link href={newDiagnosisUrl}>
<Plus className="h-4 w-4 mr-2" />
Nieuwe diagnose
</Link>
</Button>
)}
</div>
{/* Summary cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="bg-white rounded-lg border border-slate-200 p-4">
<div className="text-2xl font-bold text-slate-900">{diagnoses.length}</div>
<div className="text-sm text-slate-600">Totaal diagnoses</div>
</div>
<div className="bg-white rounded-lg border border-slate-200 p-4">
<div className="text-2xl font-bold text-green-600">{activeDiagnoses.length}</div>
<div className="text-sm text-slate-600">Actieve diagnoses</div>
</div>
<div className="bg-white rounded-lg border border-slate-200 p-4">
<div className="text-sm font-medium text-slate-900 truncate">
{primaryDiagnosis ? (
<>
<span className="font-mono">{primaryDiagnosis.code_code}</span>
{' — '}
{primaryDiagnosis.code_display}
</>
) : (
<span className="text-slate-400">Geen hoofddiagnose</span>
)}
</div>
<div className="text-sm text-slate-600">Hoofddiagnose</div>
</div>
</div>
{/* Diagnoses lijst */}
{sortedDiagnoses.length === 0 ? (
<div className="bg-white rounded-lg border border-slate-200 p-12 text-center"> <div className="bg-white rounded-lg border border-slate-200 p-12 text-center">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-purple-50 mb-4"> <div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-purple-50 mb-4">
<Stethoscope className="h-8 w-8 text-purple-500" /> <Stethoscope className="h-8 w-8 text-purple-500" />
</div> </div>
<h3 className="text-lg font-semibold text-slate-900 mb-2"> <h3 className="text-lg font-semibold text-slate-900 mb-2">
Diagnose Module - Coming Soon Nog geen diagnoses
</h3> </h3>
<p className="text-sm text-slate-600 max-w-md mx-auto"> <p className="text-sm text-slate-600 max-w-md mx-auto mb-4">
De diagnose functionaliteit wordt geïmplementeerd in Epic 6. Dit omvat Er zijn nog geen diagnoses geregistreerd voor deze patiënt.
DSM-5 diagnose registratie en behandeladvies formulering. Diagnoses worden vastgelegd tijdens een intake.
</p> </p>
{newDiagnosisUrl && (
<Button asChild>
<Link href={newDiagnosisUrl}>
<Plus className="h-4 w-4 mr-2" />
Eerste diagnose toevoegen
</Link>
</Button>
)}
</div> </div>
) : (
<div className="space-y-3">
{sortedDiagnoses.map((diagnosis) => (
<DiagnosisOverviewCard
key={diagnosis.id}
diagnosis={diagnosis}
patientId={patientId}
/>
))}
</div>
)}
</div> </div>
); );
} }

View File

@@ -312,14 +312,17 @@ export async function getDiagnoses(intakeId: string) {
return data || []; return data || [];
} }
type ClinicalStatus = 'active' | 'recurrence' | 'relapse' | 'inactive' | 'remission' | 'resolved';
export interface DiagnosisPayload { export interface DiagnosisPayload {
patientId: string; patientId: string;
intakeId: string; intakeId: string;
code: string; code: string;
description: string; description: string;
severity?: string; severity?: string;
status?: string; status?: ClinicalStatus;
notes?: string; notes?: string;
diagnosisType?: 'primary' | 'secondary';
} }
export async function createDiagnosis(payload: DiagnosisPayload) { export async function createDiagnosis(payload: DiagnosisPayload) {
@@ -329,26 +332,98 @@ export async function createDiagnosis(payload: DiagnosisPayload) {
encounter_id: payload.intakeId, encounter_id: payload.intakeId,
code_code: payload.code, code_code: payload.code,
code_display: payload.description, code_display: payload.description,
code_system: 'DSM-5', code_system: 'ICD-10',
clinical_status: 'active', clinical_status: payload.status || 'active',
severity_display: payload.severity || null, severity_display: payload.severity || null,
category: payload.diagnosisType === 'primary' ? 'primary-diagnosis' : 'encounter-diagnosis',
note: payload.notes, note: payload.notes,
recorded_date: new Date().toISOString(), recorded_date: new Date().toISOString(),
}); });
if (error) { if (error) {
console.error('createDiagnosis error', error); console.error('createDiagnosis error', error);
throw new Error(error.message); throw new Error('Diagnose opslaan mislukt');
} }
revalidatePath(buildPath(payload.patientId, payload.intakeId, 'diagnosis')); revalidatePath(buildPath(payload.patientId, payload.intakeId, 'diagnosis'));
revalidatePath(buildPath(payload.patientId, payload.intakeId)); revalidatePath(buildPath(payload.patientId, payload.intakeId));
} }
export interface DiagnosisUpdatePayload {
code?: string;
description?: string;
severity?: string;
status?: string;
notes?: string;
diagnosisType?: 'primary' | 'secondary';
}
export async function updateDiagnosis(
diagnosisId: string,
payload: DiagnosisUpdatePayload
): Promise<{ success: boolean; error?: string }> {
const supabase = await getSupabase();
// Haal eerst de diagnosis op om patientId en intakeId te krijgen voor revalidatie
const { data: diagnosis, error: fetchError } = await supabase
.from('conditions')
.select('patient_id, encounter_id')
.eq('id', diagnosisId)
.single();
if (fetchError || !diagnosis) {
console.error('updateDiagnosis fetch error', fetchError);
return { success: false, error: 'Diagnose niet gevonden' };
}
const updateData: Record<string, unknown> = {
updated_at: new Date().toISOString(),
};
if (payload.code !== undefined) {
updateData.code_code = payload.code;
}
if (payload.description !== undefined) {
updateData.code_display = payload.description;
}
if (payload.status !== undefined) {
updateData.clinical_status = payload.status;
}
if (payload.severity !== undefined) {
updateData.severity_display = payload.severity;
}
if (payload.notes !== undefined) {
updateData.note = payload.notes;
}
if (payload.diagnosisType !== undefined) {
updateData.category = payload.diagnosisType === 'primary' ? 'primary-diagnosis' : 'encounter-diagnosis';
}
const { error } = await supabase
.from('conditions')
.update(updateData)
.eq('id', diagnosisId);
if (error) {
console.error('updateDiagnosis error', error);
return { success: false, error: 'Diagnose bijwerken mislukt' };
}
// Revalidate paths met correcte patientId en intakeId
const patientId = diagnosis.patient_id;
const intakeId = diagnosis.encounter_id;
if (patientId && intakeId) {
revalidatePath(buildPath(patientId, intakeId, 'diagnosis'));
revalidatePath(buildPath(patientId, intakeId));
}
return { success: true };
}
export async function deleteDiagnosis(patientId: string, intakeId: string, diagnosisId: string) { export async function deleteDiagnosis(patientId: string, intakeId: string, diagnosisId: string) {
const supabase = await getSupabase(); const supabase = await getSupabase();
const { error } = await supabase.from('conditions').delete().eq('id', diagnosisId); const { error } = await supabase.from('conditions').delete().eq('id', diagnosisId);
if (error) { if (error) {
console.error('deleteDiagnosis error', error); console.error('deleteDiagnosis error', error);
throw new Error(error.message); throw new Error('Diagnose verwijderen mislukt');
} }
revalidatePath(buildPath(patientId, intakeId, 'diagnosis')); revalidatePath(buildPath(patientId, intakeId, 'diagnosis'));
revalidatePath(buildPath(patientId, intakeId)); revalidatePath(buildPath(patientId, intakeId));

View File

@@ -0,0 +1,197 @@
'use client';
/**
* Diagnose Card Component
*
* Visuele weergave van een diagnose met badges, expand/collapse en acties.
*/
import { useState } from 'react';
import { format } from 'date-fns';
import { nl } from 'date-fns/locale';
import { ChevronDown, ChevronUp, MoreVertical, Pencil, Trash2, Loader2 } from 'lucide-react';
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { type Condition } from '../../actions';
interface DiagnosisCardProps {
diagnosis: Condition;
isPrimary?: boolean;
onEdit: () => void;
onDelete: () => void;
isDeleting?: boolean;
}
// Status badge configuratie
const STATUS_CONFIG: Record<string, { label: string; color: string; bgColor: string }> = {
active: {
label: 'Actief',
color: 'text-green-700',
bgColor: 'bg-green-100 border-green-300',
},
remission: {
label: 'In remissie',
color: 'text-blue-700',
bgColor: 'bg-blue-100 border-blue-300',
},
resolved: {
label: 'Opgelost',
color: 'text-slate-700',
bgColor: 'bg-slate-100 border-slate-300',
},
'entered-in-error': {
label: 'Foutief',
color: 'text-red-700',
bgColor: 'bg-red-100 border-red-300',
},
};
// Severity labels
const SEVERITY_LABELS: Record<string, string> = {
licht: 'Licht',
matig: 'Matig',
ernstig: 'Ernstig',
};
export function DiagnosisCard({
diagnosis,
isPrimary = false,
onEdit,
onDelete,
isDeleting = false,
}: DiagnosisCardProps) {
const [isNotesExpanded, setIsNotesExpanded] = useState(false);
const code = diagnosis.code_code || '';
const description = diagnosis.code_display || '';
const severity = diagnosis.severity_display || '';
const status = diagnosis.clinical_status || 'active';
const notes = diagnosis.note || '';
const recordedDate = diagnosis.recorded_date
? new Date(diagnosis.recorded_date)
: null;
const statusConfig = STATUS_CONFIG[status] || STATUS_CONFIG.active;
const hasNotes = notes.trim().length > 0;
return (
<Card className="transition-all hover:border-teal-300 hover:shadow-sm">
<CardHeader className="p-4 pb-3">
<div className="flex items-start justify-between gap-3">
{/* Code + beschrijving */}
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-slate-900">
{code && description ? (
<>
<span className="font-mono">{code}</span>
{' — '}
<span>{description}</span>
</>
) : (
code || description || 'Geen diagnose code'
)}
</h3>
</div>
{/* Badges en menu */}
<div className="flex items-center gap-2 shrink-0">
{/* HOOFD badge */}
{isPrimary && (
<Badge
className="bg-green-600 text-white border-green-700 hover:bg-green-600"
>
HOOFD
</Badge>
)}
{/* Status badge */}
<Badge
variant="outline"
className={`text-xs ${statusConfig.color} ${statusConfig.bgColor}`}
>
{statusConfig.label}
</Badge>
{/* Context menu */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
className="p-1 rounded-md hover:bg-slate-100 text-slate-500 hover:text-slate-700 transition-colors"
disabled={isDeleting}
aria-label="Meer opties"
>
<MoreVertical className="h-4 w-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={onEdit} disabled={isDeleting}>
<Pencil className="mr-2 h-4 w-4" />
Bewerken
</DropdownMenuItem>
<DropdownMenuItem
onClick={onDelete}
disabled={isDeleting}
className="text-red-600 focus:text-red-600 focus:bg-red-50"
>
{isDeleting ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Trash2 className="mr-2 h-4 w-4" />
)}
Verwijderen
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</CardHeader>
<CardContent className="p-4 pt-0 space-y-3">
{/* Meta informatie */}
<div className="flex flex-wrap items-center gap-4 text-sm text-slate-600">
{severity && (
<div>
<span className="font-medium">Ernst:</span>{' '}
<span>{SEVERITY_LABELS[severity] || severity}</span>
</div>
)}
{recordedDate && (
<div>
<span className="font-medium">Datum:</span>{' '}
<span>{format(recordedDate, 'd MMM yyyy', { locale: nl })}</span>
</div>
)}
</div>
{/* Onderbouwing (expand/collapse) */}
{hasNotes && (
<div className="border-t border-slate-100 pt-3">
<button
onClick={() => setIsNotesExpanded(!isNotesExpanded)}
className="flex items-center gap-2 w-full text-left text-sm font-medium text-slate-700 hover:text-slate-900 transition-colors"
>
{isNotesExpanded ? (
<ChevronUp className="h-4 w-4 text-slate-500" />
) : (
<ChevronDown className="h-4 w-4 text-slate-500" />
)}
<span>Onderbouwing</span>
</button>
{isNotesExpanded && (
<div className="mt-2 pl-6 text-sm text-slate-600 whitespace-pre-line">
{notes}
</div>
)}
</div>
)}
</CardContent>
</Card>
);
}

View File

@@ -1,13 +1,27 @@
'use client'; 'use client';
import { useState, useTransition } from 'react'; /**
import { createDiagnosis, deleteDiagnosis, type Condition } from '../../actions'; * Diagnosis Manager Component
import { Loader2, Trash2 } from 'lucide-react'; *
import { format } from 'date-fns'; * Beheert de lijst van diagnoses met modal voor toevoegen/bewerken en cards voor weergave.
import { nl } from 'date-fns/locale'; */
const severities = ['licht', 'matig', 'ernstig']; import { useState, useMemo } from 'react';
const statuses = ['active', 'resolved', 'entered-in-error']; import { useTransition } from 'react';
import { Plus, Loader2, AlertTriangle } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { DiagnosisCard } from './diagnosis-card';
import { DiagnosisModal } from './diagnosis-modal';
import { deleteDiagnosis, type Condition } from '../../actions';
import { toast } from '@/hooks/use-toast';
interface DiagnosisManagerProps { interface DiagnosisManagerProps {
patientId: string; patientId: string;
@@ -16,136 +30,180 @@ interface DiagnosisManagerProps {
} }
export function DiagnosisManager({ patientId, intakeId, diagnoses }: DiagnosisManagerProps) { export function DiagnosisManager({ patientId, intakeId, diagnoses }: DiagnosisManagerProps) {
const [form, setForm] = useState({ code: '', description: '', severity: severities[0], status: statuses[0], notes: '' }); const [modalOpen, setModalOpen] = useState(false);
const [error, setError] = useState<string | null>(null); const [editingDiagnosis, setEditingDiagnosis] = useState<Condition | undefined>();
const [isPending, startTransition] = useTransition();
const [deletingId, setDeletingId] = useState<string | null>(null); const [deletingId, setDeletingId] = useState<string | null>(null);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [isPending, startTransition] = useTransition();
const handleSubmit = () => { // Sorteer diagnoses: hoofddiagnoses eerst, dan op datum
if (!form.code || !form.description) { const sortedDiagnoses = useMemo(() => {
setError('Code en omschrijving zijn verplicht.'); return [...diagnoses].sort((a, b) => {
return; // Hoofddiagnoses eerst
} const aIsPrimary = a.category === 'primary-diagnosis';
startTransition(async () => { const bIsPrimary = b.category === 'primary-diagnosis';
try { if (aIsPrimary && !bIsPrimary) return -1;
await createDiagnosis({ if (!aIsPrimary && bIsPrimary) return 1;
patientId,
intakeId, // Dan op datum (nieuwste eerst)
code: form.code, const aDate = a.recorded_date ? new Date(a.recorded_date).getTime() : 0;
description: form.description, const bDate = b.recorded_date ? new Date(b.recorded_date).getTime() : 0;
severity: form.severity, return bDate - aDate;
status: form.status,
notes: form.notes,
});
setForm({ ...form, notes: '' });
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : 'Opslaan mislukt');
}
}); });
}, [diagnoses]);
const handleNewDiagnosis = () => {
setEditingDiagnosis(undefined);
setModalOpen(true);
}; };
const handleDelete = (id: string) => { const handleEditDiagnosis = (diagnosis: Condition) => {
setDeletingId(id); setEditingDiagnosis(diagnosis);
setModalOpen(true);
};
const handleDeleteClick = (diagnosisId: string) => {
setDeletingId(diagnosisId);
setShowDeleteConfirm(true);
};
const handleDeleteConfirm = () => {
if (!deletingId) return;
startTransition(async () => { startTransition(async () => {
try { try {
await deleteDiagnosis(patientId, intakeId, id); await deleteDiagnosis(patientId, intakeId, deletingId);
} catch (err) { toast({
setError(err instanceof Error ? err.message : 'Verwijderen mislukt'); title: 'Diagnose verwijderd',
} finally { description: 'De diagnose is succesvol verwijderd.',
});
setShowDeleteConfirm(false);
setDeletingId(null);
} catch (error) {
toast({
variant: 'destructive',
title: 'Verwijderen mislukt',
description: error instanceof Error ? error.message : 'Er ging iets mis bij het verwijderen.',
});
setDeletingId(null); setDeletingId(null);
} }
}); });
}; };
const handleModalSuccess = () => {
setModalOpen(false);
setEditingDiagnosis(undefined);
// Toast wordt al getoond door de modal
};
const diagnosisToDelete = deletingId ? diagnoses.find((d) => d.id === deletingId) : null;
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="grid grid-cols-1 gap-3"> {/* Header met button */}
{diagnoses.length === 0 && <p className="text-sm text-slate-500">Nog geen diagnoses.</p>}
{diagnoses.map((diag) => (
<div key={diag.id} className="rounded-lg border border-slate-200 p-4 space-y-1">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<p className="font-medium text-slate-900">{diag.code_code} {diag.code_display}</p> <h3 className="text-sm font-semibold text-slate-900">
<p className="text-xs text-slate-500"> Diagnoses ({diagnoses.length})
{diag.recorded_date </h3>
? format(new Date(diag.recorded_date), 'd MMM yyyy', { locale: nl })
: 'Onbekende datum'}
</p>
</div> </div>
<button <Button onClick={handleNewDiagnosis} size="sm">
type="button" <Plus className="h-4 w-4 mr-2" />
onClick={() => handleDelete(diag.id)} Nieuwe diagnose
disabled={deletingId === diag.id} </Button>
className="inline-flex items-center gap-1 rounded-md border border-red-200 px-3 py-1.5 text-xs text-red-600"
>
{deletingId === diag.id ? <Loader2 className="h-4 w-4 animate-spin" /> : <Trash2 className="h-4 w-4" />} Verwijder
</button>
</div>
{diag.note && <p className="text-sm text-slate-700">{diag.note}</p>}
</div>
))}
</div> </div>
<div className="rounded-lg border border-slate-200 p-4 space-y-3"> {/* Diagnoses lijst */}
<h3 className="text-sm font-semibold text-slate-900">Nieuwe diagnose</h3> {sortedDiagnoses.length === 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3"> <div className="text-center py-8 text-slate-500">
<input <p className="text-sm">Nog geen diagnoses geregistreerd.</p>
type="text" <p className="text-xs mt-1">Klik op &quot;Nieuwe diagnose&quot; om er een toe te voegen.</p>
placeholder="DSM code"
value={form.code}
onChange={(e) => setForm((prev) => ({ ...prev, code: e.target.value }))}
className="h-10 rounded-md border border-slate-300 px-3 text-sm"
/>
<input
type="text"
placeholder="Omschrijving"
value={form.description}
onChange={(e) => setForm((prev) => ({ ...prev, description: e.target.value }))}
className="h-10 rounded-md border border-slate-300 px-3 text-sm"
/>
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3"> ) : (
<select <div className="space-y-3">
value={form.severity} {sortedDiagnoses.map((diagnosis) => {
onChange={(e) => setForm((prev) => ({ ...prev, severity: e.target.value }))} const isPrimary = diagnosis.category === 'primary-diagnosis';
className="h-10 rounded-md border border-slate-300 px-3 text-sm" return (
> <DiagnosisCard
{severities.map((sev) => ( key={diagnosis.id}
<option key={sev} value={sev}> diagnosis={diagnosis}
{sev} isPrimary={isPrimary}
</option> onEdit={() => handleEditDiagnosis(diagnosis)}
))} onDelete={() => handleDeleteClick(diagnosis.id)}
</select> isDeleting={deletingId === diagnosis.id && isPending}
<select
value={form.status}
onChange={(e) => setForm((prev) => ({ ...prev, status: e.target.value }))}
className="h-10 rounded-md border border-slate-300 px-3 text-sm"
>
{statuses.map((status) => (
<option key={status} value={status}>
{status}
</option>
))}
</select>
</div>
<textarea
value={form.notes}
onChange={(e) => setForm((prev) => ({ ...prev, notes: e.target.value }))}
placeholder="Notities"
className="w-full rounded-md border border-slate-300 px-3 py-2 text-sm"
/> />
{error && <p className="text-sm text-red-600">{error}</p>} );
<button })}
type="button" </div>
onClick={handleSubmit} )}
{/* Diagnosis Modal */}
<DiagnosisModal
open={modalOpen}
onOpenChange={setModalOpen}
patientId={patientId}
intakeId={intakeId}
diagnosis={editingDiagnosis}
onSuccess={handleModalSuccess}
/>
{/* Delete Confirmation Dialog */}
<Dialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-red-600">
<AlertTriangle className="h-5 w-5" />
Diagnose verwijderen?
</DialogTitle>
<DialogDescription asChild>
<div className="space-y-4 pt-2">
<p>
Weet je zeker dat je deze diagnose wilt verwijderen? Deze actie kan
niet ongedaan worden gemaakt.
</p>
{diagnosisToDelete && (
<div className="bg-red-50 rounded-lg p-4 border border-red-100">
<div className="font-medium text-slate-900">
{diagnosisToDelete.code_code} {diagnosisToDelete.code_display}
</div>
{diagnosisToDelete.severity_display && (
<div className="text-sm text-slate-600 mt-1">
Ernst: {diagnosisToDelete.severity_display}
</div>
)}
</div>
)}
</div>
</DialogDescription>
</DialogHeader>
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={() => {
setShowDeleteConfirm(false);
setDeletingId(null);
}}
disabled={isPending} disabled={isPending}
className="inline-flex items-center gap-2 rounded-md bg-teal-600 px-4 py-2 text-sm font-medium text-white"
> >
{isPending && <Loader2 className="h-4 w-4 animate-spin" />} Annuleren
Opslaan </Button>
</button> <Button
</div> variant="destructive"
onClick={handleDeleteConfirm}
disabled={isPending}
>
{isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Verwijderen...
</>
) : (
'Verwijderen'
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div> </div>
); );
} }

View File

@@ -0,0 +1,372 @@
'use client';
/**
* Diagnose Modal Component
*
* Modal voor toevoegen en bewerken van diagnoses met ICD-10 classificatie.
*/
import { useState, useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { Loader2 } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { toast } from '@/hooks/use-toast';
import { ICD10Combobox } from './icd10-combobox';
import {
diagnosisSchema,
diagnosisDefaults,
type DiagnosisFormData,
DIAGNOSIS_SEVERITIES,
DIAGNOSIS_TYPES,
DIAGNOSIS_STATUSES,
} from '@/lib/schemas/diagnosis';
import { createDiagnosis, updateDiagnosis, type Condition } from '../../actions';
import type { ICD10Code } from '@/lib/types/icd10';
interface DiagnosisModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
patientId: string;
intakeId: string;
diagnosis?: Condition; // undefined = nieuw, Condition = bewerk
onSuccess: () => void; // Callback na succesvol opslaan
}
// Status labels voor weergave
const STATUS_LABELS: Record<string, string> = {
active: 'Actief',
remission: 'In remissie',
resolved: 'Opgelost',
inactive: 'Inactief',
};
// Severity labels voor weergave
const SEVERITY_LABELS: Record<string, string> = {
licht: 'Licht',
matig: 'Matig',
ernstig: 'Ernstig',
};
// Diagnose type labels
const DIAGNOSIS_TYPE_LABELS: Record<string, string> = {
primary: 'Hoofddiagnose',
secondary: 'Nevendiagnose',
};
export function DiagnosisModal({
open,
onOpenChange,
patientId,
intakeId,
diagnosis,
onSuccess,
}: DiagnosisModalProps) {
const [isSubmitting, setIsSubmitting] = useState(false);
const [selectedICD10Code, setSelectedICD10Code] = useState<ICD10Code | null>(null);
const isEditMode = !!diagnosis;
const {
register,
handleSubmit,
formState: { errors },
reset,
setValue,
watch,
} = useForm<DiagnosisFormData>({
resolver: zodResolver(diagnosisSchema),
defaultValues: diagnosisDefaults,
});
const diagnosisType = watch('diagnosisType');
const severity = watch('severity');
const status = watch('status');
// Reset form wanneer modal opent/sluit of diagnosis wijzigt
useEffect(() => {
if (open) {
if (diagnosis) {
// Bewerk modus: vul form met bestaande data
setValue('code', diagnosis.code_code || '');
setValue('description', diagnosis.code_display || '');
setValue('severity', (diagnosis.severity_display as 'licht' | 'matig' | 'ernstig') || 'matig');
setValue('status', (diagnosis.clinical_status as 'active' | 'remission' | 'resolved' | 'inactive') || 'active');
setValue('diagnosisType', diagnosis.category === 'primary-diagnosis' ? 'primary' : 'secondary');
setValue('dsm5Reference', diagnosis.code_system === 'DSM-5' ? diagnosis.code_code || '' : '');
setValue('notes', diagnosis.note || '');
// Set selected ICD-10 code voor combobox
if (diagnosis.code_code && diagnosis.code_display) {
setSelectedICD10Code({
code: diagnosis.code_code,
display: diagnosis.code_display,
keywords: [],
});
}
} else {
// Nieuw: reset naar defaults
reset(diagnosisDefaults);
setSelectedICD10Code(null);
}
} else {
// Modal gesloten: reset
reset(diagnosisDefaults);
setSelectedICD10Code(null);
}
}, [open, diagnosis, reset, setValue]);
const handleICD10Select = (code: ICD10Code) => {
setSelectedICD10Code(code);
setValue('code', code.code);
setValue('description', code.display);
};
const onSubmit = async (data: DiagnosisFormData) => {
setIsSubmitting(true);
try {
if (isEditMode && diagnosis) {
// Update bestaande diagnose
const result = await updateDiagnosis(diagnosis.id, {
code: data.code,
description: data.description,
severity: data.severity,
status: data.status,
notes: data.notes || undefined,
diagnosisType: data.diagnosisType,
});
if (!result.success) {
toast({
variant: 'destructive',
title: 'Bijwerken mislukt',
description: result.error || 'Er ging iets mis bij het bijwerken.',
});
return;
}
toast({
title: 'Diagnose bijgewerkt',
description: `${data.code}${data.description} is aangepast.`,
});
} else {
// Create nieuwe diagnose
await createDiagnosis({
patientId,
intakeId,
code: data.code,
description: data.description,
severity: data.severity,
status: data.status,
notes: data.notes || undefined,
diagnosisType: data.diagnosisType,
});
toast({
title: 'Diagnose opgeslagen',
description: `${data.code}${data.description} is toegevoegd.`,
});
}
onSuccess();
onOpenChange(false);
} catch (error) {
toast({
variant: 'destructive',
title: 'Opslaan mislukt',
description: error instanceof Error ? error.message : 'Er ging iets mis bij het opslaan.',
});
} finally {
setIsSubmitting(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[600px]">
<DialogHeader>
<DialogTitle>
{isEditMode ? 'Diagnose bewerken' : 'Nieuwe diagnose'}
</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
{/* ICD-10 Code */}
<div className="space-y-2">
<Label htmlFor="icd10-code">
ICD-10 Code <span className="text-red-500">*</span>
</Label>
<ICD10Combobox
value={selectedICD10Code?.code || ''}
onSelect={handleICD10Select}
placeholder="Zoek op code of beschrijving..."
disabled={isSubmitting}
/>
{errors.code && (
<p className="text-sm text-red-600">{errors.code.message}</p>
)}
{errors.description && (
<p className="text-sm text-red-600">{errors.description.message}</p>
)}
</div>
{/* Ernst en Diagnose type */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="severity">
Ernst <span className="text-red-500">*</span>
</Label>
<Select
value={severity}
onValueChange={(value) => setValue('severity', value as 'licht' | 'matig' | 'ernstig')}
disabled={isSubmitting}
>
<SelectTrigger id="severity">
<SelectValue placeholder="Selecteer ernst" />
</SelectTrigger>
<SelectContent>
{DIAGNOSIS_SEVERITIES.map((sev) => (
<SelectItem key={sev} value={sev}>
{SEVERITY_LABELS[sev]}
</SelectItem>
))}
</SelectContent>
</Select>
{errors.severity && (
<p className="text-sm text-red-600">{errors.severity.message}</p>
)}
</div>
<div className="space-y-2">
<Label>
Diagnose type <span className="text-red-500">*</span>
</Label>
<div className="flex gap-4">
{DIAGNOSIS_TYPES.map((type) => (
<div key={type} className="flex items-center space-x-2">
<input
type="radio"
id={`diagnosis-type-${type}`}
value={type}
checked={diagnosisType === type}
onChange={(e) => setValue('diagnosisType', e.target.value as 'primary' | 'secondary')}
disabled={isSubmitting}
className="h-4 w-4 text-teal-600 focus:ring-teal-500"
/>
<Label
htmlFor={`diagnosis-type-${type}`}
className="font-normal cursor-pointer"
>
{DIAGNOSIS_TYPE_LABELS[type]}
</Label>
</div>
))}
</div>
{errors.diagnosisType && (
<p className="text-sm text-red-600">{errors.diagnosisType.message}</p>
)}
</div>
</div>
{/* Status */}
<div className="space-y-2">
<Label htmlFor="status">
Status <span className="text-red-500">*</span>
</Label>
<Select
value={status}
onValueChange={(value) => setValue('status', value as 'active' | 'remission' | 'resolved' | 'inactive')}
disabled={isSubmitting}
>
<SelectTrigger id="status">
<SelectValue placeholder="Selecteer status" />
</SelectTrigger>
<SelectContent>
{DIAGNOSIS_STATUSES.map((stat) => (
<SelectItem key={stat} value={stat}>
{STATUS_LABELS[stat]}
</SelectItem>
))}
</SelectContent>
</Select>
{errors.status && (
<p className="text-sm text-red-600">{errors.status.message}</p>
)}
</div>
{/* DSM-5 referentie */}
<div className="space-y-2">
<Label htmlFor="dsm5-reference">
DSM-5 referentie (optioneel)
</Label>
<Input
id="dsm5-reference"
{...register('dsm5Reference')}
placeholder="bijv. Major Depressive Disorder"
disabled={isSubmitting}
maxLength={100}
/>
{errors.dsm5Reference && (
<p className="text-sm text-red-600">{errors.dsm5Reference.message}</p>
)}
</div>
{/* Onderbouwing */}
<div className="space-y-2">
<Label htmlFor="notes">
Onderbouwing (optioneel)
</Label>
<Textarea
id="notes"
{...register('notes')}
placeholder="Klinische redenering..."
rows={4}
disabled={isSubmitting}
maxLength={500}
/>
<p className="text-xs text-slate-500">
{watch('notes')?.length || 0} / 500 karakters
</p>
{errors.notes && (
<p className="text-sm text-red-600">{errors.notes.message}</p>
)}
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isSubmitting}
>
Annuleren
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{isSubmitting ? 'Opslaan...' : 'Opslaan'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,192 @@
'use client';
import { useState, useEffect, useMemo } from 'react';
import { Check, ChevronsUpDown } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import icd10CodesData from '@/lib/data/icd10-ggz-codes.json';
import {
type ICD10Code,
type FlatICD10Code,
flattenICD10Codes,
searchICD10Codes,
getFrequentCodes,
type ICD10CodeList,
} from '@/lib/types/icd10';
interface ICD10ComboboxProps {
value: string;
onSelect: (code: ICD10Code) => void;
placeholder?: string;
disabled?: boolean;
}
// Debounce hook
function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value);
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => {
clearTimeout(handler);
};
}, [value, delay]);
return debouncedValue;
}
export function ICD10Combobox({
value,
onSelect,
placeholder = 'Zoek ICD-10 code...',
disabled = false,
}: ICD10ComboboxProps) {
const [open, setOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
// Flatten ICD-10 codes once
const flatCodes = useMemo(() => {
return flattenICD10Codes(icd10CodesData as ICD10CodeList);
}, []);
// Debounce search query (200ms)
const debouncedQuery = useDebounce(searchQuery, 200);
// Get search results or frequent codes
const displayCodes = useMemo(() => {
if (debouncedQuery.trim()) {
// Search mode: max 8 results
return searchICD10Codes(flatCodes, debouncedQuery, 8);
} else {
// Empty field: show top 5 frequent codes
return getFrequentCodes(flatCodes, icd10CodesData.frequentCodes).slice(0, 5);
}
}, [debouncedQuery, flatCodes]);
// Find selected code object
const selectedCode = useMemo(() => {
if (!value) return null;
return flatCodes.find((code) => code.code === value) || null;
}, [value, flatCodes]);
const handleSelect = (code: FlatICD10Code) => {
onSelect({
code: code.code,
display: code.display,
keywords: code.keywords,
});
setOpen(false);
setSearchQuery('');
};
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="w-full justify-between"
disabled={disabled}
>
{selectedCode ? (
<span className="truncate">
{selectedCode.code} {selectedCode.display}
</span>
) : (
<span className="text-muted-foreground">{placeholder}</span>
)}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[400px] p-0" align="start">
<Command>
<CommandInput
placeholder="Zoek op code of beschrijving..."
value={searchQuery}
onValueChange={setSearchQuery}
/>
<CommandList>
<CommandEmpty>
{debouncedQuery.trim()
? 'Geen codes gevonden.'
: 'Begin met typen om te zoeken...'}
</CommandEmpty>
{!debouncedQuery.trim() && (
<CommandGroup heading="Veelgebruikte codes">
{displayCodes.map((code) => (
<CommandItem
key={code.code}
value={code.code}
onSelect={() => handleSelect(code)}
>
<Check
className={cn(
'mr-2 h-4 w-4',
selectedCode?.code === code.code
? 'opacity-100'
: 'opacity-0'
)}
/>
<div className="flex flex-col">
<span className="font-medium">{code.code}</span>
<span className="text-xs text-muted-foreground">
{code.display}
</span>
</div>
</CommandItem>
))}
</CommandGroup>
)}
{debouncedQuery.trim() && (
<CommandGroup heading="Zoekresultaten">
{displayCodes.map((code) => (
<CommandItem
key={code.code}
value={code.code}
onSelect={() => handleSelect(code)}
>
<Check
className={cn(
'mr-2 h-4 w-4',
selectedCode?.code === code.code
? 'opacity-100'
: 'opacity-0'
)}
/>
<div className="flex flex-col">
<span className="font-medium">{code.code}</span>
<span className="text-xs text-muted-foreground">
{code.display}
</span>
<span className="text-xs text-muted-foreground italic">
{code.category}
</span>
</div>
</CommandItem>
))}
</CommandGroup>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}

View File

@@ -14,7 +14,7 @@ export default async function IntakeDiagnosisPage({
<div> <div>
<h2 className="text-lg font-semibold text-slate-900">Diagnoses</h2> <h2 className="text-lg font-semibold text-slate-900">Diagnoses</h2>
<p className="text-sm text-slate-600"> <p className="text-sm text-slate-600">
Registreer DSM-5 diagnoses gekoppeld aan deze intake. Registreer diagnoses met ICD-10 classificatie gekoppeld aan deze intake.
</p> </p>
</div> </div>
<DiagnosisManager patientId={id} intakeId={intakeId} diagnoses={diagnoses} /> <DiagnosisManager patientId={id} intakeId={intakeId} diagnoses={diagnoses} />

View File

@@ -11,11 +11,21 @@ import {
ArrowRight, ArrowRight,
AlertCircle, AlertCircle,
Calendar, Calendar,
Clock,
} from 'lucide-react'; } from 'lucide-react';
import { getIntakesByPatientId } from './intakes/actions'; import { getIntakesByPatientId } from './intakes/actions';
import type { Intake } from '@/lib/types/intake'; import type { Intake } from '@/lib/types/intake';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { nl } from 'date-fns/locale'; import { nl } from 'date-fns/locale';
import { getPatientEncounters } from '@/app/epd/agenda/actions';
import { getActiveCarePlan, getPatientIntakes } from './behandelplan/actions';
import type { SmartGoal, Intervention, Behandelstructuur, Evaluatiemoment } from '@/lib/types/behandelplan';
function extractHulpvraag(notes: string | null): string | null {
if (!notes) return null;
const firstSentence = notes.split(/[.!?]/)[0];
return firstSentence.length > 150 ? firstSentence.slice(0, 150) + '...' : firstSentence;
}
export default async function PatientDashboardPage({ export default async function PatientDashboardPage({
params, params,
@@ -34,6 +44,45 @@ export default async function PatientDashboardPage({
console.error('Failed to fetch intakes for dashboard:', error); console.error('Failed to fetch intakes for dashboard:', error);
} }
// Fetch encounters (vandaag, toekomst en recente)
let upcomingEncounters: any[] = [];
let recentEncounters: any[] = [];
try {
const allEncounters = await getPatientEncounters(id);
const now = new Date();
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
// Split into upcoming (vandaag + toekomst) and recent (verleden)
const upcoming = allEncounters.filter(e => new Date(e.period_start) >= todayStart);
const recent = allEncounters.filter(e => new Date(e.period_start) < todayStart);
// Take 5 most relevant: prioritize upcoming, then recent
upcomingEncounters = upcoming.slice(0, 5);
if (upcomingEncounters.length < 5) {
recentEncounters = recent.slice(0, 5 - upcomingEncounters.length);
}
} catch (error) {
console.error('Failed to fetch encounters:', error);
}
// Fetch active care plan
let activeCarePlan: any = null;
let hulpvraag: string | null = null;
try {
activeCarePlan = await getActiveCarePlan(id);
// Get hulpvraag from linked intake
if (activeCarePlan?.based_on_intake_id) {
const intakes = await getPatientIntakes(id);
const linkedIntake = intakes.find(i => i.id === activeCarePlan.based_on_intake_id);
if (linkedIntake?.notes) {
hulpvraag = extractHulpvraag(linkedIntake.notes);
}
}
} catch (error) {
console.error('Failed to fetch care plan:', error);
}
return ( return (
<div className="p-6"> <div className="p-6">
{/* Page Header */} {/* Page Header */}
@@ -160,6 +209,219 @@ export default async function PatientDashboardPage({
</div> </div>
)} )}
{/* Agenda Afspraken Section */}
{(upcomingEncounters.length > 0 || recentEncounters.length > 0) && (
<div className="bg-white rounded-lg border border-slate-200 p-6 mb-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-slate-900">Agenda Afspraken</h3>
<Link
href={`/epd/agenda?encounterId=${upcomingEncounters[0]?.id || recentEncounters[0]?.id}`}
className="text-sm text-teal-600 hover:text-teal-700 font-medium"
>
Bekijk agenda
</Link>
</div>
<div className="space-y-3">
{[...upcomingEncounters, ...recentEncounters].slice(0, 5).map((encounter) => {
const encounterDate = new Date(encounter.period_start);
const isPast = encounterDate < new Date();
const isToday = encounterDate.toDateString() === new Date().toDateString();
return (
<Link
key={encounter.id}
href={`/epd/agenda?encounterId=${encounter.id}`}
className="block p-3 rounded-lg border border-slate-200 hover:border-teal-300 hover:bg-teal-50 transition-colors group"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className={`w-8 h-8 rounded-md flex items-center justify-center ${
isPast ? 'bg-slate-100' : isToday ? 'bg-blue-50' : 'bg-teal-50'
} group-hover:bg-teal-100 transition-colors`}>
<Calendar className={`h-4 w-4 ${
isPast ? 'text-slate-500' : isToday ? 'text-blue-600' : 'text-teal-600'
}`} />
</div>
<div>
<p className="font-medium text-slate-900 group-hover:text-teal-700">
{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>
</div>
<span className={`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'
: 'bg-slate-50 text-slate-700'
}`}>
{encounter.status === 'planned' ? 'Gepland' :
encounter.status === 'arrived' ? 'Aangekomen' :
encounter.status === 'finished' ? 'Afgerond' : encounter.status}
</span>
</div>
</Link>
);
})}
</div>
</div>
)}
{/* Behandelplan Section */}
{activeCarePlan && (
<div className="bg-white rounded-lg border border-slate-200 p-6 mb-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-slate-900">Actief Behandelplan</h3>
<Link
href={`/epd/patients/${id}/behandelplan`}
className="text-sm text-teal-600 hover:text-teal-700 font-medium"
>
Bekijk volledig plan
</Link>
</div>
{/* Hulpvraag */}
{hulpvraag && (
<div className="mb-4 p-3 bg-slate-50 rounded-lg">
<p className="text-xs font-medium text-slate-600 mb-1">Hulpvraag</p>
<p className="text-sm text-slate-700 italic">&ldquo;{hulpvraag}&rdquo;</p>
</div>
)}
{/* Behandelstructuur */}
{activeCarePlan.behandelstructuur && (
<div className="mb-4 p-3 bg-teal-50 rounded-lg">
<p className="text-xs font-medium text-teal-700 mb-2">Behandelstructuur</p>
<div className="grid grid-cols-2 gap-2 text-sm text-teal-900">
<div>
<span className="font-medium">Duur:</span> {activeCarePlan.behandelstructuur.duur}
</div>
<div>
<span className="font-medium">Frequentie:</span> {activeCarePlan.behandelstructuur.frequentie}
</div>
<div>
<span className="font-medium">Aantal sessies:</span> {activeCarePlan.behandelstructuur.aantalSessies}
</div>
<div>
<span className="font-medium">Vorm:</span> {activeCarePlan.behandelstructuur.vorm}
</div>
</div>
</div>
)}
{/* Doelen Overzicht */}
{activeCarePlan.goals && Array.isArray(activeCarePlan.goals) && activeCarePlan.goals.length > 0 && (
<div className="mb-4">
<p className="text-xs font-medium text-slate-600 mb-2">Doelen ({activeCarePlan.goals.length})</p>
<div className="space-y-2">
{activeCarePlan.goals.slice(0, 3).map((goal: SmartGoal) => (
<div key={goal.id} className="p-2 bg-slate-50 rounded border border-slate-200">
<div className="flex items-start justify-between mb-1">
<p className="text-sm font-medium text-slate-900">{goal.title}</p>
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${
goal.status === 'bezig' ? 'bg-blue-50 text-blue-700' :
goal.status === 'gehaald' ? 'bg-green-50 text-green-700' :
'bg-slate-50 text-slate-700'
}`}>
{goal.status === 'bezig' ? 'Bezig' :
goal.status === 'gehaald' ? 'Gehaald' :
goal.status === 'niet_gestart' ? 'Niet gestart' : goal.status}
</span>
</div>
{goal.progress > 0 && (
<div className="mt-1">
<div className="h-1.5 bg-slate-200 rounded-full overflow-hidden">
<div
className="h-full bg-teal-500 transition-all"
style={{ width: `${goal.progress}%` }}
/>
</div>
<p className="text-xs text-slate-500 mt-0.5">{goal.progress}% voltooid</p>
</div>
)}
</div>
))}
{activeCarePlan.goals.length > 3 && (
<p className="text-xs text-slate-500 text-center">
+{activeCarePlan.goals.length - 3} meer doelen
</p>
)}
</div>
</div>
)}
{/* Interventies Overzicht */}
{activeCarePlan.activities && Array.isArray(activeCarePlan.activities) && activeCarePlan.activities.length > 0 && (
<div className="mb-4">
<p className="text-xs font-medium text-slate-600 mb-2">Interventies ({activeCarePlan.activities.length})</p>
<div className="flex flex-wrap gap-2">
{activeCarePlan.activities.slice(0, 5).map((intervention: Intervention) => (
<span
key={intervention.id}
className="px-2 py-1 bg-purple-50 text-purple-700 rounded text-xs font-medium"
>
{intervention.name}
</span>
))}
{activeCarePlan.activities.length > 5 && (
<span className="px-2 py-1 bg-slate-100 text-slate-600 rounded text-xs">
+{activeCarePlan.activities.length - 5} meer
</span>
)}
</div>
</div>
)}
{/* Aankomende Evaluatiemomenten */}
{activeCarePlan.evaluatiemomenten &&
Array.isArray(activeCarePlan.evaluatiemomenten) &&
activeCarePlan.evaluatiemomenten.length > 0 && (
<div>
<p className="text-xs font-medium text-slate-600 mb-2">Aankomende evaluatiemomenten</p>
<div className="space-y-2">
{activeCarePlan.evaluatiemomenten
.filter((evaluatie: Evaluatiemoment) => evaluatie.status === 'gepland')
.slice(0, 2)
.map((evaluatie: Evaluatiemoment) => (
<div key={evaluatie.id} className="p-2 bg-amber-50 rounded border border-amber-200">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-amber-900">
{evaluatie.type === 'tussentijds' ? 'Tussentijdse evaluatie' :
evaluatie.type === 'eind' ? 'Eindevaluatie' : 'Crisis evaluatie'}
</p>
{evaluatie.plannedDate && (
<p className="text-xs text-amber-700 mt-0.5">
Week {evaluatie.weekNumber} {format(new Date(evaluatie.plannedDate), 'd MMM yyyy', { locale: nl })}
</p>
)}
</div>
<span className="px-2 py-0.5 bg-amber-100 text-amber-800 rounded-full text-xs font-medium">
Gepland
</span>
</div>
</div>
))}
</div>
</div>
)}
</div>
)}
{/* Next Steps Section */} {/* Next Steps Section */}
<div className="bg-blue-50 border border-blue-200 rounded-lg p-6"> <div className="bg-blue-50 border border-blue-200 rounded-lg p-6">
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">

View File

@@ -86,7 +86,6 @@ export const metadata: Metadata = {
metadataBase: new URL(process.env.NEXT_PUBLIC_APP_URL || 'https://aispeedrun.vercel.app'), metadataBase: new URL(process.env.NEXT_PUBLIC_APP_URL || 'https://aispeedrun.vercel.app'),
title: { title: {
default: 'AI Speedrun - Software on Demand', default: 'AI Speedrun - Software on Demand',
template: '%s | AI Speedrun',
}, },
description: 'Jensen Huang: "AI is going to eat software". Een experiment: bouw een EPD in 4 weken voor €200.', description: 'Jensen Huang: "AI is going to eat software". Een experiment: bouw een EPD in 4 weken voor €200.',
keywords: ['AI', 'Software on Demand', 'EPD', 'Development', 'Build in Public'], keywords: ['AI', 'Software on Demand', 'EPD', 'Development', 'Build in Public'],

View File

@@ -0,0 +1,160 @@
'use client';
import { useState } from 'react';
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Progress } from '@/components/ui/progress';
import { cn } from '@/lib/utils';
import { type Behandeldoel, GOAL_STATUS_LABELS } from '@/lib/types/behandelplan';
import { LIFE_DOMAIN_META } from '@/lib/types/leefgebieden';
import { Target, Pencil, ChevronDown, ChevronUp } from 'lucide-react';
import { BehandeldoelForm } from './behandeldoel-form';
interface BehandeldoelCardProps {
doel: Behandeldoel;
isEditing: boolean;
onEdit: () => void;
onSave: (doel: Behandeldoel) => Promise<void>;
onCancel: () => void;
onDelete?: () => Promise<void>;
className?: string;
}
/**
* Behandeldoel Card
* View mode: Compact card met doel + interventies
* Edit mode: Inline form met alle velden
*/
export function BehandeldoelCard({
doel,
isEditing,
onEdit,
onSave,
onCancel,
onDelete,
className,
}: BehandeldoelCardProps) {
const [isExpanded, setIsExpanded] = useState(false);
if (isEditing) {
return (
<BehandeldoelForm
doel={doel}
onSave={onSave}
onCancel={onCancel}
onDelete={onDelete}
className={className}
/>
);
}
const meta = LIFE_DOMAIN_META[doel.lifeDomain];
const statusInfo = GOAL_STATUS_LABELS[doel.status];
return (
<Card
className={cn(
'transition-all hover:border-indigo-300 hover:shadow-sm',
className
)}
>
<CardHeader className="p-4 pb-2">
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-2 min-w-0">
<Target className="h-4 w-4 text-indigo-600 shrink-0" />
<h3 className="font-medium text-slate-900 truncate">{doel.title}</h3>
</div>
<div className="flex items-center gap-2 shrink-0">
<Badge
variant="outline"
className="text-xs border-0"
style={{ backgroundColor: meta.color, color: 'white' }}
>
{meta.shortLabel}
</Badge>
<Badge
variant="outline"
className="text-xs"
style={{ borderColor: statusInfo.color, color: statusInfo.color }}
>
{statusInfo.label}
</Badge>
</div>
</div>
</CardHeader>
<CardContent className="p-4 pt-0 space-y-3">
{/* Client version (B1 tekst) - altijd zichtbaar */}
<div className="bg-blue-50 border border-blue-100 rounded-md p-2.5">
<p className="text-sm text-blue-800 italic">
&ldquo;{doel.clientVersion}&rdquo;
</p>
</div>
{/* Interventies */}
{doel.interventies.length > 0 && (
<div className="space-y-1">
<span className="text-xs font-medium text-slate-500 uppercase tracking-wide">
Aanpak
</span>
<ul className="space-y-1">
{doel.interventies.map((int) => (
<li key={int.id} className="flex items-start gap-2 text-sm">
<span className="text-slate-400"></span>
<span>
<span className="font-medium text-slate-700">{int.name}</span>
{int.description && (
<span className="text-slate-500"> - {int.description}</span>
)}
</span>
</li>
))}
</ul>
</div>
)}
{/* Progress & timeline */}
<div className="flex items-center justify-between gap-4 pt-2">
<div className="flex-1 space-y-1">
<div className="flex items-center justify-between text-xs text-slate-500">
<span>Week {doel.startWeek}-{doel.endWeek}</span>
<span>{doel.progress}%</span>
</div>
<Progress value={doel.progress} className="h-2" />
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => setIsExpanded(!isExpanded)}
className="text-slate-500 h-8 w-8 p-0"
>
{isExpanded ? (
<ChevronUp className="h-4 w-4" />
) : (
<ChevronDown className="h-4 w-4" />
)}
</Button>
<Button
variant="ghost"
size="sm"
onClick={onEdit}
className="text-slate-500 h-8 w-8 p-0 hover:text-indigo-600"
>
<Pencil className="h-4 w-4" />
</Button>
</div>
</div>
{/* Expanded details (optional) */}
{isExpanded && (
<div className="pt-2 border-t border-slate-100 text-xs text-slate-500 space-y-1">
<p>Leefgebied: {meta.label}</p>
<p>Status: {statusInfo.label}</p>
</div>
)}
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,357 @@
'use client';
import { useState } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import { Slider } from '@/components/ui/slider';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { cn } from '@/lib/utils';
import {
type Behandeldoel,
type EmbeddedInterventie,
type GoalStatus,
GOAL_STATUSES,
GOAL_STATUS_LABELS,
createEmptyEmbeddedInterventie,
} from '@/lib/types/behandelplan';
import { type LifeDomain, LIFE_DOMAINS, LIFE_DOMAIN_META } from '@/lib/types/leefgebieden';
import { Plus, X, Sparkles, Trash2, Save } from 'lucide-react';
interface BehandeldoelFormProps {
doel: Behandeldoel;
onSave: (doel: Behandeldoel) => Promise<void>;
onCancel: () => void;
onDelete?: () => Promise<void>;
className?: string;
}
/**
* Inline edit form voor Behandeldoel
* Alle velden in één uitklapbare card
*/
export function BehandeldoelForm({
doel,
onSave,
onCancel,
onDelete,
className,
}: BehandeldoelFormProps) {
const [formData, setFormData] = useState<Behandeldoel>(doel);
const [isSaving, setIsSaving] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const handleSave = async () => {
setIsSaving(true);
try {
await onSave(formData);
} finally {
setIsSaving(false);
}
};
const handleDelete = async () => {
if (!onDelete) return;
if (!confirm('Weet je zeker dat je dit behandeldoel wilt verwijderen?')) return;
setIsDeleting(true);
try {
await onDelete();
} finally {
setIsDeleting(false);
}
};
const updateField = <K extends keyof Behandeldoel>(
field: K,
value: Behandeldoel[K]
) => {
setFormData((prev) => ({ ...prev, [field]: value }));
};
const addInterventie = () => {
setFormData((prev) => ({
...prev,
interventies: [...prev.interventies, createEmptyEmbeddedInterventie()],
}));
};
const updateInterventie = (
index: number,
field: keyof EmbeddedInterventie,
value: string
) => {
setFormData((prev) => ({
...prev,
interventies: prev.interventies.map((int, i) =>
i === index ? { ...int, [field]: value } : int
),
}));
};
const removeInterventie = (index: number) => {
setFormData((prev) => ({
...prev,
interventies: prev.interventies.filter((_, i) => i !== index),
}));
};
const isValid =
formData.title.trim().length >= 5 &&
formData.clientVersion.trim().length >= 5;
return (
<Card className={cn('border-indigo-300 shadow-md', className)}>
<CardHeader className="p-4 pb-2 border-b bg-indigo-50/50">
<CardTitle className="text-base font-medium text-indigo-900">
Behandeldoel bewerken
</CardTitle>
</CardHeader>
<CardContent className="p-4 space-y-4">
{/* Doel titel */}
<div className="space-y-1.5">
<Label htmlFor="title" className="text-sm font-medium">
Doel <span className="text-red-500">*</span>
</Label>
<Input
id="title"
value={formData.title}
onChange={(e) => updateField('title', e.target.value)}
placeholder="Bijv. Weer 4 dagen per week stabiel kunnen werken"
className="text-sm"
/>
</div>
{/* Client versie (B1) */}
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<Label htmlFor="clientVersion" className="text-sm font-medium">
Cliënt-versie (B1) <span className="text-red-500">*</span>
</Label>
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 text-xs text-indigo-600 hover:text-indigo-700"
disabled // TODO: Implementeer AI generatie
>
<Sparkles className="h-3 w-3 mr-1" />
Genereer met AI
</Button>
</div>
<Textarea
id="clientVersion"
value={formData.clientVersion}
onChange={(e) => updateField('clientVersion', e.target.value)}
placeholder="Bijv. Ik kan weer 4 dagen werken zonder veel stress"
className="text-sm min-h-[60px] resize-none"
/>
<p className="text-xs text-slate-500">
Formuleer in eenvoudige taal (B1-niveau) zodat de cliënt het begrijpt.
</p>
</div>
{/* Leefgebied & Periode - inline */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label className="text-sm font-medium">Leefgebied</Label>
<Select
value={formData.lifeDomain}
onValueChange={(v) => updateField('lifeDomain', v as LifeDomain)}
>
<SelectTrigger className="text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{LIFE_DOMAINS.map((domain) => {
const meta = LIFE_DOMAIN_META[domain];
return (
<SelectItem key={domain} value={domain}>
<span className="flex items-center gap-2">
<span>{meta.emoji}</span>
<span>{meta.shortLabel}</span>
</span>
</SelectItem>
);
})}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-sm font-medium">Periode</Label>
<div className="flex items-center gap-2">
<Input
type="number"
min={1}
max={52}
value={formData.startWeek}
onChange={(e) =>
updateField('startWeek', parseInt(e.target.value) || 1)
}
className="w-16 text-sm text-center"
/>
<span className="text-slate-500 text-sm">t/m</span>
<Input
type="number"
min={1}
max={52}
value={formData.endWeek}
onChange={(e) =>
updateField('endWeek', parseInt(e.target.value) || 8)
}
className="w-16 text-sm text-center"
/>
<span className="text-slate-500 text-sm">weken</span>
</div>
</div>
</div>
{/* Interventies */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-sm font-medium">Aanpak (interventies)</Label>
<Button
type="button"
variant="ghost"
size="sm"
onClick={addInterventie}
className="h-7 text-xs text-indigo-600 hover:text-indigo-700"
>
<Plus className="h-3 w-3 mr-1" />
Toevoegen
</Button>
</div>
<div className="space-y-2">
{formData.interventies.length === 0 ? (
<p className="text-sm text-slate-500 italic py-2">
Nog geen interventies toegevoegd
</p>
) : (
formData.interventies.map((int, index) => (
<div
key={int.id}
className="flex items-start gap-2 p-2 bg-slate-50 rounded-md"
>
<div className="flex-1 grid grid-cols-3 gap-2">
<Input
value={int.name}
onChange={(e) =>
updateInterventie(index, 'name', e.target.value)
}
placeholder="CGT"
className="text-sm"
/>
<Input
value={int.description}
onChange={(e) =>
updateInterventie(index, 'description', e.target.value)
}
placeholder="Korte beschrijving"
className="text-sm col-span-2"
/>
</div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => removeInterventie(index)}
className="h-8 w-8 p-0 text-slate-400 hover:text-red-500"
>
<X className="h-4 w-4" />
</Button>
</div>
))
)}
</div>
</div>
{/* Status & Voortgang */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label className="text-sm font-medium">Status</Label>
<Select
value={formData.status}
onValueChange={(v) => updateField('status', v as GoalStatus)}
>
<SelectTrigger className="text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{GOAL_STATUSES.map((status) => {
const info = GOAL_STATUS_LABELS[status];
return (
<SelectItem key={status} value={status}>
{info.label}
</SelectItem>
);
})}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-sm font-medium">
Voortgang: {formData.progress}%
</Label>
<Slider
value={[formData.progress]}
onValueChange={([v]) => updateField('progress', v)}
min={0}
max={100}
step={5}
className="mt-2"
/>
</div>
</div>
{/* Actions */}
<div className="flex items-center justify-between pt-2 border-t">
{onDelete && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={handleDelete}
disabled={isDeleting}
className="text-red-600 hover:text-red-700 hover:bg-red-50"
>
<Trash2 className="h-4 w-4 mr-1" />
{isDeleting ? 'Verwijderen...' : 'Verwijderen'}
</Button>
)}
<div className="flex items-center gap-2 ml-auto">
<Button
type="button"
variant="outline"
size="sm"
onClick={onCancel}
disabled={isSaving}
>
Annuleren
</Button>
<Button
type="button"
size="sm"
onClick={handleSave}
disabled={!isValid || isSaving}
className="bg-indigo-600 hover:bg-indigo-700"
>
<Save className="h-4 w-4 mr-1" />
{isSaving ? 'Opslaan...' : 'Opslaan'}
</Button>
</div>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,290 @@
'use client';
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { cn } from '@/lib/utils';
import {
type Behandeldoel,
type Behandelstructuur,
type Evaluatiemoment,
type Veiligheidsplan,
type SmartGoal,
type Intervention,
type FhirCarePlanStatus,
FHIR_STATUS_LABELS,
transformToFlat,
createEmptyBehandeldoel,
calculateBehandeldoelenProgress,
} from '@/lib/types/behandelplan';
import { type LifeDomainScore } from '@/lib/types/leefgebieden';
import { ContextHeader } from './context-header';
import { BehandeldoelCard } from './behandeldoel-card';
import { PlanningSection } from './planning-section';
import { Plus, Sparkles, FileText, CheckCircle2 } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Progress } from '@/components/ui/progress';
interface Condition {
id: string;
category: string;
code_display: string;
severity_code: string | null;
severity_display: string | null;
}
interface CarePlan {
id: string;
title: string;
status: FhirCarePlanStatus;
version: number | null;
goals: SmartGoal[] | null;
activities: Intervention[] | null;
behandelstructuur: Behandelstructuur | null;
sessie_planning: unknown[] | null;
evaluatiemomenten: Evaluatiemoment[] | null;
veiligheidsplan: Veiligheidsplan | null;
created_at: string | null;
published_at: string | null;
period_start: string | null;
}
interface BehandelplanFlatProps {
patientId: string;
carePlan: CarePlan | null;
condition: Condition | null;
hulpvraag: string | null;
lifeDomainScores: LifeDomainScore[] | null;
// Callbacks
onGenerate?: () => Promise<void>;
onCreateManual?: () => Promise<void>;
onStatusChange?: (status: FhirCarePlanStatus) => Promise<void>;
onSaveBehandeldoel?: (doel: Behandeldoel) => Promise<void>;
onDeleteBehandeldoel?: (doelId: string) => Promise<void>;
className?: string;
}
/**
* BehandelplanFlat - Hoofdcomponent voor plat behandelplan
*
* 3 blokken:
* 1. Context Header (read-only): Diagnose, hulpvraag, leefgebieden
* 2. Behandeldoelen (editable): Cards met inline interventies
* 3. Planning & Evaluatie (collapsed): Evaluaties, sessies, veiligheidsplan
*/
export function BehandelplanFlat({
patientId,
carePlan,
condition,
hulpvraag,
lifeDomainScores,
onGenerate,
onCreateManual,
onStatusChange,
onSaveBehandeldoel,
onDeleteBehandeldoel,
className,
}: BehandelplanFlatProps) {
const [editingDoelId, setEditingDoelId] = useState<string | null>(null);
const [isGenerating, setIsGenerating] = useState(false);
const [isCreating, setIsCreating] = useState(false);
// Transform old structure to flat
const behandeldoelen: Behandeldoel[] = carePlan?.goals && carePlan?.activities
? transformToFlat(carePlan.goals, carePlan.activities)
: [];
const totalProgress = calculateBehandeldoelenProgress(behandeldoelen);
const statusInfo = carePlan?.status ? FHIR_STATUS_LABELS[carePlan.status] : null;
// Handlers
const handleGenerate = async () => {
if (!onGenerate) return;
setIsGenerating(true);
try {
await onGenerate();
} finally {
setIsGenerating(false);
}
};
const handleCreateManual = async () => {
if (!onCreateManual) return;
setIsCreating(true);
try {
await onCreateManual();
} finally {
setIsCreating(false);
}
};
const handleSaveDoel = async (doel: Behandeldoel) => {
if (!onSaveBehandeldoel) return;
await onSaveBehandeldoel(doel);
setEditingDoelId(null);
};
const handleDeleteDoel = async (doelId: string) => {
if (!onDeleteBehandeldoel) return;
await onDeleteBehandeldoel(doelId);
setEditingDoelId(null);
};
const handleAddDoel = () => {
const newDoel = createEmptyBehandeldoel();
// Start editing immediately
setEditingDoelId(newDoel.id);
// We need to save this empty doel first, then edit
// For now, we'll handle this in the parent component
};
// No plan yet - show creation options
if (!carePlan) {
return (
<div className={cn('space-y-4', className)}>
{/* Context header */}
<ContextHeader
condition={condition}
hulpvraag={hulpvraag}
lifeDomainScores={lifeDomainScores}
/>
{/* Creation options */}
<Card className="border-dashed border-2 border-slate-300">
<CardContent className="p-6 text-center space-y-4">
<div className="mx-auto w-12 h-12 rounded-full bg-indigo-100 flex items-center justify-center">
<FileText className="h-6 w-6 text-indigo-600" />
</div>
<div>
<h3 className="font-medium text-slate-900">
Nog geen behandelplan
</h3>
<p className="text-sm text-slate-500 mt-1">
Maak een nieuw behandelplan aan
</p>
</div>
<div className="flex items-center justify-center gap-3">
<Button
onClick={handleGenerate}
disabled={isGenerating}
className="bg-indigo-600 hover:bg-indigo-700"
>
<Sparkles className="h-4 w-4 mr-2" />
{isGenerating ? 'Genereren...' : 'Genereer met AI'}
</Button>
<Button
variant="outline"
onClick={handleCreateManual}
disabled={isCreating}
>
<Plus className="h-4 w-4 mr-2" />
{isCreating ? 'Aanmaken...' : 'Handmatig aanmaken'}
</Button>
</div>
</CardContent>
</Card>
</div>
);
}
return (
<div className={cn('space-y-4', className)}>
{/* Plan header with status */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<h2 className="text-lg font-semibold text-slate-900">
{carePlan.title || 'Behandelplan'}
</h2>
{statusInfo && (
<Badge
variant="outline"
style={{ borderColor: statusInfo.color, color: statusInfo.color }}
>
{statusInfo.label}
</Badge>
)}
{carePlan.version && (
<span className="text-sm text-slate-500">v{carePlan.version}</span>
)}
</div>
<div className="flex items-center gap-3">
{/* Overall progress */}
<div className="flex items-center gap-2 text-sm text-slate-600">
<span>Voortgang:</span>
<div className="w-24">
<Progress value={totalProgress} className="h-2" />
</div>
<span className="font-medium">{totalProgress}%</span>
</div>
{/* Status actions */}
{carePlan.status === 'draft' && onStatusChange && (
<Button
size="sm"
onClick={() => onStatusChange('active')}
className="bg-green-600 hover:bg-green-700"
>
<CheckCircle2 className="h-4 w-4 mr-1" />
Activeren
</Button>
)}
</div>
</div>
{/* Block 1: Context Header */}
<ContextHeader
condition={condition}
hulpvraag={hulpvraag}
lifeDomainScores={lifeDomainScores}
/>
{/* Block 2: Behandeldoelen */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-slate-700 uppercase tracking-wide">
Behandeldoelen ({behandeldoelen.length})
</h3>
<Button
variant="ghost"
size="sm"
onClick={handleAddDoel}
className="text-indigo-600 hover:text-indigo-700"
>
<Plus className="h-4 w-4 mr-1" />
Nieuw doel
</Button>
</div>
{behandeldoelen.length === 0 ? (
<Card className="border-dashed">
<CardContent className="p-6 text-center">
<p className="text-sm text-slate-500">
Nog geen behandeldoelen. Klik op &quot;Nieuw doel&quot; om te beginnen.
</p>
</CardContent>
</Card>
) : (
<div className="space-y-3">
{behandeldoelen.map((doel) => (
<BehandeldoelCard
key={doel.id}
doel={doel}
isEditing={editingDoelId === doel.id}
onEdit={() => setEditingDoelId(doel.id)}
onSave={handleSaveDoel}
onCancel={() => setEditingDoelId(null)}
onDelete={() => handleDeleteDoel(doel.id)}
/>
))}
</div>
)}
</div>
{/* Block 3: Planning & Evaluatie */}
<PlanningSection
behandelstructuur={carePlan.behandelstructuur}
evaluatiemomenten={carePlan.evaluatiemomenten}
veiligheidsplan={carePlan.veiligheidsplan}
/>
</div>
);
}

View File

@@ -0,0 +1,130 @@
'use client';
import { Card, CardContent } from '@/components/ui/card';
import { cn } from '@/lib/utils';
import { type LifeDomain, type LifeDomainScore, LIFE_DOMAIN_META } from '@/lib/types/leefgebieden';
import { Stethoscope, MessageSquareQuote } from 'lucide-react';
interface Condition {
id: string;
category: string;
code_display: string;
severity_code: string | null;
severity_display: string | null;
}
interface ContextHeaderProps {
condition: Condition | null;
hulpvraag: string | null;
lifeDomainScores: LifeDomainScore[] | null;
className?: string;
}
/**
* Blok 1: Context Header
* Read-only samenvatting van diagnose, hulpvraag en leefgebieden
*/
export function ContextHeader({
condition,
hulpvraag,
lifeDomainScores,
className,
}: ContextHeaderProps) {
// Filter op leefgebieden met hoge prioriteit of lage scores
const priorityDomains = lifeDomainScores?.filter(
(s) => s.priority === 'hoog' || s.baseline <= 2
) || [];
return (
<Card className={cn('bg-slate-50 border-slate-200', className)}>
<CardContent className="p-4 space-y-3">
{/* Diagnose */}
<div className="flex items-start gap-2">
<Stethoscope className="h-4 w-4 text-slate-500 mt-0.5 shrink-0" />
<div className="min-w-0">
<span className="text-xs font-medium text-slate-500 uppercase tracking-wide">
Diagnose
</span>
{condition ? (
<p className="text-sm font-medium text-slate-900">
{condition.code_display}
{condition.severity_display && (
<span className="text-slate-500 font-normal ml-1">
({condition.severity_display})
</span>
)}
</p>
) : (
<p className="text-sm text-slate-500 italic">Geen diagnose vastgesteld</p>
)}
</div>
</div>
{/* Hulpvraag */}
{hulpvraag && (
<div className="flex items-start gap-2">
<MessageSquareQuote className="h-4 w-4 text-slate-500 mt-0.5 shrink-0" />
<div className="min-w-0">
<span className="text-xs font-medium text-slate-500 uppercase tracking-wide">
Hulpvraag
</span>
<p className="text-sm text-slate-700 italic">&ldquo;{hulpvraag}&rdquo;</p>
</div>
</div>
)}
{/* Leefgebieden bars */}
{priorityDomains.length > 0 && (
<div className="pt-2 border-t border-slate-200">
<span className="text-xs font-medium text-slate-500 uppercase tracking-wide block mb-2">
Prioritaire leefgebieden
</span>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-2">
{priorityDomains.map((score) => (
<LifeDomainBar key={score.domain} score={score} />
))}
</div>
</div>
)}
</CardContent>
</Card>
);
}
interface LifeDomainBarProps {
score: LifeDomainScore;
}
function LifeDomainBar({ score }: LifeDomainBarProps) {
const meta = LIFE_DOMAIN_META[score.domain];
const progressPercent = (score.baseline / 5) * 100;
const targetPercent = (score.target / 5) * 100;
return (
<div className="space-y-1">
<div className="flex items-center justify-between text-xs">
<span className="font-medium text-slate-700">
{meta.emoji} {meta.shortLabel}
</span>
<span className="text-slate-500">
{score.baseline} {score.target}
</span>
</div>
<div className="h-2 bg-slate-200 rounded-full relative overflow-hidden">
{/* Target indicator */}
<div
className="absolute h-full w-0.5 bg-slate-400 z-10"
style={{ left: `${targetPercent}%` }}
/>
{/* Current progress */}
<div
className="h-full rounded-full transition-all"
style={{
width: `${progressPercent}%`,
backgroundColor: meta.color,
}}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,5 @@
export { BehandelplanFlat } from './behandelplan-flat';
export { ContextHeader } from './context-header';
export { BehandeldoelCard } from './behandeldoel-card';
export { BehandeldoelForm } from './behandeldoel-form';
export { PlanningSection } from './planning-section';

View File

@@ -0,0 +1,251 @@
'use client';
import { useState } from 'react';
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import {
type Behandelstructuur,
type Evaluatiemoment,
type Veiligheidsplan,
EVALUATION_STATUSES,
} from '@/lib/types/behandelplan';
import {
ChevronDown,
ChevronRight,
Calendar,
Clock,
Shield,
AlertTriangle,
Phone,
CheckCircle2,
} from 'lucide-react';
interface PlanningSectionProps {
behandelstructuur: Behandelstructuur | null;
evaluatiemomenten: Evaluatiemoment[] | null;
veiligheidsplan: Veiligheidsplan | null;
className?: string;
}
/**
* Blok 3: Planning & Evaluatie
* Collapsed by default, bevat:
* - Evaluatiemomenten
* - Behandelstructuur
* - Veiligheidsplan (indien aanwezig)
*/
export function PlanningSection({
behandelstructuur,
evaluatiemomenten,
veiligheidsplan,
className,
}: PlanningSectionProps) {
const [isExpanded, setIsExpanded] = useState(false);
const evaluatiesCount = evaluatiemomenten?.length || 0;
const hasVeiligheidsplan = !!veiligheidsplan;
// Count pending evaluations
const pendingEvaluaties =
evaluatiemomenten?.filter((e) => e.status === 'gepland').length || 0;
return (
<Card className={cn('', className)}>
{/* Collapsed header */}
<CardHeader
className="p-3 cursor-pointer hover:bg-slate-50 transition-colors"
onClick={() => setIsExpanded(!isExpanded)}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{isExpanded ? (
<ChevronDown className="h-4 w-4 text-slate-500" />
) : (
<ChevronRight className="h-4 w-4 text-slate-500" />
)}
<Calendar className="h-4 w-4 text-slate-500" />
<span className="font-medium text-slate-700">
Planning & Evaluatie
</span>
</div>
<div className="flex items-center gap-2">
{pendingEvaluaties > 0 && (
<Badge variant="secondary" className="text-xs">
{pendingEvaluaties} gepland
</Badge>
)}
{hasVeiligheidsplan && (
<Badge variant="outline" className="text-xs text-orange-600 border-orange-300">
<Shield className="h-3 w-3 mr-1" />
Veiligheidsplan
</Badge>
)}
</div>
</div>
</CardHeader>
{/* Expanded content */}
{isExpanded && (
<CardContent className="p-4 pt-0 space-y-4 border-t">
{/* Behandelstructuur */}
{behandelstructuur && (
<div className="space-y-2">
<h4 className="text-xs font-medium text-slate-500 uppercase tracking-wide">
Behandelstructuur
</h4>
<div className="flex flex-wrap gap-3 text-sm">
<div className="flex items-center gap-1.5 text-slate-700">
<Clock className="h-4 w-4 text-slate-400" />
<span>{behandelstructuur.duur}</span>
</div>
<span className="text-slate-300"></span>
<span className="text-slate-700">{behandelstructuur.frequentie}</span>
<span className="text-slate-300"></span>
<span className="text-slate-700">
{behandelstructuur.aantalSessies} sessies
</span>
<span className="text-slate-300"></span>
<span className="text-slate-700">{behandelstructuur.vorm}</span>
</div>
</div>
)}
{/* Evaluatiemomenten */}
{evaluatiemomenten && evaluatiemomenten.length > 0 && (
<div className="space-y-2">
<h4 className="text-xs font-medium text-slate-500 uppercase tracking-wide">
Evaluatiemomenten
</h4>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
{evaluatiemomenten.map((eval_) => (
<EvaluatieItem key={eval_.id} evaluatie={eval_} />
))}
</div>
</div>
)}
{/* Veiligheidsplan */}
{veiligheidsplan && (
<VeiligheidsplanSection veiligheidsplan={veiligheidsplan} />
)}
</CardContent>
)}
</Card>
);
}
interface EvaluatieItemProps {
evaluatie: Evaluatiemoment;
}
function EvaluatieItem({ evaluatie }: EvaluatieItemProps) {
const isCompleted = evaluatie.status === 'afgerond';
const typeLabel =
evaluatie.type === 'tussentijds'
? 'Tussentijds'
: evaluatie.type === 'eind'
? 'Eind'
: 'Crisis';
return (
<div
className={cn(
'flex items-center gap-2 p-2 rounded-md text-sm',
isCompleted ? 'bg-green-50' : 'bg-slate-50'
)}
>
{isCompleted ? (
<CheckCircle2 className="h-4 w-4 text-green-600 shrink-0" />
) : (
<div className="h-4 w-4 rounded-full border-2 border-slate-300 shrink-0" />
)}
<div className="min-w-0">
<p className="font-medium text-slate-700 truncate">
Week {evaluatie.weekNumber}: {typeLabel}
</p>
{evaluatie.plannedDate && (
<p className="text-xs text-slate-500">
{new Date(evaluatie.plannedDate).toLocaleDateString('nl-NL', {
day: 'numeric',
month: 'short',
})}
</p>
)}
</div>
</div>
);
}
interface VeiligheidsplanSectionProps {
veiligheidsplan: Veiligheidsplan;
}
function VeiligheidsplanSection({ veiligheidsplan }: VeiligheidsplanSectionProps) {
return (
<div className="space-y-3 p-3 bg-orange-50 border border-orange-200 rounded-md">
<div className="flex items-center gap-2 text-orange-700">
<Shield className="h-4 w-4" />
<h4 className="font-medium text-sm">Veiligheidsplan</h4>
</div>
{/* Waarschuwingssignalen */}
{veiligheidsplan.waarschuwingssignalen.length > 0 && (
<div className="space-y-1">
<p className="text-xs font-medium text-orange-700 flex items-center gap-1">
<AlertTriangle className="h-3 w-3" />
Waarschuwingssignalen
</p>
<ul className="text-sm text-orange-900 space-y-0.5">
{veiligheidsplan.waarschuwingssignalen.map((signal, i) => (
<li key={i} className="flex items-start gap-1.5">
<span className="text-orange-400"></span>
<span>{signal}</span>
</li>
))}
</ul>
</div>
)}
{/* Coping strategieën */}
{veiligheidsplan.copingStrategieen.length > 0 && (
<div className="space-y-1">
<p className="text-xs font-medium text-orange-700">
Coping strategieën
</p>
<ul className="text-sm text-orange-900 space-y-0.5">
{veiligheidsplan.copingStrategieen.map((strategy, i) => (
<li key={i} className="flex items-start gap-1.5">
<span className="text-orange-400"></span>
<span>{strategy}</span>
</li>
))}
</ul>
</div>
)}
{/* Contacten */}
{veiligheidsplan.contacten.length > 0 && (
<div className="space-y-1">
<p className="text-xs font-medium text-orange-700 flex items-center gap-1">
<Phone className="h-3 w-3" />
Noodcontacten
</p>
<div className="grid grid-cols-2 gap-2">
{veiligheidsplan.contacten.map((contact, i) => (
<div
key={i}
className="text-sm bg-white/50 rounded p-1.5 text-orange-900"
>
<p className="font-medium">{contact.naam}</p>
<p className="text-xs text-orange-700">{contact.rol}</p>
<p className="text-xs">{contact.telefoon}</p>
</div>
))}
</div>
</div>
)}
</div>
);
}

156
components/ui/command.tsx Normal file
View File

@@ -0,0 +1,156 @@
"use client"
import * as React from "react"
import { type DialogProps } from "@radix-ui/react-dialog"
import { Command as CommandPrimitive } from "cmdk"
import { Search } from "lucide-react"
import { cn } from "@/lib/utils"
import { Dialog, DialogContent } from "@/components/ui/dialog"
const Command = React.forwardRef<
React.ElementRef<typeof CommandPrimitive>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
>(({ className, ...props }, ref) => (
<CommandPrimitive
ref={ref}
className={cn(
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
className
)}
{...props}
/>
))
Command.displayName = CommandPrimitive.displayName
interface CommandDialogProps extends DialogProps {}
const CommandDialog = ({ children, ...props }: CommandDialogProps) => {
return (
<Dialog {...props}>
<DialogContent className="overflow-hidden p-0 shadow-lg">
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
)
}
const CommandInput = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => (
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
ref={ref}
className={cn(
"flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
</div>
))
CommandInput.displayName = CommandPrimitive.Input.displayName
const CommandList = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.List>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
>(({ className, ...props }, ref) => (
<CommandPrimitive.List
ref={ref}
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
{...props}
/>
))
CommandList.displayName = CommandPrimitive.List.displayName
const CommandEmpty = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Empty>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
>((props, ref) => (
<CommandPrimitive.Empty
ref={ref}
className="py-6 text-center text-sm"
{...props}
/>
))
CommandEmpty.displayName = CommandPrimitive.Empty.displayName
const CommandGroup = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Group>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Group
ref={ref}
className={cn(
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
className
)}
{...props}
/>
))
CommandGroup.displayName = CommandPrimitive.Group.displayName
const CommandSeparator = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Separator
ref={ref}
className={cn("-mx-1 h-px bg-border", className)}
{...props}
/>
))
CommandSeparator.displayName = CommandPrimitive.Separator.displayName
const CommandItem = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none aria-selected:bg-accent aria-selected:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
/>
))
CommandItem.displayName = CommandPrimitive.Item.displayName
const CommandShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground",
className
)}
{...props}
/>
)
}
CommandShortcut.displayName = "CommandShortcut"
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandSeparator,
CommandShortcut,
}

32
components/ui/popover.tsx Normal file
View File

@@ -0,0 +1,32 @@
"use client"
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import { cn } from "@/lib/utils"
const Popover = PopoverPrimitive.Root
const PopoverTrigger = PopoverPrimitive.Trigger
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
))
PopoverContent.displayName = PopoverPrimitive.Content.displayName
export { Popover, PopoverTrigger, PopoverContent }

View File

@@ -1,6 +1,6 @@
{ {
"manifesto": { "manifesto": {
"title": "Het experiment: EPD bouwen in 4 weken", "title": "AI Speedrun | Het experiment: EPD bouwen in 4 weken",
"description": "Jensen Huang: 'AI is going to eat software'. Een experiment: bouw een EPD in 4 weken voor €200.", "description": "Jensen Huang: 'AI is going to eat software'. Een experiment: bouw een EPD in 4 weken voor €200.",
"ogTitle": "Software on Demand - AI Speedrun", "ogTitle": "Software on Demand - AI Speedrun",
"ogDescription": "Van €100k en 12 maanden naar €200 en 4 weken. Het nieuwe development.", "ogDescription": "Van €100k en 12 maanden naar €200 en 4 weken. Het nieuwe development.",

View File

@@ -0,0 +1,768 @@
# Mission Control — Bouwplan Diagnose Module
**Projectnaam:** Diagnose Module - Mini EPD
**Versie:** v1.1
**Datum:** 11-12-2024
**Auteur:** Colin Lit
---
## 1. Doel en context
**Doel:** De bestaande diagnose-functionaliteit uitbreiden naar een volwaardige module met doorzoekbare ICD-10 classificatie, verbeterde UX en hoofd-/nevendiagnose ondersteuning.
**Toelichting:**
De huidige implementatie (`diagnosis-manager.tsx`) is een basic formulier met handmatige code-invoer. Dit bouwplan beschrijft de upgrade naar:
- Doorzoekbare **ICD-10 codelijst** (~50 GGZ-codes, client-side filtering)
- **Modal-based invoer** (in plaats van inline formulier)
- **Diagnose cards** met visuele status badges
- Hoofd-/nevendiagnose markering
- DSM-5 referentieveld (vrije tekst)
**Referentiedocumenten:**
- PRD: `docs/specs/diagnose/prd-diagnose-module-v1.md` (v1.1)
- FO: `docs/specs/diagnose/fo-diagnose-module-v1.md` (v1.1)
- TO: `docs/specs/diagnose/to-diagnose-module-v1.md` (v1.0)
---
## 2. Uitgangspunten
### 2.1 Technische Stack
- **Frontend:** Next.js 14 App Router + Tailwind CSS + shadcn/ui
- **Database:** Supabase PostgreSQL (bestaande `conditions` tabel)
- **UI Components:** shadcn/ui Dialog, Command (cmdk), Badge, Card
- **Form Handling:** react-hook-form + zod
- **Icons:** Lucide React
### 2.2 Projectkaders
- **Scope:** Prototype voor demo
- **Data:** Statische ICD-10 JSON (~50 codes), geen API call
- **Licentie:** ICD-10 = publiek domein (WHO). DSM-5 vereist licentie.
- **AI:** Optioneel / post-MVP
### 2.3 Programmeer Uitgangspunten
**Code Quality Principles:**
- **DRY:** Herbruikbare ICD-10 zoekcomponent, diagnose types
- **KISS:** Client-side filtering (geen server call voor 50 codes)
- **SOC:** Modal logica gescheiden van lijst weergave
- **YAGNI:** Geen AI-suggesties in v1, geen patiënt-breed overzicht
**Development Practices:**
- Bestaande `conditions` tabel gebruiken (geen schema wijzigingen)
- Server actions in bestaande `actions.ts` uitbreiden
- shadcn/ui Command component voor autocomplete
---
## 3. Epics & Stories Overzicht
| Epic ID | Titel | Doel | Status | Stories | Opmerkingen |
|---------|-------|------|--------|---------|-------------|
| E0 | Data & Types | ICD-10 codelijst en TypeScript types | ✅ Gereed | 3 | JSON + types + schema |
| E1 | UI Componenten | Nieuwe componenten voor diagnose UI | ⏳ To Do | 4 | Modal, Combobox, Card |
| E2 | Integratie | Bestaande code refactoren | ⏳ To Do | 3 | Actions + Manager refactor |
| E3 | Polish & Test | Afronding en testen | ⏳ To Do | 2 | States + handmatige tests |
**Belangrijk:** Voer niet in 1x het volledige plan uit. Bouw per epic en per story.
**Belangrijk:** Installatie van `cmdk` dependency moet eerst aan Colin worden gemeld.
---
## 4. Epics & Stories (Uitwerking)
### Epic 0 — Data & Types
**Epic Doel:** Statische ICD-10 codelijst en TypeScript types voor de module.
| Story ID | Beschrijving | Acceptatiecriteria | Status | Afhankelijkheden | Story Points |
|----------|--------------|---------------------|--------|------------------|--------------|
| E0.S1 | ICD-10 JSON codelijst maken | `lib/data/icd10-ggz-codes.json` met ~50 GGZ-codes, categorieën, keywords | ✅ | — | 3 |
| E0.S2 | TypeScript types voor ICD-10 | `lib/types/icd10.ts` met interfaces en helper functies | ✅ | E0.S1 | 1 |
| E0.S3 | Zod validatieschema | `lib/schemas/diagnosis.ts` voor form validatie met Nederlandse foutmeldingen | ✅ | E0.S2 | 1 |
**Technical Notes:**
- ICD-10 codes: F32.x (depressie), F40.x-F41.x (angst), F43.x (trauma), F60.x (persoonlijkheid), etc.
- Keywords toevoegen voor betere zoekresultaten
- Code format: `/^F\d{2}(\.\d{1,2})?$/`
---
#### E0.S1 — ICD-10 JSON Codelijst
**Doel:** Statische JSON met ~50 GGZ-relevante ICD-10 codes voor client-side filtering.
**Locatie:** `lib/data/icd10-ggz-codes.json`
**Data structuur:**
```json
{
"version": "ICD-10-GM 2024",
"source": "WHO (publiek domein)",
"categories": [
{
"name": "Depressieve stoornissen",
"codes": [
{ "code": "F32.0", "display": "Lichte depressieve episode", "keywords": ["depressie", "licht"] }
]
}
],
"frequentCodes": ["F32.1", "F41.1", "F43.1", "F41.0", "F60.3"]
}
```
**Categorie indeling (~55 codes):**
| Categorie | Codes | Aantal |
|-----------|-------|--------|
| Depressieve stoornissen | F32.x, F33.x | 7 |
| Angststoornissen | F40.x, F41.x | 8 |
| Trauma/stress | F43.x | 4 |
| OCD | F42.x | 3 |
| Bipolaire stoornissen | F31.x | 4 |
| Persoonlijkheidsstoornissen | F60.x | 7 |
| ADHD | F90.x | 2 |
| Autisme | F84.x | 2 |
| Eetstoornissen | F50.x | 3 |
| Schizofrenie/psychose | F20.x, F23.x | 3 |
| Middelengebruik | F10.x-F19.x | 6 |
| Somatoforme/dissociatief | F44.x, F45.x | 4 |
| Slaapstoornissen | F51.x | 2 |
**Acceptatiecriteria:**
- [ ] JSON is valid en parsed zonder errors
- [ ] Alle codes hebben `code`, `display` en `keywords` velden
- [ ] Keywords zijn in het Nederlands
- [ ] `frequentCodes` bevat top 5 meest gebruikte GGZ-codes
---
#### E0.S2 — TypeScript Types
**Doel:** Type-safe interfaces voor ICD-10 data en diagnose operaties.
**Locatie:** `lib/types/icd10.ts`
**Te implementeren types:**
```typescript
// Data types
interface ICD10Code {
code: string;
display: string;
keywords: string[];
}
interface ICD10Category {
name: string;
codes: ICD10Code[];
}
interface ICD10CodeList {
version: string;
source: string;
categories: ICD10Category[];
frequentCodes: string[];
}
// Diagnose types (FHIR compatible)
type DiagnosisSeverity = 'licht' | 'matig' | 'ernstig';
type DiagnosisClinicalStatus = 'active' | 'remission' | 'resolved' | ...;
type DiagnosisType = 'primary' | 'secondary';
// Helper type
type FlatICD10Code = ICD10Code & { category: string };
```
**Helper functies:**
```typescript
flattenICD10Codes(codeList): FlatICD10Code[]
searchICD10Codes(codes, query, maxResults): FlatICD10Code[]
getFrequentCodes(codes, frequentIds): FlatICD10Code[]
```
**Acceptatiecriteria:**
- [ ] Alle types exporteren correct
- [ ] Helper functies zijn type-safe
- [ ] `searchICD10Codes` zoekt op code, display en keywords
- [ ] `pnpm build` slaagt zonder type errors
---
#### E0.S3 — Zod Validatieschema
**Doel:** Form validatie met Nederlandse foutmeldingen.
**Locatie:** `lib/schemas/diagnosis.ts`
**Schema velden:**
| Veld | Type | Verplicht | Validatie |
|------|------|-----------|-----------|
| `code` | string | Ja | Regex: `/^F\d{2}(\.\d{1,2})?$/` |
| `description` | string | Ja | Min 1, max 200 tekens |
| `severity` | enum | Ja | licht / matig / ernstig |
| `diagnosisType` | enum | Ja | primary / secondary |
| `status` | enum | Ja | active / remission / resolved / entered-in-error |
| `dsm5Reference` | string | Nee | Max 100 tekens |
| `notes` | string | Nee | Max 500 tekens |
**Exports:**
```typescript
// Constants
export const DIAGNOSIS_SEVERITIES = ['licht', 'matig', 'ernstig'] as const;
export const DIAGNOSIS_TYPES = ['primary', 'secondary'] as const;
export const DIAGNOSIS_STATUSES = ['active', 'remission', 'resolved', 'entered-in-error'] as const;
// Schemas
export const diagnosisSchema = z.object({...});
export const diagnosisPayloadSchema = diagnosisSchema.extend({ patientId, intakeId });
export const diagnosisUpdateSchema = diagnosisSchema.partial().extend({ id });
// Types
export type DiagnosisFormData = z.infer<typeof diagnosisSchema>;
export type DiagnosisPayload = z.infer<typeof diagnosisPayloadSchema>;
// Defaults
export const diagnosisDefaults: DiagnosisFormData = {...};
```
**Acceptatiecriteria:**
- [ ] Alle foutmeldingen zijn in het Nederlands
- [ ] `diagnosisDefaults` heeft correcte default waarden
- [ ] Schema's zijn compatibel met react-hook-form
- [ ] `pnpm build` slaagt zonder type errors
---
### Epic 1 — UI Componenten
**Epic Doel:** Nieuwe React componenten voor de diagnose-UI.
| Story ID | Beschrijving | Acceptatiecriteria | Status | Afhankelijkheden | Story Points |
|----------|--------------|---------------------|--------|------------------|--------------|
| E1.S1 | ICD-10 Combobox component | Autocomplete zoeken in ICD-10 codes, debounce, max 8 resultaten | ⏳ | E0.S1, E0.S2 | 3 |
| E1.S2 | Diagnose Modal component | Dialog met form, validatie, opslaan/bewerken | ⏳ | E0.S3, E1.S1 | 5 |
| E1.S3 | Diagnose Card component | Weergave per diagnose met badges, expand/collapse, acties | ⏳ | — | 2 |
| E1.S4 | cmdk dependency installeren | `pnpm add cmdk` uitvoeren (Colin) | ⏳ | — | 1 |
**Technical Notes:**
- **E1.S4:** Colin moet `pnpm add cmdk` goedkeuren/uitvoeren
- Gebruik shadcn/ui patterns voor consistentie
- Command component voor autocomplete (cmdk based)
---
#### E1.S1 — ICD-10 Combobox Component
**Doel:** Autocomplete zoekcomponent voor ICD-10 codes met keyboard navigatie.
**Locatie:** `app/epd/patients/[id]/intakes/[intakeId]/diagnosis/components/icd10-combobox.tsx`
**Props interface:**
```typescript
interface ICD10ComboboxProps {
value: string; // Geselecteerde code (bijv. "F32.1")
onSelect: (code: FlatICD10Code) => void; // Callback bij selectie
placeholder?: string; // Default: "Zoek op code of beschrijving..."
disabled?: boolean;
}
```
**Functionaliteit:**
| Feature | Beschrijving |
|---------|--------------|
| Zoeken | Filter op code (F32), display (depressie) en keywords |
| Debounce | 200ms delay voor filtering |
| Max resultaten | 8 items in dropdown |
| Snelkeuze | Bij leeg veld: toon top 5 veelgebruikte codes |
| Keyboard | Arrow keys navigatie, Enter selecteert, Escape sluit |
| Display | Code vetgedrukt, beschrijving normaal |
**UI States:**
| State | Weergave |
|-------|----------|
| Leeg veld | Placeholder + snelkeuze dropdown |
| Typing | Zoekresultaten dropdown |
| Geen resultaten | "Geen codes gevonden voor '{query}'" |
| Geselecteerd | Geselecteerde code + beschrijving in input |
**Acceptatiecriteria:**
- [ ] Zoeken werkt op code, display en keywords
- [ ] Debounce voorkomt te veel renders
- [ ] Max 8 resultaten worden getoond
- [ ] Snelkeuze toont top 5 bij leeg veld
- [ ] Keyboard navigatie werkt correct
- [ ] Focus management correct (blur sluit dropdown)
---
#### E1.S2 — Diagnose Modal Component
**Doel:** Modal dialog voor toevoegen en bewerken van diagnoses.
**Locatie:** `app/epd/patients/[id]/intakes/[intakeId]/diagnosis/components/diagnosis-modal.tsx`
**Props interface:**
```typescript
interface DiagnosisModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
patientId: string;
intakeId: string;
diagnosis?: Condition; // undefined = nieuw, Condition = bewerk
onSuccess: () => void; // Callback na succesvol opslaan
}
```
**Form velden:**
| Veld | Component | Verplicht | Notes |
|------|-----------|-----------|-------|
| ICD-10 Code | ICD10Combobox | Ja | Autocomplete |
| Ernst | Select | Ja | licht / matig / ernstig |
| Diagnose type | RadioGroup | Ja | Hoofd / Neven |
| Status | Select | Ja | Actief / In remissie / Opgelost |
| DSM-5 referentie | Input | Nee | Vrije tekst |
| Onderbouwing | Textarea | Nee | Max 500 tekens |
**Gedrag:**
| Scenario | Actie |
|----------|-------|
| Nieuw | Lege form met defaults |
| Bewerk | Form voorgevuld met bestaande data |
| Submit | Validatie → Server action → onSuccess → Close |
| Validatiefout | Inline errors onder velden |
| Server error | Toast met foutmelding |
**UI Layout:**
```
┌─────────────────────────────────────────────────────────────┐
│ Nieuwe diagnose / Diagnose bewerken [✕] │
├─────────────────────────────────────────────────────────────┤
│ │
│ ICD-10 Code * │
│ [Combobox: Zoek op code of beschrijving...] │
│ │
│ Ernst * Diagnose type * │
│ [Select: Matig ▼] ○ Hoofddiagnose │
│ ● Nevendiagnose │
│ │
│ Status * │
│ [Select: Actief ▼] │
│ │
│ DSM-5 referentie (optioneel) │
│ [Input: bijv. Major Depressive Disorder] │
│ │
│ Onderbouwing (optioneel) │
│ [Textarea: Klinische redenering...] │
│ │
│ [Annuleren] [Opslaan] │
└─────────────────────────────────────────────────────────────┘
```
**Acceptatiecriteria:**
- [ ] Modal opent/sluit correct
- [ ] Form validatie werkt met Nederlandse foutmeldingen
- [ ] ICD-10 combobox integreert correct
- [ ] Bewerk modus vult form voor met bestaande data
- [ ] Opslaan roept juiste server action aan
- [ ] Loading state tijdens opslaan
- [ ] Modal sluit na succesvolle actie
---
#### E1.S3 — Diagnose Card Component
**Doel:** Visuele weergave van een diagnose met acties.
**Locatie:** `app/epd/patients/[id]/intakes/[intakeId]/diagnosis/components/diagnosis-card.tsx`
**Props interface:**
```typescript
interface DiagnosisCardProps {
diagnosis: Condition;
isPrimary?: boolean;
onEdit: () => void;
onDelete: () => void;
isDeleting?: boolean;
}
```
**UI Layout:**
```
┌─────────────────────────────────────────────────────────────┐
│ F32.1 — Matige depressieve episode HOOFD [⋮] │
├─────────────────────────────────────────────────────────────┤
│ Ernst: Matig │ Status: Actief │ 15 nov 2024 │
├─────────────────────────────────────────────────────────────┤
│ ▼ Onderbouwing │
│ Patiënt voldoet aan 6 van de 9 criteria... │
└─────────────────────────────────────────────────────────────┘
```
**Elementen:**
| Element | Beschrijving |
|---------|--------------|
| Code + beschrijving | Vetgedrukt, altijd zichtbaar |
| HOOFD badge | Groen, alleen bij isPrimary |
| Status badge | Kleur per status (groen/blauw/grijs/rood) |
| Ernst | Tekst weergave |
| Datum | Format: d MMM yyyy (NL locale) |
| Onderbouwing | Ingeklapt, expand via chevron |
| Context menu | Bewerk, Verwijder |
**Status badge kleuren:**
| Status | Kleur | Tekst |
|--------|-------|-------|
| active | Groen | Actief |
| remission | Blauw | In remissie |
| resolved | Grijs | Opgelost |
| entered-in-error | Rood | Foutief |
**Acceptatiecriteria:**
- [ ] Card toont alle diagnose informatie
- [ ] HOOFD badge alleen zichtbaar bij isPrimary
- [ ] Status badge heeft correcte kleur
- [ ] Onderbouwing is expand/collapse
- [ ] Context menu met Bewerk/Verwijder
- [ ] Loading state bij verwijderen
---
#### E1.S4 — cmdk Dependency Installeren
**Doel:** Command menu library installeren voor autocomplete functionaliteit.
**Commando:**
```bash
pnpm add cmdk
```
**Alternatief (indien cmdk niet gewenst):**
Custom autocomplete bouwen met:
- `@radix-ui/react-popover` (reeds aanwezig)
- `@radix-ui/react-scroll-area`
- Custom filtering logic
**Acceptatiecriteria:**
- [ ] `pnpm add cmdk` uitgevoerd door Colin
- [ ] Package toegevoegd aan `package.json`
- [ ] `pnpm install` succesvol
- [ ] `pnpm build` slaagt
---
### Epic 2 — Integratie
**Epic Doel:** Bestaande code refactoren en nieuwe componenten integreren.
| Story ID | Beschrijving | Acceptatiecriteria | Status | Afhankelijkheden | Story Points |
|----------|--------------|---------------------|--------|------------------|--------------|
| E2.S1 | Server actions uitbreiden | `updateDiagnosis` functie, code_system naar ICD-10 | ⏳ | E0.S3 | 2 |
| E2.S2 | DiagnosisManager refactoren | Vervang inline form door modal, gebruik DiagnosisCard | ⏳ | E1.S2, E1.S3 | 3 |
| E2.S3 | Page.tsx aanpassen | Pagina tekst updaten, imports aanpassen | ⏳ | E2.S2 | 1 |
---
#### E2.S1 — Server Actions Uitbreiden
**Doel:** Bestaande server actions aanpassen en uitbreiden voor nieuwe functionaliteit.
**Locatie:** `app/epd/patients/[id]/intakes/[intakeId]/actions.ts`
**Wijzigingen in `createDiagnosis`:**
```typescript
// Huidige implementatie
code_system: undefined // of 'DSM-5'
// Nieuwe implementatie
code_system: 'ICD-10',
category: payload.diagnosisType === 'primary' ? 'primary-diagnosis' : 'encounter-diagnosis',
```
**Nieuwe functie `updateDiagnosis`:**
```typescript
export async function updateDiagnosis(
diagnosisId: string,
payload: DiagnosisUpdatePayload
): Promise<{ success: boolean; error?: string }> {
const supabase = await createClient();
const { error } = await supabase
.from('conditions')
.update({
code_code: payload.code,
code_display: payload.description,
clinical_status: payload.status,
severity_display: payload.severity,
note: payload.notes,
category: payload.diagnosisType === 'primary' ? 'primary-diagnosis' : 'encounter-diagnosis',
updated_at: new Date().toISOString(),
})
.eq('id', diagnosisId);
if (error) {
return { success: false, error: 'Diagnose bijwerken mislukt' };
}
revalidatePath(`/epd/patients/[id]/intakes/[intakeId]/diagnosis`);
return { success: true };
}
```
**Acceptatiecriteria:**
- [ ] `createDiagnosis` zet code_system op 'ICD-10'
- [ ] `createDiagnosis` zet category correct (primary/encounter)
- [ ] `updateDiagnosis` functie werkt correct
- [ ] Path revalidatie na create/update/delete
- [ ] Foutafhandeling met Nederlandse meldingen
---
#### E2.S2 — DiagnosisManager Refactoren
**Doel:** Bestaande component vervangen door nieuwe UI met modal en cards.
**Locatie:** `app/epd/patients/[id]/intakes/[intakeId]/diagnosis/components/diagnosis-manager.tsx`
**Huidige structuur (te vervangen):**
```
- Inline diagnose lijst
- Inline toevoeg formulier
- Verwijder buttons
```
**Nieuwe structuur:**
```
- DiagnosisCard lijst (met sorteer: hoofd eerst)
- [+ Nieuwe diagnose] button → opent DiagnosisModal
- DiagnosisModal (create/edit)
- Verwijder bevestiging dialog
```
**State management:**
```typescript
const [modalOpen, setModalOpen] = useState(false);
const [editingDiagnosis, setEditingDiagnosis] = useState<Condition | undefined>();
const [deletingId, setDeletingId] = useState<string | null>(null);
```
**Gedrag:**
| Actie | Effect |
|-------|--------|
| [+ Nieuwe diagnose] | `setModalOpen(true)`, `setEditingDiagnosis(undefined)` |
| Card: Bewerk | `setModalOpen(true)`, `setEditingDiagnosis(diagnosis)` |
| Card: Verwijder | Confirm dialog → `deleteDiagnosis()` |
| Modal: Opslaan | `create/updateDiagnosis()` → close modal |
**Acceptatiecriteria:**
- [ ] Inline form verwijderd
- [ ] DiagnosisCard voor elke diagnose
- [ ] Hoofddiagnoses worden eerst getoond
- [ ] Modal opent voor nieuw/bewerk
- [ ] Verwijderen met bevestiging
- [ ] Loading states correct
---
#### E2.S3 — Page.tsx Aanpassen
**Doel:** Server component aanpassen voor nieuwe module.
**Locatie:** `app/epd/patients/[id]/intakes/[intakeId]/diagnosis/page.tsx`
**Wijzigingen:**
```typescript
// Huidige tekst
<p className="text-sm text-slate-600">
Registreer DSM-5 diagnoses gekoppeld aan deze intake.
</p>
// Nieuwe tekst
<p className="text-sm text-slate-600">
Registreer diagnoses met ICD-10 classificatie gekoppeld aan deze intake.
</p>
```
**Acceptatiecriteria:**
- [ ] Tekst verwijst naar ICD-10 i.p.v. DSM-5
- [ ] Imports zijn correct
- [ ] Pagina laadt zonder errors
---
### Epic 3 — Polish & Test
**Epic Doel:** Afronding met correcte feedback en handmatige tests.
| Story ID | Beschrijving | Acceptatiecriteria | Status | Afhankelijkheden | Story Points |
|----------|--------------|---------------------|--------|------------------|--------------|
| E3.S1 | Empty states en feedback | Toast bij acties, lege staat tekst, loading indicators | ⏳ | E2.S2 | 1 |
| E3.S2 | Handmatige tests uitvoeren | Alle test scenario's doorlopen en documenteren | ⏳ | E3.S1 | 1 |
---
#### E3.S1 — Empty States en Feedback
**Doel:** Consistente feedback en lege staten voor goede UX.
**Empty states:**
| Context | Tekst | Actie |
|---------|-------|-------|
| Geen diagnoses | "Nog geen diagnoses geregistreerd." | [+ Nieuwe diagnose] button prominent |
| Geen zoekresultaten | "Geen codes gevonden voor '{query}'" | — |
**Toast feedback:**
| Actie | Type | Tekst |
|-------|------|-------|
| Diagnose opgeslagen | Success | "Diagnose opgeslagen" |
| Diagnose bijgewerkt | Success | "Diagnose bijgewerkt" |
| Diagnose verwijderd | Success | "Diagnose verwijderd" |
| Opslaan mislukt | Error | "Opslaan mislukt: {error}" |
**Loading indicators:**
| Context | Indicator |
|---------|-----------|
| Modal opslaan | Button disabled + spinner |
| Verwijderen | Card disabled + spinner |
**Acceptatiecriteria:**
- [ ] Lege staat toont correcte tekst en button
- [ ] Toast feedback bij alle acties
- [ ] Loading indicators zichtbaar
- [ ] Geen UI jumps tijdens laden
---
#### E3.S2 — Handmatige Tests
**Doel:** Alle functionaliteit testen en documenteren.
**Test scenario's:**
| # | Scenario | Stappen | Verwacht resultaat | Status |
|---|----------|---------|-------------------|--------|
| 1 | Zoek op beschrijving | Type "depressie" in combobox | Toont F32.x codes | ⏳ |
| 2 | Zoek op code | Type "F41" in combobox | Toont angststoornissen | ⏳ |
| 3 | Selecteer code | Klik op F32.1 in dropdown | Vult code + beschrijving | ⏳ |
| 4 | Validatie | Opslaan zonder code | Toont foutmelding | ⏳ |
| 5 | Nieuwe diagnose | Vul form in → Opslaan | Card verschijnt in lijst | ⏳ |
| 6 | Bewerk diagnose | Klik Bewerk → wijzig → Opslaan | Card toont nieuwe data | ⏳ |
| 7 | Verwijder diagnose | Klik Verwijder → Bevestig | Card verdwijnt | ⏳ |
| 8 | Snelkeuze | Focus op lege combobox | Toont top 5 codes | ⏳ |
| 9 | Hoofddiagnose | Selecteer "Hoofddiagnose" | HOOFD badge zichtbaar | ⏳ |
| 10 | Build | `pnpm build` | Geen errors | ⏳ |
**Acceptatiecriteria:**
- [ ] Alle scenario's doorlopen
- [ ] Eventuele bugs gedocumenteerd
- [ ] `pnpm build` slaagt
- [ ] `pnpm lint` geen errors
---
## 5. Kwaliteit & Testplan
### Test Types
| Test Type | Scope | Tools | Verantwoordelijke |
|-----------|-------|-------|-------------------|
| Type Check | Alle nieuwe files | `pnpm build` | Developer |
| Lint | Alle nieuwe files | `pnpm lint` | Developer |
| Manual Tests | Diagnose flows | Handmatig | Developer |
### Manual Test Checklist (voor demo)
- [ ] ICD-10 zoeken werkt op code en beschrijving
- [ ] Nieuwe diagnose toevoegen via modal
- [ ] Bestaande diagnose bewerken
- [ ] Diagnose verwijderen met bevestiging
- [ ] Hoofddiagnose markeren (visuele badge)
- [ ] Ernst en status selecteren
- [ ] Onderbouwing toevoegen en bekijken
- [ ] Lege staat correct weergegeven
- [ ] Toast feedback bij opslaan/verwijderen
- [ ] Build slaagt zonder errors
---
## 6. Bestandsstructuur (Na Implementatie)
```
app/epd/patients/[id]/intakes/[intakeId]/diagnosis/
├── page.tsx # Server component (aangepast)
└── components/
├── diagnosis-manager.tsx # Refactored: lijst + modal trigger
├── diagnosis-card.tsx # Nieuw: diagnose weergave
├── diagnosis-modal.tsx # Nieuw: invoer/bewerk modal
└── icd10-combobox.tsx # Nieuw: autocomplete zoeken
lib/data/
└── icd10-ggz-codes.json # Nieuw: statische codelijst (55 codes)
lib/types/
└── icd10.ts # Nieuw: ICD-10 TypeScript types
lib/schemas/
└── diagnosis.ts # Nieuw: Zod validatieschema
```
---
## 7. Risico's & Mitigatie
| Risico | Kans | Impact | Mitigatie | Owner |
|--------|------|--------|-----------|-------|
| cmdk dependency te groot | Laag | Laag | ~10KB gzipped, acceptabel | Developer |
| ICD-10 codes incompleet voor demo | Laag | Middel | Start met 55, uitbreiden op verzoek | Developer |
| Bestaande diagnoses breken | Laag | Hoog | Backward compatible, DSM-5 data blijft | Developer |
| Modal UX niet intuïtief | Middel | Middel | Volg shadcn/ui patterns | Developer |
---
## 8. Dependency Check
### Benodigde nieuwe dependency
```bash
pnpm add cmdk
```
### Bestaande dependencies (geen actie)
- `@radix-ui/react-dialog` — Dialog voor modal
- `@radix-ui/react-select` — Dropdowns
- `react-hook-form` — Form handling
- `@hookform/resolvers` — Zod integration
- `zod` — Validatie
- `lucide-react` — Icons
- `date-fns` — Datum formatting
---
## 9. Referenties
**Mission Control Documents:**
- PRD: `docs/specs/diagnose/prd-diagnose-module-v1.md`
- FO: `docs/specs/diagnose/fo-diagnose-module-v1.md`
- TO: `docs/specs/diagnose/to-diagnose-module-v1.md`
**Bestaande Implementatie:**
- `app/epd/patients/[id]/intakes/[intakeId]/diagnosis/` — Huidige pagina
- `app/epd/patients/[id]/intakes/[intakeId]/actions.ts` — Server actions
- `lib/supabase/database.types.ts` — Database types (conditions)
**Externe Bronnen:**
- [WHO ICD-10 (publiek domein)](https://www.who.int/standards/classifications/classification-of-diseases)
- [shadcn/ui Command](https://ui.shadcn.com/docs/components/command)
- [cmdk documentation](https://cmdk.paco.me/)
---
## 10. Glossary
| Term | Betekenis |
|------|-----------|
| ICD-10 | International Classification of Diseases, 10e revisie (WHO) |
| DSM-5 | Diagnostic and Statistical Manual of Mental Disorders (APA) |
| GGZ | Geestelijke gezondheidszorg |
| cmdk | Command menu library voor React (autocomplete) |
| FHIR | Fast Healthcare Interoperability Resources (standaard) |
---
**Versiehistorie:**
| Versie | Datum | Auteur | Wijziging |
|--------|-------|--------|-----------|
| v1.0 | 11-12-2024 | Colin Lit | Initiële versie |
| v1.1 | 11-12-2024 | Colin Lit | Uitgebreide story beschrijvingen, E0 gereed |

View File

@@ -0,0 +1,397 @@
# 🧩 Functioneel Ontwerp (FO) Diagnose Module
**Projectnaam:** Diagnose Module - Mini EPD
**Versie:** v1.1
**Datum:** 11-12-2024
**Auteur:** Colin Lit
---
## 1. Doel en relatie met het PRD
🎯 **Doel van dit document:**
Het Functioneel Ontwerp beschrijft **hoe** de diagnosemodule in de praktijk werkt voor GGZ-professionals. Dit document vertaalt de requirements uit het PRD naar concrete schermen, acties en interacties.
📘 **Toelichting aan de lezer:**
Dit FO beschrijft de diagnose-functionaliteit voor het prototype met **ICD-10 classificatie** (publiek domein) als basis en optionele DSM-5 referentie. De focus ligt op een eenvoudige, werkende flow voor demo-doeleinden.
**Relatie met PRD:**
Dit FO implementeert de requirements uit `prd-diagnose-module-v1.md` (v1.1), specifiek:
- ICD-10 classificatie browser (PRD 3.1)
- Diagnose registratie met DSM-5 referentieveld (PRD 3.2)
- AI-ondersteuning (PRD 3.3) — optioneel voor prototype
> **Licentie-opmerking:** DSM-5 vereist licentie. Dit prototype gebruikt ICD-10 (publiek domein).
---
## 2. Overzicht van de belangrijkste onderdelen
1. **Diagnose Tab** (binnen Intake) — Hoofdlocatie voor diagnose-registratie
2. **ICD-10 Code Zoeken** — Autocomplete met GGZ-subset (~50 codes)
3. **Diagnose Invoer Modal** — Formulier voor nieuwe/bewerken diagnose
4. **Diagnose Detail Card** — Weergave per geregistreerde diagnose
5. *(Optioneel)* **AI Diagnose Assistent** — Suggesties op basis van intake
---
## 3. Userstories (Prototype)
| ID | Rol | Doel / Actie | Verwachte waarde | Prioriteit |
|----|------|---------------|------------------|-------------|
| US-01 | Psycholoog | ICD-10 code zoeken op beschrijving | Snel juiste code vinden | Hoog |
| US-02 | Psycholoog | ICD-10 code zoeken op code (F32) | Direct navigeren bij bekende code | Hoog |
| US-03 | Psycholoog | Hoofddiagnose markeren | Duidelijke prioritering | Hoog |
| US-04 | Psycholoog | Nevendiagnose(s) toevoegen | Comorbiditeit vastleggen | Hoog |
| US-05 | Psycholoog | Ernst classificeren | Behandelniveau bepalen | Hoog |
| US-06 | Psycholoog | DSM-5 referentie toevoegen | Eigen notatie mogelijk | Middel |
| US-07 | Psycholoog | Onderbouwing vastleggen | Klinische redenering documenteren | Middel |
| US-08 | Psycholoog | Diagnose bewerken/verwijderen | Correcties doorvoeren | Hoog |
| US-09 | Systeem | Diagnoses tonen in overdracht | Relevante info voor collega's | Hoog |
---
## 4. Functionele werking per onderdeel
### 4.1 Diagnose Tab (binnen Intake)
**Locatie:** `/epd/patients/[id]/intakes/[intakeId]/diagnosis`
**Schermopbouw:**
```
┌─────────────────────────────────────────────────────────────┐
│ Diagnoses │
│ Registreer DSM-5 diagnoses gekoppeld aan deze intake. │
├─────────────────────────────────────────────────────────────┤
│ │
│ ┌─ Hoofddiagnose ────────────────────────────────────────┐ │
│ │ F32.1 — Depressieve stoornis, matig [Bewerk] │ │
│ │ Ernst: Matig | Status: Actief | 15 nov 2024 │ │
│ │ "Voldoet aan 6/9 criteria, significant functioneel..." │ │
│ └────────────────────────────────────────────────────────┘ │
│ │
│ ┌─ Nevendiagnose ────────────────────────────────────────┐ │
│ │ F41.1 — Gegeneraliseerde angststoornis [Bewerk] │ │
│ │ Ernst: Licht | Status: Actief | 15 nov 2024 │ │
│ └────────────────────────────────────────────────────────┘ │
│ │
│ [+ Nieuwe diagnose] [AI Analyseer intake] │
│ │
└─────────────────────────────────────────────────────────────┘
```
**Functionaliteit:**
**Diagnoselijst:**
- Toont alle diagnoses gekoppeld aan deze intake
- Hoofddiagnose bovenaan met visuele markering (badge/border)
- Per diagnose: code, beschrijving, ernst, status, datum
- Optionele notitie ingeklapt, uitklappen via chevron
- Acties per kaart: Bewerk, Verwijder
**Lege staat:**
- Tekst: "Nog geen diagnoses geregistreerd."
- Prominente [+ Nieuwe diagnose] knop
**Acties:**
- [+ Nieuwe diagnose] → Opent diagnose-invoer modal
- [AI Analyseer intake] → Start AI-analyse, toont suggesties
---
### 4.2 Diagnose Invoer Modal
**Trigger:** Klik op [+ Nieuwe diagnose] of [Bewerk]
**Schermopbouw:**
```
┌─────────────────────────────────────────────────────────────┐
│ Nieuwe diagnose [✕] │
├─────────────────────────────────────────────────────────────┤
│ │
│ ICD-10 Code * │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ 🔍 Zoek op code of beschrijving... │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ ┌─ Veelgebruikt ─────────────────────────────────────────┐ │
│ │ F32.1 Matige depressieve episode │ │
│ │ F41.1 Gegeneraliseerde angststoornis │ │
│ │ F43.1 Posttraumatische stressstoornis │ │
│ └────────────────────────────────────────────────────────┘ │
│ │
│ Ernst * Diagnose type │
│ ┌──────────────────────┐ ┌──────────────────────┐ │
│ │ Matig ▼ │ │ ○ Hoofddiagnose │ │
│ └──────────────────────┘ │ ● Nevendiagnose │ │
│ └──────────────────────┘ │
│ │
│ Status │
│ ┌──────────────────────┐ │
│ │ Actief ▼ │ │
│ └──────────────────────┘ │
│ │
│ DSM-5 referentie (optioneel) │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Major Depressive Disorder, moderate │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ Onderbouwing / Notities │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Patiënt presenteert met sombere stemming, verminderde │ │
│ │ interesse en slaapproblemen sinds 3 maanden... │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ [Annuleren] [Opslaan] │
└─────────────────────────────────────────────────────────────┘
```
**Velden:**
| Veld | Type | Verplicht | Opties/Validatie |
|------|------|-----------|------------------|
| ICD-10 Code | Autocomplete | Ja | Zoeken in ~50 GGZ codes |
| Ernst | Dropdown | Ja | Licht, Matig, Ernstig |
| Diagnose type | Radio | Ja | Hoofddiagnose, Nevendiagnose |
| Status | Dropdown | Ja | Actief, In remissie, Opgelost |
| DSM-5 referentie | Text input | Nee | Vrije tekst voor DSM-5 equivalent |
| Onderbouwing | Textarea | Nee | Max 500 tekens |
**Gedrag:**
- Bij invoer in zoekveld: live filtering van ICD-10 codelijst
- Selectie code: vult automatisch beschrijving in
- Bij wijzigen bestaande: velden voorgevuld
- Validatie: minimaal code + ernst verplicht
- Maximaal 1 hoofddiagnose per intake (toggle andere om bij selectie)
---
### 4.3 ICD-10 Code Zoeken (Inline Autocomplete)
**Trigger:** Focus op code-zoekveld in modal
**Gedrag:**
Voor het prototype gebruiken we een eenvoudige inline autocomplete in plaats van een aparte browser/drawer.
```
┌─────────────────────────────────────────────────────────────┐
│ ICD-10 Code * │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ depre │ │
│ └─────────────────────────────────────────────────────────┘ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ F32.0 Lichte depressieve episode │ │
│ │ F32.1 Matige depressieve episode ← │ │
│ │ F32.2 Ernstige depressieve episode zonder psychose │ │
│ │ F32.3 Ernstige depressieve episode met psychose │ │
│ │ F33.0 Recidiverende depressie, lichte episode │ │
│ │ F33.1 Recidiverende depressie, matige episode │ │
│ └─────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
```
**Functionaliteit:**
**Zoeken:**
- Zoekt op code (F32, F41.1) en beschrijving (depressie, angst)
- Client-side filtering van ~50 GGZ codes
- Debounce 200ms
- Max 8 resultaten in dropdown
**Snelkeuze (bij leeg veld):**
- Toon top 5 veelgebruikte GGZ-diagnoses
- Depressie (F32.1), Angst (F41.1), PTSS (F43.1), etc.
**Selectie:**
- Klik of Enter: selecteert code, vult beschrijving in
- Escape: sluit dropdown
---
### 4.4 AI Diagnose Assistent (Optioneel - Post-MVP)
> **Prototype scope:** AI-suggesties zijn optioneel voor de eerste versie. Focus eerst op de handmatige invoer-flow. Onderstaande specificatie is voor een latere iteratie.
**Trigger:** Klik op [AI Analyseer intake]
**Schermopbouw (indien geïmplementeerd):**
```
┌─────────────────────────────────────────────────────────────┐
│ AI Diagnose Suggesties [✕] │
├─────────────────────────────────────────────────────────────┤
│ ⚠️ Dit zijn suggesties ter ondersteuning. De clinicus │
│ neemt altijd de eindbeslissing. │
├─────────────────────────────────────────────────────────────┤
│ │
│ ┌─ Suggestie 1 ──────────────────────────────────────────┐ │
│ │ F32.1 — Matige depressieve episode │ │
│ │ │ │
│ │ Onderbouwing: │ │
│ │ • "Patiënt meldt al 3 maanden somber te zijn" │ │
│ │ • "Verminderde interesse in dagelijkse activiteiten" │ │
│ │ │ │
│ │ [Overnemen] │ │
│ └────────────────────────────────────────────────────────┘ │
│ │
│ ┌─ Suggestie 2 ──────────────────────────────────────────┐ │
│ │ F41.1 — Gegeneraliseerde angststoornis │ │
│ │ ... │ │
│ │ [Overnemen] │ │
│ └────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘
```
**Vereenvoudigde flow voor prototype:**
- Input: intake-notities + anamnese
- Output: 2-3 ICD-10 suggesties met onderbouwing
- Actie: [Overnemen] opent modal met voorgevulde code
---
### 4.5 Diagnose Overzicht (Post-MVP)
> **Prototype scope:** Patiënt-breed diagnose-overzicht is post-MVP. Voor het prototype zijn diagnoses alleen zichtbaar binnen de intake waar ze zijn geregistreerd.
**Toekomstige locatie:** `/epd/patients/[id]/diagnoses`
Voor nu volstaat de diagnose-tab binnen de intake.
---
### 4.6 Diagnose Detail Card
**Component voor weergave in lijsten:**
```
┌─────────────────────────────────────────────────────────────┐
│ F32.1 — Depressieve stoornis, matig HOOFD [⋮] │
├─────────────────────────────────────────────────────────────┤
│ Ernst: Matig Status: Actief 15 nov 2024 │
├─────────────────────────────────────────────────────────────┤
│ ▼ Onderbouwing │
│ Patiënt voldoet aan 6 van de 9 DSM-5 criteria voor een │
│ depressieve episode. Significant functioneel verlies op │
│ werk en in sociale relaties. │
└─────────────────────────────────────────────────────────────┘
```
**Elementen:**
- Code + beschrijving (altijd zichtbaar)
- Type badge: HOOFD (groen) of geen (nevendiagnose)
- Status badge: kleurcodering per status
- Ernst, status, datum regel
- Onderbouwing: standaard ingeklapt, uitklappen via chevron
- Context menu [⋮]: Bewerk, Status wijzigen, Verwijderen
**Status kleuren:**
| Status | Badge kleur |
|--------|-------------|
| Actief | Groen |
| In remissie | Blauw |
| Opgelost | Grijs |
| Ingevoerd-in-fout | Rood/doorgestreept |
---
## 5. UI-overzicht (visuele structuur)
### Diagnose Tab Layout (Prototype)
```
┌─────────────────────────────────────────────────────────────────────┐
│ [← Terug naar intake] Jan de Vries │
├─────────────────────────────────────────────────────────────────────┤
│ Contacts │ Kindcheck │ Risico │ Anamnese │ Onderzoek │ DIAGNOSE │ Advies │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ Diagnoses │
│ Registreer diagnoses gekoppeld aan deze intake. │
│ │
│ ┌────────────────────────────────────────────────────────────┐ │
│ │ [Diagnose Card - Hoofddiagnose] │ │
│ └────────────────────────────────────────────────────────────┘ │
│ │
│ ┌────────────────────────────────────────────────────────────┐ │
│ │ [Diagnose Card - Nevendiagnose] │ │
│ └────────────────────────────────────────────────────────────┘ │
│ │
│ [+ Nieuwe diagnose] │
│ │
└─────────────────────────────────────────────────────────────────────┘
```
> **Prototype:** De [AI Analyseer intake] knop is optioneel en kan in een latere iteratie worden toegevoegd.
---
## 6. Interacties met AI (Optioneel - Post-MVP)
> **Prototype scope:** AI-functionaliteit is optioneel voor de eerste versie. Onderstaande tabel beschrijft de beoogde functionaliteit voor een latere iteratie.
| Locatie | AI-actie | Trigger | Output |
|---------|----------|---------|--------|
| Diagnose Tab | Analyseer intake | Klik [AI Analyseer] | 2-3 ICD-10 suggesties met onderbouwing |
**Vereenvoudigde flow:**
1. Gebruiker klikt [AI Analyseer intake]
2. Systeem analyseert intake-notities + anamnese
3. Toont 2-3 ICD-10 suggesties met citaten
4. Gebruiker klikt [Overnemen] → opent modal met voorgevulde code
---
## 7. Gebruikersrollen en rechten (Prototype)
| Rol | Toegang | Acties |
|-----|---------|--------|
| Demo-user | Alle patiënten | Volledig CRUD |
> **Prototype:** Geen rollen-onderscheid. Alle gebruikers hebben volledige toegang tot demo-data.
---
## 8. States en Feedback
### Lege staten
| Context | Weergave |
|---------|----------|
| Geen diagnoses | "Nog geen diagnoses geregistreerd." + [+ Nieuwe diagnose] |
| Geen zoekresultaten | "Geen codes gevonden voor '{query}'" |
### Succes feedback
| Actie | Feedback |
|-------|----------|
| Diagnose opgeslagen | Toast: "Diagnose opgeslagen" |
| Diagnose verwijderd | Toast: "Diagnose verwijderd" |
### Error feedback
| Fout | Weergave |
|------|----------|
| Opslaan mislukt | Inline error |
| Validatiefout | Inline onder veld |
---
## 9. Bijlagen & Referenties
### Gerelateerde documenten
- PRD Diagnose Module v1.1 (`docs/specs/diagnose/prd-diagnose-module-v1.md`)
- FO Screening & Intake (`docs/specs/screening-intake/fo-screening-intake-v1_0.md`)
- UX Stylesheet (`docs/specs/ux-stylesheet.md`)
### Bestaande implementatie
- `app/epd/patients/[id]/intakes/[intakeId]/diagnosis/` — Huidige diagnose-tab
- `app/epd/patients/[id]/intakes/[intakeId]/actions.ts` — Server actions
- `lib/supabase/database.types.ts` — conditions tabel
### Component referenties
- shadcn/ui: Dialog, Command (voor autocomplete), Badge, Card
- Lucide icons: Search, Plus, ChevronDown
### Externe bronnen
- [WHO-FIC Nederland - ICD-10/DSM-5 mapping](https://www.whofic.nl/dsm-5icd-10)
- [ICD-10-GM codelijst](https://www.dimdi.de/dynamic/de/klassifikationen/icd/icd-10-gm/) (publiek domein)
---
*Document laatst bijgewerkt: 11-12-2024*

View File

@@ -0,0 +1,291 @@
# 📄 Product Requirements Document (PRD) Diagnose Module
**Projectnaam:** Diagnose Module - Mini EPD
**Versie:** v1.1
**Datum:** 11-12-2024
**Auteur:** Colin Lit
---
## 1. Doelstelling
🎯 **Doel:** Een diagnosemodule bouwen voor het prototype die psychologen in staat stelt diagnoses gestructureerd vast te leggen met ICD-10 codes (publiek domein) en DSM-5 referenties.
📘 **Toelichting:**
De huidige diagnose-functionaliteit is minimaal: een simpel formulier met handmatige code-invoer. Deze MVP breidt dit uit naar een werkende module met:
- Doorzoekbare **ICD-10 classificatie** (publiek domein, geen licentie nodig)
- DSM-5 equivalent als referentieveld (vrije tekst)
- Meervoudige diagnoses per intake (hoofd- en nevendiagnoses)
- Status tracking (actief, in remissie, opgelost)
- AI-ondersteuning voor diagnose-suggesties
**Type:** Prototype voor demo en validatie.
> **Licentie-opmerking:** DSM-5 classificaties vallen onder licentie van Boom uitgevers. Voor productie is een licentie vereist. Dit prototype gebruikt ICD-10 (WHO, publiek domein) als primaire classificatie.
---
## 2. Doelgroep
🎯 **Primaire doelgroep:**
- **Psychologen / Regiebehandelaren:** Stellen diagnoses en hebben behoefte aan efficiënte classificatie met onderbouwing.
- **Psychiaters:** Valideren diagnoses, voegen specialistische classificaties toe.
📘 **Secundaire doelgroep:**
- **Product Owners & Managers:** Demo van AI-potentieel in diagnostisch proces.
- **Developers:** Referentie-implementatie voor medische classificatiesystemen.
**Persona's:**
> - **Marieke (GZ-psycholoog):** Wil snel DSM-5 codes kunnen vinden zonder handboek. Wil zien welke diagnoses passen bij de intake-bevindingen.
> - **Peter (Psychiater):** Wil comorbiditeit vastleggen met hoofd/nevendiagnose structuur. Heeft behoefte aan differentiaaldiagnostiek.
---
## 3. Kernfunctionaliteiten (MVP-scope)
### 3.1 ICD-10 Classificatie Browser
1. **Doorzoekbare codelijst:** Zoeken op code (F32.1) of beschrijving ("depressieve episode").
2. **Categorie navigatie:** Hiërarchische structuur (Hoofdgroep → Subgroep → Specifieke diagnose).
3. **Snelkeuze:** Veelvoorkomende GGZ-diagnoses (top 30).
### 3.2 Diagnose Registratie
4. **ICD-10 code:** Primaire classificatie (verplicht).
5. **DSM-5 referentie:** Optioneel vrije-tekst veld voor DSM-5 equivalent.
6. **Meervoudige diagnoses:** Hoofd- en nevendiagnoses (primair/secundair markering).
7. **Ernst classificatie:** Licht / Matig / Ernstig.
8. **Status tracking:** Actief / In remissie / Opgelost / Ingevoerd-in-fout.
9. **Notities veld:** Onderbouwing en klinische redenering.
### 3.3 AI-ondersteuning
10. **Diagnose-suggesties:** Op basis van intake-notities en anamnese (ICD-10 codes).
11. **Differentiaal-helper:** Toon vergelijkbare diagnoses met onderscheidende kenmerken.
### 3.4 Integratie
12. **Behandelplan-koppeling:** Diagnoses beschikbaar als input voor behandelplan.
13. **Overdracht-integratie:** Actieve diagnoses in overdrachtsamenvatting.
### *(Stretch / Post-prototype)*
- Volledige DSM-5 integratie (na licentie-afsluiting)
- Diagnose-overzicht patiënt-breed
- Versiebeheer diagnoses
---
## 4. Gebruikersflows (MVP-flows)
### Flow 1: Diagnose toevoegen via zoeken
```
1. Psycholoog opent Diagnose tab binnen intake
2. Klikt [+ Nieuwe diagnose]
3. Zoekt op "depressie" of "F32"
4. Selecteert "F32.1 - Depressieve stoornis, matig"
5. Vult ernst in (dropdown: matig)
6. Markeert als hoofddiagnose (toggle)
7. Voegt optionele notitie toe
8. Klikt [Opslaan]
```
### Flow 2: AI-suggestie gebruiken
```
1. Psycholoog opent Diagnose tab
2. Klikt [AI Analyseer intake]
3. Systeem analyseert intake-notities + anamnese
4. Toont 2-4 diagnose-suggesties met confidence score
5. Per suggestie: onderbouwing uit intake-tekst (citaten)
6. Psycholoog selecteert relevante suggestie(s)
7. Past aan indien nodig → [Opslaan]
```
### Flow 3: Differentiaaldiagnose bekijken
```
1. Bij geselecteerde diagnose (bijv. F41.1 GAD)
2. Klikt [Differentiaal bekijken]
3. Toont gerelateerde diagnoses:
- F41.0 Paniekstoornis
- F43.1 PTSS
- F32.1 Depressie met angstkenmerken
4. Per alternatief: kernverschillen uitgelicht
5. Psycholoog bevestigt of past diagnose aan
```
### Flow 4: Diagnose-overzicht raadplegen
```
1. Psycholoog opent Patiënt dossier
2. Navigeert naar Diagnoses sectie (L2 menu)
3. Ziet overzicht alle diagnoses (actief + historisch)
4. Filter op status: Actief / In remissie / Alle
5. Klik op diagnose → details + gekoppelde intake
```
---
## 5. Niet in Scope (Prototype)
| Feature | Reden |
|---------|-------|
| Volledige DSM-5 classificatie | Licentie vereist (Boom uitgevers) |
| DSM-5 criteria/checklists | Licentiegebonden content |
| Multi-disciplinaire validatie workflow | Complexiteit, geen meerwaarde voor demo |
| Automatische DBC/ZPM-koppeling | Vereist externe integraties |
| Medicatie-diagnose interacties | Buiten scope |
| ICD-11 ondersteuning | Nog niet gangbaar in NL GGZ |
---
## 6. Succescriteria
| Criterium | Meetbaar doel |
|-----------|---------------|
| Code-zoekfunctie | < 3 klikken van zoeken naar selectie |
| AI-suggesties | Relevante suggestie in top-3 bij 80% van intakes |
| Differentiaal | Toon minimaal 2 relevante alternatieven |
| Demo-flow | Volledige diagnose-registratie in < 60 seconden |
| Data-integriteit | Diagnoses correct gekoppeld aan intake + patiënt |
| Overdracht | Actieve diagnoses automatisch in AI-samenvatting |
---
## 7. Risico's & Mitigatie
| Risico | Impact | Mitigatie |
|--------|--------|-----------|
| AI-suggesties onjuist/onvolledig | Hoog | Duidelijke disclaimer, suggesties als hulpmiddel niet als diagnose |
| ICD-10 minder bekend bij GGZ | Laag | Codes zijn identiek aan DSM-5, alleen beschrijvingen verschillen |
| Complexe classificatie UI | Middel | Focus op top 30 GGZ-diagnoses, rest via zoeken |
| Performance bij grote codelijst | Laag | Client-side filtering + debounce |
---
## 8. Roadmap / Vervolg (Post-Prototype)
### Fase 2: DSM-5 Licentie
- Licentie afsluiten met Boom uitgevers
- DSM-5 beschrijvingen en criteria toevoegen
- Volledige DSM-5/ICD-10 mapping
### Fase 3: Verdieping
- Diagnose-overzicht patiënt-breed (over intakes)
- Versiebeheer diagnoses (audittrail)
- Comorbiditeit-visualisatie
### Fase 4: Compliance
- DBC/ZPM declaratie-integratie
- NEN 7510 logging
- Multi-disciplinaire validatie workflow (MDO)
---
## 9. Bijlagen & Referenties
### Bestaande documentatie
- FO Screening & Intake v1.0 (`docs/specs/screening-intake/fo-screening-intake-v1_0.md`)
- TO Mini EPD v1.2 (`docs/specs/to-mini-ecd-v1_2.md`)
- UX Stylesheet (`docs/specs/ux-stylesheet.md`)
- PRD AI Prefill Behandelplan (`docs/specs/ai-integratie/prd-ai-prefill-behandelplan-v1.md`)
### Bestaande implementatie
- `app/epd/patients/[id]/intakes/[intakeId]/diagnosis/` - Huidige diagnose-tab
- `lib/supabase/database.types.ts` - `conditions` tabel definitie
- `app/epd/patients/[id]/intakes/[intakeId]/actions.ts` - CRUD operaties
### Externe referenties
- [WHO-FIC Nederland - ICD-10/DSM-5 mapping](https://www.whofic.nl/dsm-5icd-10)
- [Zorgprestatiemodel - DSM-5 codelijst](https://www.zorgprestatiemodel.nl/aan-de-slag/downloads/)
- [ICD-10-GM codelijst](https://www.dimdi.de/dynamic/de/klassifikationen/icd/icd-10-gm/) (publiek domein)
- FHIR Condition Resource (R4)
---
## 10. Technische Uitgangspunten
### Database (bestaande `conditions` tabel)
```typescript
// Huidige velden (FHIR-inspired)
- code_code: string // ICD-10 code (bijv. "F32.1")
- code_display: string // ICD-10 beschrijving
- code_system: string // 'ICD-10' (primair voor prototype)
- clinical_status: enum // active | remission | resolved
- verification_status: enum // provisional | confirmed | entered-in-error
- severity_code: string
- severity_display: string
- onset_datetime: timestamp
- recorded_date: timestamp
- note: text
- patient_id: uuid
- encounter_id: uuid // Gekoppelde intake
```
### Nieuwe velden (Prototype)
```typescript
- is_primary: boolean // Hoofd- vs nevendiagnose
- dsm5_reference: text // Optioneel: DSM-5 equivalent (vrije tekst)
```
### Statische data (JSON)
```typescript
// lib/data/icd10-ggz-codes.json
// Top ~50 GGZ-relevante ICD-10 codes met Nederlandse beschrijvingen
[
{ "code": "F32.0", "display": "Lichte depressieve episode", "category": "Depressie" },
{ "code": "F32.1", "display": "Matige depressieve episode", "category": "Depressie" },
...
]
```
### API Endpoints
```
GET /api/diagnose/codes?q={search} // Zoek ICD-10 codes (client-side fallback)
POST /api/diagnose/suggest // AI suggesties (optioneel)
```
---
## 11. ICD-10 GGZ Code Subset (Prototype)
Focus op meest voorkomende GGZ-diagnoses (~50 codes). De ICD-10 codes zijn identiek aan DSM-5, alleen de beschrijvingen komen uit de WHO-classificatie (publiek domein).
| Categorie | ICD-10 Codes | Voorbeelden |
|-----------|--------------|-------------|
| **Depressieve stoornissen** | F32.x, F33.x | Depressieve episode (licht/matig/ernstig) |
| **Angststoornissen** | F40.x, F41.x | Sociale fobie, GAD, Paniekstoornis |
| **Trauma/stress** | F43.x | PTSS, Aanpassingsstoornis |
| **OCD** | F42.x | Obsessief-compulsieve stoornis |
| **Persoonlijkheid** | F60.x | Borderline, Antisociaal, Vermijdend |
| **Bipolair** | F31.x | Bipolaire stoornis |
| **ADHD** | F90.x | Aandachtstekortstoornis |
| **Autisme** | F84.x | Autismespectrumstoornis |
| **Eetstoornissen** | F50.x | Anorexia, Boulimia |
> **Opmerking:** Voor productie kunnen de volledige DSM-5 beschrijvingen worden toegevoegd na licentie-afsluiting.
---
## 12. AI Prompt Strategie (Optioneel)
### Diagnose-suggestie prompt
```
Je bent een klinisch ondersteuningssysteem voor GGZ-professionals.
CONTEXT:
- Intake notities: {intakeContent}
- Anamnese: {anamneseContent}
OPDRACHT:
Analyseer de informatie en geef maximaal 3 diagnose-suggesties met ICD-10 codes.
Per suggestie:
1. ICD-10 code en beschrijving
2. Onderbouwing met citaten uit de intake
3. Ernst-indicatie (licht/matig/ernstig)
BELANGRIJK:
- Dit zijn SUGGESTIES ter ondersteuning, geen diagnoses
- De clinicus neemt altijd de eindbeslissing
- Gebruik alleen ICD-10 codes uit de GGZ-subset (F-codes)
```
> **Prototype scope:** AI-suggesties zijn optioneel voor de eerste versie. Focus eerst op de handmatige invoer-flow.
---
*Document laatst bijgewerkt: 11-12-2024*

View File

@@ -0,0 +1,467 @@
# ⚙️ Technisch Ontwerp (TO) Diagnose Module
**Projectnaam:** Diagnose Module - Mini EPD
**Versie:** v1.0
**Datum:** 11-12-2024
**Auteur:** Colin Lit
---
## 1. Doel en relatie met PRD en FO
🎯 **Doel van dit document:**
Het Technisch Ontwerp beschrijft **hoe** de diagnosemodule technisch wordt geïmplementeerd. Dit document vertaalt het FO naar concrete code-structuren, database-wijzigingen en componenten.
📘 **Toelichting:**
De diagnosemodule bouwt voort op de bestaande `conditions` tabel (FHIR-inspired) en breidt de huidige basic UI uit met een doorzoekbare ICD-10 codelijst en verbeterde UX.
**Relatie met documenten:**
- PRD: `docs/specs/diagnose/prd-diagnose-module-v1.md` (v1.1)
- FO: `docs/specs/diagnose/fo-diagnose-module-v1.md` (v1.1)
---
## 2. Technische Architectuur Overzicht
```
┌─────────────────────────────────────────────────────────────────┐
│ Frontend (Next.js) │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────┐ │
│ │ DiagnosisTab │───▶│ DiagnosisModal │───▶│ ICD10Search │ │
│ │ (page.tsx) │ │ (Dialog) │ │ (Combobox) │ │
│ └─────────────────┘ └─────────────────┘ └─────────────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Server Actions (actions.ts) │ │
│ │ • createDiagnosis() • updateDiagnosis() • deleteDiagnosis() │
│ └─────────────────────────────────────────────────────────┘ │
│ │ │
└──────────────────────────────┼──────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ Supabase (PostgreSQL) │
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │
│ │ patients │◀──▶│ conditions │◀──▶│ intakes │ │
│ └─────────────┘ └─────────────┘ └─────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ Statische Data (JSON) │
│ │
│ lib/data/icd10-ggz-codes.json (~50 codes, client-side) │
│ │
└─────────────────────────────────────────────────────────────────┘
```
---
## 3. Dependency Analyse
### Bestaande dependencies (reeds aanwezig)
| Dependency | Versie | Gebruik |
|------------|--------|---------|
| `@radix-ui/react-dialog` | ^1.1.15 | Modal voor diagnose invoer |
| `@radix-ui/react-select` | ^2.2.6 | Dropdowns (ernst, status) |
| `lucide-react` | ^0.553.0 | Icons (Search, Plus, Trash) |
| `date-fns` | ^4.1.0 | Datum formatting |
| `zod` | ^4.1.12 | Input validatie |
| `react-hook-form` | ^7.66.1 | Form handling |
| `@hookform/resolvers` | ^5.2.2 | Zod resolver |
### Nieuwe dependency (toe te voegen)
| Dependency | Versie | Gebruik | Alternatief |
|------------|--------|---------|-------------|
| `cmdk` | ^1.0.0 | Autocomplete/Combobox voor ICD-10 zoeken | Custom met Radix Popover |
**Aanbeveling:** Voeg `cmdk` toe voor de autocomplete functionaliteit. Dit is de standaard voor shadcn/ui Command component.
```bash
pnpm add cmdk
```
**Alternatief:** Bouw custom autocomplete met bestaande `@radix-ui/react-popover` + input. Minder features maar geen extra dependency.
---
## 4. Database Schema
### Bestaande `conditions` tabel (geen wijzigingen nodig)
De huidige `conditions` tabel is al FHIR-compliant en bevat alle benodigde velden:
```sql
-- Bestaande tabel (lib/supabase/database.types.ts:291-369)
conditions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
patient_id UUID NOT NULL REFERENCES patients(id),
encounter_id UUID REFERENCES intakes(id), -- Gekoppelde intake
-- ICD-10/DSM-5 classificatie
code_code TEXT NOT NULL, -- "F32.1"
code_display TEXT NOT NULL, -- "Matige depressieve episode"
code_system TEXT DEFAULT 'ICD-10', -- "ICD-10" of "DSM-5"
-- Status (FHIR enums)
clinical_status condition_clinical_status DEFAULT 'active',
-- active | recurrence | relapse | inactive | remission | resolved
verification_status condition_verification_status DEFAULT 'confirmed',
-- unconfirmed | provisional | differential | confirmed | refuted | entered-in-error
-- Ernst
severity_code TEXT, -- "mild" | "moderate" | "severe"
severity_display TEXT, -- "Licht" | "Matig" | "Ernstig"
-- Timing
onset_datetime TIMESTAMPTZ,
recorded_date DATE DEFAULT CURRENT_DATE,
-- Extra
note TEXT, -- Onderbouwing
category TEXT DEFAULT 'encounter-diagnosis',
-- Metadata
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
```
### Optionele schema-uitbreiding (later)
Voor de toekomst kunnen deze velden worden toegevoegd via migratie:
```sql
-- Optioneel: nieuwe velden voor uitgebreide functionaliteit
ALTER TABLE conditions ADD COLUMN IF NOT EXISTS is_primary BOOLEAN DEFAULT false;
ALTER TABLE conditions ADD COLUMN IF NOT EXISTS dsm5_reference TEXT;
```
**Besluit:** Voor het prototype gebruiken we de bestaande velden. `is_primary` kan worden afgeleid uit volgorde of in `note` worden vastgelegd.
---
## 5. Statische ICD-10 Data
### Bestandslocatie
```
lib/data/icd10-ggz-codes.json
```
### Data structuur
```typescript
// lib/types/icd10.ts
export interface ICD10Code {
code: string; // "F32.1"
display: string; // "Matige depressieve episode"
category: string; // "Depressie"
keywords?: string[]; // ["depressief", "somber", "neerslachtig"]
}
export interface ICD10Category {
name: string;
codes: ICD10Code[];
}
```
### Voorbeeld data (~50 codes)
```json
{
"version": "ICD-10-GM 2024",
"source": "WHO (publiek domein)",
"categories": [
{
"name": "Depressieve stoornissen",
"codes": [
{ "code": "F32.0", "display": "Lichte depressieve episode", "keywords": ["depressie", "licht"] },
{ "code": "F32.1", "display": "Matige depressieve episode", "keywords": ["depressie", "matig"] },
{ "code": "F32.2", "display": "Ernstige depressieve episode zonder psychotische kenmerken", "keywords": ["depressie", "ernstig"] },
{ "code": "F32.3", "display": "Ernstige depressieve episode met psychotische kenmerken", "keywords": ["depressie", "psychose"] },
{ "code": "F33.0", "display": "Recidiverende depressieve stoornis, huidige episode licht", "keywords": ["recidiverend"] },
{ "code": "F33.1", "display": "Recidiverende depressieve stoornis, huidige episode matig", "keywords": ["recidiverend"] },
{ "code": "F33.2", "display": "Recidiverende depressieve stoornis, huidige episode ernstig", "keywords": ["recidiverend"] }
]
},
{
"name": "Angststoornissen",
"codes": [
{ "code": "F40.0", "display": "Agorafobie", "keywords": ["angst", "plein", "open ruimte"] },
{ "code": "F40.1", "display": "Sociale fobie", "keywords": ["sociaal", "angst", "vermijding"] },
{ "code": "F40.2", "display": "Specifieke fobie", "keywords": ["fobie", "specifiek"] },
{ "code": "F41.0", "display": "Paniekstoornis", "keywords": ["paniek", "aanval"] },
{ "code": "F41.1", "display": "Gegeneraliseerde angststoornis", "keywords": ["gad", "piekeren", "angst"] },
{ "code": "F41.2", "display": "Gemengde angststoornis en depressieve stoornis", "keywords": ["gemengd"] }
]
},
{
"name": "Trauma- en stressorgerelateerde stoornissen",
"codes": [
{ "code": "F43.0", "display": "Acute stressreactie", "keywords": ["stress", "acuut"] },
{ "code": "F43.1", "display": "Posttraumatische stressstoornis", "keywords": ["ptss", "trauma"] },
{ "code": "F43.2", "display": "Aanpassingsstoornis", "keywords": ["aanpassing", "stress"] }
]
}
]
}
```
---
## 6. Component Structuur
### Bestandsstructuur
```
app/epd/patients/[id]/intakes/[intakeId]/diagnosis/
├── page.tsx # Server component (bestaand)
└── components/
├── diagnosis-manager.tsx # Refactor: lijst + modal trigger
├── diagnosis-card.tsx # Nieuwe: diagnose weergave card
├── diagnosis-modal.tsx # Nieuwe: invoer/bewerk modal
└── icd10-combobox.tsx # Nieuwe: autocomplete zoeken
lib/data/
└── icd10-ggz-codes.json # Statische codelijst
lib/types/
└── icd10.ts # TypeScript types
```
### Component specificaties
#### 1. `DiagnosisModal` (nieuw)
```typescript
// components/diagnosis-modal.tsx
interface DiagnosisModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
patientId: string;
intakeId: string;
diagnosis?: Condition; // undefined = nieuw, anders = bewerk
}
// Features:
// - Dialog wrapper (Radix)
// - Form met react-hook-form + zod
// - ICD10Combobox voor code selectie
// - Dropdowns voor ernst/status
// - Textarea voor onderbouwing
```
#### 2. `ICD10Combobox` (nieuw)
```typescript
// components/icd10-combobox.tsx
interface ICD10ComboboxProps {
value: string;
onSelect: (code: ICD10Code) => void;
placeholder?: string;
}
// Features:
// - cmdk Command component
// - Client-side filtering op code + display + keywords
// - Debounce 200ms
// - Max 8 resultaten
// - Snelkeuze bij leeg veld (top 5)
```
#### 3. `DiagnosisCard` (nieuw)
```typescript
// components/diagnosis-card.tsx
interface DiagnosisCardProps {
diagnosis: Condition;
isPrimary?: boolean;
onEdit: () => void;
onDelete: () => void;
}
// Features:
// - Code + beschrijving
// - Ernst + status badges
// - Datum
// - Expand/collapse voor onderbouwing
// - Context menu (bewerk, verwijder)
```
---
## 7. Server Actions Refactor
### Huidige actions (aanpassen)
```typescript
// app/epd/patients/[id]/intakes/[intakeId]/actions.ts
// Bestaand - werkt, kleine aanpassing code_system
export async function createDiagnosis(payload: DiagnosisPayload) {
const supabase = await getSupabase();
const { error } = await supabase.from('conditions').insert({
patient_id: payload.patientId,
encounter_id: payload.intakeId,
code_code: payload.code,
code_display: payload.description,
code_system: 'ICD-10', // Wijzig van 'DSM-5' naar 'ICD-10'
clinical_status: payload.status || 'active',
severity_display: payload.severity || null,
note: payload.notes,
recorded_date: new Date().toISOString(),
});
// ...
}
// Nieuw - update functie
export async function updateDiagnosis(
diagnosisId: string,
payload: Partial<DiagnosisPayload>
) {
const supabase = await getSupabase();
const { error } = await supabase
.from('conditions')
.update({
code_code: payload.code,
code_display: payload.description,
clinical_status: payload.status,
severity_display: payload.severity,
note: payload.notes,
updated_at: new Date().toISOString(),
})
.eq('id', diagnosisId);
// ...
}
```
### Uitgebreide payload type
```typescript
export interface DiagnosisPayload {
patientId: string;
intakeId: string;
code: string;
description: string;
severity?: 'licht' | 'matig' | 'ernstig';
status?: 'active' | 'remission' | 'resolved' | 'entered-in-error';
notes?: string;
dsm5Reference?: string; // Optioneel vrije tekst veld
}
```
---
## 8. Zod Validatie Schema
```typescript
// lib/schemas/diagnosis.ts
import { z } from 'zod';
export const diagnosisSchema = z.object({
code: z.string()
.min(1, 'ICD-10 code is verplicht')
.regex(/^F\d{2}(\.\d{1,2})?$/, 'Ongeldige ICD-10 code'),
description: z.string()
.min(1, 'Beschrijving is verplicht')
.max(200, 'Beschrijving mag maximaal 200 tekens zijn'),
severity: z.enum(['licht', 'matig', 'ernstig']),
status: z.enum(['active', 'remission', 'resolved', 'entered-in-error'])
.default('active'),
notes: z.string()
.max(500, 'Onderbouwing mag maximaal 500 tekens zijn')
.optional(),
dsm5Reference: z.string()
.max(100, 'DSM-5 referentie mag maximaal 100 tekens zijn')
.optional(),
});
export type DiagnosisFormData = z.infer<typeof diagnosisSchema>;
```
---
## 9. Implementatie Stappenplan
### Fase 1: Data & Types (1-2 uur)
1. Creëer `lib/data/icd10-ggz-codes.json` met ~50 codes
2. Creëer `lib/types/icd10.ts` met TypeScript types
3. Creëer `lib/schemas/diagnosis.ts` met Zod schema
### Fase 2: Componenten (3-4 uur)
4. Installeer `cmdk` dependency
5. Creëer `ICD10Combobox` component
6. Creëer `DiagnosisModal` component
7. Creëer `DiagnosisCard` component
### Fase 3: Integratie (2 uur)
8. Refactor `diagnosis-manager.tsx` naar nieuwe componenten
9. Update server actions (ICD-10, update functie)
10. Test flows: toevoegen, bewerken, verwijderen
### Fase 4: Polish (1 uur)
11. Lege staten en error handling
12. Loading states
13. Toast feedback
---
## 10. Testen
### Handmatige test scenario's
| # | Scenario | Verwacht resultaat |
|---|----------|-------------------|
| 1 | Zoek "depressie" | Toont F32.x codes |
| 2 | Zoek "F41" | Toont angststoornissen |
| 3 | Selecteer code | Vult beschrijving automatisch |
| 4 | Opslaan zonder code | Validatiefout |
| 5 | Bewerk bestaande | Modal met voorgevulde waarden |
| 6 | Verwijder diagnose | Bevestiging + toast |
| 7 | Leeg veld | Toont top 5 snelkeuze |
### Build verificatie
```bash
pnpm build # Moet slagen zonder type errors
```
---
## 11. Risico's & Mitigatie
| Risico | Impact | Mitigatie |
|--------|--------|-----------|
| cmdk bundle size | Laag | ~10KB gzipped, acceptabel |
| ICD-10 codes incompleet | Laag | Start met 50, uitbreiden op aanvraag |
| Performance filtering | Laag | 50 codes is instant client-side |
| Bestaande data breekt | Middel | Backward compatible, DSM-5 data blijft werken |
---
## 12. Bijlagen & Referenties
### Projectdocumenten
- PRD: `docs/specs/diagnose/prd-diagnose-module-v1.md`
- FO: `docs/specs/diagnose/fo-diagnose-module-v1.md`
### Bestaande code
- `app/epd/patients/[id]/intakes/[intakeId]/diagnosis/` — Huidige implementatie
- `app/epd/patients/[id]/intakes/[intakeId]/actions.ts` — Server actions
- `lib/supabase/database.types.ts` — Database types
### Externe referenties
- [cmdk documentation](https://cmdk.paco.me/)
- [shadcn/ui Command](https://ui.shadcn.com/docs/components/command)
- [ICD-10-GM 2024](https://www.dimdi.de/dynamic/de/klassifikationen/icd/icd-10-gm/)
---
*Document laatst bijgewerkt: 11-12-2024*

View File

@@ -0,0 +1,195 @@
# Diagnose Module - Implementatieplan
## Overzicht
Implementatie van een verbeterde diagnosemodule voor het Mini-EPD prototype met:
- ICD-10 classificatie (publiek domein, geen licentie nodig)
- Autocomplete zoekfunctionaliteit
- Consistente UX/UI volgens bestaande EPD patterns
## Documentatie Status ✅
- [x] PRD v1.1 - `docs/specs/diagnose/prd-diagnose-module-v1.md`
- [x] FO v1.1 - `docs/specs/diagnose/fo-diagnose-module-v1.md`
- [x] TO v1.0 - `docs/specs/diagnose/to-diagnose-module-v1.md`
- [x] UX/UI Specificaties (zie hieronder)
---
## UX/UI Design Beslissingen
### Kleurenschema (conform ux-stylesheet.md)
| Element | Kleur | Hex |
|---------|-------|-----|
| Primary action (knoppen) | Teal | `#0D9488` (teal-600) |
| Borders | Slate | `#E2E8F0` (slate-200) |
| Text primary | Slate | `#0F172A` (slate-900) |
| Text secondary | Slate | `#64748B` (slate-500) |
| Error | Red | `#DC2626` |
| Success toast | Green | `#16A34A` |
### Badge Kleuren (Ernst/Status)
| Type | Achtergrond | Tekst |
|------|-------------|-------|
| Ernst: Licht | `#E5E7EB` | `#374151` |
| Ernst: Matig | `#FEF3C7` | `#92400E` |
| Ernst: Ernstig | `#FEE2E2` | `#991B1B` |
| Status: Actief | `#ECFDF5` | `#16A34A` |
| Status: In remissie | `#EFF6FF` | `#3B82F6` |
| Status: Opgelost | `#F1F5F9` | `#64748B` |
| HOOFD badge | `#0D9488` | `#FFFFFF` |
### Component Patterns (hergebruik bestaande)
- **Card layout**: Zoals `anamnese-manager.tsx` en `risk-manager.tsx`
- **Modal/Dialog**: Zoals `appointment-modal.tsx`
- **Form styling**: Bestaande input/select classes
- **Empty state**: Zoals `intake-list.tsx`
---
## Implementatie Stappenplan
### Epic 1: Data & Types (~1 uur)
**Story 1.1: ICD-10 Codelijst JSON**
```
lib/data/icd10-ggz-codes.json
```
- ~50 meest voorkomende GGZ codes
- Categorieën: Depressie, Angst, Trauma, OCD, Persoonlijkheid, etc.
- Velden: code, display, category, keywords
**Story 1.2: TypeScript Types**
```
lib/types/icd10.ts
```
- ICD10Code interface
- ICD10Category interface
**Story 1.3: Zod Validatie Schema**
```
lib/schemas/diagnosis.ts
```
- diagnosisSchema met code, severity, status validatie
---
### Epic 2: UI Componenten (~3-4 uur)
**Story 2.1: Installeer cmdk**
```bash
pnpm add cmdk
```
**Story 2.2: ICD10Combobox Component**
```
app/epd/patients/[id]/intakes/[intakeId]/diagnosis/components/icd10-combobox.tsx
```
- Autocomplete met cmdk
- Client-side filtering
- Snelkeuze bij leeg veld
- Keyboard navigatie
**Story 2.3: DiagnosisCard Component**
```
app/epd/patients/[id]/intakes/[intakeId]/diagnosis/components/diagnosis-card.tsx
```
- Code + beschrijving header
- Ernst/Status/Datum badges
- HOOFD badge voor hoofddiagnose
- Expandable onderbouwing
- Bewerk/Verwijder acties
**Story 2.4: DiagnosisModal Component**
```
app/epd/patients/[id]/intakes/[intakeId]/diagnosis/components/diagnosis-modal.tsx
```
- Dialog wrapper
- ICD10Combobox integratie
- Ernst dropdown (Licht/Matig/Ernstig)
- Status dropdown (Actief/In remissie/Opgelost)
- Type radio (Hoofd/Nevendiagnose)
- DSM-5 referentie tekstveld (optioneel)
- Onderbouwing textarea
- Validatie met zod
---
### Epic 3: Integratie (~2 uur)
**Story 3.1: Refactor DiagnosisManager**
```
app/epd/patients/[id]/intakes/[intakeId]/diagnosis/components/diagnosis-manager.tsx
```
- Vervang inline form door modal trigger
- Integreer DiagnosisCard voor lijst
- Lege staat met dashed border
- + Nieuwe diagnose knop
**Story 3.2: Update Server Actions**
```
app/epd/patients/[id]/intakes/[intakeId]/actions.ts
```
- Wijzig code_system naar 'ICD-10'
- Voeg updateDiagnosis() functie toe
- Uitbreid DiagnosisPayload type
**Story 3.3: Toast Integratie**
- Succes toast bij opslaan
- Succes toast bij verwijderen
- Error handling
---
### Epic 4: Polish (~1 uur)
**Story 4.1: Empty State**
- Dashed border container
- Icon + tekst
- Prominente CTA knop
**Story 4.2: Loading States**
- Button spinner bij opslaan
- Disabled state tijdens transitie
**Story 4.3: Validatie Feedback**
- Inline errors onder velden
- Focus op eerste error veld
---
## Kritieke Bestanden
### Te wijzigen
- `app/epd/patients/[id]/intakes/[intakeId]/diagnosis/components/diagnosis-manager.tsx`
- `app/epd/patients/[id]/intakes/[intakeId]/actions.ts`
### Nieuw aan te maken
- `lib/data/icd10-ggz-codes.json`
- `lib/types/icd10.ts`
- `lib/schemas/diagnosis.ts`
- `app/epd/.../diagnosis/components/icd10-combobox.tsx`
- `app/epd/.../diagnosis/components/diagnosis-card.tsx`
- `app/epd/.../diagnosis/components/diagnosis-modal.tsx`
### Dependencies
- `cmdk` (toe te voegen)
---
## Acceptatiecriteria
1. **Zoekfunctie**: Zoeken op "depressie" toont F32.x codes binnen 200ms
2. **Code selectie**: Selecteren vult beschrijving automatisch in
3. **Validatie**: Opslaan zonder code toont inline error
4. **CRUD**: Toevoegen, bewerken, verwijderen werkt correct
5. **Badges**: Ernst en status tonen correcte kleuren
6. **Hoofddiagnose**: Maximaal 1 per intake, duidelijk gemarkeerd
7. **Build**: `pnpm build` slaagt zonder errors
---
## Geschatte Doorlooptijd
- Epic 1: 1 uur
- Epic 2: 3-4 uur
- Epic 3: 2 uur
- Epic 4: 1 uur
- **Totaal: ~7-8 uur**

View File

@@ -0,0 +1,134 @@
{
"version": "ICD-10-GM 2024",
"source": "WHO (publiek domein)",
"description": "GGZ-relevante ICD-10 codes voor Mini EPD prototype",
"categories": [
{
"name": "Depressieve stoornissen",
"codes": [
{ "code": "F32.0", "display": "Lichte depressieve episode", "keywords": ["depressie", "licht", "somber"] },
{ "code": "F32.1", "display": "Matige depressieve episode", "keywords": ["depressie", "matig", "somber"] },
{ "code": "F32.2", "display": "Ernstige depressieve episode zonder psychotische kenmerken", "keywords": ["depressie", "ernstig", "somber"] },
{ "code": "F32.3", "display": "Ernstige depressieve episode met psychotische kenmerken", "keywords": ["depressie", "ernstig", "psychose", "waan"] },
{ "code": "F33.0", "display": "Recidiverende depressieve stoornis, huidige episode licht", "keywords": ["depressie", "recidiverend", "terugkerend"] },
{ "code": "F33.1", "display": "Recidiverende depressieve stoornis, huidige episode matig", "keywords": ["depressie", "recidiverend", "terugkerend"] },
{ "code": "F33.2", "display": "Recidiverende depressieve stoornis, huidige episode ernstig", "keywords": ["depressie", "recidiverend", "terugkerend", "ernstig"] }
]
},
{
"name": "Angststoornissen",
"codes": [
{ "code": "F40.0", "display": "Agorafobie", "keywords": ["angst", "plein", "open ruimte", "fobie"] },
{ "code": "F40.1", "display": "Sociale fobie", "keywords": ["sociaal", "angst", "vermijding", "fobie"] },
{ "code": "F40.2", "display": "Specifieke fobie", "keywords": ["fobie", "specifiek", "angst"] },
{ "code": "F41.0", "display": "Paniekstoornis", "keywords": ["paniek", "aanval", "angst", "hartkloppingen"] },
{ "code": "F41.1", "display": "Gegeneraliseerde angststoornis", "keywords": ["gad", "piekeren", "angst", "zorgen"] },
{ "code": "F41.2", "display": "Gemengde angststoornis en depressieve stoornis", "keywords": ["angst", "depressie", "gemengd"] },
{ "code": "F41.3", "display": "Andere gemengde angststoornissen", "keywords": ["angst", "gemengd"] },
{ "code": "F41.9", "display": "Angststoornis, niet gespecificeerd", "keywords": ["angst", "nos"] }
]
},
{
"name": "Trauma- en stressorgerelateerde stoornissen",
"codes": [
{ "code": "F43.0", "display": "Acute stressreactie", "keywords": ["stress", "acuut", "trauma"] },
{ "code": "F43.1", "display": "Posttraumatische stressstoornis", "keywords": ["ptss", "trauma", "herbeleven", "nachtmerries"] },
{ "code": "F43.2", "display": "Aanpassingsstoornis", "keywords": ["aanpassing", "stress", "life event"] },
{ "code": "F43.8", "display": "Andere reacties op ernstige stress", "keywords": ["stress", "trauma"] }
]
},
{
"name": "Obsessief-compulsieve stoornissen",
"codes": [
{ "code": "F42.0", "display": "Overwegend obsessieve gedachten of ruminaties", "keywords": ["ocd", "obsessie", "dwang", "gedachten"] },
{ "code": "F42.1", "display": "Overwegend compulsieve handelingen", "keywords": ["ocd", "compulsie", "dwang", "rituelen"] },
{ "code": "F42.2", "display": "Gemengde obsessieve gedachten en handelingen", "keywords": ["ocd", "obsessie", "compulsie", "dwang"] }
]
},
{
"name": "Bipolaire stoornissen",
"codes": [
{ "code": "F31.0", "display": "Bipolaire stoornis, huidige episode hypomaan", "keywords": ["bipolair", "hypomanie", "manisch"] },
{ "code": "F31.1", "display": "Bipolaire stoornis, huidige episode manisch zonder psychose", "keywords": ["bipolair", "manie", "manisch"] },
{ "code": "F31.3", "display": "Bipolaire stoornis, huidige episode licht of matig depressief", "keywords": ["bipolair", "depressie"] },
{ "code": "F31.4", "display": "Bipolaire stoornis, huidige episode ernstig depressief", "keywords": ["bipolair", "depressie", "ernstig"] }
]
},
{
"name": "Persoonlijkheidsstoornissen",
"codes": [
{ "code": "F60.0", "display": "Paranoïde persoonlijkheidsstoornis", "keywords": ["persoonlijkheid", "paranoide", "wantrouwen"] },
{ "code": "F60.1", "display": "Schizoïde persoonlijkheidsstoornis", "keywords": ["persoonlijkheid", "schizoid", "teruggetrokken"] },
{ "code": "F60.2", "display": "Antisociale persoonlijkheidsstoornis", "keywords": ["persoonlijkheid", "antisociaal", "psychopathie"] },
{ "code": "F60.3", "display": "Borderline persoonlijkheidsstoornis", "keywords": ["persoonlijkheid", "borderline", "emotieregulatie", "instabiel"] },
{ "code": "F60.4", "display": "Theatrale persoonlijkheidsstoornis", "keywords": ["persoonlijkheid", "theatraal", "histrionisch"] },
{ "code": "F60.6", "display": "Vermijdende persoonlijkheidsstoornis", "keywords": ["persoonlijkheid", "vermijdend", "angst", "afwijzing"] },
{ "code": "F60.7", "display": "Afhankelijke persoonlijkheidsstoornis", "keywords": ["persoonlijkheid", "afhankelijk", "dependent"] }
]
},
{
"name": "ADHD en gedragsstoornissen",
"codes": [
{ "code": "F90.0", "display": "Aandachtstekortstoornis met hyperactiviteit", "keywords": ["adhd", "add", "concentratie", "hyperactief"] },
{ "code": "F90.1", "display": "Hyperkinetische gedragsstoornis", "keywords": ["adhd", "gedrag", "hyperactief"] }
]
},
{
"name": "Autismespectrumstoornissen",
"codes": [
{ "code": "F84.0", "display": "Autisme", "keywords": ["autisme", "ass", "spectrum"] },
{ "code": "F84.5", "display": "Syndroom van Asperger", "keywords": ["asperger", "autisme", "ass", "spectrum"] }
]
},
{
"name": "Eetstoornissen",
"codes": [
{ "code": "F50.0", "display": "Anorexia nervosa", "keywords": ["eetstoornis", "anorexia", "gewicht", "eten"] },
{ "code": "F50.2", "display": "Boulimia nervosa", "keywords": ["eetstoornis", "boulimia", "eetbuien", "braken"] },
{ "code": "F50.9", "display": "Eetstoornis, niet gespecificeerd", "keywords": ["eetstoornis", "nos"] }
]
},
{
"name": "Schizofrenie en psychotische stoornissen",
"codes": [
{ "code": "F20.0", "display": "Paranoïde schizofrenie", "keywords": ["schizofrenie", "psychose", "waan", "hallucinatie"] },
{ "code": "F20.9", "display": "Schizofrenie, niet gespecificeerd", "keywords": ["schizofrenie", "psychose", "nos"] },
{ "code": "F23.0", "display": "Acute polymorf psychotische stoornis zonder symptomen van schizofrenie", "keywords": ["psychose", "acuut"] }
]
},
{
"name": "Stoornissen door middelengebruik",
"codes": [
{ "code": "F10.1", "display": "Schadelijk gebruik van alcohol", "keywords": ["alcohol", "verslaving", "middelen"] },
{ "code": "F10.2", "display": "Alcoholafhankelijkheid", "keywords": ["alcohol", "verslaving", "afhankelijk"] },
{ "code": "F12.1", "display": "Schadelijk gebruik van cannabis", "keywords": ["cannabis", "wiet", "middelen"] },
{ "code": "F12.2", "display": "Cannabisafhankelijkheid", "keywords": ["cannabis", "wiet", "verslaving"] },
{ "code": "F19.1", "display": "Schadelijk gebruik van meerdere middelen", "keywords": ["middelen", "drugs", "verslaving"] },
{ "code": "F19.2", "display": "Afhankelijkheid van meerdere middelen", "keywords": ["middelen", "drugs", "verslaving", "afhankelijk"] }
]
},
{
"name": "Somatoforme en dissociatieve stoornissen",
"codes": [
{ "code": "F44.0", "display": "Dissociatieve amnesie", "keywords": ["dissociatie", "amnesie", "geheugen"] },
{ "code": "F44.1", "display": "Dissociatieve fugue", "keywords": ["dissociatie", "fugue"] },
{ "code": "F45.0", "display": "Somatisatiestoornis", "keywords": ["somatisch", "lichamelijk", "klachten"] },
{ "code": "F45.2", "display": "Hypochondrische stoornis", "keywords": ["hypochondrie", "ziekte", "angst"] }
]
},
{
"name": "Slaapstoornissen",
"codes": [
{ "code": "F51.0", "display": "Niet-organische insomnie", "keywords": ["slaap", "insomnie", "slapeloosheid"] },
{ "code": "F51.1", "display": "Niet-organische hypersomnie", "keywords": ["slaap", "hypersomnie", "slaperigheid"] }
]
}
],
"frequentCodes": [
"F32.1",
"F41.1",
"F43.1",
"F41.0",
"F60.3"
]
}

89
lib/schemas/diagnosis.ts Normal file
View File

@@ -0,0 +1,89 @@
import { z } from 'zod';
/** Severity options */
export const DIAGNOSIS_SEVERITIES = ['licht', 'matig', 'ernstig'] as const;
/** Diagnosis type options */
export const DIAGNOSIS_TYPES = ['primary', 'secondary'] as const;
/** Clinical status options (moet matchen met database enum condition_clinical_status) */
export const DIAGNOSIS_STATUSES = ['active', 'remission', 'resolved', 'inactive'] as const;
/**
* Zod schema voor diagnose formulier validatie
*/
export const diagnosisSchema = z.object({
/** ICD-10 code (verplicht, format F##.#) */
code: z
.string()
.min(1, 'ICD-10 code is verplicht')
.regex(/^F\d{2}(\.\d{1,2})?$/, 'Ongeldige ICD-10 code (verwacht format: F##.#)'),
/** Beschrijving van de diagnose (verplicht) */
description: z
.string()
.min(1, 'Beschrijving is verplicht')
.max(200, 'Beschrijving mag maximaal 200 tekens zijn'),
/** Ernst classificatie (verplicht) */
severity: z.enum(DIAGNOSIS_SEVERITIES, {
message: 'Selecteer een geldige ernst',
}),
/** Diagnose type: hoofd of nevendiagnose (verplicht) */
diagnosisType: z.enum(DIAGNOSIS_TYPES, {
message: 'Selecteer hoofd- of nevendiagnose',
}),
/** Klinische status (verplicht) */
status: z.enum(DIAGNOSIS_STATUSES, {
message: 'Selecteer een geldige status',
}),
/** DSM-5 referentie (optioneel, vrije tekst) */
dsm5Reference: z
.string()
.max(100, 'DSM-5 referentie mag maximaal 100 tekens zijn')
.optional()
.or(z.literal('')),
/** Onderbouwing / notities (optioneel) */
notes: z
.string()
.max(500, 'Onderbouwing mag maximaal 500 tekens zijn')
.optional()
.or(z.literal('')),
});
/** TypeScript type afgeleid van het schema */
export type DiagnosisFormData = z.infer<typeof diagnosisSchema>;
/** Default waarden voor nieuw diagnose formulier */
export const diagnosisDefaults: DiagnosisFormData = {
code: '',
description: '',
severity: 'matig',
diagnosisType: 'secondary',
status: 'active',
dsm5Reference: '',
notes: '',
};
/**
* Schema voor server-side payload (inclusief patient/intake IDs)
*/
export const diagnosisPayloadSchema = diagnosisSchema.extend({
patientId: z.string().uuid('Ongeldige patient ID'),
intakeId: z.string().uuid('Ongeldige intake ID'),
});
export type DiagnosisPayload = z.infer<typeof diagnosisPayloadSchema>;
/**
* Schema voor update operatie (alle velden optioneel behalve ID)
*/
export const diagnosisUpdateSchema = diagnosisSchema.partial().extend({
id: z.string().uuid('Ongeldige diagnose ID'),
});
export type DiagnosisUpdatePayload = z.infer<typeof diagnosisUpdateSchema>;

View File

@@ -384,3 +384,237 @@ export const GOAL_STATUS_LABELS: Record<GoalStatus, { label: string; color: stri
gehaald: { label: 'Gehaald', color: '#10b981' }, gehaald: { label: 'Gehaald', color: '#10b981' },
bijgesteld: { label: 'Bijgesteld', color: '#f59e0b' }, bijgesteld: { label: 'Bijgesteld', color: '#f59e0b' },
}; };
// =============================================================================
// FLAT BEHANDELPLAN TYPES (Nieuwe platte structuur)
// =============================================================================
/**
* Embedded interventie binnen een behandeldoel
* Simpelere versie zonder linkedGoalIds (want embedded in doel)
*/
export interface EmbeddedInterventie {
id: string;
name: string; // bijv. "CGT", "EMDR", "ACT"
description: string; // Korte beschrijving van de aanpak
}
/**
* Behandeldoel met embedded interventies
* Kerntype voor de "platte" behandelplan structuur
*/
export interface Behandeldoel {
id: string;
// Doel informatie
title: string; // Professionele formulering
clientVersion: string; // B1-taal versie voor cliënt
// Classificatie
lifeDomain: LifeDomain; // Gekoppeld leefgebied
// Embedded interventies (KERNVERANDERING - niet meer apart)
interventies: EmbeddedInterventie[];
// Timeline
startWeek: number; // Start week (1-52)
endWeek: number; // Eind week (1-52)
// Status & voortgang
status: GoalStatus;
progress: number; // 0-100
}
// =============================================================================
// FLAT BEHANDELPLAN ZOD SCHEMAS
// =============================================================================
export const EmbeddedInterventieSchema = z.object({
id: z.string(),
name: z.string().min(1).max(100),
description: z.string().max(500),
});
export const BehandeldoelSchema = z.object({
id: z.string(),
title: z.string().min(5).max(200),
clientVersion: z.string().min(5).max(300),
lifeDomain: z.enum(LIFE_DOMAINS),
interventies: z.array(EmbeddedInterventieSchema),
startWeek: z.number().min(1).max(52),
endWeek: z.number().min(1).max(52),
status: z.enum(GOAL_STATUSES),
progress: z.number().min(0).max(100),
});
/**
* Schema voor AI-gegenereerd flat behandelplan
*/
export const GeneratedPlanFlatSchema = z.object({
behandelstructuur: BehandelstructuurSchema,
behandeldoelen: z.array(BehandeldoelSchema).min(1).max(6),
evaluatiemomenten: z.array(EvaluatiemomentSchema).min(1),
veiligheidsplan: VeiligheidsplanSchema.optional(),
});
export type GeneratedPlanFlat = z.infer<typeof GeneratedPlanFlatSchema>;
// =============================================================================
// TRANSFORMATIE FUNCTIES (Oude <-> Nieuwe structuur)
// =============================================================================
/**
* Transformeer oude structuur (goals + interventions apart) naar flat (behandeldoelen)
* Koppelt interventies aan doelen op basis van linkedGoalIds
*/
export function transformToFlat(
goals: SmartGoal[],
interventions: Intervention[]
): Behandeldoel[] {
return goals.map((goal) => {
// Vind interventies die aan dit doel gekoppeld zijn
const linkedInterventions = interventions
.filter((int) => int.linkedGoalIds.includes(goal.id))
.map((int) => ({
id: int.id,
name: int.name,
description: int.description,
}));
return {
id: goal.id,
title: goal.title,
clientVersion: goal.clientVersion,
lifeDomain: goal.lifeDomain,
interventies: linkedInterventions,
startWeek: 1, // Default, kan later uit goal.timelineWeeks berekend worden
endWeek: goal.timelineWeeks,
status: goal.status,
progress: goal.progress,
};
});
}
/**
* Transformeer flat structuur terug naar oude structuur (voor backwards compatibility)
* Zet embedded interventies om naar aparte array met linkedGoalIds
*/
export function transformFromFlat(behandeldoelen: Behandeldoel[]): {
goals: SmartGoal[];
interventions: Intervention[];
} {
const goals: SmartGoal[] = [];
const interventionMap = new Map<string, Intervention>();
for (const doel of behandeldoelen) {
// Maak SmartGoal van Behandeldoel
goals.push({
id: doel.id,
title: doel.title,
description: '', // Niet meer gebruikt in flat structuur
clientVersion: doel.clientVersion,
lifeDomain: doel.lifeDomain,
priority: 'middel', // Default
measurability: '', // Niet meer gebruikt in flat structuur
timelineWeeks: doel.endWeek,
status: doel.status,
progress: doel.progress,
});
// Verzamel interventies en koppel aan doel
for (const int of doel.interventies) {
const existing = interventionMap.get(int.id);
if (existing) {
// Interventie bestaat al, voeg dit doel toe aan linkedGoalIds
existing.linkedGoalIds.push(doel.id);
} else {
// Nieuwe interventie
interventionMap.set(int.id, {
id: int.id,
name: int.name,
description: int.description,
rationale: '', // Niet meer gebruikt in flat structuur
linkedGoalIds: [doel.id],
});
}
}
}
return {
goals,
interventions: Array.from(interventionMap.values()),
};
}
/**
* Maak een nieuw leeg behandeldoel
*/
export function createEmptyBehandeldoel(lifeDomain: LifeDomain = 'dlv'): Behandeldoel {
return {
id: generateId(),
title: '',
clientVersion: '',
lifeDomain,
interventies: [],
startWeek: 1,
endWeek: 8,
status: 'niet_gestart',
progress: 0,
};
}
/**
* Maak een nieuwe lege embedded interventie
*/
export function createEmptyEmbeddedInterventie(): EmbeddedInterventie {
return {
id: generateId(),
name: '',
description: '',
};
}
/**
* Bereken totale voortgang van behandeldoelen
*/
export function calculateBehandeldoelenProgress(doelen: Behandeldoel[]): number {
if (doelen.length === 0) return 0;
const sum = doelen.reduce((acc, doel) => acc + doel.progress, 0);
return Math.round(sum / doelen.length);
}
/**
* Check of flat plan klaar is voor publicatie
*/
export function canPublishFlat(
behandeldoelen: Behandeldoel[],
behandelstructuur: Behandelstructuur,
evaluatiemomenten: Evaluatiemoment[]
): { valid: boolean; errors: string[] } {
const errors: string[] = [];
if (behandeldoelen.length === 0) {
errors.push('Minimaal 1 behandeldoel is vereist');
}
// Check of elk doel minimaal 1 interventie heeft
const doelenZonderInterventie = behandeldoelen.filter(
(d) => d.interventies.length === 0
);
if (doelenZonderInterventie.length > 0) {
errors.push('Elk behandeldoel moet minimaal 1 interventie hebben');
}
if (!behandelstructuur.duur || !behandelstructuur.frequentie) {
errors.push('Behandelstructuur moet compleet zijn');
}
if (evaluatiemomenten.length < 2) {
errors.push('Minimaal 2 evaluatiemomenten zijn vereist');
}
return {
valid: errors.length === 0,
errors,
};
}

110
lib/types/icd10.ts Normal file
View File

@@ -0,0 +1,110 @@
/**
* ICD-10 GGZ Code Types
* Voor gebruik met lib/data/icd10-ggz-codes.json
*/
export interface ICD10Code {
/** ICD-10 code (bijv. "F32.1") */
code: string;
/** Nederlandse beschrijving */
display: string;
/** Zoekwoorden voor filtering */
keywords: string[];
}
export interface ICD10Category {
/** Categorie naam (bijv. "Depressieve stoornissen") */
name: string;
/** Codes binnen deze categorie */
codes: ICD10Code[];
}
export interface ICD10CodeList {
/** Versie van de codelijst */
version: string;
/** Bron (WHO publiek domein) */
source: string;
/** Beschrijving */
description: string;
/** Categorieën met codes */
categories: ICD10Category[];
/** Veelgebruikte codes (top 5) */
frequentCodes: string[];
}
/** Ernst classificatie voor diagnoses */
export type DiagnosisSeverity = 'licht' | 'matig' | 'ernstig';
/** Klinische status (FHIR compatible) */
export type DiagnosisClinicalStatus =
| 'active'
| 'recurrence'
| 'relapse'
| 'inactive'
| 'remission'
| 'resolved';
/** Verificatie status (FHIR compatible) */
export type DiagnosisVerificationStatus =
| 'unconfirmed'
| 'provisional'
| 'differential'
| 'confirmed'
| 'refuted'
| 'entered-in-error';
/** Diagnose type (hoofd/neven) */
export type DiagnosisType = 'primary' | 'secondary';
/** Helper type voor platte lijst van alle codes */
export type FlatICD10Code = ICD10Code & {
category: string;
};
/**
* Flatten ICD-10 categories naar platte lijst
*/
export function flattenICD10Codes(codeList: ICD10CodeList): FlatICD10Code[] {
return codeList.categories.flatMap((category) =>
category.codes.map((code) => ({
...code,
category: category.name,
}))
);
}
/**
* Zoek ICD-10 codes op query (code, display of keywords)
*/
export function searchICD10Codes(
codes: FlatICD10Code[],
query: string,
maxResults = 8
): FlatICD10Code[] {
if (!query.trim()) return [];
const normalizedQuery = query.toLowerCase().trim();
return codes
.filter((code) => {
const matchesCode = code.code.toLowerCase().includes(normalizedQuery);
const matchesDisplay = code.display.toLowerCase().includes(normalizedQuery);
const matchesKeyword = code.keywords.some((kw) =>
kw.toLowerCase().includes(normalizedQuery)
);
return matchesCode || matchesDisplay || matchesKeyword;
})
.slice(0, maxResults);
}
/**
* Haal veelgebruikte codes op
*/
export function getFrequentCodes(
codes: FlatICD10Code[],
frequentCodeIds: string[]
): FlatICD10Code[] {
return frequentCodeIds
.map((id) => codes.find((c) => c.code === id))
.filter((c): c is FlatICD10Code => c !== undefined);
}

View File

@@ -25,6 +25,7 @@
"@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-icons": "^1.3.2", "@radix-ui/react-icons": "^1.3.2",
"@radix-ui/react-label": "^2.1.8", "@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-progress": "^1.1.8", "@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-select": "^2.2.6", "@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slider": "^1.3.6", "@radix-ui/react-slider": "^1.3.6",
@@ -40,6 +41,7 @@
"@tiptap/starter-kit": "^3.11.0", "@tiptap/starter-kit": "^3.11.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"framer-motion": "^12.23.24", "framer-motion": "^12.23.24",
"gray-matter": "^4.0.3", "gray-matter": "^4.0.3",

76
pnpm-lock.yaml generated
View File

@@ -47,6 +47,9 @@ importers:
'@radix-ui/react-label': '@radix-ui/react-label':
specifier: ^2.1.8 specifier: ^2.1.8
version: 2.1.8(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) version: 2.1.8(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-popover':
specifier: ^1.1.15
version: 1.1.15(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-progress': '@radix-ui/react-progress':
specifier: ^1.1.8 specifier: ^1.1.8
version: 1.1.8(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) version: 1.1.8(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@@ -92,6 +95,9 @@ importers:
clsx: clsx:
specifier: ^2.1.1 specifier: ^2.1.1
version: 2.1.1 version: 2.1.1
cmdk:
specifier: ^1.1.1
version: 1.1.1(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
date-fns: date-fns:
specifier: ^4.1.0 specifier: ^4.1.0
version: 4.1.0 version: 4.1.0
@@ -766,6 +772,19 @@ packages:
'@types/react-dom': '@types/react-dom':
optional: true optional: true
'@radix-ui/react-popover@1.1.15':
resolution: {integrity: sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-popper@1.2.8': '@radix-ui/react-popper@1.2.8':
resolution: {integrity: sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==} resolution: {integrity: sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==}
peerDependencies: peerDependencies:
@@ -1823,6 +1842,12 @@ packages:
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
engines: {node: '>=6'} engines: {node: '>=6'}
cmdk@1.1.1:
resolution: {integrity: sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==}
peerDependencies:
react: ^18 || ^19 || ^19.0.0-rc
react-dom: ^18 || ^19 || ^19.0.0-rc
collapse-white-space@2.1.0: collapse-white-space@2.1.0:
resolution: {integrity: sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw==} resolution: {integrity: sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw==}
@@ -4426,6 +4451,29 @@ snapshots:
'@types/react': 18.3.27 '@types/react': 18.3.27
'@types/react-dom': 18.3.7(@types/react@18.3.27) '@types/react-dom': 18.3.7(@types/react@18.3.27)
'@radix-ui/react-popover@1.1.15(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.27)(react@18.3.1)
'@radix-ui/react-context': 1.1.2(@types/react@18.3.27)(react@18.3.1)
'@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-focus-guards': 1.1.3(@types/react@18.3.27)(react@18.3.1)
'@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-id': 1.1.1(@types/react@18.3.27)(react@18.3.1)
'@radix-ui/react-popper': 1.2.8(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-portal': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-slot': 1.2.3(@types/react@18.3.27)(react@18.3.1)
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.27)(react@18.3.1)
aria-hidden: 1.2.6
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
react-remove-scroll: 2.7.1(@types/react@18.3.27)(react@18.3.1)
optionalDependencies:
'@types/react': 18.3.27
'@types/react-dom': 18.3.7(@types/react@18.3.27)
'@radix-ui/react-popper@1.2.8(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': '@radix-ui/react-popper@1.2.8(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies: dependencies:
'@floating-ui/react-dom': 2.1.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@floating-ui/react-dom': 2.1.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@@ -5535,6 +5583,18 @@ snapshots:
clsx@2.1.1: {} clsx@2.1.1: {}
cmdk@1.1.1(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies:
'@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.27)(react@18.3.1)
'@radix-ui/react-dialog': 1.1.15(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-id': 1.1.1(@types/react@18.3.27)(react@18.3.1)
'@radix-ui/react-primitive': 2.1.4(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
transitivePeerDependencies:
- '@types/react'
- '@types/react-dom'
collapse-white-space@2.1.0: {} collapse-white-space@2.1.0: {}
color-convert@2.0.1: color-convert@2.0.1:
@@ -5832,8 +5892,8 @@ snapshots:
'@typescript-eslint/parser': 8.47.0(eslint@8.57.1)(typescript@5.9.3) '@typescript-eslint/parser': 8.47.0(eslint@8.57.1)(typescript@5.9.3)
eslint: 8.57.1 eslint: 8.57.1
eslint-import-resolver-node: 0.3.9 eslint-import-resolver-node: 0.3.9
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1) eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.47.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1)
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.47.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1) eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.47.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.47.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1)
eslint-plugin-jsx-a11y: 6.10.2(eslint@8.57.1) eslint-plugin-jsx-a11y: 6.10.2(eslint@8.57.1)
eslint-plugin-react: 7.37.5(eslint@8.57.1) eslint-plugin-react: 7.37.5(eslint@8.57.1)
eslint-plugin-react-hooks: 5.0.0-canary-7118f5dd7-20230705(eslint@8.57.1) eslint-plugin-react-hooks: 5.0.0-canary-7118f5dd7-20230705(eslint@8.57.1)
@@ -5852,7 +5912,7 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1): eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.47.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1):
dependencies: dependencies:
'@nolyfill/is-core-module': 1.0.39 '@nolyfill/is-core-module': 1.0.39
debug: 4.4.3 debug: 4.4.3
@@ -5863,22 +5923,22 @@ snapshots:
tinyglobby: 0.2.15 tinyglobby: 0.2.15
unrs-resolver: 1.11.1 unrs-resolver: 1.11.1
optionalDependencies: optionalDependencies:
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.47.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1) eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.47.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.47.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1)
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
eslint-module-utils@2.12.1(@typescript-eslint/parser@8.47.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1): eslint-module-utils@2.12.1(@typescript-eslint/parser@8.47.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.47.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1):
dependencies: dependencies:
debug: 3.2.7 debug: 3.2.7
optionalDependencies: optionalDependencies:
'@typescript-eslint/parser': 8.47.0(eslint@8.57.1)(typescript@5.9.3) '@typescript-eslint/parser': 8.47.0(eslint@8.57.1)(typescript@5.9.3)
eslint: 8.57.1 eslint: 8.57.1
eslint-import-resolver-node: 0.3.9 eslint-import-resolver-node: 0.3.9
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1) eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.47.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1)
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.47.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1): eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.47.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.47.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1):
dependencies: dependencies:
'@rtsao/scc': 1.1.0 '@rtsao/scc': 1.1.0
array-includes: 3.1.9 array-includes: 3.1.9
@@ -5889,7 +5949,7 @@ snapshots:
doctrine: 2.1.0 doctrine: 2.1.0
eslint: 8.57.1 eslint: 8.57.1
eslint-import-resolver-node: 0.3.9 eslint-import-resolver-node: 0.3.9
eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.47.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1) eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.47.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.47.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1)
hasown: 2.0.2 hasown: 2.0.2
is-core-module: 2.16.1 is-core-module: 2.16.1
is-glob: 4.0.3 is-glob: 4.0.3