From 265d3a971f711c400a9ca4073a760753ae0fb987 Mon Sep 17 00:00:00 2001 From: colinislit Date: Fri, 12 Dec 2025 13:29:59 +0100 Subject: [PATCH] feat(diagnose+agenda): Diagnose module met ICD-10 en agenda uitbreidingen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- app/epd/agenda/components/agenda-view.tsx | 2 +- .../agenda/components/appointment-modal.tsx | 420 +++++++++- .../components/patient-context-card.tsx | 228 ++++++ app/epd/patients/[id]/behandelplan/actions.ts | 132 ++- .../[id]/behandelplan/page-client.tsx | 164 +++- app/epd/patients/[id]/diagnose/actions.ts | 71 ++ .../components/diagnosis-overview-card.tsx | 182 +++++ app/epd/patients/[id]/diagnose/page.tsx | 147 +++- .../[id]/intakes/[intakeId]/actions.ts | 85 +- .../diagnosis/components/diagnosis-card.tsx | 197 +++++ .../components/diagnosis-manager.tsx | 296 ++++--- .../diagnosis/components/diagnosis-modal.tsx | 372 +++++++++ .../diagnosis/components/icd10-combobox.tsx | 192 +++++ .../intakes/[intakeId]/diagnosis/page.tsx | 2 +- app/epd/patients/[id]/page.tsx | 262 ++++++ app/layout.tsx | 1 - .../behandelplan/flat/behandeldoel-card.tsx | 160 ++++ .../behandelplan/flat/behandeldoel-form.tsx | 357 ++++++++ .../behandelplan/flat/behandelplan-flat.tsx | 290 +++++++ .../behandelplan/flat/context-header.tsx | 130 +++ components/behandelplan/flat/index.ts | 5 + .../behandelplan/flat/planning-section.tsx | 251 ++++++ components/ui/command.tsx | 156 ++++ components/ui/popover.tsx | 32 + content/nl/metadata.json | 2 +- .../diagnose/bouwplan-diagnose-module-v1.md | 768 ++++++++++++++++++ docs/specs/diagnose/fo-diagnose-module-v1.md | 397 +++++++++ docs/specs/diagnose/prd-diagnose-module-v1.md | 291 +++++++ docs/specs/diagnose/to-diagnose-module-v1.md | 467 +++++++++++ .../diagnose/vectorized-brewing-stearns.md | 195 +++++ lib/data/icd10-ggz-codes.json | 134 +++ lib/schemas/diagnosis.ts | 89 ++ lib/types/behandelplan.ts | 234 ++++++ lib/types/icd10.ts | 110 +++ package.json | 2 + pnpm-lock.yaml | 76 +- 36 files changed, 6663 insertions(+), 236 deletions(-) create mode 100644 app/epd/agenda/components/patient-context-card.tsx create mode 100644 app/epd/patients/[id]/diagnose/actions.ts create mode 100644 app/epd/patients/[id]/diagnose/components/diagnosis-overview-card.tsx create mode 100644 app/epd/patients/[id]/intakes/[intakeId]/diagnosis/components/diagnosis-card.tsx create mode 100644 app/epd/patients/[id]/intakes/[intakeId]/diagnosis/components/diagnosis-modal.tsx create mode 100644 app/epd/patients/[id]/intakes/[intakeId]/diagnosis/components/icd10-combobox.tsx create mode 100644 components/behandelplan/flat/behandeldoel-card.tsx create mode 100644 components/behandelplan/flat/behandeldoel-form.tsx create mode 100644 components/behandelplan/flat/behandelplan-flat.tsx create mode 100644 components/behandelplan/flat/context-header.tsx create mode 100644 components/behandelplan/flat/index.ts create mode 100644 components/behandelplan/flat/planning-section.tsx create mode 100644 components/ui/command.tsx create mode 100644 components/ui/popover.tsx create mode 100644 docs/specs/diagnose/bouwplan-diagnose-module-v1.md create mode 100644 docs/specs/diagnose/fo-diagnose-module-v1.md create mode 100644 docs/specs/diagnose/prd-diagnose-module-v1.md create mode 100644 docs/specs/diagnose/to-diagnose-module-v1.md create mode 100644 docs/specs/diagnose/vectorized-brewing-stearns.md create mode 100644 lib/data/icd10-ggz-codes.json create mode 100644 lib/schemas/diagnosis.ts create mode 100644 lib/types/icd10.ts diff --git a/app/epd/agenda/components/agenda-view.tsx b/app/epd/agenda/components/agenda-view.tsx index 9890439..2f58399 100644 --- a/app/epd/agenda/components/agenda-view.tsx +++ b/app/epd/agenda/components/agenda-view.tsx @@ -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'; diff --git a/app/epd/agenda/components/appointment-modal.tsx b/app/epd/agenda/components/appointment-modal.tsx index f4f2dc9..9794a28 100644 --- a/app/epd/agenda/components/appointment-modal.tsx +++ b/app/epd/agenda/components/appointment-modal.tsx @@ -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([]); const [isLoadingReports, setIsLoadingReports] = useState(false); + // Report composer state + const [showReportComposer, setShowReportComposer] = useState(false); + const [selectedReportType, setSelectedReportType] = useState('vrije_notitie'); + const [reportContent, setReportContent] = useState(''); + const [isSavingReport, setIsSavingReport] = useState(false); + + // Report edit state + const [editingReport, setEditingReport] = useState(null); + const [editReportContent, setEditReportContent] = useState(''); + const [isUpdatingReport, setIsUpdatingReport] = useState(false); + // Form state const [date, setDate] = useState( 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 ( - - - + + + {editingEvent ? 'Afspraak bewerken' : 'Nieuwe Afspraak'} -
+ +
{/* Patient Search */}
@@ -609,7 +796,7 @@ export function AppointmentModal({ setClassCode(e.target.value as LocationClassCode)} + onChange={(e: React.ChangeEvent) => setClassCode(e.target.value as LocationClassCode)} className={selectClassName} > {Object.entries(LOCATION_CLASSES).map(([code, label]) => ( @@ -647,15 +834,90 @@ export function AppointmentModal({