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:
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
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