'use client'; /** * Appointment Modal Component * * Modal for creating and editing appointments (encounters). */ import { 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 Link from 'next/link'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, } from '@/components/ui/dialog'; import { Button } from '@/components/ui/button'; import { toast } from '@/hooks/use-toast'; import { createEncounter, updateEncounter, cancelEncounter, getEncounterReports } from '../actions'; import { CancelDialog } from './cancel-dialog'; import { APPOINTMENT_TYPES, LOCATION_CLASSES, type AppointmentTypeCode, type LocationClassCode, type CalendarEvent, } from '../types'; interface Patient { id: string; name_family: string; name_given: string[]; birth_date: string; identifier_bsn?: string; identifier_client_number?: string; } interface LinkedReport { id: string; type: string; content: string; created_at: string; } interface AppointmentModalProps { open: boolean; onOpenChange: (open: boolean) => void; initialDate?: Date; initialStartTime?: string; initialEndTime?: string; editingEvent?: CalendarEvent; 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 labelClassName = "block text-sm font-medium text-slate-700 mb-1"; export function AppointmentModal({ open, onOpenChange, initialDate, initialStartTime, initialEndTime, editingEvent, onSuccess, }: AppointmentModalProps) { const [isSubmitting, setIsSubmitting] = useState(false); const [patientSearch, setPatientSearch] = useState(''); const [patients, setPatients] = useState([]); const [selectedPatient, setSelectedPatient] = useState(null); const [isSearching, setIsSearching] = useState(false); const [showPatientDropdown, setShowPatientDropdown] = useState(false); // Recent patients const [recentPatients, setRecentPatients] = useState([]); const [isInputFocused, setIsInputFocused] = useState(false); // Cancel dialog state const [showCancelDialog, setShowCancelDialog] = useState(false); const [isCancelling, setIsCancelling] = useState(false); // Linked reports state const [linkedReports, setLinkedReports] = useState([]); const [isLoadingReports, setIsLoadingReports] = useState(false); // Form state const [date, setDate] = useState( initialDate ? format(initialDate, 'yyyy-MM-dd') : format(new Date(), 'yyyy-MM-dd') ); const [startTime, setStartTime] = useState(initialStartTime || '09:00'); const [endTime, setEndTime] = useState(initialEndTime || '10:00'); const [typeCode, setTypeCode] = useState('behandeling'); const [classCode, setClassCode] = useState('AMB'); const [notes, setNotes] = useState(''); // Determine if we're in edit mode const isEditMode = !!editingEvent; // Reset form when modal opens/closes useEffect(() => { if (open) { if (editingEvent) { // Edit mode: pre-fill from existing event const encounter = editingEvent.extendedProps.encounter; const patient = editingEvent.extendedProps.patient; // Set patient if (patient) { setSelectedPatient(patient as Patient); setPatientSearch(`${patient.name_given?.[0] || ''} ${patient.name_family || ''}`.trim()); } // Set date/time const startDate = new Date(encounter.period_start); setDate(format(startDate, 'yyyy-MM-dd')); setStartTime(format(startDate, 'HH:mm')); if (encounter.period_end) { const endDate = new Date(encounter.period_end); setEndTime(format(endDate, 'HH:mm')); } else { setEndTime(''); } // Set type and location setTypeCode((encounter.type_code as AppointmentTypeCode) || 'behandeling'); setClassCode((encounter.class_code as LocationClassCode) || 'AMB'); setNotes(encounter.notes || ''); } else { // Create mode: use initial values if (initialDate) { setDate(format(initialDate, 'yyyy-MM-dd')); } if (initialStartTime) { setStartTime(initialStartTime); } if (initialEndTime) { setEndTime(initialEndTime); } } } else { // Reset on close setSelectedPatient(null); setPatientSearch(''); setNotes(''); setTypeCode('behandeling'); setClassCode('AMB'); setLinkedReports([]); } }, [open, initialDate, initialStartTime, initialEndTime, editingEvent]); // Fetch linked reports when editing an appointment useEffect(() => { if (open && editingEvent) { setIsLoadingReports(true); getEncounterReports(editingEvent.id) .then((reports) => { setLinkedReports(reports); }) .catch((error) => { console.error('Failed to fetch linked reports:', error); }) .finally(() => { setIsLoadingReports(false); }); } }, [open, editingEvent]); // Fetch recent patients (last 5 by updated_at) const fetchRecentPatients = useCallback(async () => { try { const response = await fetch('/api/fhir/Patient?_count=5'); if (response.ok) { const data = await response.json(); const mappedPatients = data.entry?.map((e: { resource: unknown }) => mapFhirPatient(e.resource as Parameters[0]) ) || []; setRecentPatients(mappedPatients); } } catch (error) { console.error('Failed to fetch recent patients:', error); } }, []); // Fetch recent patients when modal opens (for new appointments) useEffect(() => { if (open && !editingEvent) { fetchRecentPatients(); } }, [open, editingEvent, fetchRecentPatients]); // Map FHIR Patient to internal format const mapFhirPatient = (fhirPatient: { id: string; name?: Array<{ family?: string; given?: string[] }>; birthDate?: string; identifier?: Array<{ system?: string; value?: string }>; }): Patient => { const bsnIdentifier = fhirPatient.identifier?.find( (id) => id.system === 'http://fhir.nl/fhir/NamingSystem/bsn' ); const clientNumberIdentifier = fhirPatient.identifier?.find( (id) => id.system?.includes('client') || id.system?.includes('999.7.6') ); return { id: fhirPatient.id, name_family: fhirPatient.name?.[0]?.family || '', name_given: fhirPatient.name?.[0]?.given || [], birth_date: fhirPatient.birthDate || '', identifier_bsn: bsnIdentifier?.value, identifier_client_number: clientNumberIdentifier?.value, }; }; // Search patients const searchPatients = useCallback(async (query: string) => { if (query.length < 2) { setPatients([]); return; } setIsSearching(true); try { // Use general search parameter that searches name, BSN, and client number const response = await fetch(`/api/fhir/Patient?q=${encodeURIComponent(query)}`); if (response.ok) { const data = await response.json(); const mappedPatients = data.entry?.map((e: { resource: unknown }) => mapFhirPatient(e.resource as Parameters[0]) ) || []; setPatients(mappedPatients); } } catch (error) { console.error('Failed to search patients:', error); } finally { setIsSearching(false); } }, []); // Debounced search - only search when no patient is selected useEffect(() => { // Skip search if patient is already selected if (selectedPatient) { setShowPatientDropdown(false); return; } const timer = setTimeout(() => { if (patientSearch.length >= 2) { searchPatients(patientSearch); setShowPatientDropdown(true); } else { setPatients([]); setShowPatientDropdown(false); } }, 300); return () => clearTimeout(timer); }, [patientSearch, searchPatients, selectedPatient]); // Handle patient selection const handleSelectPatient = (patient: Patient) => { setSelectedPatient(patient); setPatientSearch(`${patient.name_given?.[0] || ''} ${patient.name_family || ''}`.trim()); setShowPatientDropdown(false); }; // Handle form submission const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); if (!selectedPatient && !isEditMode) { toast({ variant: 'destructive', title: 'Selecteer een patiënt', description: 'Kies een patiënt uit de zoekresultaten.', }); return; } setIsSubmitting(true); try { const periodStart = `${date}T${startTime}:00`; const periodEnd = endTime ? `${date}T${endTime}:00` : null; if (isEditMode && editingEvent) { // Update existing encounter const result = await updateEncounter(editingEvent.id, { periodStart, periodEnd, typeCode, typeDisplay: APPOINTMENT_TYPES[typeCode], classCode, classDisplay: LOCATION_CLASSES[classCode], notes: notes || '', }); if (result.success) { toast({ title: 'Afspraak bijgewerkt', description: `${APPOINTMENT_TYPES[typeCode]} is aangepast.`, }); onOpenChange(false); onSuccess?.(); } else { toast({ variant: 'destructive', title: 'Bewerken mislukt', description: result.error, }); } } else { // Create new encounter const result = await createEncounter({ patientId: selectedPatient!.id, practitionerId: '', // TODO: Get current practitioner periodStart, periodEnd: periodEnd || undefined, typeCode, typeDisplay: APPOINTMENT_TYPES[typeCode], classCode, classDisplay: LOCATION_CLASSES[classCode], notes: notes || undefined, }); if (result.success) { toast({ title: 'Afspraak aangemaakt', description: `${APPOINTMENT_TYPES[typeCode]} met ${selectedPatient!.name_given?.[0] || ''} ${selectedPatient!.name_family || ''}`.trim(), }); onOpenChange(false); onSuccess?.(); } else { toast({ variant: 'destructive', title: 'Afspraak aanmaken mislukt', description: result.error, }); } } } catch (error) { toast({ variant: 'destructive', title: 'Er ging iets mis', description: 'Probeer het opnieuw.', }); } finally { setIsSubmitting(false); } }; // Handle cancel appointment const handleCancelAppointment = async () => { if (!editingEvent) return; setIsCancelling(true); try { const result = await cancelEncounter(editingEvent.id); if (result.success) { toast({ title: 'Afspraak geannuleerd', description: 'De afspraak is succesvol geannuleerd.', }); setShowCancelDialog(false); onOpenChange(false); onSuccess?.(); } else { toast({ variant: 'destructive', title: 'Annuleren mislukt', description: result.error, }); } } catch { toast({ variant: 'destructive', title: 'Er ging iets mis', description: 'Probeer het opnieuw.', }); } finally { setIsCancelling(false); } }; const formatPatientName = (patient: Patient) => { const name = `${patient.name_given?.[0] || ''} ${patient.name_family || ''}`.trim(); const birthDate = patient.birth_date ? format(new Date(patient.birth_date), 'd MMM yyyy', { locale: nl }) : ''; // Show client number or BSN (prefer client number) const identifier = patient.identifier_client_number ? `#${patient.identifier_client_number}` : patient.identifier_bsn ? `BSN ${patient.identifier_bsn.slice(-4)}` : ''; return { name, birthDate, identifier }; }; return ( {editingEvent ? 'Afspraak bewerken' : 'Nieuwe Afspraak'}
{/* Patient Search */}
{ setPatientSearch(e.target.value); if (selectedPatient) { setSelectedPatient(null); } }} onFocus={() => setIsInputFocused(true)} onBlur={() => { // Delay to allow click on dropdown items setTimeout(() => setIsInputFocused(false), 200); }} placeholder="Zoek op naam, BSN of clientnummer..." className={`${inputClassName} pl-9`} required /> {selectedPatient && ( )}
{/* Patient Dropdown */} {showPatientDropdown && patients.length > 0 && (
{patients.map((patient) => { const { name, birthDate, identifier } = formatPatientName(patient); return ( ); })}
)} {isSearching && (
Zoeken...
)} {showPatientDropdown && patients.length === 0 && patientSearch.length >= 2 && !isSearching && (
Geen patiënten gevonden
)} {/* Recent Patients Dropdown - shown when focused but no search query */} {isInputFocused && !selectedPatient && patientSearch.length < 2 && recentPatients.length > 0 && !isEditMode && (
Recente patiënten
{recentPatients.map((patient) => { const { name, birthDate, identifier } = formatPatientName(patient); return ( ); })}
)}
{/* Quick Patient Info Card - shown when patient is selected */} {selectedPatient && (
{selectedPatient.name_given?.[0]} {selectedPatient.name_family}
Geb. {selectedPatient.birth_date ? format(new Date(selectedPatient.birth_date), 'd MMMM yyyy', { locale: nl }) : 'Onbekend'}
{selectedPatient.identifier_client_number && (
Clientnr: {selectedPatient.identifier_client_number}
)} {selectedPatient.identifier_bsn && !selectedPatient.identifier_client_number && (
BSN: ***{selectedPatient.identifier_bsn.slice(-4)}
)}
)} {/* Date and Time */}
setDate(e.target.value)} className={inputClassName} required />
setStartTime(e.target.value)} className={inputClassName} required />
setEndTime(e.target.value)} className={inputClassName} />
{/* Type and Location */}
{/* Notes */}