feat(swift): implement agenda planning module (Epic 4 UI)

- Add AgendaBlock core component with list, create, cancel, reschedule modes
- Implement AgendaListView with patient/type/location details and actions
- Implement AgendaCreateForm with fuzzy patient search and validation
- Implement AgendaCancelView with disambiguation support
- Implement AgendaRescheduleForm with date/time picker
- Integrate with server actions (create, cancel, reschedule)
- Add radio-group UI component
- Update documentation and status
This commit is contained in:
colinislit
2025-12-27 22:29:11 +01:00
parent 0031bd5fd1
commit a6b63665e1
18 changed files with 5874 additions and 19 deletions

View File

@@ -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 (
<AgendaListView
appointments={appointments}
dateRange={dateRange}
onClose={onClose}
onCancelAppointment={(evt) => console.log('Cancel requested', evt)}
onViewDetails={(evt) => window.location.href = `/epd/agenda?focus=${evt.id}`}
/>
);
case 'create':
return <AgendaCreateForm prefillData={prefillData} onClose={onClose} />;
case 'cancel':
return (
<AgendaCancelView
disambiguationOptions={disambiguationOptions}
prefillData={prefillData}
onClose={onClose}
/>
);
case 'reschedule':
return <AgendaRescheduleForm prefillData={prefillData} onClose={onClose} />;
default:
return <div className="p-4 text-red-500">Unknown mode: {mode}</div>;
}
};
return (
<div className="w-full max-w-[600px] max-h-[80vh] overflow-y-auto bg-white border rounded shadow-sm">
{renderContent()}
</div>
);
}

View File

@@ -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<string | undefined>(
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<string | null>(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 (
<div className="flex flex-col items-center justify-center p-8 text-center h-full">
<div className="bg-green-50 p-3 rounded-full mb-4">
<CheckCircle2 className="h-8 w-8 text-green-600" />
</div>
<h3 className="text-lg font-semibold text-gray-900">Afspraak geannuleerd</h3>
<p className="text-sm text-gray-500 mt-1 mb-6">De afspraak is succesvol verwijderd uit de agenda.</p>
<Button onClick={onClose} variant="outline">Sluiten</Button>
</div>
);
}
// Disambiguation Mode
if (!effectiveEncounter && disambiguationOptions && disambiguationOptions.length > 1) {
return (
<div className="flex flex-col h-full bg-white">
<div className="flex items-center justify-between p-4 border-b">
<h3 className="font-semibold text-lg text-red-700">Afspraak annuleren</h3>
<Button variant="ghost" size="icon" onClick={onClose} className="h-8 w-8 hover:bg-gray-100 rounded-full">
<X className="h-4 w-4" />
</Button>
</div>
<div className="flex-1 p-4 overflow-y-auto">
<p className="text-sm text-gray-600 mb-4">
Er zijn meerdere afspraken gevonden. Welke wil je annuleren?
</p>
<RadioGroup value={selectedEncounterId} onValueChange={setSelectedEncounterId} className="space-y-3">
{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 (
<div key={evt.id} className="flex items-center space-x-2 border rounded-lg p-3 hover:bg-gray-50 cursor-pointer">
<RadioGroupItem value={evt.id} id={evt.id} />
<Label htmlFor={evt.id} className="flex-1 cursor-pointer">
<div className="font-medium text-gray-900">{evt.title}</div>
<div className="text-sm text-gray-500">
{dateStr} om {timeStr} {encounter.type_display || APPOINTMENT_TYPES[typeCode]}
</div>
</Label>
</div>
);
})}
</RadioGroup>
</div>
<div className="p-4 bg-gray-50 border-t flex justify-between gap-3">
<Button variant="outline" onClick={onClose} className="flex-1">Annuleren</Button>
<Button
onClick={() => { /* State updates automatically via RadioGroup */ }}
disabled={!selectedEncounterId}
className="flex-1 bg-red-600 hover:bg-red-700 text-white"
>
Volgende
</Button>
</div>
</div>
);
}
// 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 (
<div className="flex flex-col h-full bg-white">
<div className="flex items-center justify-between p-4 border-b">
<h3 className="font-semibold text-lg text-red-700">Weet je het zeker?</h3>
<Button variant="ghost" size="icon" onClick={onClose} className="h-8 w-8 hover:bg-gray-100 rounded-full">
<X className="h-4 w-4" />
</Button>
</div>
<div className="flex-1 p-4">
<div className="bg-red-50 border border-red-100 rounded-lg p-4 mb-6">
<div className="flex items-start gap-3">
<AlertTriangle className="h-5 w-5 text-red-600 mt-0.5" />
<div className="text-sm text-red-800">
<p className="font-medium">Deze actie kan niet ongedaan worden gemaakt.</p>
<p className="mt-1 opacity-90">De afspraak wordt permanent uit de agenda verwijderd.</p>
</div>
</div>
</div>
<div className="border rounded-lg p-4 bg-white shadow-sm">
<h4 className="font-medium text-gray-900 mb-2">{effectiveEncounter.title}</h4>
<div className="space-y-2 text-sm text-gray-600">
<div className="flex items-center gap-2">
<Calendar className="h-4 w-4 text-gray-400" />
<span className="capitalize">{dateStr}</span>
</div>
<div className="flex items-center gap-2">
<Clock className="h-4 w-4 text-gray-400" />
<span>{timeStr} {endTimeStr && `- ${endTimeStr}`}</span>
</div>
<div className="flex items-center gap-2">
<span className="inline-block w-20 text-gray-400">Type:</span>
<span className="font-medium">{encounter.type_display || APPOINTMENT_TYPES[typeCode]}</span>
</div>
</div>
</div>
{error && (
<div className="mt-4 p-3 bg-red-100 text-red-700 text-sm rounded-md">
{error}
</div>
)}
</div>
<div className="p-4 bg-gray-50 border-t flex justify-between gap-3">
<Button variant="outline" onClick={onClose} disabled={isSubmitting} className="flex-1">
Terug
</Button>
<Button
onClick={handleCancel}
disabled={isSubmitting}
className="flex-1 bg-red-600 hover:bg-red-700 text-white"
>
{isSubmitting ? 'Annuleren...' : 'Ja, annuleer afspraak'}
</Button>
</div>
</div>
);
}
// Fallback / Loading
return (
<div className="p-4">
<p>Geen afspraak geselecteerd.</p>
<Button onClick={onClose} variant="link">Sluiten</Button>
</div>
);
}

View File

@@ -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<string>(prefillData?.patient?.id || '');
const [patientName, setPatientName] = useState<string>(prefillData?.patient?.name || '');
const [date, setDate] = useState<string>(
prefillData?.datetime?.date ? format(prefillData.datetime.date, 'yyyy-MM-dd') : format(new Date(), 'yyyy-MM-dd')
);
const [time, setTime] = useState<string>(prefillData?.datetime?.time || '09:00');
const [type, setType] = useState<AppointmentTypeCode>(prefillData?.type || 'behandeling');
const [location, setLocation] = useState<LocationClassCode>(prefillData?.location || 'AMB');
const [notes, setNotes] = useState<string>(prefillData?.notes || '');
// UI State
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
// Patient Search State
const [searchQuery, setSearchQuery] = useState('');
const [searchResults, setSearchResults] = useState<PatientResult[]>([]);
const [isSearching, setIsSearching] = useState(false);
const [showResults, setShowResults] = useState(false);
const searchRef = useRef<HTMLDivElement>(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<HTMLInputElement>) => {
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 (
<div className="flex flex-col h-full bg-white">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b">
<h3 className="font-semibold text-lg text-teal-700">Nieuwe afspraak inplannen</h3>
<Button variant="ghost" size="icon" onClick={onClose} className="h-8 w-8 hover:bg-gray-100 rounded-full">
<X className="h-4 w-4" />
</Button>
</div>
{/* Form Body */}
<form onSubmit={handleSubmit} className="flex-1 overflow-y-auto p-4 space-y-5">
{error && (
<div className="p-3 bg-red-50 border border-red-200 rounded-md flex items-center gap-2 text-sm text-red-700">
<AlertCircle className="h-4 w-4" />
{error}
</div>
)}
{/* Patient Selection */}
<div className="space-y-1.5" ref={searchRef}>
<Label htmlFor="patient" className="text-sm font-medium text-gray-700">Patiënt <span className="text-red-500">*</span></Label>
<div className="relative">
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400">
<User className="h-4 w-4" />
</div>
<Input
id="patient"
value={searchQuery}
onChange={handleSearchChange}
placeholder="Zoek op naam..."
className="pl-9"
autoComplete="off"
/>
{isSearching && (
<div className="absolute right-3 top-1/2 -translate-y-1/2">
<div className="animate-spin h-3 w-3 border-2 border-teal-500 border-t-transparent rounded-full"></div>
</div>
)}
{showResults && searchResults.length > 0 && (
<div className="absolute z-10 w-full mt-1 bg-white border rounded-md shadow-lg max-h-48 overflow-y-auto">
{searchResults.map((p) => (
<button
key={p.id}
type="button"
onClick={() => handlePatientSelect(p)}
className="w-full text-left px-3 py-2 hover:bg-teal-50 text-sm flex flex-col border-b last:border-0"
>
<span className="font-medium text-gray-900">{p.name}</span>
<span className="text-xs text-gray-500">
{p.birthDate && format(new Date(p.birthDate), 'dd-MM-yyyy')}
{p.bsn && ` • BSN: ${p.bsn}`}
</span>
</button>
))}
</div>
)}
</div>
</div>
{/* Date & Time */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label htmlFor="date" className="text-sm font-medium text-gray-700">Datum <span className="text-red-500">*</span></Label>
<div className="relative">
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400">
<Calendar className="h-4 w-4" />
</div>
<Input
id="date"
type="date"
value={date}
onChange={(e) => setDate(e.target.value)}
className="pl-9"
required
/>
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="time" className="text-sm font-medium text-gray-700">Tijd <span className="text-red-500">*</span></Label>
<div className="relative">
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400">
<Clock className="h-4 w-4" />
</div>
<Input
id="time"
type="time"
value={time}
onChange={(e) => setTime(e.target.value)}
className="pl-9"
required
/>
</div>
</div>
</div>
{/* Type Selection */}
<div className="space-y-1.5">
<Label className="text-sm font-medium text-gray-700">Type afspraak <span className="text-red-500">*</span></Label>
<div className="grid grid-cols-2 gap-2">
{(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 (
<button
key={t}
type="button"
onClick={() => setType(t)}
className={`
px-3 py-2 text-xs font-medium rounded-md border text-left transition-all
${isActive ? 'ring-2 ring-offset-1 ring-teal-500' : 'hover:bg-gray-50'}
`}
style={{
backgroundColor: isActive ? bg : 'white',
color: isActive ? text : '#374151',
borderColor: isActive ? border : '#e5e7eb'
}}
>
<div className="flex items-center justify-between">
<span>{APPOINTMENT_TYPES[t]}</span>
{isActive && <Check className="h-3 w-3" />}
</div>
</button>
);
})}
</div>
</div>
{/* Location Selection */}
<div className="space-y-1.5">
<Label className="text-sm font-medium text-gray-700">Locatie <span className="text-red-500">*</span></Label>
<div className="flex gap-2">
{(Object.keys(LOCATION_CLASSES) as LocationClassCode[]).map((l) => (
<button
key={l}
type="button"
onClick={() => setLocation(l)}
className={`
flex-1 py-2 px-3 text-xs font-medium rounded-md border flex items-center justify-center gap-1.5 transition-all
${location === l
? 'bg-teal-50 border-teal-200 text-teal-800 ring-2 ring-teal-500 ring-opacity-20'
: 'bg-white border-gray-200 text-gray-600 hover:bg-gray-50'}
`}
>
{l === 'AMB' && <MapPin className="h-3 w-3" />}
{l === 'VR' && <div className="h-3 w-3 border rounded-full" />}
{l === 'HH' && <div className="h-3 w-3 bg-current rounded-sm" />}
{LOCATION_CLASSES[l]}
</button>
))}
</div>
</div>
{/* Notes */}
<div className="space-y-1.5">
<Label htmlFor="notes" className="text-sm font-medium text-gray-700">Notities (optioneel)</Label>
<Textarea
id="notes"
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Bijv. bijzonderheden, reden van komst..."
className="h-20 text-sm resize-none"
/>
</div>
<div className="pt-2"></div>
</form>
{/* Footer / Actions */}
<div className="p-4 bg-gray-50 border-t flex justify-between gap-3">
<Button variant="outline" type="button" onClick={onClose} disabled={isSubmitting} className="flex-1">
Annuleren
</Button>
<Button
type="button"
onClick={handleSubmit}
disabled={isSubmitting}
className="flex-1 bg-teal-600 hover:bg-teal-700 text-white"
>
{isSubmitting ? (
<div className="flex items-center gap-2">
<div className="h-3 w-3 border-2 border-white/50 border-t-white rounded-full animate-spin" />
<span>Bezig...</span>
</div>
) : (
'Afspraak inplannen'
)}
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,172 @@
'use client';
import React from 'react';
import { format } from 'date-fns';
import { nl } from 'date-fns/locale';
import { Calendar, MapPin, Globe, Home, Clock, X, Info, ChevronRight, Ban } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import {
CalendarEvent,
APPOINTMENT_TYPE_COLORS,
APPOINTMENT_TYPES,
LOCATION_CLASSES,
LocationClassCode,
AppointmentTypeCode
} from '@/app/epd/agenda/types';
interface AgendaListViewProps {
appointments?: CalendarEvent[];
dateRange?: { start: Date; end: Date; label: string };
onClose?: () => void;
onCancelAppointment?: (encounter: CalendarEvent) => void;
onViewDetails?: (encounter: CalendarEvent) => void;
}
export function AgendaListView({
appointments = [],
dateRange,
onClose,
onCancelAppointment,
onViewDetails
}: AgendaListViewProps) {
const formatDateLabel = () => {
if (dateRange?.label) {
if (dateRange.label === 'vandaag' || dateRange.label === 'morgen') {
const dateStr = format(dateRange.start, 'd MMMM', { locale: nl });
return `Afspraken ${dateRange.label} - ${dateStr}`;
}
return `Afspraken ${dateRange.label}`;
}
// Fallback if no specific label
if (appointments.length > 0) {
return `Afspraken ${format(new Date(appointments[0].start), 'd MMMM', { locale: nl })}`;
}
return 'Afspraken';
};
const getLocationIcon = (classCode: string) => {
switch (classCode as LocationClassCode) {
case 'AMB': return <MapPin className="h-3 w-3" />;
case 'VR': return <Globe className="h-3 w-3" />;
case 'HH': return <Home className="h-3 w-3" />;
default: return <MapPin className="h-3 w-3" />;
}
};
return (
<div className="flex flex-col h-full bg-white">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b">
<div className="flex items-center gap-2 text-teal-700">
<Calendar className="h-5 w-5" />
<h3 className="font-semibold text-lg capitalize">{formatDateLabel()}</h3>
</div>
<Button variant="ghost" size="icon" onClick={onClose} className="h-8 w-8 hover:bg-gray-100 rounded-full">
<X className="h-4 w-4" />
</Button>
</div>
{/* Body */}
<div className="flex-1 overflow-y-auto p-4 space-y-3">
{appointments.length === 0 ? (
<div className="flex flex-col items-center justify-center py-10 text-center text-gray-500">
<div className="bg-gray-50 p-4 rounded-full mb-3">
<Calendar className="h-8 w-8 text-gray-400" />
</div>
<p className="font-medium">Geen afspraken gevonden</p>
<p className="text-sm text-gray-400 mt-1">
Er staan geen afspraken gepland voor deze periode.
</p>
<Button variant="outline" className="mt-4 gap-2 text-teal-600 border-teal-200 hover:bg-teal-50">
<span className="text-lg leading-none">+</span> Maak nieuwe afspraak
</Button>
</div>
) : (
appointments.map((evt) => {
const encounter = evt.extendedProps.encounter;
const patient = evt.extendedProps.patient;
const typeCode = encounter.type_code as AppointmentTypeCode;
const typeColor = APPOINTMENT_TYPE_COLORS[typeCode] || APPOINTMENT_TYPE_COLORS.overig;
const classCode = encounter.class_code as LocationClassCode;
const startTime = format(new Date(evt.start), 'HH:mm');
const endTime = evt.end ? format(new Date(evt.end), 'HH:mm') : '';
return (
<div
key={evt.id}
className="group border rounded-lg p-3 hover:border-teal-200 hover:shadow-sm transition-all bg-white"
>
<div className="flex justify-between items-start mb-2">
<div className="flex flex-col">
<div className="flex items-center gap-2 text-sm font-medium text-gray-900">
<Clock className="h-3.5 w-3.5 text-gray-400" />
<span>{startTime} {endTime && `- ${endTime}`}</span>
</div>
<button
className="text-left font-semibold text-teal-700 hover:underline mt-0.5"
onClick={() => console.log('Open patient context', patient?.id)}
>
{evt.title}
</button>
</div>
<Badge
variant="outline"
style={{
backgroundColor: typeColor.bg,
color: typeColor.text,
borderColor: typeColor.border
}}
className="whitespace-nowrap shadow-none"
>
{encounter.type_display || APPOINTMENT_TYPES[typeCode] || typeCode}
</Badge>
</div>
<div className="flex items-center gap-4 text-xs text-gray-500 mt-2">
<div className="flex items-center gap-1.5" title={LOCATION_CLASSES[classCode]}>
{getLocationIcon(classCode)}
<span>{encounter.class_display || LOCATION_CLASSES[classCode] || classCode}</span>
</div>
{encounter.status === 'cancelled' && (
<Badge variant="destructive" className="h-5 px-1.5">Geannuleerd</Badge>
)}
</div>
<div className="flex items-center justify-end gap-2 mt-3 pt-2 border-t border-gray-50 opacity-0 group-hover:opacity-100 transition-opacity">
<Button
variant="ghost"
size="sm"
className="h-7 text-xs text-gray-500 hover:text-red-600 hover:bg-red-50"
onClick={() => onCancelAppointment?.(evt)}
>
Annuleren
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 text-xs text-teal-600 hover:bg-teal-50"
onClick={() => onViewDetails?.(evt)}
>
Details <ChevronRight className="ml-1 h-3 w-3" />
</Button>
</div>
</div>
);
})
)}
</div>
{/* Footer */}
<div className="p-3 bg-gray-50 border-t text-center">
<a
href="/epd/agenda"
className="text-xs font-medium text-teal-600 hover:text-teal-700 hover:underline inline-flex items-center gap-1"
>
Open volledige agenda <ChevronRight className="h-3 w-3" />
</a>
</div>
</div>
);
}

View File

@@ -0,0 +1,210 @@
'use client';
import React, { useState, useEffect } from 'react';
import { format, addHours } from 'date-fns';
import { nl } from 'date-fns/locale';
import { Calendar, Clock, X, ArrowRight, AlertCircle, CheckCircle2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { rescheduleEncounter } from '@/app/epd/agenda/actions';
import { CalendarEvent } from '@/app/epd/agenda/types';
interface AgendaRescheduleFormProps {
prefillData?: {
identifier?: { encounterId?: string; encounter?: CalendarEvent };
newDatetime?: { date: Date; time: string };
};
onClose?: () => void;
}
export function AgendaRescheduleForm({ prefillData, onClose }: AgendaRescheduleFormProps) {
// Use encounter from prefill if provided directly (we might need to pass it in prefillData from parent)
const encounter = prefillData?.identifier?.encounter;
const encounterId = prefillData?.identifier?.encounterId;
// State for new date/time
const [date, setDate] = useState<string>(
prefillData?.newDatetime?.date
? format(prefillData.newDatetime.date, 'yyyy-MM-dd')
: encounter ? format(new Date(encounter.start), 'yyyy-MM-dd') : format(new Date(), 'yyyy-MM-dd')
);
const [time, setTime] = useState<string>(
prefillData?.newDatetime?.time
? prefillData.newDatetime.time
: encounter ? format(new Date(encounter.start), 'HH:mm') : '09:00'
);
const [isSubmitting, setIsSubmitting] = useState(false);
const [isSuccess, setIsSuccess] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!encounterId) {
setError('Geen afspraak ID gevonden.');
return;
}
setIsSubmitting(true);
setError(null);
try {
const startDate = new Date(`${date}T${time}`);
// Keep same duration? For simplicty default to 1 hour or original duration if we knew it.
// API rescheduleEncounter takes start/end.
let endDate = addHours(startDate, 1);
if (encounter && encounter.end) {
const originalDuration = new Date(encounter.end).getTime() - new Date(encounter.start).getTime();
endDate = new Date(startDate.getTime() + originalDuration);
}
// Check if not in past
if (startDate < new Date()) {
setError('Kan niet verplaatsen naar een datum in het verleden.');
setIsSubmitting(false);
return;
}
const result = await rescheduleEncounter(
encounterId,
startDate.toISOString(),
endDate.toISOString()
);
if (result.success) {
setIsSuccess(true);
setTimeout(() => onClose?.(), 2000);
} else {
setError(result.error || 'Kon de afspraak niet verzetten.');
}
} catch (err) {
console.error('Reschedule error:', err);
setError('Er is een onverwachte fout opgetreden.');
} finally {
setIsSubmitting(false);
}
};
if (isSuccess) {
return (
<div className="flex flex-col items-center justify-center p-8 text-center h-full">
<div className="bg-green-50 p-3 rounded-full mb-4">
<CheckCircle2 className="h-8 w-8 text-green-600" />
</div>
<h3 className="text-lg font-semibold text-gray-900">Afspraak verzet</h3>
<p className="text-sm text-gray-500 mt-1 mb-6">
De afspraak is verplaatst naar {format(new Date(`${date}T${time}`), 'd MMMM HH:mm', { locale: nl })}.
</p>
<Button onClick={onClose} variant="outline">Sluiten</Button>
</div>
);
}
if (!encounter && !encounterId) {
return (
<div className="p-4 text-center">
<p className="text-red-500">Geen afspraak gevonden om te verzetten.</p>
<Button onClick={onClose} variant="link">Sluiten</Button>
</div>
);
}
// Calculate current date/time for display
const currentStart = encounter ? new Date(encounter.start) : null;
const currentStr = currentStart ? format(currentStart, 'd MMMM yyyy HH:mm', { locale: nl }) : 'Onbekend';
return (
<div className="flex flex-col h-full bg-white">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b">
<h3 className="font-semibold text-lg text-teal-700">Afspraak verzetten</h3>
<Button variant="ghost" size="icon" onClick={onClose} className="h-8 w-8 hover:bg-gray-100 rounded-full">
<X className="h-4 w-4" />
</Button>
</div>
{/* Form */}
<form onSubmit={handleSubmit} className="flex-1 p-4 overflow-y-auto space-y-6">
{/* Info Card */}
<div className="bg-blue-50 border border-blue-100 rounded-lg p-4">
{encounter && <h4 className="font-medium text-blue-900 mb-2">{encounter.title}</h4>}
<div className="flex items-center gap-2 text-sm text-blue-700 opacity-80 decoration-slate-400">
<Calendar className="h-4 w-4" />
<span className="line-through decoration-blue-900/40">{currentStr}</span>
</div>
<div className="flex justify-center my-1">
<ArrowRight className="h-4 w-4 text-blue-400 rotate-90 sm:rotate-0" />
</div>
<div className="flex items-center gap-2 text-sm font-semibold text-blue-800">
<Calendar className="h-4 w-4" />
<span>
{date && time ? format(new Date(`${date}T${time}`), 'd MMMM yyyy HH:mm', { locale: nl }) : '...'}
</span>
</div>
</div>
{error && (
<div className="p-3 bg-red-50 border border-red-200 rounded-md flex items-center gap-2 text-sm text-red-700">
<AlertCircle className="h-4 w-4" />
{error}
</div>
)}
<div className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="date" className="text-sm font-medium text-gray-700">Nieuwe datum</Label>
<div className="relative">
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400">
<Calendar className="h-4 w-4" />
</div>
<Input
id="date"
type="date"
value={date}
onChange={(e) => setDate(e.target.value)}
className="pl-9"
required
/>
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="time" className="text-sm font-medium text-gray-700">Nieuwe tijd</Label>
<div className="relative">
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400">
<Clock className="h-4 w-4" />
</div>
<Input
id="time"
type="time"
value={time}
onChange={(e) => setTime(e.target.value)}
className="pl-9"
required
/>
</div>
</div>
</div>
</form>
{/* Footer */}
<div className="p-4 bg-gray-50 border-t flex justify-between gap-3">
<Button variant="outline" type="button" onClick={onClose} disabled={isSubmitting} className="flex-1">
Annuleren
</Button>
<Button
type="button"
onClick={handleSubmit}
disabled={isSubmitting}
className="flex-1 bg-teal-600 hover:bg-teal-700 text-white"
>
{isSubmitting ? 'Verplaatsen...' : 'Bevestig wijziging'}
</Button>
</div>
</div>
);
}

View File

@@ -68,10 +68,13 @@ export function FallbackPicker({ originalInput }: FallbackPickerProps) {
openBlock(option.type, prefillData); openBlock(option.type, prefillData);
addRecentAction({ // Only add to recent actions if it's a valid SwiftIntent (not patient-dashboard)
intent: option.type, if (option.type !== 'patient-dashboard') {
label: option.label, addRecentAction({
}); intent: option.type,
label: option.label,
});
}
}, },
[openBlock, addRecentAction, originalInput] [openBlock, addRecentAction, originalInput]
); );

View File

@@ -0,0 +1,44 @@
"use client"
import * as React from "react"
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
import { Circle } from "lucide-react"
import { cn } from "@/lib/utils"
const RadioGroup = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
>(({ className, ...props }, ref) => {
return (
<RadioGroupPrimitive.Root
className={cn("grid gap-2", className)}
{...props}
ref={ref}
/>
)
})
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName
const RadioGroupItem = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
>(({ className, ...props }, ref) => {
return (
<RadioGroupPrimitive.Item
ref={ref}
className={cn(
"aspect-square h-4 w-4 rounded-full border border-primary text-primary shadow focus:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
<Circle className="h-3.5 w-3.5 fill-primary" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
)
})
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName
export { RadioGroup, RadioGroupItem }

View File

@@ -0,0 +1,879 @@
# Architecture: Intent System Schaalbaarheid
**Document:** Intent System Scalability & Optimization
**Versie:** 1.0
**Datum:** 27-12-2024
**Auteur:** Colin Lit
---
## 📊 Probleem Analyse
### Huidige Situatie
**Aantal intents:** 7 (dagnotitie, zoeken, overdracht, + 4 agenda intents)
**Patterns per intent:** ~5-10
**Totaal patterns:** ~60
**Performance nu:**
- Classification time: ~10-15ms
- O(n) linear search door alle patterns
- Acceptable voor huidige schaal
### Toekomstige Schaal (geschat)
Bij volledige EPD uitbreiding:
| Module | Nieuwe Intents | Patterns per Intent | Totaal |
|--------|----------------|---------------------|--------|
| **Medicatie** | 5 (voorschrijven, toedienen, stop, bijwerking, controle) | 8 | 40 |
| **Diagnostiek** | 4 (lab aanvragen, uitslagen, röntgen, echo) | 6 | 24 |
| **Behandelplan** | 4 (maken, wijzigen, evalueren, afsluiten) | 7 | 28 |
| **Verpleegkundige acties** | 6 (wondverzorging, katheter, infuus, etc.) | 5 | 30 |
| **Communicatie** | 3 (brief, consult aanvraag, telefoonnota) | 6 | 18 |
| **Rapportages** | 5 (MDO, intake, evaluatie, ontslagbrief) | 7 | 35 |
| **Huidig** | 7 | ~8 | 60 |
| **TOTAAL** | **34 intents** | **~7 avg** | **~235 patterns** |
**Geschatte performance bij 235 patterns:**
- Classification time: ~40-60ms (4x slower)
- Meer pattern conflicts (overlap)
- Moeilijker te maintainen
---
## 🎯 Optimalisatie Strategieën
## Strategie 1: Categoriegebaseerde Hierarchie ⭐ **AANBEVOLEN**
### Concept
Groepeer intents in categorieën en gebruik **two-phase classification**:
1. **Phase 1:** Detect categorie (snel, 5-10 opties)
2. **Phase 2:** Detect intent binnen categorie (kleiner search space)
### Categorie Structuur
```typescript
enum IntentCategory {
DOCUMENTATION = 'documentation', // Notities, rapportages
PATIENT_CARE = 'patient_care', // Medicatie, metingen, acties
SCHEDULING = 'scheduling', // Agenda, planning
COMMUNICATION = 'communication', // Brieven, consults
DIAGNOSTIC = 'diagnostic', // Lab, beeldvorming
ADMINISTRATIVE = 'administrative', // Overdracht, MDO
SEARCH = 'search', // Zoeken, info opvragen
}
type SwiftIntent =
// DOCUMENTATION
| 'dagnotitie'
| 'rapportage_intake'
| 'rapportage_evaluatie'
| 'rapportage_ontslag'
| 'vrije_notitie'
// PATIENT_CARE
| 'medicatie_toedienen'
| 'medicatie_voorschrijven'
| 'medicatie_stop'
| 'meting_vitaal'
| 'wondverzorging'
| 'katheter_verzorging'
// SCHEDULING
| 'agenda_query'
| 'create_appointment'
| 'cancel_appointment'
| 'reschedule_appointment'
// DIAGNOSTIC
| 'lab_aanvraag'
| 'lab_uitslag'
| 'rontgen_aanvraag'
| 'echo_aanvraag'
// COMMUNICATION
| 'brief_huisarts'
| 'consult_aanvraag'
| 'telefoonnota'
// ADMINISTRATIVE
| 'overdracht'
| 'mdo_verslag'
// SEARCH
| 'zoeken'
| 'patient_info'
| 'medicatie_info'
| 'unknown';
```
### Implementation
```typescript
// lib/swift/intent-classifier-hierarchical.ts
interface CategoryPattern {
pattern: RegExp;
category: IntentCategory;
weight: number;
}
// Step 1: Category patterns (small set, ~20 patterns)
const CATEGORY_PATTERNS: CategoryPattern[] = [
// DOCUMENTATION keywords
{ pattern: /\b(notitie|rapportage|verslag|schrijf|document)\b/i,
category: IntentCategory.DOCUMENTATION, weight: 0.9 },
// PATIENT_CARE keywords
{ pattern: /\b(medicatie|toedien|voorschrijf|bloeddruk|temperatuur|pols|wond|katheter|infuus)\b/i,
category: IntentCategory.PATIENT_CARE, weight: 0.9 },
// SCHEDULING keywords
{ pattern: /\b(afspraak|agenda|planning|verzet|annuleer|plan)\b/i,
category: IntentCategory.SCHEDULING, weight: 0.95 },
// DIAGNOSTIC keywords
{ pattern: /\b(lab|bloed|urine|röntgen|echo|scan|onderzoek)\b/i,
category: IntentCategory.DIAGNOSTIC, weight: 0.9 },
// COMMUNICATION keywords
{ pattern: /\b(brief|consult|telefoon|contact|specialist)\b/i,
category: IntentCategory.COMMUNICATION, weight: 0.85 },
// ADMINISTRATIVE keywords
{ pattern: /\b(overdracht|mdo|bespreking|overleg)\b/i,
category: IntentCategory.ADMINISTRATIVE, weight: 0.9 },
// SEARCH keywords (should be last, lowest priority)
{ pattern: /\b(zoek|vind|wie|waar|wanneer|info|gegevens)\b/i,
category: IntentCategory.SEARCH, weight: 0.7 },
];
// Step 2: Intent patterns per category (smaller sets)
const INTENT_PATTERNS_BY_CATEGORY: Record<IntentCategory, Record<string, PatternConfig[]>> = {
[IntentCategory.DOCUMENTATION]: {
dagnotitie: [
{ pattern: /^dagnotitie\b/i, weight: 1.0 },
{ pattern: /^notitie\b/i, weight: 1.0 },
{ pattern: /^\w+\s+(medicatie|adl|gedrag)/i, weight: 0.9 },
],
rapportage_intake: [
{ pattern: /^intake\b/i, weight: 1.0 },
{ pattern: /\bintake\s+(verslag|rapportage)\b/i, weight: 1.0 },
],
vrije_notitie: [
{ pattern: /^vrije\s+notitie\b/i, weight: 1.0 },
{ pattern: /^schrijf\b/i, weight: 0.8 },
],
},
[IntentCategory.PATIENT_CARE]: {
medicatie_toedienen: [
{ pattern: /^medicatie\s+(geven|toedienen)/i, weight: 1.0 },
{ pattern: /^(geef|toedienen)\s+medicatie/i, weight: 1.0 },
{ pattern: /^\w+\s+medicatie\s+(gegeven|toegediend)/i, weight: 0.95 },
],
medicatie_voorschrijven: [
{ pattern: /^voorschrijf\s+medicatie/i, weight: 1.0 },
{ pattern: /^medicatie\s+voorschrijven/i, weight: 1.0 },
{ pattern: /^start\s+medicatie/i, weight: 0.95 },
],
meting_vitaal: [
{ pattern: /^(bloeddruk|temperatuur|pols|saturatie)\b/i, weight: 1.0 },
{ pattern: /^vitale\s+(functies|metingen)/i, weight: 1.0 },
{ pattern: /^\w+\s+(bloeddruk|temperatuur)/i, weight: 0.9 },
],
},
[IntentCategory.SCHEDULING]: {
agenda_query: [
{ pattern: /^afspraken?\b/i, weight: 1.0 },
{ pattern: /^agenda\b/i, weight: 1.0 },
{ pattern: /^wat\s+zijn\s+mijn\s+afspraken/i, weight: 1.0 },
],
create_appointment: [
{ pattern: /^maak\s+afspraak/i, weight: 1.0 },
{ pattern: /^plan\s+(intake|afspraak)/i, weight: 1.0 },
],
cancel_appointment: [
{ pattern: /^annuleer\s+afspraak/i, weight: 1.0 },
],
},
// ... other categories
};
// Two-phase classification
export function classifyIntentHierarchical(input: string): ClassificationResult {
const startTime = performance.now();
// PHASE 1: Detect category (fast, ~20 patterns)
let bestCategory: IntentCategory | null = null;
let categoryConfidence = 0;
for (const { pattern, category, weight } of CATEGORY_PATTERNS) {
if (pattern.test(input)) {
if (weight > categoryConfidence) {
bestCategory = category;
categoryConfidence = weight;
}
}
}
// If no category detected, use SEARCH as fallback
if (!bestCategory || categoryConfidence < 0.5) {
bestCategory = IntentCategory.SEARCH;
}
// PHASE 2: Detect intent within category (smaller search space)
const categoryIntents = INTENT_PATTERNS_BY_CATEGORY[bestCategory];
let bestIntent: SwiftIntent = 'unknown';
let intentConfidence = 0;
for (const [intent, patterns] of Object.entries(categoryIntents)) {
for (const { pattern, weight } of patterns) {
if (pattern.test(input)) {
if (weight > intentConfidence) {
bestIntent = intent as SwiftIntent;
intentConfidence = weight;
}
if (weight === 1.0) break; // Perfect match
}
}
if (intentConfidence === 1.0) break;
}
const processingTimeMs = performance.now() - startTime;
return {
intent: bestIntent,
confidence: Math.min(categoryConfidence, intentConfidence), // Take lowest
category: bestCategory,
processingTimeMs,
};
}
```
### Performance Impact
**Voor 34 intents met 235 patterns:**
| Metric | Flat Structure | Hierarchical | Improvement |
|--------|----------------|--------------|-------------|
| Avg patterns tested | 117 (~50%) | 10 + 12 = 22 | **5.3x faster** |
| Worst case | 235 (all) | 20 + 35 = 55 | **4.3x faster** |
| Best case | 1 | 1 + 1 = 2 | Similar |
| Estimated time | ~50ms | ~12ms | **4.2x faster** |
**Complexity:**
- Flat: O(n) where n = total patterns
- Hierarchical: O(c + i) where c = category patterns, i = intent patterns in category
- Typically: c ≈ 20, i ≈ 10-15 → O(30-35) vs O(235)
---
## Strategie 2: Keyword Index / Trie Structure
### Concept
Pre-index patterns by first keyword voor instant lookup.
```typescript
// Build index at startup
const KEYWORD_INDEX = new Map<string, IntentPattern[]>();
// Index building
for (const [intent, patterns] of Object.entries(INTENT_PATTERNS)) {
for (const pattern of patterns) {
const keywords = extractKeywords(pattern);
for (const keyword of keywords) {
if (!KEYWORD_INDEX.has(keyword)) {
KEYWORD_INDEX.set(keyword, []);
}
KEYWORD_INDEX.get(keyword)!.push({ intent, pattern });
}
}
}
// Fast lookup
function classifyWithIndex(input: string): ClassificationResult {
const firstWord = input.trim().split(/\s+/)[0].toLowerCase();
// O(1) lookup
const candidatePatterns = KEYWORD_INDEX.get(firstWord) || [];
// Test only relevant patterns (typically 3-10 instead of 235)
for (const { intent, pattern } of candidatePatterns) {
if (pattern.test(input)) {
return { intent, confidence: pattern.weight };
}
}
// Fallback: test all patterns (rare)
return classifyFull(input);
}
```
**Voordelen:**
- ✅ O(1) lookup voor common patterns
- ✅ Makkelijk te implementeren
- ✅ Backward compatible
**Nadelen:**
- ❌ Misses patterns zonder duidelijk keyword
- ❌ Extra memory overhead
- ❌ Requires maintenance of index
---
## Strategie 3: Intent Prioriteit (Analytics-Driven)
### Concept
Order intents op basis van gebruiksfrequentie.
```typescript
interface IntentMetrics {
intent: SwiftIntent;
frequency: number; // Times used
avgConfidence: number; // Average confidence
avgProcessingTime: number;
}
// Track usage
const INTENT_STATS = new Map<SwiftIntent, IntentMetrics>();
function trackIntentUsage(intent: SwiftIntent, confidence: number, time: number) {
const stats = INTENT_STATS.get(intent) || {
intent,
frequency: 0,
avgConfidence: 0,
avgProcessingTime: 0,
};
stats.frequency++;
stats.avgConfidence = (stats.avgConfidence * (stats.frequency - 1) + confidence) / stats.frequency;
stats.avgProcessingTime = (stats.avgProcessingTime * (stats.frequency - 1) + time) / stats.frequency;
INTENT_STATS.set(intent, stats);
}
// Periodically reorder patterns based on frequency
function optimizePatternOrder() {
const sorted = Array.from(INTENT_STATS.values())
.sort((a, b) => b.frequency - a.frequency);
// Rebuild INTENT_PATTERNS with high-frequency intents first
const optimized = {};
for (const { intent } of sorted) {
optimized[intent] = INTENT_PATTERNS[intent];
}
return optimized;
}
```
**Impact:**
Als 80% van queries 3 intents gebruikt (dagnotitie, agenda_query, zoeken):
- Average patterns tested: 15 instead of 117
- **7.8x speedup** for common cases
---
## Strategie 4: Compositional Intents
### Concept
Split intents in **base action** + **subject** + **modifiers**.
```typescript
// Instead of flat intents:
type OldIntent =
| 'medicatie_toedienen'
| 'medicatie_voorschrijven'
| 'medicatie_stop'
| 'medicatie_bijwerking'
| 'lab_aanvraag'
| 'lab_uitslag'
| 'rontgen_aanvraag'
// ... 30+ more
// Use compositional structure:
interface ComposedIntent {
action: Action; // toedienen, voorschrijven, aanvragen, etc.
subject: Subject; // medicatie, lab, röntgen, etc.
modifiers?: Modifier[]; // urgent, herhaling, etc.
}
type Action =
| 'create' | 'read' | 'update' | 'delete' // CRUD
| 'toedienen' | 'voorschrijven' | 'stop' // Medicatie-specific
| 'aanvragen' | 'bekijken' | 'afmelden' // Request-specific
;
type Subject =
| 'medicatie' | 'lab' | 'rontgen' | 'echo'
| 'afspraak' | 'notitie' | 'brief'
;
type Modifier =
| 'urgent' | 'spoed' | 'herhaling'
;
// Pattern matching
const ACTION_PATTERNS = {
toedienen: /\b(geef|toedien|gegeven)\b/i,
voorschrijven: /\b(voorschrijf|start|begin)\b/i,
stop: /\b(stop|afbouwen|be[eë]indig)\b/i,
aanvragen: /\b(vraag|aanvraag|aanvragen)\b/i,
};
const SUBJECT_PATTERNS = {
medicatie: /\b(medicatie|medicijn|tablet|pil)\b/i,
lab: /\b(lab|bloed|urine)\b/i,
rontgen: /\b(r[oö]ntgen|x-?ray)\b/i,
};
// Compose intent
function classifyCompositional(input: string): ComposedIntent {
const action = detectAction(input); // Fast, ~10 patterns
const subject = detectSubject(input); // Fast, ~10 patterns
const modifiers = detectModifiers(input); // Optional, ~5 patterns
return { action, subject, modifiers };
}
// Map to legacy intent
function toLegacyIntent(composed: ComposedIntent): SwiftIntent {
const key = `${composed.subject}_${composed.action}`;
const mapping = {
'medicatie_toedienen': 'medicatie_toedienen',
'medicatie_voorschrijven': 'medicatie_voorschrijven',
'lab_aanvragen': 'lab_aanvraag',
// ... etc
};
return mapping[key] || 'unknown';
}
```
**Voordelen:**
- ✅ Veel kleiner pattern set (~25 vs 235)
- ✅ Makkelijker om nieuwe combinaties toe te voegen
- ✅ Natuurlijker voor AI reasoning
**Nadelen:**
- ❌ Requires refactoring
- ❌ Less precise than specific patterns
- ❌ May need disambiguation more often
---
## Strategie 5: Smarter AI Routing (Hybrid Approach)
### Concept
Use **AI for categorization** (fast, cheap) then **local patterns** for specific intent.
```typescript
// Step 1: AI categorizes (very fast with Haiku)
const category = await categorizeWithAI(input); // ~100ms
// Step 2: Local patterns within category
const intent = classifyLocalInCategory(input, category); // ~5ms
// Total: ~105ms (but higher accuracy than pure local)
```
**AI System Prompt for Categorization:**
```typescript
const CATEGORIZATION_PROMPT = `Categoriseer de volgende input in één categorie:
Categorieën:
1. documentation - Notities, verslagen maken
2. patient_care - Medicatie, metingen, verzorging
3. scheduling - Agenda, afspraken
4. diagnostic - Lab, beeldvorming
5. communication - Brieven, consults
6. administrative - Overdracht, MDO
7. search - Zoeken, informatie opvragen
Antwoord met ALLEEN de categorie naam (lowercase).
Input: "${input}"
Categorie:`;
```
**Performance:**
- Categorization: ~100ms (AI call)
- Intent detection: ~5ms (local, small set)
- **Total: ~105ms** (vs ~50ms pure local, but more accurate)
**Trade-off:**
- Slower than pure local (2x)
- But handles ambiguous cases better
- Cheaper than full AI classification (smaller prompt)
---
## Strategie 6: Pattern Optimization
### Specific Optimizations
#### A. Pre-compiled Regex
```typescript
// ❌ BAD: Compile regex on every call
function classify(input: string) {
const pattern = new RegExp(`^${keyword}\\b`, 'i');
return pattern.test(input);
}
// ✅ GOOD: Pre-compile at module load
const PATTERNS = {
dagnotitie: /^dagnotitie\b/i,
zoeken: /^zoek\b/i,
};
function classify(input: string) {
return PATTERNS.dagnotitie.test(input);
}
```
**Impact:** 10-20% faster
#### B. Early Exit on Perfect Match
```typescript
for (const { pattern, weight } of patterns) {
if (pattern.test(input)) {
bestMatch = { pattern, weight };
// Early exit for perfect match
if (weight === 1.0) {
break; // Don't test remaining patterns
}
}
}
```
**Impact:** 30-50% faster for common exact matches
#### C. Pattern Ordering
```typescript
// Order patterns by likelihood (high weight first)
const patterns = [
{ pattern: /^exact\b/i, weight: 1.0 }, // Most likely
{ pattern: /^exact\s+\w+/i, weight: 0.95 }, // Second
{ pattern: /\bpartial\b/i, weight: 0.7 }, // Less likely
];
```
**Impact:** 20-40% faster on average
---
## 📊 Aanbevolen Implementatie Roadmap
### Fase 1: Quick Wins (Week 1)
**Implementeer nu (backward compatible):**
1.**Pattern Optimization**
- Pre-compile all regex
- Add early exit on perfect match
- Reorder patterns by weight (high first)
- **Effort:** 2 uur
- **Gain:** 30-40% sneller
2.**Intent Metrics Tracking**
- Add analytics to track intent frequency
- Log classification times
- **Effort:** 4 uur
- **Gain:** Data voor fase 2
### Fase 2: Hierarchie (Week 2-3)
**Implementeer categorieën:**
3.**Category-based Classification**
- Define 7 categories
- Build category patterns
- Restructure INTENT_PATTERNS by category
- Add two-phase classifier
- Keep old classifier for fallback
- **Effort:** 2 dagen
- **Gain:** 4-5x sneller, better scalability
4.**A/B Testing**
- Test old vs new classifier
- Compare accuracy & performance
- **Effort:** 1 dag
- **Gain:** Confidence in new approach
### Fase 3: Advanced (Maand 2)
**Optioneel, als nodig:**
5. ⚠️ **Keyword Index** (if performance still issue)
- Build keyword → pattern index
- **Effort:** 1 dag
- **Gain:** Extra 2x sneller
6. ⚠️ **Compositional Intents** (if too many intents)
- Refactor to action + subject
- **Effort:** 1 week
- **Gain:** Smaller pattern set, easier to extend
---
## 🎯 Concrete Voorstel voor Swift
### Voor Huidige Situatie (7 intents)
**Aanbeveling:** **Blijf bij huidige flat structure** + pattern optimizations
**Waarom:**
- Current performance is acceptable (<20ms)
- Complexity niet worth it voor 7 intents
- Quick wins genoeg (pre-compile, early exit)
**Implementeer WEL:**
- ✅ Pattern optimization (fase 1)
- ✅ Intent metrics tracking (voor later)
### Voor Toekomst (15+ intents)
**Aanbeveling:** **Overstap naar categorie-based hierarchie**
**Trigger points:**
- Wanneer >15 intents
- Wanneer classification >30ms
- Wanneer veel pattern conflicts
**Implementatie:**
1. Define 7 categories
2. Categorize existing intents
3. Build two-phase classifier
4. Keep old classifier als fallback
5. A/B test
### Code Structuur
```
lib/swift/
├── intent-classifier.ts # Current (keep for now)
├── intent-classifier-hierarchical.ts # New (implement in fase 2)
├── intent-classifier-ai.ts # Current AI fallback
├── intent-categories.ts # Category definitions
├── intent-patterns/ # Split patterns by category
│ ├── documentation.ts
│ ├── patient-care.ts
│ ├── scheduling.ts
│ ├── diagnostic.ts
│ ├── communication.ts
│ ├── administrative.ts
│ └── search.ts
└── types.ts
```
---
## 📈 Performance Benchmarks
### Target Metrics
| Metric | Current | Phase 1 Target | Phase 2 Target | Phase 3 Target |
|--------|---------|----------------|----------------|----------------|
| **Avg classification time** | 12ms | 8ms | 5ms | 3ms |
| **95th percentile** | 25ms | 15ms | 12ms | 8ms |
| **Max intents supported** | 10 | 15 | 40 | 100+ |
| **Memory usage** | 100KB | 120KB | 150KB | 200KB |
### Test Suite
```typescript
// __tests__/performance.test.ts
describe('Intent Classification Performance', () => {
it('should classify in <10ms (avg)', () => {
const inputs = generateTestInputs(1000);
const times = inputs.map(input => {
const start = performance.now();
classifyIntent(input);
return performance.now() - start;
});
const avg = times.reduce((a, b) => a + b) / times.length;
expect(avg).toBeLessThan(10);
});
it('should classify in <30ms (p95)', () => {
const times = [...]; // from above
const p95 = percentile(times, 95);
expect(p95).toBeLessThan(30);
});
it('should handle 40 intents efficiently', () => {
const classifierWith40Intents = buildClassifier(40);
const time = measureClassification(classifierWith40Intents);
expect(time).toBeLessThan(15);
});
});
```
---
## 🔧 Migration Guide
### Van Flat naar Hierarchical
**Step 1: Define Categories**
```typescript
// lib/swift/intent-categories.ts
export const INTENT_CATEGORY_MAP: Record<SwiftIntent, IntentCategory> = {
// Documentation
'dagnotitie': IntentCategory.DOCUMENTATION,
'rapportage_intake': IntentCategory.DOCUMENTATION,
// Patient Care
'meting_vitaal': IntentCategory.PATIENT_CARE,
'medicatie_toedienen': IntentCategory.PATIENT_CARE,
// Scheduling
'agenda_query': IntentCategory.SCHEDULING,
'create_appointment': IntentCategory.SCHEDULING,
// Search
'zoeken': IntentCategory.SEARCH,
// ... etc
};
```
**Step 2: Restructure Patterns**
```bash
# Create pattern files per category
mkdir lib/swift/intent-patterns
touch lib/swift/intent-patterns/documentation.ts
touch lib/swift/intent-patterns/patient-care.ts
# ... etc
```
```typescript
// lib/swift/intent-patterns/documentation.ts
export const DOCUMENTATION_PATTERNS = {
dagnotitie: [
{ pattern: /^dagnotitie\b/i, weight: 1.0 },
// ...
],
rapportage_intake: [
// ...
],
};
```
**Step 3: Build Hierarchical Classifier**
```typescript
// lib/swift/intent-classifier-hierarchical.ts
import { DOCUMENTATION_PATTERNS } from './intent-patterns/documentation';
import { PATIENT_CARE_PATTERNS } from './intent-patterns/patient-care';
// ... import all
export const PATTERNS_BY_CATEGORY = {
[IntentCategory.DOCUMENTATION]: DOCUMENTATION_PATTERNS,
[IntentCategory.PATIENT_CARE]: PATIENT_CARE_PATTERNS,
// ...
};
```
**Step 4: Feature Flag**
```typescript
// Use feature flag for gradual rollout
const USE_HIERARCHICAL_CLASSIFIER = process.env.NEXT_PUBLIC_USE_HIERARCHICAL === 'true';
export function classifyIntent(input: string) {
if (USE_HIERARCHICAL_CLASSIFIER) {
return classifyIntentHierarchical(input);
}
return classifyIntentFlat(input); // Old implementation
}
```
**Step 5: A/B Test & Monitor**
```typescript
// Log both results for comparison
const flatResult = classifyIntentFlat(input);
const hierarchicalResult = classifyIntentHierarchical(input);
analytics.track('intent_classification_comparison', {
input,
flatIntent: flatResult.intent,
flatConfidence: flatResult.confidence,
flatTime: flatResult.processingTimeMs,
hierarchicalIntent: hierarchicalResult.intent,
hierarchicalConfidence: hierarchicalResult.confidence,
hierarchicalTime: hierarchicalResult.processingTimeMs,
agreement: flatResult.intent === hierarchicalResult.intent,
});
// Use hierarchical if enabled
return USE_HIERARCHICAL_CLASSIFIER ? hierarchicalResult : flatResult;
```
---
## 💡 Samenvatting
### Aanbevolen Aanpak
**NU (0-7 intents):**
- ✅ Implement pattern optimizations (fase 1)
- ✅ Add metrics tracking
- ⏸️ Wait met hierarchie
**LATER (15+ intents):**
- ✅ Implement categorie-based hierarchie (fase 2)
- ✅ Optioneel: keyword index of compositional intents
**Grootste Impact:**
1. **Category hierarchie** → 4-5x sneller, schaalbaar tot 40+ intents
2. **Pattern optimization** → 30-40% sneller, makkelijk win
3. **Priority ordering** → 7-8x sneller voor common cases
**Effort vs Gain:**
| Strategie | Effort | Performance Gain | Scalability Gain | When to Implement |
|-----------|--------|------------------|------------------|-------------------|
| Pattern optimization | 2 uur | 30-40% | Low | ✅ Now |
| Category hierarchie | 2 dagen | 4-5x | High | When >15 intents |
| Keyword index | 1 dag | 2x extra | Medium | If still slow |
| Compositional | 1 week | 8-10x | Very High | When >40 intents |
| AI categorization | 3 dagen | 0x (slower) | High (accuracy) | If accuracy issues |
**Quick Decision Matrix:**
```
Current intents < 10?
→ Pattern optimization only
Current intents 10-20?
→ Pattern optimization + start planning hierarchie
Current intents 20-40?
→ Implement category hierarchie NOW
Current intents >40?
→ Consider compositional intents
```

View File

@@ -281,11 +281,11 @@ Epic doel: Swift artifact voor agenda flows.
| Story ID | Beschrijving | Acceptatiecriteria | Status | Afhankelijkheden | Story Points | | Story ID | Beschrijving | Acceptatiecriteria | Status | Afhankelijkheden | Story Points |
|----------|--------------|---------------------|--------|------------------|--------------| |----------|--------------|---------------------|--------|------------------|--------------|
| E4.S1 | AgendaBlock skeleton | Block met tabs/modes (list/create/cancel/reschedule) | To Do | E3.S1 | 3 | | E4.S1 | AgendaBlock skeleton | Block met tabs/modes (list/create/cancel/reschedule) | Done | E3.S1 | 3 |
| E4.S2 | List view | Lijst met afspraken + empty state | To Do | E4.S1 | 3 | | E4.S2 | List view | Lijst met afspraken + empty state | Done | E4.S1 | 3 |
| E4.S3 | Create form | Prefill + validatie + submit | To Do | E4.S1 | 5 | | E4.S3 | Create form | Prefill + validatie + submit | Done | E4.S1 | 5 |
| E4.S4 | Cancel view | Disambiguation + confirm flow | To Do | E4.S1 | 3 | | E4.S4 | Cancel view | Disambiguation + confirm flow | Done | E4.S1 | 3 |
| E4.S5 | Reschedule view | Edit form met nieuwe tijd | To Do | E4.S1 | 3 | | E4.S5 | Reschedule view | Edit form met nieuwe tijd | Done | E4.S1 | 3 |
**Technical notes:** **Technical notes:**

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,581 @@
# 🧩 Functioneel Ontwerp (FO) Swift Agenda & Afspraken
**Projectnaam:** Swift - Agenda & Afspraken Module
**Versie:** v1.0
**Datum:** 27-12-2024
**Auteur:** Colin Lit
---
## 1. Doel en relatie met het PRD
🎯 **Doel van dit document:**
Dit Functioneel Ontwerp beschrijft **hoe** de agenda- en afsprakenfunctionaliteit binnen Swift werkt. Swift is een conversational medical scribe interface waarin gebruikers via natuurlijke taal (Nederlands) afspraken kunnen opvragen, aanmaken, wijzigen en annuleren. Dit FO beschrijft de gebruikerservaring, UI-interacties en AI-functionaliteit.
📘 **Relatie met andere documenten:**
- **PRD:** Ephemeral UI visie (`nextgen-epd-prd-ephemeral-ui-epd.md`) - Conversational interface voor EPD
- **Swift FO v3.0:** `fo-swift-medical-scribe-v3.md` - Basis conversational interface architectuur
- **Klassieke Agenda:** `/app/epd/agenda` - Bestaande visuele kalender (blijft bestaan voor complexe planning)
- **Bouwplan:** `bouwplan-swift-standalone-module.md` - Development roadmap
**Kernprincipe:**
> Gebruikers kunnen via natuurlijke taal (chat of spraak) snel afspraken beheren zonder door menu's te klikken. Voor visueel overzicht en complexe planning blijft de klassieke kalender beschikbaar. Swift is de **snelle, hands-free** interface; klassieke agenda is de **visuele planner**.
**Toegevoegde waarde:**
| Aspect | Klassieke Agenda | Swift Agenda |
|--------|------------------|--------------|
| **Gebruik** | Visuele weekplanning | Quick actions, queries |
| **Input** | Klikken, formulieren | Natuurlijke taal, spraak |
| **Snelheid** | ~30-60 sec voor nieuwe afspraak | ~10-15 sec via chat/voice |
| **Ideaal voor** | Weekplanning, drag-drop | Tijdens telefoongesprek, hands-free |
---
## 2. Overzicht van de belangrijkste onderdelen
🎯 **Doel:** Overzicht van de functionaliteit binnen de Swift Agenda module.
### Hoofdonderdelen
1. **Agenda Queries** - Afspraken opvragen ("afspraken vandaag", "wat is volgende afspraak")
2. **Quick Create** - Snel afspraak maken ("maak afspraak jan morgen 14:00")
3. **Cancel Flow** - Afspraak annuleren ("annuleer afspraak jan")
4. **Reschedule Flow** - Afspraak verzetten ("verzet 14:00 naar 15:00")
5. **AgendaBlock** - UI component toont afspraken lijst en formulieren
6. **Intent Detection** - AI herkent wat gebruiker wil doen
### Artifact: AgendaBlock
Het **AgendaBlock** is het centrale UI-component met 4 modes:
| Mode | Functie | Trigger |
|------|---------|---------|
| **List View** | Toont chronologische lijst afspraken | "afspraken vandaag" |
| **Create Form** | Formulier voor nieuwe afspraak | "maak afspraak jan" |
| **Cancel View** | Confirmation dialog | "annuleer afspraak" |
| **Reschedule Form** | Datum/tijd aanpassing | "verzet afspraak" |
---
## 3. User Stories
🎯 **Doel:** Beschrijven wat gebruikers moeten kunnen doen vanuit hun perspectief.
### Primaire User Stories (P1)
| ID | Rol | Doel / Actie | Verwachte waarde | Prio |
|----|-----|--------------|------------------|------|
| **US-24** | Verpleegkundige | Snel overzicht afspraken vandaag | "afspraken vandaag" → lijst in AgendaBlock, <3 sec | 🔴 P1 |
| **US-25** | Verpleegkundige | Check volgende afspraak tijdens werk | "wat is mijn volgende afspraak?" → directe info | 🔴 P1 |
| **US-27** | Verpleegkundige | Snelle afspraak tijdens telefoongesprek | "maak afspraak jan morgen 14:00" → prefilled form, <15 sec | 🔴 P1 |
| **US-28** | Verpleegkundige | Context-aware planning | "maak afspraak met deze patiënt" → gebruikt actieve patiënt | 🔴 P1 |
| **US-29** | Verpleegkundige | Voice input tijdens consult | Hands-free afspraak maken via spraak | 🔴 P1 |
| **US-30** | Verpleegkundige | Annuleren via chat | "annuleer afspraak jan" → confirmation → done | 🔴 P1 |
| **US-31** | Verpleegkundige | Snel verzetten | "verzet 14:00 naar 15:00" → tijd update | 🔴 P1 |
### Secundaire User Stories (P2)
| ID | Rol | Doel / Actie | Verwachte waarde | Prio |
|----|-----|--------------|------------------|------|
| **US-26** | Verpleegkundige | Weekoverzicht bekijken | "agenda deze week" → gefilterde lijst | 🟡 P2 |
| **US-32** | Verpleegkundige | Disambiguation bij meerdere matches | Systeem vraagt "Welke Jan?" → lijst opties | 🟡 P2 |
---
## 4. Functionele werking per onderdeel
🎯 **Doel:** Per hoofdonderdeel beschrijven wat de gebruiker kan doen en wat het systeem doet.
### 4.1 Agenda Query (Afspraken opvragen)
**Wat doet de gebruiker:**
- Typt of spreekt: "afspraken vandaag", "wat is mijn volgende afspraak", "agenda morgen"
**Wat doet het systeem:**
1. **Intent detection:** Herkent dat gebruiker afspraken wil opvragen
2. **Datum parsing:** Vertaalt "vandaag", "morgen", "deze week" naar datumbereik
3. **Data ophalen:** Haalt afspraken op uit database
4. **AI response:** Chat toont samenvatting: "Je hebt vandaag 3 afspraken..."
5. **AgendaBlock opent:** Rechts verschijnt lijst met afspraken
**AgendaBlock List View bevat:**
- Header met datumbereik ("Afspraken Vandaag - 27 december")
- Per afspraak: tijd, patiënt (klikbaar), type badge, locatie
- Actions per afspraak: [Details] [Annuleren]
- Footer: Link naar volledige klassieke agenda
**States:**
- **Loading:** Spinner tijdens data fetch
- **Lijst met afspraken:** Chronologisch geordend
- **Empty state:** "Geen afspraken gevonden voor [datum]" + knop "Maak nieuwe afspraak"
- **Error:** "Fout bij ophalen afspraken" + link naar klassieke agenda
**Voorbeeld interactie:**
```
User: "afspraken vandaag"
AI: "Je hebt vandaag 3 afspraken:
- 09:00 Intake Jan de Vries
- 11:30 Behandeling Marie Jansen
- 14:00 Vervolggesprek Piet Bakker"
[AgendaBlock opens rechts met lijst van 3 afspraken]
```
---
### 4.2 Quick Create (Afspraak maken)
**Wat doet de gebruiker:**
- Typt: "maak afspraak jan morgen 14:00"
- Of spreekt via voice input (spatie-knop)
**Wat doet het systeem:**
1. **Intent detection:** Herkent 'create_appointment' intent
2. **Entity extraction:**
- Patient: "jan" (fuzzy search in database)
- Datum: "morgen" → parses naar 28-12-2024
- Tijd: "14:00"
- Type: default "behandeling" (kan gespecificeerd worden: "maak intake...")
3. **AI response:** "Ik maak een afspraak voor Jan de Vries op 28 december om 14:00."
4. **AgendaBlock opent:** Create form met pre-filled velden
5. **Gebruiker bevestigt of past aan**
6. **Opslaan:** Server action → database → toast "Afspraak aangemaakt!"
**AgendaBlock Create Form bevat:**
- **Patiënt:** Autocomplete dropdown (pre-filled "Jan de Vries")
- **Datum:** Date picker (pre-filled: 28-12-2024)
- **Tijd:** Time picker (pre-filled: 14:00)
- **Type:** Radio buttons (Intake, Behandeling, Vervolg, Telefonisch, Crisis, etc.)
- **Locatie:** Radio buttons (Praktijk, Online, Thuis)
- **Notities:** Optionele textarea
- **Conflict warning:** "⚠️ Je hebt al een afspraak om 14:00 met Marie" (indien van toepassing)
- **Actions:** [Annuleren] [✓ Afspraak maken]
**Form validatie:**
- Patiënt is verplicht
- Datum kan niet in het verleden
- Tijd moet binnen 07:00-20:00
**Voorbeeld interactie (voice):**
```
User: [Drukt spatie] "maak intake met Jan de Vries morgen 14:00"
[Deepgram transcribeert live]
AI: "Ik maak een intake-afspraak voor Jan de Vries op 28 december om 14:00."
[AgendaBlock create form opent met prefill]
User: [Klikt "Afspraak maken"]
Toast: "✓ Afspraak aangemaakt!"
Chat: "Afspraak ingepland voor Jan de Vries op 28 december om 14:00."
```
**Edge cases:**
- **Patiënt niet gevonden:** "Ik vond geen patiënt met de naam 'jan'. Bedoel je Jan de Vries of Jan Bakker?" (disambiguation)
- **Meerdere Jan's:** Toont lijst met opties in AgendaBlock
- **Tijd onduidelijk:** "Hoe laat wil je de afspraak plannen?"
- **Incomplete info:** "maak afspraak" → vraagt eerst om patiënt, dan datum/tijd
---
### 4.3 Cancel Flow (Afspraak annuleren)
**Wat doet de gebruiker:**
- Typt: "annuleer afspraak jan" of "annuleer de 14:00 afspraak"
**Wat doet het systeem:**
1. **Intent detection:** Herkent 'cancel_appointment'
2. **Search matching appointments:**
- Op patiëntnaam: zoekt "jan"
- Op tijd: zoekt afspraak om 14:00 vandaag
3. **Disambiguation (indien meerdere):**
- Toont lijst van matching afspraken in AgendaBlock
- Gebruiker selecteert welke
4. **Confirmation dialog:**
- Toont details van geselecteerde afspraak
- Waarschuwing: "Deze actie kan niet ongedaan worden gemaakt"
5. **Bevestigen:** Status → 'cancelled', toast + chat confirmation
**AgendaBlock Cancel View:**
**Single Match:**
```
┌─────────────────────────────────────────┐
│ ❌ Afspraak Annuleren [×] │
├─────────────────────────────────────────┤
│ │
│ Wil je deze afspraak annuleren? │
│ │
│ ┌─────────────────────────────────────┐ │
│ │ 28-12-2024 14:00 - 15:00 │ │
│ │ Jan de Vries - Intake │ │
│ │ Praktijk │ │
│ └─────────────────────────────────────┘ │
│ │
│ ⚠️ Actie kan niet ongedaan gemaakt │
│ │
├─────────────────────────────────────────┤
│ [Terug] [✓ Annuleren] │
└─────────────────────────────────────────┘
```
**Multiple Matches (Disambiguation):**
```
┌─────────────────────────────────────────┐
│ ❌ Afspraak Annuleren [×] │
├─────────────────────────────────────────┤
│ Welke afspraak wil je annuleren? │
│ │
│ ○ 28-12 09:00 - Jan de Vries (Intake) │
│ ○ 28-12 14:00 - Jan de Vries (Vervolg) │
│ ○ 03-01 11:00 - Jan de Vries (Behndl) │
│ │
├─────────────────────────────────────────┤
│ [Annuleren] [Volgende →] │
└─────────────────────────────────────────┘
```
**Voorbeeld interactie:**
```
User: "annuleer afspraak jan"
[Systeem vindt 3 afspraken met "jan"]
AI: "Je hebt 3 afspraken met Jan. Welke wil je annuleren?"
[AgendaBlock toont disambiguation list]
User: [Selecteert 14:00 afspraak]
[Confirmation dialog]
User: [Klikt "Annuleren"]
Toast: "Afspraak geannuleerd"
Chat: "Afspraak met Jan de Vries op 28 december om 14:00 is geannuleerd."
```
---
### 4.4 Reschedule Flow (Afspraak verzetten)
**Wat doet de gebruiker:**
- Typt: "verzet 14:00 naar 15:00" of "verzet jan naar dinsdag"
**Wat doet het systeem:**
1. **Intent detection:** Herkent 'reschedule_appointment'
2. **Parse old & new time:**
- Oude afspraak: "14:00" vandaag
- Nieuwe tijd: "15:00"
3. **Find appointment:** Zoekt matching afspraak
4. **AgendaBlock opent:** Edit form
5. **Conflict check:** Controleert of nieuwe tijd vrij is
6. **Bevestigen:** Update afspraak → toast + chat
**AgendaBlock Reschedule Form:**
```
┌─────────────────────────────────────────┐
│ 🔄 Afspraak Verzetten [×] │
├─────────────────────────────────────────┤
│ │
│ Afspraak │
│ Jan de Vries - Intake │
│ │
│ Huidige tijd │
│ 28-12-2024 14:00 - 15:00 │
│ (strikethrough) │
│ │
│ Nieuwe datum/tijd * │
│ [28-12-2024 ▼] [15:00 ▼] │
│ │
│ ✅ Geen conflicten gevonden │
│ │
├─────────────────────────────────────────┤
│ [Annuleren] [✓ Verzetten] │
└─────────────────────────────────────────┘
```
**Voorbeeld interactie:**
```
User: "verzet de 14:00 naar 15:00"
AI: "Ik verzet je afspraak van 14:00 met Jan naar 15:00."
[AgendaBlock reschedule form opent]
User: [Bevestigt of past aan]
User: [Klikt "Verzetten"]
Toast: "Afspraak verzet naar 15:00"
Chat: "Afspraak verzet naar 15:00."
```
---
### 4.5 AgendaBlock States & Lifecycle
**Artifact Lifecycle:**
1. **Closed (default):** Geen artifact zichtbaar
2. **Opening:** Slide-in animation (200ms from right)
3. **Active:** Gebruiker kan interacteren
4. **Submitting:** Form disabled, spinner op submit button
5. **Success:** Toast + chat confirmation → artifact sluit (of blijft voor volgende)
6. **Error:** Error message in artifact, re-enable form
**Max artifacts:** 3 tegelijk (tabs bovenaan bij meerdere)
- Bij 4e artifact: oudste sluit automatisch
**Keyboard shortcuts:**
- `⌘K` / `Ctrl+K` - Focus chat input
- `Escape` - Sluit artifact
- `Enter` - Submit form (in form fields)
---
## 5. UI-overzicht (visuele structuur)
🎯 **Doel:** Inzicht geven in de globale schermopbouw.
### Split-Screen Layout (Command Center)
```
┌───────────────────────────────────────────────────────────────┐
│ Context Bar: 🕐 Ochtend | 8 ptn Jan de Vries ▼ 👤 SV │
├─────────────────────────────┬─────────────────────────────────┤
│ │ │
│ CHAT PANEL (40%) │ ARTIFACT AREA (60%) │
│ │ │
│ 👤 "afspraken vandaag" │ ┌───────────────────────────┐ │
│ │ │ 📅 Afspraken Vandaag │ │
│ 🤖 Je hebt vandaag 3 │ │ │ │
│ afspraken: │ │ 09:00 - Intake │ │
│ - 09:00 Intake Jan │ │ Jan de Vries │ │
│ - 11:30 Behandeling │ │ 📍 Praktijk │ │
│ - 14:00 Vervolg │ │ [Details] [Annuleren] │ │
│ │ │ │ │
│ 👤 "maak afspraak jan │ │ 11:30 - Behandeling │ │
│ morgen 14:00" │ │ Marie Jansen │ │
│ │ │ 🌐 Online │ │
│ 🤖 Ik maak een afspraak │ │ [Details] [Annuleren] │ │
│ voor Jan de Vries... │ │ │ │
│ │ │ 14:00 - Vervolg │ │
│ [AgendaBlock opent →] │ │ Piet Bakker │ │
│ │ │ 📍 Praktijk │ │
│ │ │ [Details] [Annuleren] │ │
│ │ │ │ │
│ │ │ [📅 Open volledige │ │
│ │ │ agenda →] │ │
│ │ └───────────────────────────┘ │
│ │ │
├─────────────────────────────┤ │
│ 💬 Typ of spreek... 🎤 │ │
└─────────────────────────────┴─────────────────────────────────┘
```
### AgendaBlock Modes (UI varianten)
**Mode 1: List View**
- Header: Datum range + close button
- Body: Scrollable lijst van appointment cards
- Footer: Link naar klassieke agenda
**Mode 2: Create Form**
- Header: "Nieuwe Afspraak" + close button
- Body: Form velden (patient, datum, tijd, type, locatie, notities)
- Footer: [Annuleren] [✓ Afspraak maken]
**Mode 3: Cancel View**
- Header: "Afspraak Annuleren" + close button
- Body: Appointment details + warning message
- Footer: [Terug] [✓ Annuleren]
**Mode 4: Reschedule Form**
- Header: "Afspraak Verzetten" + close button
- Body: Huidige tijd (readonly) + nieuwe tijd (editable)
- Footer: [Annuleren] [✓ Verzetten]
### Design Tokens
**Colors:**
- Primary: Teal-700 (#0F766E)
- User message: Amber-50 bg, amber-200 border
- AI message: Slate-100 bg, slate-300 border
- Appointment types: Blauw (intake), groen (behandeling), rood (crisis)
**Spacing:**
- Context bar: h-12 (48px)
- Chat/artifact gap: 16px
- Card spacing: space-y-4
**Typography:**
- Chat messages: text-sm
- Headers: text-base font-medium
---
## 6. Interacties met AI (functionele beschrijving)
🎯 **Doel:** Uitleggen waar AI in de flow voorkomt en wat de gebruiker ziet.
### AI-functies
| Locatie | AI-actie | Trigger | Input | Output |
|---------|----------|---------|-------|--------|
| **Chat Input** | Intent detection | User message | "afspraken vandaag" | Intent: 'agenda_query', confidence: 1.0 |
| **Chat Input** | Entity extraction | User message | "maak afspraak jan morgen 14:00" | Patient: "jan", date: tomorrow, time: "14:00" |
| **Chat Input** | Verduidelijkingsvraag | Incomplete info | "maak afspraak" | "Met welke patiënt wil je afspreken?" |
| **Chat Panel** | Streaming response | Intent detected | — | "Je hebt vandaag 3 afspraken..." (typed effect) |
| **Patient Search** | Fuzzy matching | "jan" input | Database query | Matches: "Jan de Vries", "Jan Bakker" |
| **Date Parser** | Natural language parsing | "morgen", "volgende week dinsdag" | Date string | ISO date: 2024-12-28 |
### AI Intent Detection (Two-Tier)
**Tier 1: Local Pattern Matching (<50ms)**
- Fast regex-based matching
- Client-side execution
- Confidence >= 0.8 → direct gebruiken
Voorbeelden:
- "afspraken vandaag" → Pattern: `/^afspraken?\b/i` → Match! (confidence: 1.0)
- "maak afspraak" → Pattern: `/^maak\s+afspraak/i` → Match! (confidence: 1.0)
**Tier 2: AI Fallback (Claude Haiku) (~400ms)**
- Voor onduidelijke/complexe input
- Server-side execution
- Triggered als local confidence <0.8
Voorbeelden:
- "ik wil graag een gesprek plannen" → AI: intent: 'create_appointment', confidence: 0.75
- "verzet hem naar volgende week" → AI: intent: 'reschedule', confidence: 0.7 (patient onduidelijk)
**Confidence Thresholds:**
| Confidence | Actie | Voorbeeld |
|------------|-------|-----------|
| **>0.9** | Direct artifact openen | "afspraken vandaag" |
| **0.7-0.9** | Artifact + bevestigingsvraag | "maak afspraak jan" (tijd ontbreekt) |
| **0.5-0.7** | Verduidelijkingsvraag in chat | "maak afspraak" |
| **<0.5** | Fallback: "Ik begrijp het niet" | Gibberish input |
### Voice Input (Deepgram)
**Functionaliteit:**
- Live transcription tijdens spreken
- Pause detection (1.5s stilte) → auto-submit
- Nederlands language model
**User experience:**
1. User drukt spatie (of klikt mic icon)
2. Mic wordt rood 🔴, waveform animatie
3. Live transcript verschijnt in input field
4. Na 1.5s stilte: auto-submit
5. Intent detection + artifact opening
**Voorbeeld:**
```
User: [Drukt spatie]
→ Mic: 🔴 LIVE
→ User spreekt: "maak afspraak met jan morgen om twee uur"
→ Transcript: "maak afspraak met jan morgen om twee uur"
→ [1.5s pause]
→ Auto-submit
→ AI parses: "twee uur" → "14:00"
→ AgendaBlock opent
```
---
## 7. Gebruikersrollen en rechten
🎯 **Doel:** Beschrijven welke rollen toegang hebben tot agenda functionaliteit.
| Rol | Toegang | Beperkingen |
|-----|---------|-------------|
| **Verpleegkundige** | Eigen afspraken maken/wijzigen/annuleren | Alleen eigen practitioner_id |
| **Behandelaar** | Eigen afspraken + caseload patiënten | Alleen eigen + team afspraken |
| **Manager** | Lezen alle afspraken | Geen create/update/delete |
| **Demo-user** | Volledige functionaliteit met fictieve data | Alleen lezen |
**Permissies:**
| Actie | Verpleegkundige | Behandelaar | Manager |
|-------|-----------------|-------------|---------|
| **Agenda query** (eigen) | ✅ | ✅ | ✅ |
| **Agenda query** (team) | ❌ | ✅ | ✅ |
| **Create appointment** | ✅ | ✅ | ❌ |
| **Cancel appointment** (eigen) | ✅ | ✅ | ❌ |
| **Reschedule** (eigen) | ✅ | ✅ | ❌ |
**Database-level (RLS):**
- Filter op `practitioner_id = current_user_id`
- Voor managers: read-only view
---
## 8. Bijlagen & Referenties
🎯 **Doel:** Linken naar overige documenten.
### Gerelateerde Documenten
**Swift Documentatie:**
- **Swift FO v3.0:** `docs/swift/fo-swift-medical-scribe-v3.md` - Basis conversational interface
- **Swift Bouwplan:** `docs/swift/bouwplan-swift-standalone-module.md` - Development roadmap
- **Developer Guide Intent System:** `docs/swift/developer-guide-intent-system.md` - Technische details intent detection
- **Scalability Architecture:** `docs/swift/architecture-intent-scalability.md` - Schaalbaarheid optimalisaties
**Agenda Implementatie:**
- **Klassieke Agenda:** `/app/epd/agenda` - Bestaande visuele kalender
- **Agenda Actions:** `/app/epd/agenda/actions.ts` - Server actions (wordt hergebruikt)
- **Encounters Schema:** Database schema voor afspraken
**Design & UX:**
- **PRD Ephemeral UI:** Conversational interface visie
- **UX Research:** Chat + artifacts pattern analyse
### Technische Specs (voor developers)
- **Gedetailleerd FO Agenda Planning:** `docs/swift/fo-swift-agenda-planning.md` - Uitgebreide technische specificatie
- **Intent Classifier:** `lib/swift/intent-classifier.ts` - Local pattern matching
- **AI Classifier:** `lib/swift/intent-classifier-ai.ts` - Claude Haiku fallback
- **Types:** `lib/swift/types.ts` - TypeScript type definitions
### Out of Scope (Toekomstige Versies)
**Niet in MVP:**
- Full calendar grid view (blijft in klassieke agenda)
- Drag-and-drop rescheduling
- Recurring appointments ("elke dinsdag om 10:00")
- Beschikbaarheidscheck ("wanneer ben ik vrij")
- Conflict detection & resolution
- Multi-practitioner scheduling
- SMS/email notificaties
---
## Wijzigingslog
| Versie | Datum | Wijzigingen | Auteur |
|--------|-------|-------------|--------|
| v1.0 | 27-12-2024 | Initial version - Agenda & afspraken functionaliteit in Swift volgens FO template | Colin Lit |
---
**Goedkeuring:**
- [ ] Product Owner: _________________________
- [ ] Lead Developer: _________________________
- [ ] UX Designer: _________________________
**Status:** Draft - Ter review
**Volgende stappen:**
1. Review met stakeholders
2. UX wireframes maken op basis van dit FO
3. Technical implementation planning
4. User testing scenario's opstellen

File diff suppressed because it is too large Load Diff

View File

@@ -7,3 +7,4 @@ export * from './use-swift-voice';
export * from './intent-classifier'; export * from './intent-classifier';
export * from './intent-classifier-ai'; export * from './intent-classifier-ai';
export * from './entity-extractor'; export * from './entity-extractor';
export * from './date-time-parser';

View File

@@ -183,18 +183,25 @@ export async function classifyIntentWithAI(input: string): Promise<AIClassificat
const processingTimeMs = performance.now() - startTime; const processingTimeMs = performance.now() - startTime;
// Build entities object with backward compatibility
const entities: ExtractedEntities = {
patientName: validated.entities?.patientName,
category: validated.entities?.category as VerpleegkundigCategory | undefined,
content: validated.entities?.content,
query: validated.entities?.query,
// Legacy fields for backward compatibility
date: validated.entities?.date,
time: validated.entities?.time,
};
// For agenda intents, we'll rely on local entity extraction
// AI just provides the basic fields (patientName, date, time)
// and the local extractor will structure them properly
return { return {
intent: validated.intent as SwiftIntent, intent: validated.intent as SwiftIntent,
confidence: validated.confidence, confidence: validated.confidence,
entities: { entities,
patientName: validated.entities?.patientName,
category: validated.entities?.category as VerpleegkundigCategory | undefined,
content: validated.entities?.content,
query: validated.entities?.query,
date: validated.entities?.date,
time: validated.entities?.time,
identifier: validated.entities?.identifier,
},
source: 'ai', source: 'ai',
processingTimeMs, processingTimeMs,
reasoning: validated.reasoning, reasoning: validated.reasoning,

View File

@@ -32,14 +32,46 @@ export interface IntentClassificationResult {
// Extracted entities from user input // Extracted entities from user input
export interface ExtractedEntities { export interface ExtractedEntities {
// Common entities
patientName?: string; patientName?: string;
patientId?: string; patientId?: string;
// Dagnotitie entities
category?: VerpleegkundigCategory; category?: VerpleegkundigCategory;
content?: string; content?: string;
// Search entities
query?: string; query?: string;
// Agenda entities
dateRange?: {
start: Date;
end: Date;
label: 'vandaag' | 'morgen' | 'deze week' | 'volgende week' | 'custom';
};
datetime?: {
date: Date;
time: string; // "HH:mm" format
};
appointmentType?: 'intake' | 'behandeling' | 'follow-up' | 'telefonisch' |
'huisbezoek' | 'online' | 'crisis' | 'overig';
location?: 'praktijk' | 'online' | 'thuis';
identifier?: {
type: 'patient' | 'time' | 'both';
patientName?: string;
patientId?: string;
time?: string;
date?: Date;
encounterId?: string;
};
newDatetime?: {
date: Date;
time: string;
};
// Legacy fields (for backward compatibility)
date?: string; date?: string;
time?: string; time?: string;
identifier?: string;
} }
// Block sizes // Block sizes

View File

@@ -0,0 +1,102 @@
/**
* Manual verification script for entity extraction with date/time parser
* Run with: pnpm tsx lib/swift/verify-entity-extraction.ts
*/
import { extractEntities } from './entity-extractor';
import { format } from 'date-fns';
import { nl } from 'date-fns/locale';
console.log('🧪 Testing Entity Extraction with Date/Time Parser\n');
// Test cases for each agenda intent
const testCases = [
{
intent: 'agenda_query' as const,
inputs: [
'afspraken vandaag',
'agenda morgen',
'wat is volgende afspraak',
'afspraken deze week',
],
},
{
intent: 'create_appointment' as const,
inputs: [
'maak afspraak jan morgen 14:00',
'plan intake marie vrijdag 10:00',
'afspraak met piet twee uur',
'maak behandeling lisa dinsdag half drie',
],
},
{
intent: 'cancel_appointment' as const,
inputs: [
'annuleer afspraak jan',
'cancel de 14:00 afspraak',
'annuleer jan morgen',
],
},
{
intent: 'reschedule_appointment' as const,
inputs: [
'verzet 14:00 naar 15:00',
'verzet jan naar dinsdag',
'verplaats de afspraak naar morgen 10:00',
],
},
];
testCases.forEach(({ intent, inputs }) => {
console.log(`\n📋 Testing ${intent}:\n`);
inputs.forEach((input) => {
const entities = extractEntities(input, intent);
console.log(` Input: "${input}"`);
// Display extracted entities
if (entities.patientName) {
console.log(` 👤 Patient: ${entities.patientName}`);
}
if (entities.dateRange) {
const { start, end, label } = entities.dateRange;
console.log(
` 📅 Date Range: ${format(start, 'dd MMM', { locale: nl })} - ${format(end, 'dd MMM', { locale: nl })} (${label})`
);
}
if (entities.datetime) {
const { date, time } = entities.datetime;
const dateStr = format(date, 'dd MMMM yyyy', { locale: nl });
console.log(` 🕐 Datetime: ${dateStr} om ${time || '(tijd niet gespecificeerd)'}`);
}
if (entities.appointmentType) {
console.log(` 📝 Type: ${entities.appointmentType}`);
}
if (entities.location) {
console.log(` 📍 Location: ${entities.location}`);
}
if (entities.identifier) {
const { type, patientName, time, date } = entities.identifier;
let identifierStr = ` 🔍 Identifier: type=${type}`;
if (patientName) identifierStr += `, patient=${patientName}`;
if (time) identifierStr += `, time=${time}`;
if (date) identifierStr += `, date=${format(date, 'dd MMM', { locale: nl })}`;
console.log(identifierStr);
}
if (entities.newDatetime) {
const { date, time } = entities.newDatetime;
const dateStr = format(date, 'dd MMMM yyyy', { locale: nl });
console.log(` 🔄 New Datetime: ${dateStr} om ${time || '(tijd niet gespecificeerd)'}`);
}
console.log('');
});
});
console.log('✅ Verification complete!');

View File

@@ -27,6 +27,7 @@
"@radix-ui/react-label": "^2.1.8", "@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-progress": "^1.1.8", "@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-select": "^2.2.6", "@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slider": "^1.3.6", "@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-slot": "^1.2.4",

34
pnpm-lock.yaml generated
View File

@@ -53,6 +53,9 @@ importers:
'@radix-ui/react-progress': '@radix-ui/react-progress':
specifier: ^1.1.8 specifier: ^1.1.8
version: 1.1.8(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) version: 1.1.8(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-radio-group':
specifier: ^1.3.8
version: 1.3.8(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-select': '@radix-ui/react-select':
specifier: ^2.2.6 specifier: ^2.2.6
version: 2.2.6(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) version: 2.2.6(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@@ -866,6 +869,19 @@ packages:
'@types/react-dom': '@types/react-dom':
optional: true optional: true
'@radix-ui/react-radio-group@1.3.8':
resolution: {integrity: sha512-VBKYIYImA5zsxACdisNQ3BjCBfmbGH3kQlnFVqlWU4tXwjy7cGX8ta80BcrO+WJXIn5iBylEH3K6ZTlee//lgQ==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-roving-focus@1.1.11': '@radix-ui/react-roving-focus@1.1.11':
resolution: {integrity: sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==} resolution: {integrity: sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==}
peerDependencies: peerDependencies:
@@ -4543,6 +4559,24 @@ snapshots:
'@types/react': 18.3.27 '@types/react': 18.3.27
'@types/react-dom': 18.3.7(@types/react@18.3.27) '@types/react-dom': 18.3.7(@types/react@18.3.27)
'@radix-ui/react-radio-group@1.3.8(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.27)(react@18.3.1)
'@radix-ui/react-context': 1.1.2(@types/react@18.3.27)(react@18.3.1)
'@radix-ui/react-direction': 1.1.1(@types/react@18.3.27)(react@18.3.1)
'@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.27)(react@18.3.1)
'@radix-ui/react-use-previous': 1.1.1(@types/react@18.3.27)(react@18.3.1)
'@radix-ui/react-use-size': 1.1.1(@types/react@18.3.27)(react@18.3.1)
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
optionalDependencies:
'@types/react': 18.3.27
'@types/react-dom': 18.3.7(@types/react@18.3.27)
'@radix-ui/react-roving-focus@1.1.11(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': '@radix-ui/react-roving-focus@1.1.11(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies: dependencies:
'@radix-ui/primitive': 1.1.3 '@radix-ui/primitive': 1.1.3