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:
180
components/cortex/chat/chat-empty-state.tsx
Normal file
180
components/cortex/chat/chat-empty-state.tsx
Normal 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">
|
||||
“{displayExample}”
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>• “Notitie voor Jan: medicatie gegeven”</p>
|
||||
<p>• “Zoek Marie van den Berg”</p>
|
||||
<p>• “Maak overdracht voor deze dienst”</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)) {
|
||||
|
||||
160
components/cortex/chat/chat-suggestions.tsx
Normal file
160
components/cortex/chat/chat-suggestions.tsx
Normal 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'
|
||||
)}
|
||||
>
|
||||
“{example.hasPatientPlaceholder && activePatientName
|
||||
? replacePlaceholder(example.text, activePatientName)
|
||||
: example.text}”
|
||||
</button>
|
||||
))}
|
||||
</motion.div>
|
||||
{currentCategory && (
|
||||
<p className="text-[10px] text-slate-400 mt-2 px-1">
|
||||
{currentCategory.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user