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

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

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

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

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

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

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

View File

@@ -6,7 +6,7 @@
* Client-side wrapper managing calendar state, view switching, and interactions.
*/
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 { toast } from '@/hooks/use-toast';

View File

@@ -6,11 +6,14 @@
* 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 { 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 { 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 {
Dialog,
@@ -24,6 +27,7 @@ import { toast } from '@/hooks/use-toast';
import { createEncounter, updateEncounter, cancelEncounter, getEncounterReports } from '../actions';
import { CancelDialog } from './cancel-dialog';
import { PatientContextCard } from './patient-context-card';
import {
APPOINTMENT_TYPES,
LOCATION_CLASSES,
@@ -58,8 +62,8 @@ interface AppointmentModalProps {
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 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 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:ring-offset-0 focus:border-transparent text-sm bg-white box-border";
const labelClassName = "block text-sm font-medium text-slate-700 mb-1";
export function AppointmentModal({
@@ -90,6 +94,17 @@ export function AppointmentModal({
const [linkedReports, setLinkedReports] = useState<LinkedReport[]>([]);
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
const [date, setDate] = useState<string>(
initialDate ? format(initialDate, 'yyyy-MM-dd') : format(new Date(), 'yyyy-MM-dd')
@@ -153,6 +168,11 @@ export function AppointmentModal({
setTypeCode('behandeling');
setClassCode('AMB');
setLinkedReports([]);
setShowReportComposer(false);
setReportContent('');
setSelectedReportType('vrije_notitie');
setEditingReport(null);
setEditReportContent('');
}
}, [open, initialDate, initialStartTime, initialEndTime, editingEvent]);
@@ -298,16 +318,16 @@ export function AppointmentModal({
periodStart,
periodEnd,
typeCode,
typeDisplay: APPOINTMENT_TYPES[typeCode],
typeDisplay: APPOINTMENT_TYPES[typeCode as AppointmentTypeCode],
classCode,
classDisplay: LOCATION_CLASSES[classCode],
classDisplay: LOCATION_CLASSES[classCode as LocationClassCode],
notes: notes || '',
});
if (result.success) {
toast({
title: 'Afspraak bijgewerkt',
description: `${APPOINTMENT_TYPES[typeCode]} is aangepast.`,
description: `${APPOINTMENT_TYPES[typeCode as AppointmentTypeCode]} is aangepast.`,
});
onOpenChange(false);
onSuccess?.();
@@ -326,16 +346,16 @@ export function AppointmentModal({
periodStart,
periodEnd: periodEnd || undefined,
typeCode,
typeDisplay: APPOINTMENT_TYPES[typeCode],
typeDisplay: APPOINTMENT_TYPES[typeCode as AppointmentTypeCode],
classCode,
classDisplay: LOCATION_CLASSES[classCode],
classDisplay: LOCATION_CLASSES[classCode as LocationClassCode],
notes: notes || undefined,
});
if (result.success) {
toast({
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);
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 name = `${patient.name_given?.[0] || ''} ${patient.name_family || ''}`.trim();
const birthDate = patient.birth_date
@@ -406,17 +481,118 @@ export function AppointmentModal({
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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<Dialog open={open} onOpenChange={wrappedOnOpenChange}>
<DialogContent className="sm:max-w-[800px] max-h-[90vh] !grid !grid-rows-[auto_1fr_auto] !gap-0 p-0 overflow-hidden">
<DialogHeader className="flex-shrink-0 px-6 pt-6 pb-4">
<DialogTitle className="flex items-center gap-2">
<Calendar className="h-5 w-5 text-teal-600" />
{editingEvent ? 'Afspraak bewerken' : 'Nieuwe Afspraak'}
</DialogTitle>
</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 */}
<div className="relative">
<label className={labelClassName}>
@@ -428,7 +604,7 @@ export function AppointmentModal({
<input
type="text"
value={patientSearch}
onChange={(e) => {
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
setPatientSearch(e.target.value);
if (selectedPatient) {
setSelectedPatient(null);
@@ -459,8 +635,8 @@ export function AppointmentModal({
{/* Patient Dropdown */}
{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">
{patients.map((patient) => {
<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: Patient) => {
const { name, birthDate, identifier } = formatPatientName(patient);
return (
<button
@@ -483,24 +659,24 @@ export function AppointmentModal({
)}
{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...
</div>
)}
{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
</div>
)}
{/* Recent Patients Dropdown - shown when focused but no search query */}
{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">
Recente patiënten
</div>
{recentPatients.map((patient) => {
{recentPatients.map((patient: Patient) => {
const { name, birthDate, identifier } = formatPatientName(patient);
return (
<button
@@ -527,9 +703,18 @@ export function AppointmentModal({
{selectedPatient && (
<div className="mt-2 p-3 bg-teal-50 rounded-lg border border-teal-100">
<div className="flex items-start justify-between">
<div>
<div className="font-medium text-slate-900">
{selectedPatient.name_given?.[0]} {selectedPatient.name_family}
<div className="flex-1">
<div className="flex items-center gap-2">
<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 className="text-sm text-slate-600 mt-0.5">
Geb. {selectedPatient.birth_date
@@ -558,6 +743,8 @@ export function AppointmentModal({
<X className="h-4 w-4" />
</button>
</div>
{/* Patient Medical Context */}
<PatientContextCard patientId={selectedPatient.id} />
</div>
)}
@@ -571,7 +758,7 @@ export function AppointmentModal({
<input
type="date"
value={date}
onChange={(e) => setDate(e.target.value)}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setDate(e.target.value)}
className={inputClassName}
required
/>
@@ -584,7 +771,7 @@ export function AppointmentModal({
<input
type="time"
value={startTime}
onChange={(e) => setStartTime(e.target.value)}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setStartTime(e.target.value)}
className={inputClassName}
required
/>
@@ -597,7 +784,7 @@ export function AppointmentModal({
<input
type="time"
value={endTime}
onChange={(e) => setEndTime(e.target.value)}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setEndTime(e.target.value)}
className={inputClassName}
/>
</div>
@@ -609,7 +796,7 @@ export function AppointmentModal({
<label className={labelClassName}>Type afspraak *</label>
<select
value={typeCode}
onChange={(e) => setTypeCode(e.target.value as AppointmentTypeCode)}
onChange={(e: React.ChangeEvent<HTMLSelectElement>) => setTypeCode(e.target.value as AppointmentTypeCode)}
className={selectClassName}
required
>
@@ -627,7 +814,7 @@ export function AppointmentModal({
</label>
<select
value={classCode}
onChange={(e) => setClassCode(e.target.value as LocationClassCode)}
onChange={(e: React.ChangeEvent<HTMLSelectElement>) => setClassCode(e.target.value as LocationClassCode)}
className={selectClassName}
>
{Object.entries(LOCATION_CLASSES).map(([code, label]) => (
@@ -647,15 +834,90 @@ export function AppointmentModal({
</label>
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setNotes(e.target.value)}
placeholder="Optionele notities voor deze afspraak..."
rows={3}
className={`${inputClassName} resize-none`}
/>
</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 */}
{isEditMode && (
{isEditMode && !editingReport && (
<div className="border-t border-slate-200 pt-4">
<label className={labelClassName}>
<FileText className="h-4 w-4 inline mr-1" />
@@ -669,7 +931,7 @@ export function AppointmentModal({
</div>
) : (
<div className="space-y-2 mt-2">
{linkedReports.map((report) => {
{linkedReports.map((report: LinkedReport) => {
const reportDate = new Date(report.created_at);
const TYPE_LABELS: Record<string, string> = {
behandeladvies: 'Behandeladvies',
@@ -680,10 +942,11 @@ export function AppointmentModal({
contact: 'Contactnotitie',
};
return (
<Link
<button
key={report.id}
href={`/epd/patients/${editingEvent?.extendedProps.patient?.id}/rapportage?reportId=${report.id}`}
className="block p-3 bg-slate-50 hover:bg-slate-100 rounded-lg border border-slate-200 transition-colors"
type="button"
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">
<span className="text-sm font-medium text-slate-700">
@@ -697,7 +960,7 @@ export function AppointmentModal({
{report.content.substring(0, 100)}
{report.content.length > 100 ? '...' : ''}
</p>
</Link>
</button>
);
})}
</div>
@@ -705,7 +968,77 @@ export function AppointmentModal({
</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 && (
<div className="flex gap-2 mr-auto">
<Button
@@ -720,14 +1053,11 @@ export function AppointmentModal({
<Button
type="button"
variant="outline"
asChild
onClick={() => setShowReportComposer(!showReportComposer)}
disabled={isSubmitting}
>
<Link
href={`/epd/patients/${editingEvent.extendedProps.patient.id}/rapportage?encounterId=${editingEvent.id}`}
>
<PenLine className="h-4 w-4 mr-1" />
Maak verslag
</Link>
<PenLine className="h-4 w-4 mr-1" />
{showReportComposer ? 'Verberg verslag' : 'Maak verslag'}
</Button>
</div>
)}
@@ -753,7 +1083,7 @@ export function AppointmentModal({
Sluiten
</Button>
<Button type="submit" disabled={isSubmitting || (!selectedPatient && !isEditMode)}>
{isSubmitting ? 'Opslaan...' : isEditMode ? 'Wijzigingen opslaan' : 'Afspraak maken'}
{isSubmitting ? 'Opslaan...' : isEditMode ? 'Opslaan' : 'Afspraak maken'}
</Button>
</div>
</DialogFooter>

View File

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

View File

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

View File

@@ -3,6 +3,7 @@
import { useState, useCallback, useMemo } from 'react';
import { useRouter } from 'next/navigation';
import { BehandelplanView, BehandelplanList } from '@/components/behandelplan';
import { BehandelplanFlat } from '@/components/behandelplan/flat';
import {
createCarePlan,
updateCarePlanStatus,
@@ -14,10 +15,14 @@ import {
addIntervention,
updateIntervention,
deleteIntervention,
saveBehandeldoel,
deleteBehandeldoel,
} 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 { 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)
interface DbCarePlan {
@@ -124,6 +129,9 @@ export function BehandelplanPageClient({
}: BehandelplanPageClientProps) {
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
const [plans, setPlans] = useState<DbCarePlan[]>(initialPlans);
const [selectedPlanId, setSelectedPlanId] = useState<string | null>(
@@ -380,41 +388,131 @@ export function BehandelplanPageClient({
[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 (
<div className="space-y-6">
{/* Plannen overzicht */}
<BehandelplanList
plans={plans.map(p => ({
id: p.id,
title: p.title,
status: p.status,
version: p.version,
created_at: p.created_at,
published_at: p.published_at,
}))}
selectedPlanId={showCreateView ? null : selectedPlanId}
onSelectPlan={handleSelectPlan}
onCreateNew={handleShowCreateView}
isCreating={isCreatingNew}
/>
{/* Header met view toggle */}
<div className="flex items-center justify-between">
<BehandelplanList
plans={plans.map(p => ({
id: p.id,
title: p.title,
status: p.status,
version: p.version,
created_at: p.created_at,
published_at: p.published_at,
}))}
selectedPlanId={showCreateView ? null : selectedPlanId}
onSelectPlan={handleSelectPlan}
onCreateNew={handleShowCreateView}
isCreating={isCreatingNew}
/>
{/* Geselecteerd plan of create view */}
<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}
/>
{/* View mode toggle */}
<div className="flex items-center gap-1 border rounded-lg p-1 bg-slate-50">
<Button
variant={viewMode === 'flat' ? 'default' : 'ghost'}
size="sm"
onClick={() => setViewMode('flat')}
className="h-8 px-3"
>
<LayoutGrid className="h-4 w-4 mr-1.5" />
Compact
</Button>
<Button
variant={viewMode === 'detailed' ? 'default' : 'ghost'}
size="sm"
onClick={() => setViewMode('detailed')}
className="h-8 px-3"
>
<List className="h-4 w-4 mr-1.5" />
Uitgebreid
</Button>
</div>
</div>
{/* Geselecteerd plan - Flat view */}
{viewMode === 'flat' && (
<BehandelplanFlat
patientId={patientId}
carePlan={showCreateView ? null : selectedPlan}
condition={conditions[0] || null}
hulpvraag={hulpvraag}
lifeDomainScores={lifeDomainScores}
onGenerate={async () => {
const intakeId = intakes[0]?.id;
if (intakeId) await handleGenerate(intakeId);
}}
onCreateManual={async () => {
await handleCreateManual(intakes[0]?.id);
}}
onStatusChange={handleStatusChange}
onSaveBehandeldoel={handleSaveBehandeldoel}
onDeleteBehandeldoel={handleDeleteBehandeldoel}
/>
)}
{/* Geselecteerd plan - Detailed view (oude UI) */}
{viewMode === 'detailed' && (
<BehandelplanView
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>
);
}

View File

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

View File

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

View File

@@ -1,40 +1,145 @@
/**
* Diagnose Page
* E2.S3: Placeholder for diagnose functionality (to be implemented in Epic 6)
* Diagnose Overzicht Pagina
*
* 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({
params,
}: {
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 (
<div className="p-6">
<div className="p-6 space-y-6">
{/* Page Header */}
<div className="mb-6">
<h2 className="text-lg font-semibold text-slate-900">Diagnose</h2>
<p className="text-sm text-slate-600 mt-1">
DSM-5 diagnoses en behandeladvies
</p>
<div className="flex items-start justify-between">
<div>
<h2 className="text-lg font-semibold text-slate-900">Diagnoses</h2>
<p className="text-sm text-slate-600 mt-1">
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>
{/* Placeholder */}
<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" />
{/* Summary cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="bg-white rounded-lg border border-slate-200 p-4">
<div className="text-2xl font-bold text-slate-900">{diagnoses.length}</div>
<div className="text-sm text-slate-600">Totaal diagnoses</div>
</div>
<div className="bg-white rounded-lg border border-slate-200 p-4">
<div className="text-2xl font-bold text-green-600">{activeDiagnoses.length}</div>
<div className="text-sm text-slate-600">Actieve diagnoses</div>
</div>
<div className="bg-white rounded-lg border border-slate-200 p-4">
<div className="text-sm font-medium text-slate-900 truncate">
{primaryDiagnosis ? (
<>
<span className="font-mono">{primaryDiagnosis.code_code}</span>
{' — '}
{primaryDiagnosis.code_display}
</>
) : (
<span className="text-slate-400">Geen hoofddiagnose</span>
)}
</div>
<div className="text-sm text-slate-600">Hoofddiagnose</div>
</div>
<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>
{/* 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>
);
}

View File

@@ -312,14 +312,17 @@ export async function getDiagnoses(intakeId: string) {
return data || [];
}
type ClinicalStatus = 'active' | 'recurrence' | 'relapse' | 'inactive' | 'remission' | 'resolved';
export interface DiagnosisPayload {
patientId: string;
intakeId: string;
code: string;
description: string;
severity?: string;
status?: string;
status?: ClinicalStatus;
notes?: string;
diagnosisType?: 'primary' | 'secondary';
}
export async function createDiagnosis(payload: DiagnosisPayload) {
@@ -329,26 +332,98 @@ export async function createDiagnosis(payload: DiagnosisPayload) {
encounter_id: payload.intakeId,
code_code: payload.code,
code_display: payload.description,
code_system: 'DSM-5',
clinical_status: 'active',
code_system: 'ICD-10',
clinical_status: payload.status || 'active',
severity_display: payload.severity || null,
category: payload.diagnosisType === 'primary' ? 'primary-diagnosis' : 'encounter-diagnosis',
note: payload.notes,
recorded_date: new Date().toISOString(),
});
if (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));
}
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) {
const supabase = await getSupabase();
const { error } = await supabase.from('conditions').delete().eq('id', diagnosisId);
if (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));

View File

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

View File

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

View File

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

View File

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

View File

@@ -14,7 +14,7 @@ export default async function IntakeDiagnosisPage({
<div>
<h2 className="text-lg font-semibold text-slate-900">Diagnoses</h2>
<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>
</div>
<DiagnosisManager patientId={id} intakeId={intakeId} diagnoses={diagnoses} />

View File

@@ -11,11 +11,21 @@ import {
ArrowRight,
AlertCircle,
Calendar,
Clock,
} from 'lucide-react';
import { getIntakesByPatientId } from './intakes/actions';
import type { Intake } from '@/lib/types/intake';
import { format } from 'date-fns';
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({
params,
@@ -34,6 +44,45 @@ export default async function PatientDashboardPage({
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 (
<div className="p-6">
{/* Page Header */}
@@ -160,6 +209,219 @@ export default async function PatientDashboardPage({
</div>
)}
{/* Agenda Afspraken Section */}
{(upcomingEncounters.length > 0 || recentEncounters.length > 0) && (
<div className="bg-white rounded-lg border border-slate-200 p-6 mb-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-slate-900">Agenda Afspraken</h3>
<Link
href={`/epd/agenda?encounterId=${upcomingEncounters[0]?.id || recentEncounters[0]?.id}`}
className="text-sm text-teal-600 hover:text-teal-700 font-medium"
>
Bekijk agenda
</Link>
</div>
<div className="space-y-3">
{[...upcomingEncounters, ...recentEncounters].slice(0, 5).map((encounter) => {
const encounterDate = new Date(encounter.period_start);
const isPast = encounterDate < new Date();
const isToday = encounterDate.toDateString() === new Date().toDateString();
return (
<Link
key={encounter.id}
href={`/epd/agenda?encounterId=${encounter.id}`}
className="block p-3 rounded-lg border border-slate-200 hover:border-teal-300 hover:bg-teal-50 transition-colors group"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className={`w-8 h-8 rounded-md flex items-center justify-center ${
isPast ? 'bg-slate-100' : isToday ? 'bg-blue-50' : 'bg-teal-50'
} group-hover:bg-teal-100 transition-colors`}>
<Calendar className={`h-4 w-4 ${
isPast ? 'text-slate-500' : isToday ? 'text-blue-600' : 'text-teal-600'
}`} />
</div>
<div>
<p className="font-medium text-slate-900 group-hover:text-teal-700">
{encounter.type_display || 'Afspraak'}
</p>
<div className="flex items-center gap-2 text-xs text-slate-500 mt-0.5">
<Clock className="h-3 w-3" />
<span>
{format(encounterDate, 'd MMM yyyy HH:mm', { locale: nl })}
</span>
{encounter.period_end && (
<>
<span></span>
<span>
{format(new Date(encounter.period_end), 'HH:mm', { locale: nl })}
</span>
</>
)}
</div>
</div>
</div>
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${
encounter.status === 'planned' || encounter.status === 'arrived'
? 'bg-blue-50 text-blue-700'
: encounter.status === 'finished'
? 'bg-green-50 text-green-700'
: 'bg-slate-50 text-slate-700'
}`}>
{encounter.status === 'planned' ? 'Gepland' :
encounter.status === 'arrived' ? 'Aangekomen' :
encounter.status === 'finished' ? 'Afgerond' : encounter.status}
</span>
</div>
</Link>
);
})}
</div>
</div>
)}
{/* Behandelplan Section */}
{activeCarePlan && (
<div className="bg-white rounded-lg border border-slate-200 p-6 mb-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-slate-900">Actief Behandelplan</h3>
<Link
href={`/epd/patients/${id}/behandelplan`}
className="text-sm text-teal-600 hover:text-teal-700 font-medium"
>
Bekijk volledig plan
</Link>
</div>
{/* Hulpvraag */}
{hulpvraag && (
<div className="mb-4 p-3 bg-slate-50 rounded-lg">
<p className="text-xs font-medium text-slate-600 mb-1">Hulpvraag</p>
<p className="text-sm text-slate-700 italic">&ldquo;{hulpvraag}&rdquo;</p>
</div>
)}
{/* Behandelstructuur */}
{activeCarePlan.behandelstructuur && (
<div className="mb-4 p-3 bg-teal-50 rounded-lg">
<p className="text-xs font-medium text-teal-700 mb-2">Behandelstructuur</p>
<div className="grid grid-cols-2 gap-2 text-sm text-teal-900">
<div>
<span className="font-medium">Duur:</span> {activeCarePlan.behandelstructuur.duur}
</div>
<div>
<span className="font-medium">Frequentie:</span> {activeCarePlan.behandelstructuur.frequentie}
</div>
<div>
<span className="font-medium">Aantal sessies:</span> {activeCarePlan.behandelstructuur.aantalSessies}
</div>
<div>
<span className="font-medium">Vorm:</span> {activeCarePlan.behandelstructuur.vorm}
</div>
</div>
</div>
)}
{/* Doelen Overzicht */}
{activeCarePlan.goals && Array.isArray(activeCarePlan.goals) && activeCarePlan.goals.length > 0 && (
<div className="mb-4">
<p className="text-xs font-medium text-slate-600 mb-2">Doelen ({activeCarePlan.goals.length})</p>
<div className="space-y-2">
{activeCarePlan.goals.slice(0, 3).map((goal: SmartGoal) => (
<div key={goal.id} className="p-2 bg-slate-50 rounded border border-slate-200">
<div className="flex items-start justify-between mb-1">
<p className="text-sm font-medium text-slate-900">{goal.title}</p>
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${
goal.status === 'bezig' ? 'bg-blue-50 text-blue-700' :
goal.status === 'gehaald' ? 'bg-green-50 text-green-700' :
'bg-slate-50 text-slate-700'
}`}>
{goal.status === 'bezig' ? 'Bezig' :
goal.status === 'gehaald' ? 'Gehaald' :
goal.status === 'niet_gestart' ? 'Niet gestart' : goal.status}
</span>
</div>
{goal.progress > 0 && (
<div className="mt-1">
<div className="h-1.5 bg-slate-200 rounded-full overflow-hidden">
<div
className="h-full bg-teal-500 transition-all"
style={{ width: `${goal.progress}%` }}
/>
</div>
<p className="text-xs text-slate-500 mt-0.5">{goal.progress}% voltooid</p>
</div>
)}
</div>
))}
{activeCarePlan.goals.length > 3 && (
<p className="text-xs text-slate-500 text-center">
+{activeCarePlan.goals.length - 3} meer doelen
</p>
)}
</div>
</div>
)}
{/* Interventies Overzicht */}
{activeCarePlan.activities && Array.isArray(activeCarePlan.activities) && activeCarePlan.activities.length > 0 && (
<div className="mb-4">
<p className="text-xs font-medium text-slate-600 mb-2">Interventies ({activeCarePlan.activities.length})</p>
<div className="flex flex-wrap gap-2">
{activeCarePlan.activities.slice(0, 5).map((intervention: Intervention) => (
<span
key={intervention.id}
className="px-2 py-1 bg-purple-50 text-purple-700 rounded text-xs font-medium"
>
{intervention.name}
</span>
))}
{activeCarePlan.activities.length > 5 && (
<span className="px-2 py-1 bg-slate-100 text-slate-600 rounded text-xs">
+{activeCarePlan.activities.length - 5} meer
</span>
)}
</div>
</div>
)}
{/* Aankomende Evaluatiemomenten */}
{activeCarePlan.evaluatiemomenten &&
Array.isArray(activeCarePlan.evaluatiemomenten) &&
activeCarePlan.evaluatiemomenten.length > 0 && (
<div>
<p className="text-xs font-medium text-slate-600 mb-2">Aankomende evaluatiemomenten</p>
<div className="space-y-2">
{activeCarePlan.evaluatiemomenten
.filter((evaluatie: Evaluatiemoment) => evaluatie.status === 'gepland')
.slice(0, 2)
.map((evaluatie: Evaluatiemoment) => (
<div key={evaluatie.id} className="p-2 bg-amber-50 rounded border border-amber-200">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-amber-900">
{evaluatie.type === 'tussentijds' ? 'Tussentijdse evaluatie' :
evaluatie.type === 'eind' ? 'Eindevaluatie' : 'Crisis evaluatie'}
</p>
{evaluatie.plannedDate && (
<p className="text-xs text-amber-700 mt-0.5">
Week {evaluatie.weekNumber} {format(new Date(evaluatie.plannedDate), 'd MMM yyyy', { locale: nl })}
</p>
)}
</div>
<span className="px-2 py-0.5 bg-amber-100 text-amber-800 rounded-full text-xs font-medium">
Gepland
</span>
</div>
</div>
))}
</div>
</div>
)}
</div>
)}
{/* Next Steps Section */}
<div className="bg-blue-50 border border-blue-200 rounded-lg p-6">
<div className="flex items-start gap-3">

View File

@@ -86,7 +86,6 @@ export const metadata: Metadata = {
metadataBase: new URL(process.env.NEXT_PUBLIC_APP_URL || 'https://aispeedrun.vercel.app'),
title: {
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.',
keywords: ['AI', 'Software on Demand', 'EPD', 'Development', 'Build in Public'],