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:
66
components/swift/artifacts/blocks/agenda-block.tsx
Normal file
66
components/swift/artifacts/blocks/agenda-block.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
210
components/swift/artifacts/blocks/agenda-cancel-view.tsx
Normal file
210
components/swift/artifacts/blocks/agenda-cancel-view.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
363
components/swift/artifacts/blocks/agenda-create-form.tsx
Normal file
363
components/swift/artifacts/blocks/agenda-create-form.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
172
components/swift/artifacts/blocks/agenda-list-view.tsx
Normal file
172
components/swift/artifacts/blocks/agenda-list-view.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
210
components/swift/artifacts/blocks/agenda-reschedule-form.tsx
Normal file
210
components/swift/artifacts/blocks/agenda-reschedule-form.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -68,10 +68,13 @@ export function FallbackPicker({ originalInput }: FallbackPickerProps) {
|
|||||||
|
|
||||||
openBlock(option.type, prefillData);
|
openBlock(option.type, prefillData);
|
||||||
|
|
||||||
|
// Only add to recent actions if it's a valid SwiftIntent (not patient-dashboard)
|
||||||
|
if (option.type !== 'patient-dashboard') {
|
||||||
addRecentAction({
|
addRecentAction({
|
||||||
intent: option.type,
|
intent: option.type,
|
||||||
label: option.label,
|
label: option.label,
|
||||||
});
|
});
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[openBlock, addRecentAction, originalInput]
|
[openBlock, addRecentAction, originalInput]
|
||||||
);
|
);
|
||||||
|
|||||||
44
components/ui/radio-group.tsx
Normal file
44
components/ui/radio-group.tsx
Normal 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 }
|
||||||
879
docs/swift/architecture-intent-scalability.md
Normal file
879
docs/swift/architecture-intent-scalability.md
Normal 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
|
||||||
|
```
|
||||||
|
|
||||||
@@ -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:**
|
||||||
|
|
||||||
|
|||||||
1334
docs/swift/developer-guide-intent-system.md
Normal file
1334
docs/swift/developer-guide-intent-system.md
Normal file
File diff suppressed because it is too large
Load Diff
581
docs/swift/fo-agenda-afspraken.md
Normal file
581
docs/swift/fo-agenda-afspraken.md
Normal 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
|
||||||
1816
docs/swift/fo-swift-agenda-planning.md
Normal file
1816
docs/swift/fo-swift-agenda-planning.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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';
|
||||||
|
|||||||
@@ -183,18 +183,25 @@ export async function classifyIntentWithAI(input: string): Promise<AIClassificat
|
|||||||
|
|
||||||
const processingTimeMs = performance.now() - startTime;
|
const processingTimeMs = performance.now() - startTime;
|
||||||
|
|
||||||
return {
|
// Build entities object with backward compatibility
|
||||||
intent: validated.intent as SwiftIntent,
|
const entities: ExtractedEntities = {
|
||||||
confidence: validated.confidence,
|
|
||||||
entities: {
|
|
||||||
patientName: validated.entities?.patientName,
|
patientName: validated.entities?.patientName,
|
||||||
category: validated.entities?.category as VerpleegkundigCategory | undefined,
|
category: validated.entities?.category as VerpleegkundigCategory | undefined,
|
||||||
content: validated.entities?.content,
|
content: validated.entities?.content,
|
||||||
query: validated.entities?.query,
|
query: validated.entities?.query,
|
||||||
|
// Legacy fields for backward compatibility
|
||||||
date: validated.entities?.date,
|
date: validated.entities?.date,
|
||||||
time: validated.entities?.time,
|
time: validated.entities?.time,
|
||||||
identifier: validated.entities?.identifier,
|
};
|
||||||
},
|
|
||||||
|
// 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 {
|
||||||
|
intent: validated.intent as SwiftIntent,
|
||||||
|
confidence: validated.confidence,
|
||||||
|
entities,
|
||||||
source: 'ai',
|
source: 'ai',
|
||||||
processingTimeMs,
|
processingTimeMs,
|
||||||
reasoning: validated.reasoning,
|
reasoning: validated.reasoning,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
102
lib/swift/verify-entity-extraction.ts
Normal file
102
lib/swift/verify-entity-extraction.ts
Normal 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!');
|
||||||
@@ -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
34
pnpm-lock.yaml
generated
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user