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

@@ -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} />