- 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.
1040 lines
32 KiB
Markdown
1040 lines
32 KiB
Markdown
# 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:
|
|
1. User typt intent ("notitie medicatie Jan")
|
|
2. Cortex vraagt "Welke patient bedoel je?"
|
|
3. ZoekenBlock opent in artifact area
|
|
4. User klikt patient
|
|
5. 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/search` endpoint
|
|
- ZoekenBlock logica (extract naar hooks)
|
|
|
|
**Nieuw:**
|
|
- `lib/cortex/hooks/use-patient-search.ts` - Extracted hook
|
|
- `lib/cortex/hooks/use-patient-selection.ts` - Extracted hook
|
|
- `lib/fhir/patient-mapper.ts` - FHIR mapping utility
|
|
- `components/cortex/shared/patient-list-item.tsx` - Shared component
|
|
- `components/cortex/patient-sidebar/` - Sidebar components
|
|
- `components/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 `PatientListItem` voor 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 `patientSearchQuery` in store (local state)
|
|
- Geen `patientSearchResults` in store (local state)
|
|
- Geen `pendingIntent` (hergebruik `pendingAction`)
|
|
- 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:**
|
|
```typescript
|
|
// 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:**
|
|
```typescript
|
|
// 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:**
|
|
```typescript
|
|
// 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:**
|
|
```typescript
|
|
// 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):**
|
|
```typescript
|
|
// 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):**
|
|
```typescript
|
|
// 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:**
|
|
```typescript
|
|
// 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 + actions
|
|
- `components/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 button
|
|
- `components/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:**
|
|
```typescript
|
|
// 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:**
|
|
```typescript
|
|
// 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:**
|
|
```typescript
|
|
// 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:**
|
|
```typescript
|
|
// 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:**
|
|
```typescript
|
|
// 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:**
|
|
```typescript
|
|
// 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 prefill
|
|
- `components/cortex/blocks/patient-dashboard-block.tsx` - activePatient fallback voor patientId
|
|
- `lib/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:**
|
|
- [ ] `usePatientSearch` hook werkt standalone
|
|
- [ ] `PatientListItem` rendert correct
|
|
- [ ] `mapFhirToDbPatient` mapped 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:**
|
|
- [x] DagnotatieBlock prefilled met activePatient
|
|
- [x] Andere blocks prefilled (PatientDashboardBlock, OverdrachtBlock)
|
|
- [x] 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** |
|