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:
@@ -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';
|
||||||
|
|
||||||
|
|||||||
@@ -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,9 +703,18 @@ 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="font-medium text-slate-900">
|
<div className="flex items-center gap-2">
|
||||||
{selectedPatient.name_given?.[0]} {selectedPatient.name_family}
|
<div className="font-medium text-slate-900">
|
||||||
|
{selectedPatient.name_given?.[0]} {selectedPatient.name_family}
|
||||||
|
</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>
|
||||||
<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
|
||||||
@@ -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
|
<PenLine className="h-4 w-4 mr-1" />
|
||||||
href={`/epd/patients/${editingEvent.extendedProps.patient.id}/rapportage?encounterId=${editingEvent.id}`}
|
{showReportComposer ? 'Verberg verslag' : 'Maak verslag'}
|
||||||
>
|
|
||||||
<PenLine className="h-4 w-4 mr-1" />
|
|
||||||
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>
|
||||||
|
|||||||
228
app/epd/agenda/components/patient-context-card.tsx
Normal file
228
app/epd/agenda/components/patient-context-card.tsx
Normal 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'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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`);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,41 +388,131 @@ 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 */}
|
||||||
<BehandelplanList
|
<div className="flex items-center justify-between">
|
||||||
plans={plans.map(p => ({
|
<BehandelplanList
|
||||||
id: p.id,
|
plans={plans.map(p => ({
|
||||||
title: p.title,
|
id: p.id,
|
||||||
status: p.status,
|
title: p.title,
|
||||||
version: p.version,
|
status: p.status,
|
||||||
created_at: p.created_at,
|
version: p.version,
|
||||||
published_at: p.published_at,
|
created_at: p.created_at,
|
||||||
}))}
|
published_at: p.published_at,
|
||||||
selectedPlanId={showCreateView ? null : selectedPlanId}
|
}))}
|
||||||
onSelectPlan={handleSelectPlan}
|
selectedPlanId={showCreateView ? null : selectedPlanId}
|
||||||
onCreateNew={handleShowCreateView}
|
onSelectPlan={handleSelectPlan}
|
||||||
isCreating={isCreatingNew}
|
onCreateNew={handleShowCreateView}
|
||||||
/>
|
isCreating={isCreatingNew}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Geselecteerd plan of create view */}
|
{/* View mode toggle */}
|
||||||
<BehandelplanView
|
<div className="flex items-center gap-1 border rounded-lg p-1 bg-slate-50">
|
||||||
patientId={patientId}
|
<Button
|
||||||
carePlan={showCreateView ? null : selectedPlan}
|
variant={viewMode === 'flat' ? 'default' : 'ghost'}
|
||||||
intakes={mapIntakes(intakes)}
|
size="sm"
|
||||||
conditions={conditions}
|
onClick={() => setViewMode('flat')}
|
||||||
onGenerate={handleGenerate}
|
className="h-8 px-3"
|
||||||
onStatusChange={handleStatusChange}
|
>
|
||||||
onCreateManual={handleCreateManual}
|
<LayoutGrid className="h-4 w-4 mr-1.5" />
|
||||||
onUpdateBehandelstructuur={handleUpdateBehandelstructuur}
|
Compact
|
||||||
onAddGoal={handleAddGoal}
|
</Button>
|
||||||
onUpdateGoal={handleUpdateGoal}
|
<Button
|
||||||
onDeleteGoal={handleDeleteGoal}
|
variant={viewMode === 'detailed' ? 'default' : 'ghost'}
|
||||||
onAddIntervention={handleAddIntervention}
|
size="sm"
|
||||||
onUpdateIntervention={handleUpdateIntervention}
|
onClick={() => setViewMode('detailed')}
|
||||||
onDeleteIntervention={handleDeleteIntervention}
|
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
|
||||||
|
patientId={patientId}
|
||||||
|
carePlan={showCreateView ? null : selectedPlan}
|
||||||
|
intakes={mapIntakes(intakes)}
|
||||||
|
conditions={conditions}
|
||||||
|
onGenerate={handleGenerate}
|
||||||
|
onStatusChange={handleStatusChange}
|
||||||
|
onCreateManual={handleCreateManual}
|
||||||
|
onUpdateBehandelstructuur={handleUpdateBehandelstructuur}
|
||||||
|
onAddGoal={handleAddGoal}
|
||||||
|
onUpdateGoal={handleUpdateGoal}
|
||||||
|
onDeleteGoal={handleDeleteGoal}
|
||||||
|
onAddIntervention={handleAddIntervention}
|
||||||
|
onUpdateIntervention={handleUpdateIntervention}
|
||||||
|
onDeleteIntervention={handleDeleteIntervention}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
71
app/epd/patients/[id]/diagnose/actions.ts
Normal file
71
app/epd/patients/[id]/diagnose/actions.ts
Normal 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,
|
||||||
|
}));
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
<p className="text-sm text-slate-600 mt-1">
|
<h2 className="text-lg font-semibold text-slate-900">Diagnoses</h2>
|
||||||
DSM-5 diagnoses en behandeladvies
|
<p className="text-sm text-slate-600 mt-1">
|
||||||
</p>
|
Overzicht van alle diagnoses (ICD-10) voor deze patiënt.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{newDiagnosisUrl && (
|
||||||
|
<Button asChild>
|
||||||
|
<Link href={newDiagnosisUrl}>
|
||||||
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
|
Nieuwe diagnose
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Placeholder */}
|
{/* Summary cards */}
|
||||||
<div className="bg-white rounded-lg border border-slate-200 p-12 text-center">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-purple-50 mb-4">
|
<div className="bg-white rounded-lg border border-slate-200 p-4">
|
||||||
<Stethoscope className="h-8 w-8 text-purple-500" />
|
<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>
|
||||||
<h3 className="text-lg font-semibold text-slate-900 mb-2">
|
|
||||||
Diagnose Module - Coming Soon
|
|
||||||
</h3>
|
|
||||||
<p className="text-sm text-slate-600 max-w-md mx-auto">
|
|
||||||
De diagnose functionaliteit wordt geïmplementeerd in Epic 6. Dit omvat
|
|
||||||
DSM-5 diagnose registratie en behandeladvies formulering.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Diagnoses lijst */}
|
||||||
|
{sortedDiagnoses.length === 0 ? (
|
||||||
|
<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">
|
||||||
|
<Stethoscope className="h-8 w-8 text-purple-500" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-semibold text-slate-900 mb-2">
|
||||||
|
Nog geen diagnoses
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-slate-600 max-w-md mx-auto mb-4">
|
||||||
|
Er zijn nog geen diagnoses geregistreerd voor deze patiënt.
|
||||||
|
Diagnoses worden vastgelegd tijdens een intake.
|
||||||
|
</p>
|
||||||
|
{newDiagnosisUrl && (
|
||||||
|
<Button asChild>
|
||||||
|
<Link href={newDiagnosisUrl}>
|
||||||
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
|
Eerste diagnose toevoegen
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{sortedDiagnoses.map((diagnosis) => (
|
||||||
|
<DiagnosisOverviewCard
|
||||||
|
key={diagnosis.id}
|
||||||
|
diagnosis={diagnosis}
|
||||||
|
patientId={patientId}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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>}
|
<div className="flex items-center justify-between">
|
||||||
{diagnoses.map((diag) => (
|
<div>
|
||||||
<div key={diag.id} className="rounded-lg border border-slate-200 p-4 space-y-1">
|
<h3 className="text-sm font-semibold text-slate-900">
|
||||||
<div className="flex items-center justify-between">
|
Diagnoses ({diagnoses.length})
|
||||||
<div>
|
</h3>
|
||||||
<p className="font-medium text-slate-900">{diag.code_code} — {diag.code_display}</p>
|
</div>
|
||||||
<p className="text-xs text-slate-500">
|
<Button onClick={handleNewDiagnosis} size="sm">
|
||||||
{diag.recorded_date
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
? format(new Date(diag.recorded_date), 'd MMM yyyy', { locale: nl })
|
Nieuwe diagnose
|
||||||
: 'Onbekende datum'}
|
</Button>
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => handleDelete(diag.id)}
|
|
||||||
disabled={deletingId === diag.id}
|
|
||||||
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 "Nieuwe diagnose" 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>
|
</div>
|
||||||
<textarea
|
)}
|
||||||
value={form.notes}
|
|
||||||
onChange={(e) => setForm((prev) => ({ ...prev, notes: e.target.value }))}
|
{/* Diagnosis Modal */}
|
||||||
placeholder="Notities"
|
<DiagnosisModal
|
||||||
className="w-full rounded-md border border-slate-300 px-3 py-2 text-sm"
|
open={modalOpen}
|
||||||
/>
|
onOpenChange={setModalOpen}
|
||||||
{error && <p className="text-sm text-red-600">{error}</p>}
|
patientId={patientId}
|
||||||
<button
|
intakeId={intakeId}
|
||||||
type="button"
|
diagnosis={editingDiagnosis}
|
||||||
onClick={handleSubmit}
|
onSuccess={handleModalSuccess}
|
||||||
disabled={isPending}
|
/>
|
||||||
className="inline-flex items-center gap-2 rounded-md bg-teal-600 px-4 py-2 text-sm font-medium text-white"
|
|
||||||
>
|
{/* Delete Confirmation Dialog */}
|
||||||
{isPending && <Loader2 className="h-4 w-4 animate-spin" />}
|
<Dialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
|
||||||
Opslaan
|
<DialogContent>
|
||||||
</button>
|
<DialogHeader>
|
||||||
</div>
|
<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}
|
||||||
|
>
|
||||||
|
Annuleren
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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} />
|
||||||
|
|||||||
@@ -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">“{hulpvraag}”</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">
|
||||||
|
|||||||
@@ -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'],
|
||||||
|
|||||||
160
components/behandelplan/flat/behandeldoel-card.tsx
Normal file
160
components/behandelplan/flat/behandeldoel-card.tsx
Normal 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">
|
||||||
|
“{doel.clientVersion}”
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
357
components/behandelplan/flat/behandeldoel-form.tsx
Normal file
357
components/behandelplan/flat/behandeldoel-form.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
290
components/behandelplan/flat/behandelplan-flat.tsx
Normal file
290
components/behandelplan/flat/behandelplan-flat.tsx
Normal 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 "Nieuw doel" 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
130
components/behandelplan/flat/context-header.tsx
Normal file
130
components/behandelplan/flat/context-header.tsx
Normal 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">“{hulpvraag}”</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
5
components/behandelplan/flat/index.ts
Normal file
5
components/behandelplan/flat/index.ts
Normal 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';
|
||||||
251
components/behandelplan/flat/planning-section.tsx
Normal file
251
components/behandelplan/flat/planning-section.tsx
Normal 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
156
components/ui/command.tsx
Normal 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
32
components/ui/popover.tsx
Normal 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 }
|
||||||
|
|
||||||
@@ -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.",
|
||||||
|
|||||||
768
docs/specs/diagnose/bouwplan-diagnose-module-v1.md
Normal file
768
docs/specs/diagnose/bouwplan-diagnose-module-v1.md
Normal 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 |
|
||||||
397
docs/specs/diagnose/fo-diagnose-module-v1.md
Normal file
397
docs/specs/diagnose/fo-diagnose-module-v1.md
Normal 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*
|
||||||
291
docs/specs/diagnose/prd-diagnose-module-v1.md
Normal file
291
docs/specs/diagnose/prd-diagnose-module-v1.md
Normal 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*
|
||||||
467
docs/specs/diagnose/to-diagnose-module-v1.md
Normal file
467
docs/specs/diagnose/to-diagnose-module-v1.md
Normal 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*
|
||||||
195
docs/specs/diagnose/vectorized-brewing-stearns.md
Normal file
195
docs/specs/diagnose/vectorized-brewing-stearns.md
Normal 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**
|
||||||
134
lib/data/icd10-ggz-codes.json
Normal file
134
lib/data/icd10-ggz-codes.json
Normal 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
89
lib/schemas/diagnosis.ts
Normal 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>;
|
||||||
@@ -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
110
lib/types/icd10.ts
Normal 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);
|
||||||
|
}
|
||||||
@@ -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
76
pnpm-lock.yaml
generated
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user