- Updated DagnotatieBlock to use activePatient for prefill if no patientId is provided. - Enhanced PatientDashboardBlock to fallback to activePatient for patientId and name. - Adjusted usePatientSelection to handle pending actions and re-route after patient selection. - Refined chat components to manage nudge messages and integrate them into the chat flow. - Improved UI elements for better patient selection experience. This update enhances user experience by ensuring that the active patient context is utilized across multiple components, streamlining workflows and reducing manual input.
32 KiB
Bouwplan Patient Selectie v1.5 ✅ COMPLEET
Projectnaam: Patient Selectie UX Verbetering Versie: v1.5 Datum: 03-01-2025 Status: ✅ 100% Compleet (19/19 SP) Auteur: Colin Lit
1. Doel en context
Doel: De patient selectie flow in Cortex verbeteren van een multi-stap proces naar een snelle, inline ervaring met @mentions en een persistent patient sidebar.
Context: De huidige patient selectie in Cortex vereist meerdere stappen:
- User typt intent ("notitie medicatie Jan")
- Cortex vraagt "Welke patient bedoel je?"
- ZoekenBlock opent in artifact area
- User klikt patient
- User moet opnieuw intent typen
Dit kost tijd en zorgt voor context verlies. De nieuwe aanpak introduceert:
- @Mention systeem - Selecteer patient direct in chat input
- Patient Sidebar - Collapsible overlay voor snelle patient selectie
- Smart Defaults - Gebruik activePatient automatisch in alle blocks
Referenties:
- UX Analyse:
docs/intent/patient-search/ux-analyse-patient-selectie.md - Cortex Bouwplan:
docs/archive/swift/bouwplan-swift-v3.md - Store:
stores/cortex-store.ts - ZoekenBlock:
components/cortex/blocks/zoeken-block.tsx(bron voor extracties)
2. Uitgangspunten
2.1 Technische Stack
Bestaand (hergebruiken):
- Next.js 14 (App Router)
- React 18 met TypeScript
- Tailwind CSS + shadcn/ui
- Zustand (state management)
/api/patients/searchendpoint- ZoekenBlock logica (extract naar hooks)
Nieuw:
lib/cortex/hooks/use-patient-search.ts- Extracted hooklib/cortex/hooks/use-patient-selection.ts- Extracted hooklib/fhir/patient-mapper.ts- FHIR mapping utilitycomponents/cortex/shared/patient-list-item.tsx- Shared componentcomponents/cortex/patient-sidebar/- Sidebar componentscomponents/cortex/command-center/patient-mention-dropdown.tsx- Mention dropdown
2.2 Projectkaders
Tijd:
- Fase 0 (Refactor): 0.5 week
- Fase 1 (Sidebar): 0.5 week
- Fase 2 (@Mention): 1 week
- Fase 3 (Smart Defaults): 0.5 week
- Totaal: ~2.5 weken
Team:
- 1 developer (Colin)
- AI assistant (Claude Code)
Scope:
- In scope: Refactor, Patient Sidebar, @Mention, Smart Defaults
- Out of scope: Preview Cards (P2), mention chips (P2), localStorage persistence
- Dependencies: Geen nieuwe npm packages
2.3 Programmeer Uitgangspunten
DRY (Don't Repeat Yourself):
- Extract bestaande ZoekenBlock logica naar hooks
- Geen duplicatie van search/selection code
- Shared
PatientListItemvoor sidebar en dropdown
KISS (Keep It Simple):
- Sidebar als collapsible overlay, niet 3-kolom layout
- Search state lokaal in component, niet in store
- @mention zonder visuele chips (eerst tekst, chips later)
- Keyboard nav: alleen Enter + Escape voor MVP
SOC (Separation of Concerns):
- FHIR mapping in utility, niet in component
- API calls in dedicated functions
- Selection logic in hook, UI in component
YAGNI (You Aren't Gonna Need It):
- Geen
patientSearchQueryin store (local state) - Geen
patientSearchResultsin store (local state) - Geen
pendingIntent(hergebruikpendingAction) - Geen localStorage voor recent patients (session only)
- Geen arrow key navigation (MVP: Enter/Escape)
3. Epics & Stories Overzicht
| Epic ID | Titel | Doel | Status | Stories | Story Points |
|---|---|---|---|---|---|
| E0 | Refactor & Extract | DRY: extract bestaande code naar hooks | ✅ Done | 4 | 4 SP |
| E1 | Patient Sidebar | Collapsible overlay sidebar | ✅ Done | 4 | 6 SP |
| E2 | @Mention Systeem | Inline patient selectie in chat | ✅ Done | 3 | 4 SP |
| E3 | Smart Defaults | ActivePatient auto-use in blocks | ✅ Done | 3 | 5 SP |
Totaal: 14 stories, 19 Story Points
Voortgang: E0 ✅ + E1 ✅ + E2 ✅ + E3 ✅ = 19/19 SP (100%) - COMPLEET
4. Epics & Stories (Uitwerking)
Epic 0 — Refactor & Extract
Epic Doel: Bestaande ZoekenBlock logica extracten naar herbruikbare hooks en utilities (DRY principe).
| Story ID | Beschrijving | Acceptatiecriteria | Status | Afhankelijkheden | Story Points |
|---|---|---|---|---|---|
| E0.S1 | Extract usePatientSearch hook |
Hook werkt, ZoekenBlock refactored | ✅ | — | 1 |
| E0.S2 | Extract PatientListItem component |
Component werkt in ZoekenBlock | ✅ | E0.S1 | 1 |
| E0.S3 | Extract mapFhirToDbPatient utility |
Utility werkt, ZoekenBlock refactored | ✅ | — | 1 |
| E0.S4 | Extract usePatientSelection hook |
Hook werkt met store integration | ✅ | E0.S3 | 1 |
Technical Notes:
E0.S1 - usePatientSearch:
// lib/cortex/hooks/use-patient-search.ts
// Extract van ZoekenBlock:50-113
import { useState, useEffect, useRef, useCallback } from 'react';
import { useToast } from '@/hooks/use-toast';
import { safeFetch, getErrorInfo } from '@/lib/cortex/error-handler';
export interface PatientSearchResult {
id: string;
name: string;
birthDate: string;
identifier_bsn?: string;
identifier_client_number?: string;
matchScore: number;
}
interface UsePatientSearchOptions {
debounceMs?: number;
minQueryLength?: number;
limit?: number;
}
export function usePatientSearch(options: UsePatientSearchOptions = {}) {
const {
debounceMs = 300,
minQueryLength = 2,
limit = 10,
} = options;
const [query, setQuery] = useState('');
const [results, setResults] = useState<PatientSearchResult[]>([]);
const [isSearching, setIsSearching] = useState(false);
const { toast } = useToast();
const timeoutRef = useRef<NodeJS.Timeout>();
const searchPatients = useCallback(async (searchQuery: string) => {
if (searchQuery.length < minQueryLength) {
setResults([]);
return;
}
setIsSearching(true);
try {
const response = await safeFetch(
`/api/patients/search?q=${encodeURIComponent(searchQuery)}&limit=${limit}`,
undefined,
{ operation: 'Patient zoeken' }
);
const data = await response.json();
setResults(data.patients || []);
} catch (error) {
console.error('Failed to search patients:', error);
const errorInfo = getErrorInfo(error, { operation: 'Patient zoeken' });
toast({
variant: 'destructive',
title: errorInfo.title,
description: errorInfo.description,
});
setResults([]);
} finally {
setIsSearching(false);
}
}, [toast, minQueryLength, limit]);
// Debounced search effect
useEffect(() => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
if (query.length >= minQueryLength) {
timeoutRef.current = setTimeout(() => {
searchPatients(query);
}, debounceMs);
} else {
setResults([]);
}
return () => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
};
}, [query, debounceMs, minQueryLength, searchPatients]);
return {
query,
setQuery,
results,
isSearching,
searchPatients, // Voor handmatige trigger (bijv. prefill)
};
}
E0.S2 - PatientListItem:
// components/cortex/shared/patient-list-item.tsx
// Extract van ZoekenBlock:275-326
import { cn } from '@/lib/utils';
import { Loader2, Check, User } from 'lucide-react';
import type { PatientSearchResult } from '@/lib/cortex/hooks/use-patient-search';
interface PatientListItemProps {
patient: PatientSearchResult;
isSelected?: boolean;
isLoading?: boolean;
onClick: () => void;
size?: 'sm' | 'md';
}
export function PatientListItem({
patient,
isSelected = false,
isLoading = false,
onClick,
size = 'md',
}: PatientListItemProps) {
const age = patient.birthDate
? new Date().getFullYear() - new Date(patient.birthDate).getFullYear()
: null;
const initials = patient.name
.split(' ')
.map((n) => n[0])
.join('')
.slice(0, 2)
.toUpperCase();
return (
<button
type="button"
onClick={onClick}
disabled={isLoading}
className={cn(
'w-full px-3 py-2 rounded-lg border text-left transition-colors',
size === 'sm' && 'py-1.5',
isLoading
? 'bg-slate-100 border-slate-300 cursor-wait'
: 'bg-slate-50 border-slate-200 hover:bg-slate-100 hover:border-slate-300'
)}
>
<div className="flex items-center gap-3">
{/* Avatar */}
<div className={cn(
'rounded-full bg-blue-600 flex items-center justify-center text-white font-medium shrink-0',
size === 'sm' ? 'w-6 h-6 text-xs' : 'w-8 h-8 text-xs'
)}>
{initials}
</div>
{/* Info */}
<div className="flex-1 min-w-0">
<div className={cn(
'font-medium text-slate-900 truncate',
size === 'sm' ? 'text-xs' : 'text-sm'
)}>
{patient.name}
{age && <span className="text-slate-500 font-normal"> ({age} jaar)</span>}
</div>
{size === 'md' && (patient.identifier_bsn || patient.identifier_client_number) && (
<div className="flex items-center gap-3 mt-0.5">
{patient.identifier_bsn && (
<span className="text-xs text-slate-500">BSN: {patient.identifier_bsn}</span>
)}
{patient.identifier_client_number && (
<span className="text-xs text-slate-500">Client: {patient.identifier_client_number}</span>
)}
</div>
)}
</div>
{/* Status icon */}
{isLoading ? (
<Loader2 className="h-4 w-4 animate-spin text-blue-600 shrink-0" />
) : (
<Check className="h-4 w-4 text-slate-400 shrink-0" />
)}
</div>
</button>
);
}
E0.S3 - mapFhirToDbPatient:
// lib/fhir/patient-mapper.ts
// Extract van ZoekenBlock:146-187
import type { Database } from '@/lib/supabase/database.types';
type Patient = Database['public']['Tables']['patients']['Row'];
interface FhirPatient {
id: string;
name?: Array<{ family?: string; given?: string[] }>;
birthDate?: string;
gender?: string;
active?: boolean;
identifier?: Array<{ system?: string; value?: string }>;
}
const GENDER_MAP: Record<string, Patient['gender']> = {
male: 'male',
female: 'female',
other: 'other',
unknown: 'unknown',
};
export function mapFhirToDbPatient(fhir: FhirPatient): Patient {
const gender = GENDER_MAP[fhir.gender?.toLowerCase() || 'unknown'] || 'unknown';
return {
id: fhir.id,
name_family: fhir.name?.[0]?.family || '',
name_given: fhir.name?.[0]?.given || [],
birth_date: fhir.birthDate || '',
gender,
active: fhir.active !== false,
identifier_bsn: fhir.identifier?.find(
(id) => id.system === 'http://fhir.nl/fhir/NamingSystem/bsn'
)?.value || null,
identifier_client_number: fhir.identifier?.find(
(id) => id.system?.includes('client') || id.system?.includes('999.7.6')
)?.value || null,
// Null defaults for optional fields
status: null,
created_at: null,
updated_at: null,
address_line: null,
address_city: null,
address_postal_code: null,
address_country: null,
telecom_email: null,
telecom_phone: null,
name_prefix: null,
name_use: null,
emergency_contact_name: null,
emergency_contact_phone: null,
emergency_contact_relationship: null,
general_practitioner_name: null,
general_practitioner_agb: null,
insurance_company: null,
insurance_number: null,
is_john_doe: null,
};
}
E0.S4 - usePatientSelection:
// lib/cortex/hooks/use-patient-selection.ts
import { useState } from 'react';
import { useToast } from '@/hooks/use-toast';
import { useCortexStore } from '@/stores/cortex-store';
import { safeFetch, getErrorInfo } from '@/lib/cortex/error-handler';
import { mapFhirToDbPatient } from '@/lib/fhir/patient-mapper';
import type { PatientSearchResult } from './use-patient-search';
interface UsePatientSelectionOptions {
onSuccess?: (patientName: string) => void;
addToRecent?: boolean;
}
export function usePatientSelection(options: UsePatientSelectionOptions = {}) {
const { onSuccess, addToRecent = true } = options;
const [isSelecting, setIsSelecting] = useState(false);
const [selectedId, setSelectedId] = useState<string | null>(null);
const { toast } = useToast();
const { setActivePatient, addRecentPatient, addRecentAction } = useCortexStore();
const selectPatient = async (patient: PatientSearchResult) => {
setIsSelecting(true);
setSelectedId(patient.id);
try {
// Fetch full FHIR patient data
const response = await safeFetch(
`/api/fhir/Patient/${patient.id}`,
undefined,
{ operation: 'Patient data ophalen' }
);
const fhirPatient = await response.json();
// Map to DB format
const dbPatient = mapFhirToDbPatient(fhirPatient);
// Update store
setActivePatient(dbPatient);
if (addToRecent) {
addRecentPatient(dbPatient);
addRecentAction({
intent: 'zoeken',
label: `Patient geselecteerd: ${patient.name}`,
patientName: patient.name,
});
}
toast({
title: 'Patient geselecteerd',
description: `${patient.name} is nu actief`,
});
onSuccess?.(patient.name);
return dbPatient;
} catch (error) {
console.error('Failed to select patient:', error);
const errorInfo = getErrorInfo(error, { operation: 'Patient selecteren' });
toast({
variant: 'destructive',
title: errorInfo.title,
description: errorInfo.description,
});
return null;
} finally {
setIsSelecting(false);
setSelectedId(null);
}
};
return {
selectPatient,
isSelecting,
selectedId,
};
}
Deliverables E0: ✅ Completed 03-01-2025
lib/cortex/hooks/use-patient-search.ts(116 regels)lib/cortex/hooks/use-patient-selection.ts(98 regels)lib/cortex/hooks/index.ts(7 regels)lib/fhir/patient-mapper.ts(109 regels)components/cortex/shared/patient-list-item.tsx(114 regels)- ZoekenBlock refactored: 349 → 127 regels (-64%)
Epic 1 — Patient Sidebar
Epic Doel: Collapsible overlay sidebar voor snelle patient selectie.
| Story ID | Beschrijving | Acceptatiecriteria | Status | Afhankelijkheden | Story Points |
|---|---|---|---|---|---|
| E1.S1 | recentPatients in store |
Max 5, nieuwste eerst, geen duplicaten | ✅ | E0.S4 | 1 |
| E1.S2 | PatientSidebar component | Overlay sidebar met search + recent | ✅ | E1.S1 | 2 |
| E1.S3 | Toggle button + Cmd+P shortcut | Knop in ContextBar, keyboard shortcut | ✅ | E1.S2 | 2 |
| E1.S4 | Click-to-select flow | Selectie sluit sidebar, zet activePatient | ✅ | E1.S3 | 1 |
Technical Notes:
E1.S1 - Store uitbreiding (minimal):
// stores/cortex-store.ts - Alleen toevoegen:
// In interface:
recentPatients: Patient[];
patientSidebarOpen: boolean;
addRecentPatient: (patient: Patient) => void;
togglePatientSidebar: () => void;
// In initialState:
recentPatients: [],
patientSidebarOpen: false,
// In actions:
addRecentPatient: (patient) => set((state) => ({
recentPatients: [
patient,
...state.recentPatients.filter(p => p.id !== patient.id)
].slice(0, 5)
}), false, 'addRecentPatient'),
togglePatientSidebar: () => set((state) => ({
patientSidebarOpen: !state.patientSidebarOpen
}), false, 'togglePatientSidebar'),
E1.S2 - PatientSidebar (overlay):
// components/cortex/patient-sidebar/patient-sidebar.tsx
'use client';
import { useEffect, useRef } from 'react';
import { X, Search, Clock, Users } from 'lucide-react';
import { useCortexStore } from '@/stores/cortex-store';
import { usePatientSearch } from '@/lib/cortex/hooks/use-patient-search';
import { usePatientSelection } from '@/lib/cortex/hooks/use-patient-selection';
import { PatientListItem } from '@/components/cortex/shared/patient-list-item';
import { Input } from '@/components/ui/input';
import { cn } from '@/lib/utils';
export function PatientSidebar() {
const { patientSidebarOpen, togglePatientSidebar, recentPatients } = useCortexStore();
const { query, setQuery, results, isSearching } = usePatientSearch({ limit: 5 });
const { selectPatient, isSelecting, selectedId } = usePatientSelection({
onSuccess: () => togglePatientSidebar(),
});
const inputRef = useRef<HTMLInputElement>(null);
// Focus input when opened
useEffect(() => {
if (patientSidebarOpen) {
setTimeout(() => inputRef.current?.focus(), 100);
}
}, [patientSidebarOpen]);
// Close on Escape
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && patientSidebarOpen) {
togglePatientSidebar();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [patientSidebarOpen, togglePatientSidebar]);
if (!patientSidebarOpen) return null;
const showResults = query.length >= 2;
const showRecent = !showResults && recentPatients.length > 0;
return (
<>
{/* Backdrop */}
<div
className="fixed inset-0 bg-black/20 z-40"
onClick={togglePatientSidebar}
/>
{/* Sidebar */}
<div className="fixed left-0 top-0 bottom-0 w-80 bg-white shadow-xl z-50 flex flex-col">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b">
<h2 className="font-semibold text-slate-900 flex items-center gap-2">
<Users className="w-4 h-4" />
Patienten
</h2>
<button
onClick={togglePatientSidebar}
className="p-1 hover:bg-slate-100 rounded"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Search */}
<div className="p-3 border-b">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<Input
ref={inputRef}
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Zoek patient..."
className="pl-9"
/>
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-3 space-y-4">
{/* Search Results */}
{showResults && (
<div className="space-y-1">
{isSearching ? (
<p className="text-sm text-slate-500 text-center py-4">Zoeken...</p>
) : results.length > 0 ? (
results.map((patient) => (
<PatientListItem
key={patient.id}
patient={patient}
isLoading={selectedId === patient.id}
onClick={() => selectPatient(patient)}
size="sm"
/>
))
) : (
<p className="text-sm text-slate-500 text-center py-4">
Geen resultaten voor "{query}"
</p>
)}
</div>
)}
{/* Recent Patients */}
{showRecent && (
<div>
<h3 className="text-xs font-medium text-slate-500 uppercase tracking-wide mb-2 flex items-center gap-1">
<Clock className="w-3 h-3" />
Recent
</h3>
<div className="space-y-1">
{recentPatients.map((patient) => (
<PatientListItem
key={patient.id}
patient={{
id: patient.id,
name: `${patient.name_given?.[0] || ''} ${patient.name_family || ''}`.trim(),
birthDate: patient.birth_date || '',
identifier_bsn: patient.identifier_bsn || undefined,
identifier_client_number: patient.identifier_client_number || undefined,
matchScore: 1,
}}
isLoading={selectedId === patient.id}
onClick={() => selectPatient({
id: patient.id,
name: `${patient.name_given?.[0] || ''} ${patient.name_family || ''}`.trim(),
birthDate: patient.birth_date || '',
matchScore: 1,
})}
size="sm"
/>
))}
</div>
</div>
)}
{/* Empty state */}
{!showResults && !showRecent && (
<p className="text-sm text-slate-500 text-center py-8">
Typ om te zoeken of selecteer een recente patient
</p>
)}
</div>
</div>
</>
);
}
E1.S3 - Toggle in ContextBar + shortcut:
// In ContextBar, add button:
<button
onClick={togglePatientSidebar}
className="p-2 hover:bg-slate-100 rounded-lg"
title="Patienten (Cmd+P)"
>
<Users className="w-4 h-4" />
</button>
// Global shortcut (in CommandCenter):
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'p') {
e.preventDefault();
togglePatientSidebar();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [togglePatientSidebar]);
Deliverables E1: ✅ Completed 03-01-2025
stores/cortex-store.ts- recentPatients, patientSidebarOpen state + actionscomponents/cortex/patient-sidebar/patient-sidebar.tsx(195 regels)components/cortex/patient-sidebar/index.ts(1 regel)components/cortex/command-center/context-bar.tsx- Users toggle buttoncomponents/cortex/command-center/command-center.tsx- Cmd+P shortcut, PatientSidebar integratie
Epic 2 — @Mention Systeem
Epic Doel: Inline patient selectie via @naam in de chat input.
| Story ID | Beschrijving | Acceptatiecriteria | Status | Afhankelijkheden | Story Points |
|---|---|---|---|---|---|
| E2.S1 | @-detectie in CommandInput | Detecteer @ + query, track positie | ✅ | E0.S1 | 1 |
| E2.S2 | PatientMentionDropdown | Dropdown met resultaten, positioned above | ✅ | E2.S1 | 2 |
| E2.S3 | Selectie flow | Replace @query, zet activePatient | ✅ | E2.S2 | 1 |
YAGNI Versimpeling: E2.S4 en E2.S5 verwijderd - activePatient is al beschikbaar na selectie, geen aparte mention tracking nodig.
Technical Notes:
E2.S1 - @ detectie:
// In CommandInput - state toevoegen:
interface MentionState {
active: boolean;
query: string;
startIndex: number;
}
const [mentionState, setMentionState] = useState<MentionState | null>(null);
const [selectedMentions, setSelectedMentions] = useState<Array<{
patientId: string;
patientName: string;
startIndex: number;
endIndex: number;
}>>([]);
// In handleInputChange:
const handleInputChange = (value: string) => {
setInputValue(value);
// Detect @ mention
const lastAtIndex = value.lastIndexOf('@');
if (lastAtIndex !== -1) {
const textAfterAt = value.slice(lastAtIndex + 1);
// Check if there's no space after @ (still typing mention)
if (!textAfterAt.includes(' ') && textAfterAt.length > 0) {
setMentionState({
active: true,
query: textAfterAt,
startIndex: lastAtIndex,
});
return;
}
}
setMentionState(null);
};
E2.S2 - PatientMentionDropdown:
// components/cortex/command-center/patient-mention-dropdown.tsx
'use client';
import { useEffect, useRef } from 'react';
import { usePatientSearch } from '@/lib/cortex/hooks/use-patient-search';
import { PatientListItem } from '@/components/cortex/shared/patient-list-item';
import { Loader2 } from 'lucide-react';
interface PatientMentionDropdownProps {
query: string;
onSelect: (patient: { id: string; name: string }) => void;
onClose: () => void;
}
export function PatientMentionDropdown({
query,
onSelect,
onClose,
}: PatientMentionDropdownProps) {
const { results, isSearching } = usePatientSearch({
debounceMs: 150, // Faster for dropdown
limit: 5,
});
const containerRef = useRef<HTMLDivElement>(null);
// Set query directly (hook manages debounce)
useEffect(() => {
// The hook will auto-search when we update via setQuery
}, [query]);
// Close on outside click
useEffect(() => {
const handleClick = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
onClose();
}
};
document.addEventListener('mousedown', handleClick);
return () => document.removeEventListener('mousedown', handleClick);
}, [onClose]);
return (
<div
ref={containerRef}
className="absolute bottom-full left-0 mb-2 w-72 bg-white border rounded-lg shadow-lg z-50 overflow-hidden"
>
{isSearching ? (
<div className="p-3 flex items-center justify-center text-slate-500">
<Loader2 className="w-4 h-4 animate-spin mr-2" />
<span className="text-sm">Zoeken...</span>
</div>
) : results.length > 0 ? (
<div className="p-1 space-y-0.5">
{results.map((patient) => (
<PatientListItem
key={patient.id}
patient={patient}
onClick={() => onSelect({ id: patient.id, name: patient.name })}
size="sm"
/>
))}
</div>
) : query.length >= 2 ? (
<div className="p-3 text-sm text-slate-500 text-center">
Geen resultaten voor "@{query}"
</div>
) : (
<div className="p-3 text-sm text-slate-500 text-center">
Typ minimaal 2 karakters
</div>
)}
</div>
);
}
E2.S3 & E2.S4 - Keyboard + mention verwerking:
// In CommandInput:
const handleMentionSelect = (patient: { id: string; name: string }) => {
if (!mentionState) return;
// Replace @query with patient name
const beforeMention = inputValue.slice(0, mentionState.startIndex);
const afterMention = inputValue.slice(mentionState.startIndex + mentionState.query.length + 1);
const newValue = `${beforeMention}@${patient.name}${afterMention}`;
setInputValue(newValue);
// Track mention
setSelectedMentions(prev => [...prev, {
patientId: patient.id,
patientName: patient.name,
startIndex: mentionState.startIndex,
endIndex: mentionState.startIndex + patient.name.length + 1,
}]);
setMentionState(null);
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (mentionState?.active) {
if (e.key === 'Escape') {
e.preventDefault();
setMentionState(null);
}
// Enter handled by dropdown item click
}
// ... existing key handling
};
E2.S5 - API payload:
// In handleSubmit:
const handleSubmit = async () => {
// Clean message (remove @ symbols for display)
const cleanedMessage = inputValue;
const payload = {
message: cleanedMessage,
messages: chatMessages.slice(-20),
context: { activePatient, shift },
mentions: selectedMentions.map(m => ({
patientId: m.patientId,
patientName: m.patientName,
})),
};
// ... send to API
// Clear mentions after submit
setSelectedMentions([]);
};
Deliverables E2: ✅ Completed 03-01-2025
components/cortex/command-center/patient-mention-dropdown.tsx(87 regels)components/cortex/command-center/command-input.tsx- @detectie, selectie flow
Epic 3 — Smart Defaults
Epic Doel: ActivePatient automatisch gebruiken in blocks wanneer geen patient expliciet genoemd.
| Story ID | Beschrijving | Acceptatiecriteria | Status | Afhankelijkheden | Story Points |
|---|---|---|---|---|---|
| E3.S1 | DagnotatieBlock auto-prefill | Als geen prefill patient, gebruik activePatient | ✅ | E2.S5 | 2 |
| E3.S2 | Andere blocks activePatient | OverdrachtBlock, PatientDashboardBlock | ✅ | E3.S1 | 2 |
| E3.S3 | Re-route na patient selectie | Gebruik pendingAction voor re-route |
✅ | E3.S2 | 1 |
Technical Notes:
E3.S1 - DagnotatieBlock:
// In DagnotatieBlock - update initialisatie:
const activePatient = useCortexStore(s => s.activePatient);
// Prefill met fallback naar activePatient
const [patientId, setPatientId] = useState<string>(
prefill?.patientId || activePatient?.id || ''
);
const [patientName, setPatientName] = useState<string>(
prefill?.patientName ||
(activePatient
? `${activePatient.name_given?.[0] || ''} ${activePatient.name_family || ''}`.trim()
: '')
);
// Update als activePatient wijzigt (alleen als geen prefill)
useEffect(() => {
if (!prefill?.patientId && activePatient) {
setPatientId(activePatient.id);
setPatientName(
`${activePatient.name_given?.[0] || ''} ${activePatient.name_family || ''}`.trim()
);
}
}, [activePatient, prefill?.patientId]);
E3.S3 - Re-route met pendingAction:
// In usePatientSelection hook - extend onSuccess:
const { pendingAction, setPendingAction, openArtifact } = useCortexStore();
// After successful selection:
if (pendingAction && !pendingAction.entities.patientId) {
// Re-open artifact with patient info
openArtifact({
type: pendingAction.artifact?.type || pendingAction.intent,
title: `${pendingAction.intent} - ${patient.name}`,
prefill: {
...pendingAction.entities,
patientId: dbPatient.id,
patientName: patient.name,
},
});
setPendingAction(null);
}
Deliverables E3: ✅ Completed 03-01-2025
components/cortex/blocks/dagnotitie-block.tsx- activePatient fallback voor prefillcomponents/cortex/blocks/patient-dashboard-block.tsx- activePatient fallback voor patientIdlib/cortex/hooks/use-patient-selection.ts- pendingAction re-route na selectie- OverdrachtBlock was al correct geïmplementeerd (geen wijziging nodig)
5. Kwaliteit & Testplan
Test Checklist
Epic 0 - Refactor:
usePatientSearchhook werkt standalonePatientListItemrendert correctmapFhirToDbPatientmapped correct- ZoekenBlock werkt nog na refactor
- Geen regressies in bestaande functionaliteit
Epic 1 - Sidebar:
- Cmd+P opent/sluit sidebar
- Zoeken toont resultaten
- Recent patients tonen
- Click selecteert patient
- Escape sluit sidebar
- Backdrop click sluit sidebar
Epic 2 - @Mention:
- @ toont dropdown
- Typen filtert resultaten
- Enter selecteert (via click)
- Escape sluit dropdown
- @Naam vervangt @query
- Mention data in API payload
Epic 3 - Smart Defaults:
- DagnotatieBlock prefilled met activePatient
- Andere blocks prefilled (PatientDashboardBlock, OverdrachtBlock)
- pendingAction re-route werkt
6. Risico's & Mitigatie
| Risico | Kans | Impact | Mitigatie |
|---|---|---|---|
| Refactor breekt ZoekenBlock | Medium | Hoog | Stapsgewijze extract, test na elke stap |
| Sidebar overlay storend | Laag | Medium | Backdrop transparant, easy dismiss |
| @mention performance | Laag | Medium | Debounce 150ms, max 5 results |
| Mobile UX sidebar | Medium | Medium | Later itereren, focus eerst desktop |
7. Referenties
Project Documents:
- UX Analyse:
docs/intent/patient-search/ux-analyse-patient-selectie.md - Cortex Bouwplan:
docs/archive/swift/bouwplan-swift-v3.md
Code References:
- ZoekenBlock (bron):
components/cortex/blocks/zoeken-block.tsx - CommandInput:
components/cortex/command-center/command-input.tsx - Store:
stores/cortex-store.ts - Patient Search API:
app/api/patients/search/route.ts
Versiehistorie:
| Versie | Datum | Auteur | Wijziging |
|---|---|---|---|
| v1.0 | 03-01-2025 | Colin Lit | Initiele versie |
| v1.1 | 03-01-2025 | Colin Lit | Review: DRY/KISS/SOC/YAGNI toegepast, Epic 0 toegevoegd, SP gereduceerd van 38 naar 23 |
| v1.2 | 03-01-2025 | Colin Lit | Epic 0 compleet: 4 stories done, ZoekenBlock refactored (-64% code) |
| v1.3 | 03-01-2025 | Claude Code | Epic 1 compleet: Patient Sidebar met Cmd+P, search, recent patients |
| v1.4 | 03-01-2025 | Claude Code | Epic 2 compleet: @Mention systeem (YAGNI: 8→4 SP, 5→3 stories) |
| v1.5 | 03-01-2025 | Claude Code | Epic 3 compleet: Smart Defaults - DagnotatieBlock, PatientDashboardBlock, pendingAction re-route. BOUWPLAN 100% COMPLEET |