diff --git a/components/swift/artifacts/blocks/agenda-block.tsx b/components/swift/artifacts/blocks/agenda-block.tsx new file mode 100644 index 0000000..9263a16 --- /dev/null +++ b/components/swift/artifacts/blocks/agenda-block.tsx @@ -0,0 +1,66 @@ +'use client'; +import React from 'react'; +import { Encounter, AppointmentTypeCode, LocationClassCode, CalendarEvent } from '@/app/epd/agenda/types'; +import { AgendaListView } from './agenda-list-view'; +import { AgendaCreateForm } from './agenda-create-form'; +import { AgendaCancelView } from './agenda-cancel-view'; +import { AgendaRescheduleForm } from './agenda-reschedule-form'; + +export interface AgendaBlockProps { + mode: 'list' | 'create' | 'cancel' | 'reschedule'; + appointments?: CalendarEvent[]; + dateRange?: { start: Date; end: Date; label: string }; + prefillData?: { + patient?: { id: string; name: string }; + datetime?: { date: Date; time: string }; + type?: AppointmentTypeCode; + location?: LocationClassCode; + notes?: string; + }; + disambiguationOptions?: CalendarEvent[]; + onClose?: () => void; +} + +export function AgendaBlock({ + mode, + appointments, + dateRange, + prefillData, + disambiguationOptions, + onClose, +}: AgendaBlockProps) { + const renderContent = () => { + switch (mode) { + case 'list': + return ( + console.log('Cancel requested', evt)} + onViewDetails={(evt) => window.location.href = `/epd/agenda?focus=${evt.id}`} + /> + ); + case 'create': + return ; + case 'cancel': + return ( + + ); + case 'reschedule': + return ; + default: + return
Unknown mode: {mode}
; + } + }; + + return ( +
+ {renderContent()} +
+ ); +} diff --git a/components/swift/artifacts/blocks/agenda-cancel-view.tsx b/components/swift/artifacts/blocks/agenda-cancel-view.tsx new file mode 100644 index 0000000..cc20118 --- /dev/null +++ b/components/swift/artifacts/blocks/agenda-cancel-view.tsx @@ -0,0 +1,210 @@ +'use client'; + +import React, { useState } from 'react'; +import { format } from 'date-fns'; +import { nl } from 'date-fns/locale'; +import { AlertTriangle, Calendar, Clock, X, CheckCircle2 } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'; +import { Label } from '@/components/ui/label'; +import { cancelEncounter } from '@/app/epd/agenda/actions'; +import { CalendarEvent, APPOINTMENT_TYPES, AppointmentTypeCode } from '@/app/epd/agenda/types'; + +interface AgendaCancelViewProps { + disambiguationOptions?: CalendarEvent[]; + prefillData?: { + identifier?: { encounterId?: string }; + }; + onClose?: () => void; +} + +export function AgendaCancelView({ disambiguationOptions, prefillData, onClose }: AgendaCancelViewProps) { + const [selectedEncounterId, setSelectedEncounterId] = useState( + prefillData?.identifier?.encounterId + ); + + // If we have disambiguation options and no selection yet, default to first? + // Better to let user choose. + + const [isSubmitting, setIsSubmitting] = useState(false); + const [isSuccess, setIsSuccess] = useState(false); + const [error, setError] = useState(null); + + // If there's only one option provided via disambiguationOptions (and no prefill), select it automatically? + // Logic: if prefill encounterId is set, use that. + // If not, and disambiguationOptions has 1 item, use that. + // If not, wait for user selection. + + const effectiveEncounter = disambiguationOptions?.find(e => e.id === selectedEncounterId) || + (disambiguationOptions?.length === 1 ? disambiguationOptions[0] : undefined); + + const handleCancel = async () => { + const idToCancel = selectedEncounterId || effectiveEncounter?.id; + + if (!idToCancel) { + setError('Selecteer eerst een afspraak om te annuleren.'); + return; + } + + setIsSubmitting(true); + setError(null); + + try { + const result = await cancelEncounter(idToCancel); + if (result.success) { + setIsSuccess(true); + // Wait a moment before closing or let user close + setTimeout(() => onClose?.(), 2000); + } else { + setError(result.error || 'Kon de afspraak niet annuleren.'); + } + } catch (err) { + console.error('Cancel error:', err); + setError('Er is een onverwachte fout opgetreden.'); + } finally { + setIsSubmitting(false); + } + }; + + if (isSuccess) { + return ( +
+
+ +
+

Afspraak geannuleerd

+

De afspraak is succesvol verwijderd uit de agenda.

+ +
+ ); + } + + // Disambiguation Mode + if (!effectiveEncounter && disambiguationOptions && disambiguationOptions.length > 1) { + return ( +
+
+

Afspraak annuleren

+ +
+ +
+

+ Er zijn meerdere afspraken gevonden. Welke wil je annuleren? +

+ + + {disambiguationOptions.map((evt) => { + const encounter = evt.extendedProps.encounter; + const typeCode = encounter.type_code as AppointmentTypeCode; + const dateStr = format(new Date(evt.start), 'd MMM yyyy', { locale: nl }); + const timeStr = format(new Date(evt.start), 'HH:mm'); + + return ( +
+ + +
+ ); + })} +
+
+ +
+ + +
+
+ ); + } + + // Confirmation Mode (Single Match) + if (effectiveEncounter) { + const encounter = effectiveEncounter.extendedProps.encounter; + const typeCode = encounter.type_code as AppointmentTypeCode; + const dateStr = format(new Date(effectiveEncounter.start), 'EEEE d MMMM yyyy', { locale: nl }); + const timeStr = format(new Date(effectiveEncounter.start), 'HH:mm'); + const endTimeStr = effectiveEncounter.end ? format(new Date(effectiveEncounter.end), 'HH:mm') : ''; + + return ( +
+
+

Weet je het zeker?

+ +
+ +
+
+
+ +
+

Deze actie kan niet ongedaan worden gemaakt.

+

De afspraak wordt permanent uit de agenda verwijderd.

+
+
+
+ +
+

{effectiveEncounter.title}

+
+
+ + {dateStr} +
+
+ + {timeStr} {endTimeStr && `- ${endTimeStr}`} +
+
+ Type: + {encounter.type_display || APPOINTMENT_TYPES[typeCode]} +
+
+
+ + {error && ( +
+ {error} +
+ )} +
+ +
+ + +
+
+ ); + } + + // Fallback / Loading + return ( +
+

Geen afspraak geselecteerd.

+ +
+ ); +} diff --git a/components/swift/artifacts/blocks/agenda-create-form.tsx b/components/swift/artifacts/blocks/agenda-create-form.tsx new file mode 100644 index 0000000..81a5b6a --- /dev/null +++ b/components/swift/artifacts/blocks/agenda-create-form.tsx @@ -0,0 +1,363 @@ +'use client'; + +import React, { useState, useEffect, useRef } from 'react'; +import { format, addHours, parseISO } from 'date-fns'; +import { nl } from 'date-fns/locale'; +import { Calendar, Clock, MapPin, User, Check, X, AlertCircle } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Textarea } from '@/components/ui/textarea'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { Badge } from '@/components/ui/badge'; +import { createEncounter } from '@/app/epd/agenda/actions'; +import { + APPOINTMENT_TYPES, + LOCATION_CLASSES, + AppointmentTypeCode, + LocationClassCode, + APPOINTMENT_TYPE_COLORS +} from '@/app/epd/agenda/types'; + +interface AgendaCreateFormProps { + prefillData?: { + patient?: { id: string; name: string }; + datetime?: { date: Date; time: string }; + type?: AppointmentTypeCode; + location?: LocationClassCode; + notes?: string; + }; + onClose?: () => void; +} + +interface PatientResult { + id: string; + name: string; + bsn?: string; + birthDate?: string; +} + +export function AgendaCreateForm({ prefillData, onClose }: AgendaCreateFormProps) { + // Form State + const [patientId, setPatientId] = useState(prefillData?.patient?.id || ''); + const [patientName, setPatientName] = useState(prefillData?.patient?.name || ''); + const [date, setDate] = useState( + prefillData?.datetime?.date ? format(prefillData.datetime.date, 'yyyy-MM-dd') : format(new Date(), 'yyyy-MM-dd') + ); + const [time, setTime] = useState(prefillData?.datetime?.time || '09:00'); + const [type, setType] = useState(prefillData?.type || 'behandeling'); + const [location, setLocation] = useState(prefillData?.location || 'AMB'); + const [notes, setNotes] = useState(prefillData?.notes || ''); + + // UI State + const [isSubmitting, setIsSubmitting] = useState(false); + const [error, setError] = useState(null); + + // Patient Search State + const [searchQuery, setSearchQuery] = useState(''); + const [searchResults, setSearchResults] = useState([]); + const [isSearching, setIsSearching] = useState(false); + const [showResults, setShowResults] = useState(false); + const searchRef = useRef(null); + + // Initialize search query if patient is prefilled but we want to allow editing + useEffect(() => { + if (prefillData?.patient?.name) { + setSearchQuery(prefillData.patient.name); + } + }, [prefillData]); + + // Handle outside click to close search results + useEffect(() => { + function handleClickOutside(event: MouseEvent) { + if (searchRef.current && !searchRef.current.contains(event.target as Node)) { + setShowResults(false); + } + } + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + // Debounced search + useEffect(() => { + const timer = setTimeout(async () => { + if (searchQuery.length < 2 || patientId) return; // Don't search if too short or if patient already selected + + setIsSearching(true); + try { + const res = await fetch(`/api/swift/patients/search?q=${encodeURIComponent(searchQuery)}`); + if (res.ok) { + const data = await res.json(); + setSearchResults(data.patients || []); + setShowResults(true); + } + } catch (err) { + console.error('Failed to search patients', err); + } finally { + setIsSearching(false); + } + }, 300); + + return () => clearTimeout(timer); + }, [searchQuery, patientId]); + + const handlePatientSelect = (patient: PatientResult) => { + setPatientId(patient.id); + setPatientName(patient.name); + setSearchQuery(patient.name); + setShowResults(false); + }; + + const handleSearchChange = (e: React.ChangeEvent) => { + setSearchQuery(e.target.value); + setPatientId(''); // Clear selection on edit + setPatientName(''); + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!patientId) { + setError('Selecteer a.u.b. een patiΓ«nt.'); + return; + } + + setIsSubmitting(true); + setError(null); + + try { + // Construct Date objects + const startDate = new Date(`${date}T${time}`); + const endDate = addHours(startDate, 1); // Default duration 1 hour + + // Map codes to displays + const typeDisplay = APPOINTMENT_TYPES[type]; + const locationDisplay = LOCATION_CLASSES[location]; + + const result = await createEncounter({ + patientId, + periodStart: startDate.toISOString(), + periodEnd: endDate.toISOString(), + typeCode: type, + typeDisplay, + classCode: location, + classDisplay: locationDisplay, + notes: notes || undefined, + }); + + if (result.success) { + onClose?.(); // Close on success + } else { + setError(result.error || 'Er is een fout opgetreden.'); + } + } catch (err) { + console.error('Submit error:', err); + setError('Er is een onverwachte fout opgetreden.'); + } finally { + setIsSubmitting(false); + } + }; + + return ( +
+ {/* Header */} +
+

Nieuwe afspraak inplannen

+ +
+ + {/* Form Body */} +
+ + {error && ( +
+ + {error} +
+ )} + + {/* Patient Selection */} +
+ +
+
+ +
+ + {isSearching && ( +
+
+
+ )} + + {showResults && searchResults.length > 0 && ( +
+ {searchResults.map((p) => ( + + ))} +
+ )} +
+
+ + {/* Date & Time */} +
+
+ +
+
+ +
+ setDate(e.target.value)} + className="pl-9" + required + /> +
+
+ +
+ +
+
+ +
+ setTime(e.target.value)} + className="pl-9" + required + /> +
+
+
+ + {/* Type Selection */} +
+ +
+ {(Object.keys(APPOINTMENT_TYPES) as AppointmentTypeCode[]).map((t) => { + const bg = APPOINTMENT_TYPE_COLORS[t].bg; + const text = APPOINTMENT_TYPE_COLORS[t].text; + const border = APPOINTMENT_TYPE_COLORS[t].border; + const isActive = type === t; + + return ( + + ); + })} +
+
+ + {/* Location Selection */} +
+ +
+ {(Object.keys(LOCATION_CLASSES) as LocationClassCode[]).map((l) => ( + + ))} +
+
+ + {/* Notes */} +
+ +