fix(cortex): update date handling in API and UI components

- Adjusted date handling in the agenda API to ensure optional parameters for start, end, and label are correctly processed.
- Enhanced chat input and action parser to support optional date labels for relative dates.
- Updated agenda components to handle date ranges and loading states more effectively.
- Improved error handling and loading indicators in the agenda block for better user experience.

This commit ensures consistency in date handling across the application, aligning with the new requirements for relative date inputs.
This commit is contained in:
colinislit
2026-01-02 09:21:37 +01:00
parent 011815072b
commit 52e07aaf80
20 changed files with 2092 additions and 65 deletions

View File

@@ -19,6 +19,7 @@ import { PatientDashboardBlock } from '../blocks/patient-dashboard-block';
import { FallbackPicker } from '../blocks/fallback-picker';
import { APPOINTMENT_TYPES, type AppointmentTypeCode, type LocationClassCode } from '@/app/epd/agenda/types';
import type { Artifact, BlockType } from '@/stores/cortex-store';
import { parseRelativeDate, isDateRange } from '@/lib/cortex/date-time-parser';
interface ArtifactContainerProps {
artifacts: Artifact[];
@@ -55,13 +56,13 @@ function coerceDate(value: unknown): Date | undefined {
}
function coerceDateRange(raw: any): AgendaBlockProps['dateRange'] | undefined {
const start = coerceDate(raw?.start);
const end = coerceDate(raw?.end);
if (!start || !end) return undefined;
const label = typeof raw?.label === 'string' ? raw.label : undefined;
if (!label) return undefined;
return {
start,
end,
label: typeof raw?.label === 'string' ? raw.label : 'custom',
start: coerceDate(raw?.start),
end: coerceDate(raw?.end),
label,
};
}
@@ -78,6 +79,21 @@ function resolveLocation(value: unknown): LocationClassCode | undefined {
return undefined;
}
/**
* Convert date label to Date using central date-time-parser (DRY)
*/
function resolveDateFromLabel(label?: string): Date | null {
if (!label) return null;
const parsed = parseRelativeDate(label);
if (!parsed) return null;
// DateRange → return start date
if (isDateRange(parsed)) return parsed.start;
return parsed;
}
function buildAgendaPrefill(prefill: Record<string, any> | undefined): AgendaBlockProps['prefillData'] {
if (!prefill || typeof prefill !== 'object') return undefined;
@@ -90,9 +106,15 @@ function buildAgendaPrefill(prefill: Record<string, any> | undefined): AgendaBlo
}
: undefined);
// Try to resolve date from label first (more reliable than AI-generated dates)
const dateLabel = prefill?.datetime?.label || prefill?.dateRange?.label;
const resolvedDate = resolveDateFromLabel(dateLabel);
const datetimeDate =
resolvedDate || // Prefer calculated date from label
coerceDate(prefill?.datetime?.date) ||
(prefill?.datetime?.time ? new Date() : undefined);
const datetime = datetimeDate
? {
date: datetimeDate,
@@ -103,7 +125,12 @@ function buildAgendaPrefill(prefill: Record<string, any> | undefined): AgendaBlo
const appointmentType = resolveAppointmentType(prefill?.appointmentType || prefill?.type);
const location = resolveLocation(prefill?.location);
// Also resolve newDatetime from label
const newDateLabel = prefill?.newDatetime?.label;
const resolvedNewDate = resolveDateFromLabel(newDateLabel);
const newDatetimeDate =
resolvedNewDate || // Prefer calculated date from label
coerceDate(prefill?.newDatetime?.date) ||
(prefill?.newDatetime?.time ? new Date() : undefined);
const newDatetime = newDatetimeDate

View File

@@ -1,16 +1,18 @@
'use client';
import React from 'react';
import React, { useState, useEffect } from 'react';
import { 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';
import { AgendaErrorState } from './agenda-error-state';
import { motion, AnimatePresence } from 'framer-motion';
import { Loader2 } from 'lucide-react';
export interface AgendaBlockProps {
mode: 'list' | 'create' | 'cancel' | 'reschedule';
appointments?: CalendarEvent[];
dateRange?: { start: Date; end: Date; label: string };
dateRange?: { start?: Date; end?: Date; label: string };
prefillData?: {
patient?: { id: string; name: string };
datetime?: { date: Date; time: string };
@@ -26,15 +28,92 @@ export interface AgendaBlockProps {
export function AgendaBlock({
mode,
appointments,
dateRange,
appointments: initialAppointments,
dateRange: initialDateRange,
prefillData,
disambiguationOptions,
onClose,
}: AgendaBlockProps) {
// State for fetched appointments (only used in list mode)
const [appointments, setAppointments] = useState<CalendarEvent[] | undefined>(initialAppointments);
const [dateRange, setDateRange] = useState<{ start: Date; end: Date; label: string } | undefined>(initialDateRange);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [refetchKey, setRefetchKey] = useState(0);
// Fetch appointments when in list mode and no appointments are provided
// Server-side bepaalt de datum (consistent met EPD agenda)
useEffect(() => {
if (mode !== 'list') return;
if (initialAppointments && initialAppointments.length > 0) return;
const fetchAppointments = async () => {
setIsLoading(true);
setError(null);
try {
// Build query params - server bepaalt de datum
const params = new URLSearchParams();
// Stuur alleen label naar server, server berekent de datums
if (initialDateRange?.label) {
params.set('label', initialDateRange.label);
}
// Als geen label en geen expliciete datums, server defaults naar vandaag
const response = await fetch(`/api/cortex/agenda?${params}`);
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Kon afspraken niet laden');
}
const data = await response.json();
setAppointments(data.appointments || []);
// Update dateRange met server-side berekende waarden
if (data.dateRange) {
setDateRange({
start: new Date(data.dateRange.start),
end: new Date(data.dateRange.end),
label: data.dateRange.label,
});
}
} catch (err) {
console.error('Error fetching appointments:', err);
setError(err instanceof Error ? err.message : 'Er ging iets mis');
} finally {
setIsLoading(false);
}
};
fetchAppointments();
}, [mode, initialAppointments, initialDateRange, refetchKey]);
const renderContent = () => {
switch (mode) {
case 'list':
if (isLoading) {
return (
<div key="loading" className="flex flex-col items-center justify-center h-full text-slate-500">
<Loader2 className="h-8 w-8 animate-spin text-teal-600 mb-4" />
<p className="text-sm">Afspraken laden...</p>
</div>
);
}
if (error) {
return (
<AgendaErrorState
key="error"
error={error}
context="query"
onRetry={() => {
setError(null);
setRefetchKey((k) => k + 1); // Trigger refetch
}}
/>
);
}
return (
<AgendaListView
key="list"

View File

@@ -17,7 +17,7 @@ import {
interface AgendaListViewProps {
appointments?: CalendarEvent[];
dateRange?: { start: Date; end: Date; label: string };
dateRange?: { start?: Date; end?: Date; label: string };
onClose?: () => void;
onCancelAppointment?: (encounter: CalendarEvent) => void;
onViewDetails?: (encounter: CalendarEvent) => void;
@@ -48,7 +48,7 @@ export function AgendaListView({
const formatDateLabel = () => {
if (dateRange?.label) {
if (dateRange.label === 'vandaag' || dateRange.label === 'morgen') {
if ((dateRange.label === 'vandaag' || dateRange.label === 'morgen') && dateRange.start) {
const dateStr = format(dateRange.start, 'd MMMM', { locale: nl });
return `Afspraken ${dateRange.label} - ${dateStr}`;
}

View File

@@ -0,0 +1,180 @@
'use client';
/**
* Chat Empty State Component
*
* Interactive grid of cards shown when the chat has no messages.
* Each card represents a main capability category.
*/
import { motion } from 'framer-motion';
import { FileText, Calendar, Search, ClipboardList } from 'lucide-react';
import { cn } from '@/lib/utils';
interface ChatEmptyStateProps {
/** Callback when a quick action card is clicked */
onSelectAction: (text: string) => void;
/** Optional active patient name */
activePatientName?: string;
}
const quickActions = [
{
id: 'notitie',
icon: FileText,
title: 'Dagnotitie maken',
description: 'Registreer observaties, medicatie of incidenten',
example: 'Notitie [naam] medicatie gegeven',
color: 'bg-blue-50 text-blue-600 border-blue-200',
hoverColor: 'hover:bg-blue-100 hover:border-blue-300',
},
{
id: 'agenda',
icon: Calendar,
title: 'Agenda bekijken',
description: 'Bekijk of plan afspraken',
example: 'Agenda vandaag',
color: 'bg-emerald-50 text-emerald-600 border-emerald-200',
hoverColor: 'hover:bg-emerald-100 hover:border-emerald-300',
},
{
id: 'zoeken',
icon: Search,
title: 'Patiënt zoeken',
description: 'Zoek in dossiers en patiëntgegevens',
example: 'Zoek [naam]',
color: 'bg-violet-50 text-violet-600 border-violet-200',
hoverColor: 'hover:bg-violet-100 hover:border-violet-300',
},
{
id: 'overdracht',
icon: ClipboardList,
title: 'Overdracht maken',
description: 'Genereer een samenvatting voor de volgende dienst',
example: 'Overdracht',
color: 'bg-amber-50 text-amber-600 border-amber-200',
hoverColor: 'hover:bg-amber-100 hover:border-amber-300',
},
];
const containerVariants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
staggerChildren: 0.08,
},
},
};
const cardVariants = {
hidden: { opacity: 0, y: 20 },
visible: {
opacity: 1,
y: 0,
transition: {
duration: 0.3,
ease: 'easeOut',
},
},
};
export function ChatEmptyState({ onSelectAction, activePatientName }: ChatEmptyStateProps) {
const handleCardClick = (example: string) => {
// Replace [naam] with patient name if available
const text = activePatientName
? example.replace(/\[naam\]/g, activePatientName)
: example;
onSelectAction(text);
};
return (
<div className="flex-1 flex flex-col items-center justify-center p-6">
{/* Header */}
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4 }}
className="text-center mb-8"
>
<div className="text-4xl mb-3">💬</div>
<h2 className="text-xl font-semibold text-slate-800 mb-2">
Welkom bij Cortex Assistent
</h2>
<p className="text-sm text-slate-500 max-w-md">
Typ of spreek wat je wilt doen. Klik op een kaart om snel te starten.
</p>
</motion.div>
{/* Action cards grid */}
<motion.div
variants={containerVariants}
initial="hidden"
animate="visible"
className="grid grid-cols-1 sm:grid-cols-2 gap-3 w-full max-w-lg"
>
{quickActions.map((action) => {
const Icon = action.icon;
const displayExample = activePatientName
? action.example.replace(/\[naam\]/g, activePatientName)
: action.example;
return (
<motion.button
key={action.id}
variants={cardVariants}
onClick={() => handleCardClick(action.example)}
className={cn(
'group relative text-left p-4 rounded-xl border-2',
'transition-all duration-200',
'active:scale-[0.98]',
action.color,
action.hoverColor
)}
>
{/* Icon */}
<div className="flex items-start gap-3">
<div
className={cn(
'p-2 rounded-lg bg-white/60',
'group-hover:bg-white group-hover:shadow-sm',
'transition-all duration-200'
)}
>
<Icon className="w-5 h-5" />
</div>
<div className="flex-1 min-w-0">
<h3 className="font-medium text-slate-800 text-sm mb-0.5">
{action.title}
</h3>
<p className="text-xs text-slate-500 mb-2">
{action.description}
</p>
<p className="text-[11px] text-slate-400 italic truncate">
&ldquo;{displayExample}&rdquo;
</p>
</div>
</div>
</motion.button>
);
})}
</motion.div>
{/* Keyboard hint */}
<motion.p
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.5 }}
className="text-xs text-slate-400 mt-6"
>
Tip: Gebruik{' '}
<kbd className="px-1.5 py-0.5 bg-slate-100 border border-slate-300 rounded text-xs">
K
</kbd>{' '}
om direct te beginnen met typen
</motion.p>
</div>
);
}

View File

@@ -23,6 +23,7 @@ interface ChatInputProps {
export interface ChatInputHandle {
focus: () => void;
clear: () => void;
setValue: (value: string) => void;
}
export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(function ChatInput({
@@ -34,7 +35,7 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(function Ch
const textareaRef = useRef<HTMLTextAreaElement>(null);
const addChatMessage = useCortexStore((s) => s.addChatMessage);
// Expose focus and clear methods to parent
// Expose focus, clear, and setValue methods to parent
useImperativeHandle(ref, () => ({
focus: () => {
textareaRef.current?.focus();
@@ -45,6 +46,21 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(function Ch
textareaRef.current.style.height = 'auto';
}
},
setValue: (value: string) => {
setInputValue(value);
// Auto-resize after setting value
if (textareaRef.current) {
textareaRef.current.style.height = 'auto';
// Use setTimeout to ensure the value is set before measuring
setTimeout(() => {
if (textareaRef.current) {
textareaRef.current.style.height = `${textareaRef.current.scrollHeight}px`;
}
}, 0);
}
// Focus the input after setting value
textareaRef.current?.focus();
},
}));
// Handle input change and auto-resize

View File

@@ -15,12 +15,14 @@ import { ArrowDown } from 'lucide-react';
import { AnimatePresence } from 'framer-motion';
import { ChatMessage } from './chat-message';
import { ChatInput, ChatInputHandle } from './chat-input';
import { ChatSuggestions } from './chat-suggestions';
import { ChatEmptyState } from './chat-empty-state';
import { ActionChainCard } from './action-chain-card';
import { ClarificationCard } from './clarification-card';
import { ProcessingIndicator } from './processing-indicator';
import { useCortexStore } from '@/stores/cortex-store';
import { sendChatMessage } from '@/lib/cortex/chat-api';
import { parseActionFromResponse, shouldOpenArtifact, routeIntentToArtifact } from '@/lib/cortex/action-parser';
import { parseActionFromResponse, shouldOpenArtifact, routeIntentToArtifact, getDefaultConfirmationMessage } from '@/lib/cortex/action-parser';
import { evaluateNudge } from '@/lib/cortex/nudge';
import { isFeatureEnabled } from '@/lib/config/feature-flags';
import { cn } from '@/lib/utils';
@@ -61,8 +63,17 @@ export function ChatPanel() {
const [isScrolledUp, setIsScrolledUp] = useState(false);
const [showScrollButton, setShowScrollButton] = useState(false);
// Intent Helper state: minimize suggestions after first message
const [isSuggestionsMinimized, setIsSuggestionsMinimized] = useState(false);
const hasAutoMinimizedRef = useRef(false);
const hasMessages = chatMessages.length > 0;
// Get active patient name for suggestion placeholders
const activePatientName = activePatient
? `${activePatient.name_given?.[0] || ''} ${activePatient.name_family || ''}`.trim()
: undefined;
// Scroll to bottom function
const scrollToBottom = useCallback((behavior: ScrollBehavior = 'smooth') => {
messagesEndRef.current?.scrollIntoView({ behavior });
@@ -95,6 +106,19 @@ export function ChatPanel() {
scrollToBottom('auto');
}, [scrollToBottom]);
// Auto-minimize suggestions after first message is sent (only once)
useEffect(() => {
if (hasMessages && !hasAutoMinimizedRef.current) {
hasAutoMinimizedRef.current = true;
setIsSuggestionsMinimized(true);
}
}, [hasMessages]);
// Handle suggestion selection - fill input with selected text
const handleSelectSuggestion = useCallback((text: string) => {
chatInputRef.current?.setValue(text);
}, []);
// Global keyboard shortcuts
useEffect(() => {
const handleGlobalKeyDown = (e: KeyboardEvent) => {
@@ -280,23 +304,10 @@ export function ChatPanel() {
<div ref={messagesEndRef} />
</div>
) : (
<div className="flex items-center justify-center h-full">
<div className="max-w-md text-center text-slate-500">
<div className="text-4xl mb-4">💬</div>
<h3 className="text-lg font-medium text-slate-700 mb-2">
Welkom bij Cortex Assistent
</h3>
<p className="text-sm mb-4">
Typ of spreek wat je wilt doen...
</p>
<div className="text-left text-sm space-y-1 bg-slate-50 rounded-lg p-4">
<p className="font-medium text-slate-700 mb-2">Voorbeelden:</p>
<p> &ldquo;Notitie voor Jan: medicatie gegeven&rdquo;</p>
<p> &ldquo;Zoek Marie van den Berg&rdquo;</p>
<p> &ldquo;Maak overdracht voor deze dienst&rdquo;</p>
</div>
</div>
</div>
<ChatEmptyState
onSelectAction={handleSelectSuggestion}
activePatientName={activePatientName}
/>
)}
{/* Scroll to bottom button */}
@@ -319,6 +330,14 @@ export function ChatPanel() {
)}
</div>
{/* Suggestion strip above input */}
<ChatSuggestions
isMinimized={isSuggestionsMinimized}
onToggleMinimize={() => setIsSuggestionsMinimized(!isSuggestionsMinimized)}
onSelectSuggestion={handleSelectSuggestion}
activePatientName={activePatientName}
/>
{/* Chat input */}
<ChatInput
ref={chatInputRef}
@@ -361,8 +380,12 @@ export function ChatPanel() {
if (parsed.action) {
console.log('[ChatPanel] Action detected:', parsed.action);
// Update last message with cleaned text content and action
updateLastMessage(parsed.textContent, parsed.action);
// If textContent is empty but we have an action, generate a default confirmation message
const displayContent = parsed.textContent.trim() ||
getDefaultConfirmationMessage(parsed.action.intent, parsed.action.entities);
// Update last message with text content (or default) and action
updateLastMessage(displayContent, parsed.action);
// Store action in pendingAction for artifact opening (E3.S6)
if (shouldOpenArtifact(parsed.action.confidence)) {

View File

@@ -0,0 +1,160 @@
'use client';
/**
* Chat Suggestions Component
*
* A categorized suggestion strip above the chat input.
* Shows tabs for categories and clickable chips with example sentences.
*
* Features:
* - Minimizable after first message
* - Click to fill input (not send)
* - Contextual patient name replacement
*/
import { useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { ChevronDown, ChevronUp, Lightbulb } from 'lucide-react';
import { cn } from '@/lib/utils';
import { SUGGESTION_CATEGORIES, replacePlaceholder } from '@/lib/cortex/suggestions';
interface ChatSuggestionsProps {
/** Whether the strip should be minimized */
isMinimized: boolean;
/** Callback when minimized state changes */
onToggleMinimize: () => void;
/** Callback when a suggestion is clicked */
onSelectSuggestion: (text: string) => void;
/** Optional active patient name to replace [naam] placeholders */
activePatientName?: string;
}
export function ChatSuggestions({
isMinimized,
onToggleMinimize,
onSelectSuggestion,
activePatientName,
}: ChatSuggestionsProps) {
const [activeCategory, setActiveCategory] = useState(SUGGESTION_CATEGORIES[0].id);
const currentCategory = SUGGESTION_CATEGORIES.find((c) => c.id === activeCategory);
const handleChipClick = (text: string, hasPlaceholder?: boolean) => {
// Replace placeholder with patient name if available, otherwise keep placeholder
const finalText = hasPlaceholder
? replacePlaceholder(text, activePatientName)
: text;
onSelectSuggestion(finalText);
};
return (
<div className="border-t border-slate-100 bg-gradient-to-b from-slate-50/80 to-white">
{/* Minimized state - just a button */}
<AnimatePresence mode="wait">
{isMinimized ? (
<motion.button
key="minimized"
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
onClick={onToggleMinimize}
className={cn(
'w-full flex items-center justify-center gap-2 py-2',
'text-xs text-slate-500 hover:text-slate-700',
'hover:bg-slate-50 transition-colors'
)}
>
<Lightbulb className="w-3.5 h-3.5" />
<span>Toon suggesties</span>
<ChevronUp className="w-3.5 h-3.5" />
</motion.button>
) : (
<motion.div
key="expanded"
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
className="overflow-hidden"
>
{/* Header with minimize button */}
<div className="flex items-center justify-between px-4 pt-2 pb-1">
<div className="flex items-center gap-1.5 text-xs text-slate-500">
<Lightbulb className="w-3.5 h-3.5" />
<span>Wat kan ik vragen?</span>
</div>
<button
onClick={onToggleMinimize}
className={cn(
'text-slate-400 hover:text-slate-600',
'p-1 rounded hover:bg-slate-100 transition-colors'
)}
aria-label="Minimaliseer suggesties"
>
<ChevronDown className="w-4 h-4" />
</button>
</div>
{/* Category tabs */}
<div className="flex gap-1 px-3 pb-2 overflow-x-auto">
{SUGGESTION_CATEGORIES.map((category) => (
<button
key={category.id}
onClick={() => setActiveCategory(category.id)}
className={cn(
'flex items-center gap-1.5 px-3 py-1.5 rounded-full',
'text-xs font-medium whitespace-nowrap',
'transition-all duration-200',
activeCategory === category.id
? 'bg-brand-100 text-brand-700 shadow-sm'
: 'bg-white text-slate-600 hover:bg-slate-100 border border-slate-200'
)}
>
<span>{category.icon}</span>
<span>{category.label}</span>
</button>
))}
</div>
{/* Example chips */}
<div className="px-3 pb-3">
<motion.div
key={activeCategory}
initial={{ opacity: 0, y: -5 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.15 }}
className="flex flex-wrap gap-2"
>
{currentCategory?.examples.map((example, index) => (
<button
key={index}
onClick={() => handleChipClick(example.text, example.hasPatientPlaceholder)}
className={cn(
'text-[11px] leading-relaxed',
'bg-white border border-slate-200 rounded-full',
'px-3 py-1.5',
'text-slate-700',
'hover:bg-brand-50 hover:border-brand-200 hover:text-brand-700',
'active:scale-[0.98]',
'transition-all duration-150',
'shadow-sm hover:shadow'
)}
>
&ldquo;{example.hasPatientPlaceholder && activePatientName
? replacePlaceholder(example.text, activePatientName)
: example.text}&rdquo;
</button>
))}
</motion.div>
{currentCategory && (
<p className="text-[10px] text-slate-400 mt-2 px-1">
{currentCategory.description}
</p>
)}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}