refactor(cortex): Epic 0 - Extract patient search hooks & components (E0.S1-S4)
DRY refactor: Extract reusable patient search logic from ZoekenBlock. New files: - lib/cortex/hooks/use-patient-search.ts (116 lines) Debounced patient search with abort controller - lib/cortex/hooks/use-patient-selection.ts (98 lines) FHIR fetch + store update flow - lib/fhir/patient-mapper.ts (109 lines) FHIR Patient to DB Patient mapping + utilities - components/cortex/shared/patient-list-item.tsx (114 lines) Reusable patient list item with loading states Refactored: - ZoekenBlock: 349 -> 127 lines (-64%) Documentation: - docs/intent/patient-search/ux-analyse-patient-selectie.md - docs/intent/patient-search/bouwplan-patient-selectie-v1.md CLAUDE.md updated with Cortex architecture documentation. Epic 0 complete: 4/4 stories (4 SP) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
31
CLAUDE.md
31
CLAUDE.md
@@ -38,16 +38,41 @@ Required in `.env.local`:
|
|||||||
- `/api/overdracht` - Handover data: patients, patient details, AI summary generation
|
- `/api/overdracht` - Handover data: patients, patient details, AI summary generation
|
||||||
- `/api/verpleegrapportage` - Patient data for nursing report views
|
- `/api/verpleegrapportage` - Patient data for nursing report views
|
||||||
- `/api/behandelplan` - Treatment plan management
|
- `/api/behandelplan` - Treatment plan management
|
||||||
|
- `/api/cortex/*` - Cortex AI command center APIs (classify, chat, agenda, patients)
|
||||||
|
|
||||||
**API Route Pattern**: All routes use Zod validation, return Dutch error messages, and get the current user via `createClient()` from `lib/auth/server.ts`.
|
**API Route Pattern**: All routes use Zod validation, return Dutch error messages, and get the current user via `createClient()` from `lib/auth/server.ts`.
|
||||||
|
|
||||||
### EPD Modules (`app/epd/`)
|
### EPD Modules (`app/epd/`)
|
||||||
|
- `/epd/dashboard` - Main dashboard with Cortex command center
|
||||||
- `/epd/verpleegrapportage` - Overdracht overzicht (patiënten met AI-samenvatting)
|
- `/epd/verpleegrapportage` - Overdracht overzicht (patiënten met AI-samenvatting)
|
||||||
- `/epd/verpleegrapportage/rapportage` - Rapportage invoer workspace (timeline view)
|
- `/epd/verpleegrapportage/rapportage` - Rapportage invoer workspace (timeline view)
|
||||||
- `/epd/patients/[id]` - Patient dossier with intakes, conditions, observations
|
- `/epd/patients/[id]` - Patient dossier with intakes, conditions, observations
|
||||||
- `/epd/agenda` - Appointment calendar (FullCalendar)
|
- `/epd/agenda` - Appointment calendar (FullCalendar)
|
||||||
- `/epd/clients` - Client management
|
- `/epd/clients` - Client management
|
||||||
|
|
||||||
|
### Cortex - AI Command Center (`lib/cortex/`, `components/cortex/`)
|
||||||
|
|
||||||
|
Three-layer architecture for natural language processing:
|
||||||
|
|
||||||
|
**Layer 1 - Reflex Arc** (`reflex-classifier.ts`): Fast local pattern matching for common intents. Escalates to AI when confidence < 0.7 or ambiguous.
|
||||||
|
|
||||||
|
**Layer 2 - Orchestrator** (`orchestrator.ts`): Claude-powered intent classification for complex cases (multi-intent, pronouns, relative time). Handles context-dependent resolution.
|
||||||
|
|
||||||
|
**Layer 3 - Nudge** (`nudge.ts`): Proactive suggestions after action completion.
|
||||||
|
|
||||||
|
**Intent Types** (defined in `lib/cortex/types.ts`):
|
||||||
|
```typescript
|
||||||
|
type CortexIntent = 'dagnotitie' | 'zoeken' | 'overdracht' | 'agenda_query' |
|
||||||
|
'create_appointment' | 'cancel_appointment' |
|
||||||
|
'reschedule_appointment' | 'unknown';
|
||||||
|
```
|
||||||
|
|
||||||
|
**UI Components** (`components/cortex/command-center/`):
|
||||||
|
- `command-center.tsx` - Main container with command input
|
||||||
|
- `command-input.tsx` - Voice/text input with ⌘K focus, ⌘Enter submit
|
||||||
|
- `context-bar.tsx` - Shows active patient/context
|
||||||
|
- `canvas-area.tsx` - Displays action blocks based on classified intent
|
||||||
|
|
||||||
### Key Patterns
|
### Key Patterns
|
||||||
|
|
||||||
**Report Types** (stored in `reports` table):
|
**Report Types** (stored in `reports` table):
|
||||||
@@ -68,6 +93,8 @@ type Category = 'medicatie' | 'adl' | 'gedrag' | 'incident' | 'observatie';
|
|||||||
|
|
||||||
### AI Integration
|
### AI Integration
|
||||||
- Claude API for generating handover summaries (`/api/overdracht/generate`)
|
- Claude API for generating handover summaries (`/api/overdracht/generate`)
|
||||||
|
- Claude API for Cortex intent classification (`/api/cortex/classify`)
|
||||||
|
- Claude API for Cortex chat (`/api/cortex/chat`) - streaming SSE responses
|
||||||
- Deepgram for speech-to-text (`/api/deepgram`)
|
- Deepgram for speech-to-text (`/api/deepgram`)
|
||||||
- AI responses validated with Zod schemas
|
- AI responses validated with Zod schemas
|
||||||
|
|
||||||
@@ -76,6 +103,7 @@ type Category = 'medicatie' | 'adl' | 'gedrag' | 'incident' | 'observatie';
|
|||||||
- Lucide React for icons
|
- Lucide React for icons
|
||||||
- date-fns with Dutch locale for date formatting
|
- date-fns with Dutch locale for date formatting
|
||||||
- Timeline views grouped by day and day-part (nacht/ochtend/middag/avond)
|
- Timeline views grouped by day and day-part (nacht/ochtend/middag/avond)
|
||||||
|
- Zustand for state management (`lib/stores/`)
|
||||||
|
|
||||||
## Database Migrations
|
## Database Migrations
|
||||||
|
|
||||||
@@ -93,5 +121,6 @@ After schema changes:
|
|||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
- Specs in `docs/specs/` organized by module
|
- Swift/Cortex specs in `docs/swift/` (bouwplan, FO docs, implementation notes)
|
||||||
|
- General specs in `docs/specs/` organized by module
|
||||||
- Release notes in `docs/releasenotes/`
|
- Release notes in `docs/releasenotes/`
|
||||||
|
|||||||
@@ -3,342 +3,121 @@
|
|||||||
/**
|
/**
|
||||||
* Zoeken Block
|
* Zoeken Block
|
||||||
*
|
*
|
||||||
* Block voor het zoeken naar patiënten.
|
* Block voor het zoeken naar patienten.
|
||||||
* E3.S4: Volledige implementatie met input, resultaten en selectie naar store.
|
* Refactored to use extracted hooks and components (E0 - DRY).
|
||||||
|
*
|
||||||
|
* Epic: E3.S4 (Original), E0 (Refactor)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import { useToast } from '@/hooks/use-toast';
|
|
||||||
import { useCortexStore } from '@/stores/cortex-store';
|
import { useCortexStore } from '@/stores/cortex-store';
|
||||||
import { BlockContainer } from './block-container';
|
import { BlockContainer } from './block-container';
|
||||||
import type { BlockPrefillData } from '@/stores/cortex-store';
|
import type { BlockPrefillData } from '@/stores/cortex-store';
|
||||||
import { BLOCK_CONFIGS } from '@/lib/cortex/types';
|
import { BLOCK_CONFIGS } from '@/lib/cortex/types';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Loader2, Search, User, Check } from 'lucide-react';
|
import { Search, User } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
import { safeFetch, getErrorInfo } from '@/lib/cortex/error-handler';
|
// Use extracted hooks and components (DRY)
|
||||||
|
import { usePatientSearch } from '@/lib/cortex/hooks/use-patient-search';
|
||||||
|
import { usePatientSelection } from '@/lib/cortex/hooks/use-patient-selection';
|
||||||
|
import {
|
||||||
|
PatientListItem,
|
||||||
|
PatientListEmpty,
|
||||||
|
PatientListLoading,
|
||||||
|
} from '@/components/cortex/shared/patient-list-item';
|
||||||
|
|
||||||
interface ZoekenBlockProps {
|
interface ZoekenBlockProps {
|
||||||
prefill?: BlockPrefillData;
|
prefill?: BlockPrefillData;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PatientSearchResult {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
birthDate: string;
|
|
||||||
identifier_bsn?: string;
|
|
||||||
identifier_client_number?: string;
|
|
||||||
matchScore: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ZoekenBlock({ prefill }: ZoekenBlockProps) {
|
export function ZoekenBlock({ prefill }: ZoekenBlockProps) {
|
||||||
const config = BLOCK_CONFIGS.zoeken;
|
const config = BLOCK_CONFIGS.zoeken;
|
||||||
const { closeBlock, setActivePatient, addRecentAction, openArtifact } = useCortexStore();
|
const { closeBlock, openArtifact } = useCortexStore();
|
||||||
const { toast } = useToast();
|
|
||||||
const prefillQuery = prefill?.patientName || prefill?.query || '';
|
|
||||||
|
|
||||||
// Search state
|
|
||||||
const [searchQuery, setSearchQuery] = useState<string>(prefillQuery);
|
|
||||||
const [patients, setPatients] = useState<PatientSearchResult[]>([]);
|
|
||||||
const [isSearching, setIsSearching] = useState(false);
|
|
||||||
const [selectedPatientId, setSelectedPatientId] = useState<string | null>(null);
|
|
||||||
const searchTimeoutRef = useRef<NodeJS.Timeout>();
|
|
||||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// Patient search function
|
// Get prefill query from various sources
|
||||||
const searchPatients = useCallback(async (query: string) => {
|
const prefillQuery = prefill?.patientName || prefill?.query || '';
|
||||||
if (query.length < 2) {
|
|
||||||
setPatients([]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsSearching(true);
|
// Use extracted hooks
|
||||||
try {
|
const { query, setQuery, results, isSearching, searchPatients } = usePatientSearch({
|
||||||
const response = await safeFetch(
|
initialQuery: prefillQuery,
|
||||||
`/api/patients/search?q=${encodeURIComponent(query)}&limit=10`,
|
limit: 10,
|
||||||
undefined,
|
|
||||||
{ operation: 'Patiënt zoeken' }
|
|
||||||
);
|
|
||||||
const data = await response.json();
|
|
||||||
setPatients(data.patients || []);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to search patients:', error);
|
|
||||||
const statusCode = (error as any)?.statusCode;
|
|
||||||
const errorInfo = getErrorInfo(error, {
|
|
||||||
operation: 'Patiënt zoeken',
|
|
||||||
statusCode,
|
|
||||||
});
|
|
||||||
toast({
|
|
||||||
variant: 'destructive',
|
|
||||||
title: errorInfo.title,
|
|
||||||
description: errorInfo.description,
|
|
||||||
});
|
|
||||||
setPatients([]);
|
|
||||||
} finally {
|
|
||||||
setIsSearching(false);
|
|
||||||
}
|
|
||||||
}, [toast]);
|
|
||||||
|
|
||||||
// Prefill search query
|
|
||||||
useEffect(() => {
|
|
||||||
if (prefillQuery) {
|
|
||||||
setSearchQuery(prefillQuery);
|
|
||||||
// Auto-search if prefill is provided
|
|
||||||
if (prefillQuery.length >= 2) {
|
|
||||||
searchPatients(prefillQuery);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [prefillQuery, searchPatients]);
|
|
||||||
|
|
||||||
// Debounced search
|
|
||||||
useEffect(() => {
|
|
||||||
if (searchTimeoutRef.current) {
|
|
||||||
clearTimeout(searchTimeoutRef.current);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (searchQuery.length >= 2) {
|
|
||||||
searchTimeoutRef.current = setTimeout(() => {
|
|
||||||
searchPatients(searchQuery);
|
|
||||||
}, 300);
|
|
||||||
} else {
|
|
||||||
setPatients([]);
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (searchTimeoutRef.current) {
|
|
||||||
clearTimeout(searchTimeoutRef.current);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [searchQuery, searchPatients]);
|
|
||||||
|
|
||||||
// Close dropdown on outside click
|
|
||||||
useEffect(() => {
|
|
||||||
const handleClickOutside = (event: MouseEvent) => {
|
|
||||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
|
||||||
// Don't close if clicking on input
|
|
||||||
const target = event.target as HTMLElement;
|
|
||||||
if (!target.closest('input')) {
|
|
||||||
// Dropdown will close naturally when input loses focus
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
document.addEventListener('mousedown', handleClickOutside);
|
|
||||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Handle patient selection
|
|
||||||
const handleSelectPatient = async (patient: PatientSearchResult) => {
|
|
||||||
setSelectedPatientId(patient.id);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Fetch full patient data from FHIR API
|
|
||||||
const response = await safeFetch(
|
|
||||||
`/api/fhir/Patient/${patient.id}`,
|
|
||||||
undefined,
|
|
||||||
{ operation: 'Patiënt data ophalen' }
|
|
||||||
);
|
|
||||||
|
|
||||||
const fhirPatient = await response.json();
|
|
||||||
|
|
||||||
// Map FHIR Patient to database Patient format
|
|
||||||
// Map gender to enum type
|
|
||||||
const genderMap: Record<string, 'male' | 'female' | 'other' | 'unknown'> = {
|
|
||||||
male: 'male',
|
|
||||||
female: 'female',
|
|
||||||
other: 'other',
|
|
||||||
unknown: 'unknown',
|
|
||||||
};
|
|
||||||
const mappedGender = genderMap[fhirPatient.gender?.toLowerCase() || 'unknown'] || 'unknown';
|
|
||||||
|
|
||||||
const dbPatient = {
|
|
||||||
id: fhirPatient.id,
|
|
||||||
name_family: fhirPatient.name?.[0]?.family || '',
|
|
||||||
name_given: fhirPatient.name?.[0]?.given || [],
|
|
||||||
birth_date: fhirPatient.birthDate || '',
|
|
||||||
identifier_bsn: fhirPatient.identifier?.find(
|
|
||||||
(id: any) => id.system === 'http://fhir.nl/fhir/NamingSystem/bsn'
|
|
||||||
)?.value || null,
|
|
||||||
identifier_client_number: fhirPatient.identifier?.find(
|
|
||||||
(id: any) => id.system?.includes('client') || id.system?.includes('999.7.6')
|
|
||||||
)?.value || null,
|
|
||||||
gender: mappedGender as 'male' | 'female' | 'other' | 'unknown',
|
|
||||||
active: fhirPatient.active !== false,
|
|
||||||
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,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Set active patient in store
|
|
||||||
setActivePatient(dbPatient);
|
|
||||||
|
|
||||||
// Add to recent actions
|
|
||||||
addRecentAction({
|
|
||||||
intent: 'zoeken',
|
|
||||||
label: `Patiënt geselecteerd: ${patient.name}`,
|
|
||||||
patientName: patient.name,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Show success toast
|
const { selectPatient, selectedId } = usePatientSelection({
|
||||||
toast({
|
onSuccess: (dbPatient, patientName) => {
|
||||||
title: 'Patiënt geselecteerd',
|
// Close block and open patient dashboard
|
||||||
description: `${patient.name} is nu actief`,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Close legacy block (v2) and open dashboard artifact (v3)
|
|
||||||
closeBlock();
|
closeBlock();
|
||||||
openArtifact({
|
openArtifact({
|
||||||
type: 'patient-dashboard',
|
type: 'patient-dashboard',
|
||||||
title: `Dashboard - ${patient.name}`,
|
title: `Dashboard - ${patientName}`,
|
||||||
prefill: {
|
prefill: {
|
||||||
patientId: patient.id,
|
patientId: dbPatient.id,
|
||||||
patientName: patient.name,
|
patientName: patientName,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
},
|
||||||
console.error('Failed to select patient:', error);
|
|
||||||
const statusCode = (error as any)?.statusCode;
|
|
||||||
const errorInfo = getErrorInfo(error, {
|
|
||||||
operation: 'Patiënt selecteren',
|
|
||||||
statusCode,
|
|
||||||
});
|
});
|
||||||
toast({
|
|
||||||
variant: 'destructive',
|
|
||||||
title: errorInfo.title,
|
|
||||||
description: errorInfo.description,
|
|
||||||
});
|
|
||||||
setSelectedPatientId(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatPatientDisplay = (patient: PatientSearchResult): string => {
|
// Auto-search on prefill
|
||||||
let display = patient.name;
|
useEffect(() => {
|
||||||
if (patient.birthDate) {
|
if (prefillQuery && prefillQuery.length >= 2) {
|
||||||
const birthYear = new Date(patient.birthDate).getFullYear();
|
searchPatients(prefillQuery);
|
||||||
const currentYear = new Date().getFullYear();
|
|
||||||
const age = currentYear - birthYear;
|
|
||||||
display += ` (${age} jaar)`;
|
|
||||||
}
|
}
|
||||||
return display;
|
}, [prefillQuery, searchPatients]);
|
||||||
};
|
|
||||||
|
const showResults = query.length >= 2;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BlockContainer title={config.title} size={config.size}>
|
<BlockContainer title={config.title} size={config.size}>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Search Input */}
|
{/* Search Input */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="patient-search">Zoek patiënt</Label>
|
<Label htmlFor="patient-search">Zoek patient</Label>
|
||||||
<div className="relative" ref={dropdownRef}>
|
<div className="relative" ref={dropdownRef}>
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-400" />
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-400" />
|
||||||
<Input
|
<Input
|
||||||
id="patient-search"
|
id="patient-search"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Typ naam, BSN of clientnummer..."
|
placeholder="Typ naam, BSN of clientnummer..."
|
||||||
value={searchQuery}
|
value={query}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
className="pl-9"
|
className="pl-9"
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
{isSearching && (
|
|
||||||
<Loader2 className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-400 animate-spin" />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Search Results */}
|
{/* Search Results */}
|
||||||
{searchQuery.length >= 2 && (
|
{showResults && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{isSearching ? (
|
{isSearching ? (
|
||||||
<div className="flex items-center justify-center py-8 text-slate-500">
|
<PatientListLoading />
|
||||||
<Loader2 className="h-5 w-5 animate-spin mr-2" />
|
) : results.length > 0 ? (
|
||||||
<span className="text-sm">Zoeken...</span>
|
|
||||||
</div>
|
|
||||||
) : patients.length > 0 ? (
|
|
||||||
<div className="space-y-1 max-h-96 overflow-y-auto">
|
<div className="space-y-1 max-h-96 overflow-y-auto">
|
||||||
{patients.map((patient) => {
|
{results.map((patient) => (
|
||||||
const isSelected = selectedPatientId === patient.id;
|
<PatientListItem
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={patient.id}
|
key={patient.id}
|
||||||
type="button"
|
patient={patient}
|
||||||
onClick={() => handleSelectPatient(patient)}
|
isLoading={selectedId === patient.id}
|
||||||
disabled={isSelected}
|
onClick={() => selectPatient(patient)}
|
||||||
className={cn(
|
/>
|
||||||
'w-full px-3 py-2.5 rounded-lg border text-left transition-colors',
|
))}
|
||||||
isSelected
|
|
||||||
? 'bg-slate-100 border-slate-300 cursor-wait'
|
|
||||||
: 'bg-slate-50 border-slate-200 hover:bg-slate-100 hover:border-slate-300 cursor-pointer'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
|
||||||
<div className="w-8 h-8 rounded-full bg-blue-600 flex items-center justify-center text-xs font-medium text-white shrink-0">
|
|
||||||
{patient.name
|
|
||||||
.split(' ')
|
|
||||||
.map((n) => n[0])
|
|
||||||
.join('')
|
|
||||||
.slice(0, 2)
|
|
||||||
.toUpperCase()}
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="text-sm font-medium text-slate-900 truncate">
|
|
||||||
{formatPatientDisplay(patient)}
|
|
||||||
</div>
|
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
{isSelected ? (
|
|
||||||
<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>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col items-center justify-center py-8 text-slate-400">
|
<PatientListEmpty
|
||||||
<User className="h-8 w-8 mb-2 opacity-50" />
|
message="Geen patienten gevonden"
|
||||||
<p className="text-sm">Geen patiënten gevonden</p>
|
submessage="Probeer een andere zoekterm"
|
||||||
<p className="text-xs mt-1">Probeer een andere zoekterm</p>
|
/>
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Empty State */}
|
{/* Empty State - waiting for input */}
|
||||||
{searchQuery.length < 2 && (
|
{!showResults && (
|
||||||
<div className="flex flex-col items-center justify-center py-8 text-slate-400">
|
<div className="flex flex-col items-center justify-center py-8 text-slate-400">
|
||||||
<Search className="h-8 w-8 mb-2 opacity-50" />
|
<User className="h-8 w-8 mb-2 opacity-50" />
|
||||||
<p className="text-sm">Typ minimaal 2 karakters om te zoeken</p>
|
<p className="text-sm">Typ minimaal 2 karakters om te zoeken</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
162
components/cortex/shared/patient-list-item.tsx
Normal file
162
components/cortex/shared/patient-list-item.tsx
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PatientListItem Component
|
||||||
|
*
|
||||||
|
* Reusable patient list item for search results, recent patients, etc.
|
||||||
|
* Extracted from ZoekenBlock for DRY compliance.
|
||||||
|
*
|
||||||
|
* Epic: E0.S2 (Patient Selectie - Refactor)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Loader2, Check } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import type { PatientSearchResult } from '@/lib/cortex/hooks/use-patient-search';
|
||||||
|
import { getPatientInitials } from '@/lib/fhir/patient-mapper';
|
||||||
|
|
||||||
|
interface PatientListItemProps {
|
||||||
|
/** Patient data */
|
||||||
|
patient: PatientSearchResult;
|
||||||
|
/** Whether this item is currently selected/loading */
|
||||||
|
isLoading?: boolean;
|
||||||
|
/** Click handler */
|
||||||
|
onClick: () => void;
|
||||||
|
/** Size variant */
|
||||||
|
size?: 'sm' | 'md';
|
||||||
|
/** Show identifiers (BSN, client number) */
|
||||||
|
showIdentifiers?: boolean;
|
||||||
|
/** Additional class names */
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate age from birth date string
|
||||||
|
*/
|
||||||
|
function calculateAge(birthDate: string): number | null {
|
||||||
|
if (!birthDate) return null;
|
||||||
|
|
||||||
|
const birth = new Date(birthDate);
|
||||||
|
if (isNaN(birth.getTime())) return null;
|
||||||
|
|
||||||
|
const today = new Date();
|
||||||
|
let age = today.getFullYear() - birth.getFullYear();
|
||||||
|
const monthDiff = today.getMonth() - birth.getMonth();
|
||||||
|
|
||||||
|
if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birth.getDate())) {
|
||||||
|
age--;
|
||||||
|
}
|
||||||
|
|
||||||
|
return age;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PatientListItem({
|
||||||
|
patient,
|
||||||
|
isLoading = false,
|
||||||
|
onClick,
|
||||||
|
size = 'md',
|
||||||
|
showIdentifiers = true,
|
||||||
|
className,
|
||||||
|
}: PatientListItemProps) {
|
||||||
|
const age = calculateAge(patient.birthDate);
|
||||||
|
const initials = getPatientInitials(patient.name);
|
||||||
|
|
||||||
|
const isSm = size === 'sm';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
disabled={isLoading}
|
||||||
|
className={cn(
|
||||||
|
'w-full rounded-lg border text-left transition-colors',
|
||||||
|
isSm ? 'px-2.5 py-1.5' : 'px-3 py-2.5',
|
||||||
|
isLoading
|
||||||
|
? 'bg-slate-100 border-slate-300 cursor-wait'
|
||||||
|
: 'bg-slate-50 border-slate-200 hover:bg-slate-100 hover:border-slate-300 cursor-pointer',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<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',
|
||||||
|
isSm ? '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',
|
||||||
|
isSm ? 'text-xs' : 'text-sm'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{patient.name}
|
||||||
|
{age !== null && (
|
||||||
|
<span className="text-slate-500 font-normal"> ({age} jaar)</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Identifiers - only show on md size */}
|
||||||
|
{!isSm &&
|
||||||
|
showIdentifiers &&
|
||||||
|
(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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Empty state component for patient search
|
||||||
|
*/
|
||||||
|
export function PatientListEmpty({
|
||||||
|
message = 'Geen patienten gevonden',
|
||||||
|
submessage,
|
||||||
|
}: {
|
||||||
|
message?: string;
|
||||||
|
submessage?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center py-8 text-slate-400">
|
||||||
|
<p className="text-sm">{message}</p>
|
||||||
|
{submessage && <p className="text-xs mt-1">{submessage}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loading state component for patient search
|
||||||
|
*/
|
||||||
|
export function PatientListLoading({ message = 'Zoeken...' }: { message?: string }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-8 text-slate-500">
|
||||||
|
<Loader2 className="h-5 w-5 animate-spin mr-2" />
|
||||||
|
<span className="text-sm">{message}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
1037
docs/intent/patient-search/bouwplan-patient-selectie-v1.md
Normal file
1037
docs/intent/patient-search/bouwplan-patient-selectie-v1.md
Normal file
File diff suppressed because it is too large
Load Diff
274
docs/intent/patient-search/ux-analyse-patient-selectie.md
Normal file
274
docs/intent/patient-search/ux-analyse-patient-selectie.md
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
# UX Analyse: Patient Selectie in Cortex
|
||||||
|
|
||||||
|
**Datum:** 2025-01-03
|
||||||
|
**Auteur:** Colin Lit + Claude
|
||||||
|
**Status:** Analyse compleet, ready for implementation planning
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Probleemstelling
|
||||||
|
|
||||||
|
### Huidige Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
User: "Notitie medicatie Jan"
|
||||||
|
|
|
||||||
|
Cortex: "Welke patient bedoel je?"
|
||||||
|
|
|
||||||
|
[ZoekenBlock opent in artifact area]
|
||||||
|
|
|
||||||
|
User klikt op "Jan de Vries"
|
||||||
|
|
|
||||||
|
[Patient wordt actief, ZoekenBlock sluit]
|
||||||
|
|
|
||||||
|
User moet OPNIEUW "notitie medicatie" typen
|
||||||
|
|
|
||||||
|
[DagnotatieBlock opent, maar zonder prefill!]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pain Points
|
||||||
|
|
||||||
|
| # | Pain Point | Ernst | Omschrijving |
|
||||||
|
|---|------------|-------|--------------|
|
||||||
|
| 1 | Twee-staps flow | Hoog | Chat vraagt, apart venster, klik, terug naar notitie |
|
||||||
|
| 2 | Context verlies | Hoog | activePatient uit store wordt NIET gebruikt in DagnotatieBlock |
|
||||||
|
| 3 | Dubbele zoekfunctie | Medium | ZoekenBlock en DagnotatieBlock hebben beide patient search |
|
||||||
|
| 4 | Geen inline disambiguatie | Hoog | Moet naar apart panel switchen |
|
||||||
|
| 5 | Clarification niet geimplementeerd | Hoog | resolveClarification() doet niets na selectie |
|
||||||
|
| 6 | Geen re-processing | Hoog | Na patient selectie moet user opnieuw intent typen |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Oplossingsrichtingen
|
||||||
|
|
||||||
|
### 2.1 @Mention Systeem (P1)
|
||||||
|
|
||||||
|
Type `@` gevolgd door naam voor autocomplete dropdown in chat input.
|
||||||
|
|
||||||
|
```
|
||||||
|
+-----------------------------------------------------+
|
||||||
|
| Notitie voor @jan |
|
||||||
|
| +----------------------+ |
|
||||||
|
| | Jan de Vries (52) | |
|
||||||
|
| | Jan Bakker (34) | |
|
||||||
|
| | Jan Pieterse (67) | |
|
||||||
|
| +----------------------+ |
|
||||||
|
+-----------------------------------------------------+
|
||||||
|
```
|
||||||
|
|
||||||
|
**Voordelen:**
|
||||||
|
- Disambiguatie voor verzenden, niet erna
|
||||||
|
- Bekende UX pattern (Slack, GitHub, Discord)
|
||||||
|
- Patient ID direct beschikbaar voor AI
|
||||||
|
- Geen extra API calls na submit
|
||||||
|
|
||||||
|
### 2.2 Persistent Patient Sidebar (P1)
|
||||||
|
|
||||||
|
Altijd zichtbare patient lijst aan de linkerkant.
|
||||||
|
|
||||||
|
```
|
||||||
|
+----------+----------------------------------------+
|
||||||
|
| Zoek | |
|
||||||
|
+----------| Chat Panel |
|
||||||
|
| Recent | |
|
||||||
|
| -------- | |
|
||||||
|
| * Jan | User: Notitie medicatie |
|
||||||
|
| * Marie | Cortex: Ik maak een notitie... |
|
||||||
|
| * Piet | |
|
||||||
|
| | |
|
||||||
|
| Alle A-Z | Artifact Area |
|
||||||
|
| -------- | |
|
||||||
|
| Bakker | +- DagnotatieBlock ----------------+ |
|
||||||
|
| Berg, M | | Patient: Jan de Vries | |
|
||||||
|
| De Vrie. | | Categorie: Medicatie | |
|
||||||
|
| ... | +----------------------------------+ |
|
||||||
|
+----------+----------------------------------------+
|
||||||
|
```
|
||||||
|
|
||||||
|
**Layout Wijziging:**
|
||||||
|
- Huidig: Chat (40%) + Artifact (60%)
|
||||||
|
- Nieuw: Sidebar (15%) + Chat (35%) + Artifact (50%)
|
||||||
|
|
||||||
|
### 2.3 Preview-First Confirmation (P2)
|
||||||
|
|
||||||
|
Toon complete preview in chat, direct opslaan zonder artifact.
|
||||||
|
|
||||||
|
```
|
||||||
|
+-----------------------------------------------+
|
||||||
|
| Cortex: |
|
||||||
|
| |
|
||||||
|
| +- Notitie Preview -----------------------+ |
|
||||||
|
| | Jan de Vries (52) [wijzig] | |
|
||||||
|
| | Categorie: Medicatie [wijzig] | |
|
||||||
|
| | "Medicatie gegeven volgens schema" | |
|
||||||
|
| | | |
|
||||||
|
| | [Opslaan] [Uitgebreid] [Annuleer] | |
|
||||||
|
| +-----------------------------------------+ |
|
||||||
|
+-----------------------------------------------+
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Implementatie Roadmap
|
||||||
|
|
||||||
|
### Fase 1: Foundation (Week 1) - 10 SP
|
||||||
|
|
||||||
|
| Story | Beschrijving | SP |
|
||||||
|
|-------|--------------|---:|
|
||||||
|
| F1.S1 | Patient Sidebar layout wijziging (15/35/50 split) | 3 |
|
||||||
|
| F1.S2 | PatientSidebar component skeleton | 2 |
|
||||||
|
| F1.S3 | Patient search in sidebar (hergebruik API) | 2 |
|
||||||
|
| F1.S4 | Recent patients sectie | 2 |
|
||||||
|
| F1.S5 | Click-to-select activePatient | 1 |
|
||||||
|
|
||||||
|
### Fase 2: @Mention Systeem (Week 2) - 13 SP
|
||||||
|
|
||||||
|
| Story | Beschrijving | SP |
|
||||||
|
|-------|--------------|---:|
|
||||||
|
| F2.S1 | @-detectie in CommandInput | 2 |
|
||||||
|
| F2.S2 | PatientMentionDropdown component | 3 |
|
||||||
|
| F2.S3 | Patient search API integratie (debounced) | 2 |
|
||||||
|
| F2.S4 | Mention chip rendering in input | 3 |
|
||||||
|
| F2.S5 | Mention data meesturen naar chat API | 2 |
|
||||||
|
| F2.S6 | AI prompt update voor mentions | 1 |
|
||||||
|
|
||||||
|
### Fase 3: Smart Defaults (Week 3) - 10 SP
|
||||||
|
|
||||||
|
| Story | Beschrijving | SP |
|
||||||
|
|-------|--------------|---:|
|
||||||
|
| F3.S1 | DagnotatieBlock: auto-use activePatient | 2 |
|
||||||
|
| F3.S2 | Alle blocks: activePatient als default | 3 |
|
||||||
|
| F3.S3 | PendingIntent state in store | 2 |
|
||||||
|
| F3.S4 | Re-process na patient selectie | 3 |
|
||||||
|
|
||||||
|
### Fase 4: Preview Cards (Week 4) - 12 SP
|
||||||
|
|
||||||
|
| Story | Beschrijving | SP |
|
||||||
|
|-------|--------------|---:|
|
||||||
|
| F4.S1 | PreviewCard component design | 3 |
|
||||||
|
| F4.S2 | Inline edit in preview card | 3 |
|
||||||
|
| F4.S3 | Direct save van preview card | 2 |
|
||||||
|
| F4.S4 | Uitgebreid - open artifact flow | 2 |
|
||||||
|
| F4.S5 | AI prompt update voor preview generation | 2 |
|
||||||
|
|
||||||
|
**Totaal: 45 SP (~4 weken)**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Technische Details
|
||||||
|
|
||||||
|
### 4.1 Store Uitbreiding
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// stores/cortex-store.ts
|
||||||
|
interface CortexStore {
|
||||||
|
// Bestaand
|
||||||
|
activePatient: Patient | null;
|
||||||
|
|
||||||
|
// Nieuw
|
||||||
|
recentPatients: Patient[]; // Max 5
|
||||||
|
pendingIntent: PendingIntent | null; // Bewaar intent tijdens disambiguatie
|
||||||
|
patientSidebarOpen: boolean; // Collapse state
|
||||||
|
|
||||||
|
// Acties
|
||||||
|
addRecentPatient: (patient: Patient) => void;
|
||||||
|
setPendingIntent: (intent: PendingIntent | null) => void;
|
||||||
|
togglePatientSidebar: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PendingIntent {
|
||||||
|
originalMessage: string;
|
||||||
|
intent: CortexIntent;
|
||||||
|
entities: Partial<ExtractedEntities>;
|
||||||
|
awaitingPatientSelection: boolean;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 API Wijzigingen
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// app/api/cortex/chat/route.ts
|
||||||
|
const RequestSchema = z.object({
|
||||||
|
message: z.string(),
|
||||||
|
messages: z.array(ChatMessageSchema).optional(),
|
||||||
|
context: z.object({...}).optional(),
|
||||||
|
// Nieuw
|
||||||
|
mentions: z.array(z.object({
|
||||||
|
patientId: z.string().uuid(),
|
||||||
|
patientName: z.string(),
|
||||||
|
position: z.object({ start: z.number(), end: z.number() }),
|
||||||
|
})).optional(),
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 Component Structuur
|
||||||
|
|
||||||
|
```
|
||||||
|
components/cortex/
|
||||||
|
+-- patient-sidebar/
|
||||||
|
| +-- patient-sidebar.tsx
|
||||||
|
| +-- patient-list.tsx
|
||||||
|
| +-- patient-list-item.tsx
|
||||||
|
| +-- patient-search-input.tsx
|
||||||
|
| +-- recent-patients.tsx
|
||||||
|
+-- command-center/
|
||||||
|
+-- patient-mention-dropdown.tsx (nieuw)
|
||||||
|
+-- mention-chip.tsx (nieuw)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. UI/UX Specificaties
|
||||||
|
|
||||||
|
### 5.1 Patient Sidebar
|
||||||
|
|
||||||
|
- **Desktop (>1024px):** 240px vast, collapsible naar 48px, Cmd+B toggle
|
||||||
|
- **Tablet (768-1024px):** Overlay mode, swipe gesture
|
||||||
|
- **Mobile (<768px):** Bottom sheet
|
||||||
|
|
||||||
|
### 5.2 @Mention Dropdown
|
||||||
|
|
||||||
|
- **Trigger:** @ karakter in input
|
||||||
|
- **Minimale query:** 1 karakter na @
|
||||||
|
- **Debounce:** 200ms
|
||||||
|
- **Max resultaten:** 5
|
||||||
|
- **Keyboard:** Up/Down, Enter, Escape
|
||||||
|
|
||||||
|
### 5.3 Mention Chip Styling
|
||||||
|
|
||||||
|
```css
|
||||||
|
.mention-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
padding: 2px 6px;
|
||||||
|
background: amber-100;
|
||||||
|
border: 1px solid amber-300;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Success Metrics
|
||||||
|
|
||||||
|
| Metric | Baseline | Target |
|
||||||
|
|--------|----------|--------|
|
||||||
|
| Clicks voor patient selectie | 3-4 | 1-2 |
|
||||||
|
| Tijd tot notitie opgeslagen | ~15 sec | ~8 sec |
|
||||||
|
| "Welke patient?" prompts | ~40% | <10% |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Volgende Stappen
|
||||||
|
|
||||||
|
1. [ ] Review met Colin - prioriteiten bevestigen
|
||||||
|
2. [ ] Design mockups maken
|
||||||
|
3. [ ] Bouwplan schrijven voor Fase 1 + 2
|
||||||
|
4. [ ] Start implementatie Patient Sidebar
|
||||||
|
5. [ ] Parallel: @mention dropdown prototype
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Gerelateerde Documenten:**
|
||||||
|
- `docs/archive/swift/bouwplan-swift-v3.md`
|
||||||
|
- `stores/cortex-store.ts`
|
||||||
|
- `components/cortex/blocks/zoeken-block.tsx`
|
||||||
8
lib/cortex/hooks/index.ts
Normal file
8
lib/cortex/hooks/index.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* Cortex Hooks
|
||||||
|
*
|
||||||
|
* Reusable hooks for Cortex functionality.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { usePatientSearch, type PatientSearchResult } from './use-patient-search';
|
||||||
|
export { usePatientSelection } from './use-patient-selection';
|
||||||
153
lib/cortex/hooks/use-patient-search.ts
Normal file
153
lib/cortex/hooks/use-patient-search.ts
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* usePatientSearch Hook
|
||||||
|
*
|
||||||
|
* Reusable hook for patient search with debouncing.
|
||||||
|
* Extracted from ZoekenBlock for DRY compliance.
|
||||||
|
*
|
||||||
|
* Epic: E0.S1 (Patient Selectie - Refactor)
|
||||||
|
*/
|
||||||
|
|
||||||
|
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 {
|
||||||
|
/** Debounce delay in ms (default: 300) */
|
||||||
|
debounceMs?: number;
|
||||||
|
/** Minimum query length to trigger search (default: 2) */
|
||||||
|
minQueryLength?: number;
|
||||||
|
/** Maximum number of results (default: 10) */
|
||||||
|
limit?: number;
|
||||||
|
/** Initial query value */
|
||||||
|
initialQuery?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UsePatientSearchReturn {
|
||||||
|
/** Current search query */
|
||||||
|
query: string;
|
||||||
|
/** Set search query */
|
||||||
|
setQuery: (query: string) => void;
|
||||||
|
/** Search results */
|
||||||
|
results: PatientSearchResult[];
|
||||||
|
/** Loading state */
|
||||||
|
isSearching: boolean;
|
||||||
|
/** Manually trigger search (bypasses debounce) */
|
||||||
|
searchPatients: (query: string) => Promise<void>;
|
||||||
|
/** Clear results */
|
||||||
|
clearResults: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePatientSearch(
|
||||||
|
options: UsePatientSearchOptions = {}
|
||||||
|
): UsePatientSearchReturn {
|
||||||
|
const {
|
||||||
|
debounceMs = 300,
|
||||||
|
minQueryLength = 2,
|
||||||
|
limit = 10,
|
||||||
|
initialQuery = '',
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
const [query, setQuery] = useState(initialQuery);
|
||||||
|
const [results, setResults] = useState<PatientSearchResult[]>([]);
|
||||||
|
const [isSearching, setIsSearching] = useState(false);
|
||||||
|
const { toast } = useToast();
|
||||||
|
const timeoutRef = useRef<NodeJS.Timeout>();
|
||||||
|
const abortControllerRef = useRef<AbortController>();
|
||||||
|
|
||||||
|
const searchPatients = useCallback(
|
||||||
|
async (searchQuery: string) => {
|
||||||
|
if (searchQuery.length < minQueryLength) {
|
||||||
|
setResults([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancel previous request
|
||||||
|
if (abortControllerRef.current) {
|
||||||
|
abortControllerRef.current.abort();
|
||||||
|
}
|
||||||
|
abortControllerRef.current = new AbortController();
|
||||||
|
|
||||||
|
setIsSearching(true);
|
||||||
|
try {
|
||||||
|
const response = await safeFetch(
|
||||||
|
`/api/patients/search?q=${encodeURIComponent(searchQuery)}&limit=${limit}`,
|
||||||
|
{ signal: abortControllerRef.current.signal },
|
||||||
|
{ operation: 'Patient zoeken' }
|
||||||
|
);
|
||||||
|
const data = await response.json();
|
||||||
|
setResults(data.patients || []);
|
||||||
|
} catch (error) {
|
||||||
|
// Ignore abort errors
|
||||||
|
if (error instanceof Error && error.name === 'AbortError') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
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]
|
||||||
|
);
|
||||||
|
|
||||||
|
const clearResults = useCallback(() => {
|
||||||
|
setResults([]);
|
||||||
|
setQuery('');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 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]);
|
||||||
|
|
||||||
|
// Cleanup on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (abortControllerRef.current) {
|
||||||
|
abortControllerRef.current.abort();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
query,
|
||||||
|
setQuery,
|
||||||
|
results,
|
||||||
|
isSearching,
|
||||||
|
searchPatients,
|
||||||
|
clearResults,
|
||||||
|
};
|
||||||
|
}
|
||||||
130
lib/cortex/hooks/use-patient-selection.ts
Normal file
130
lib/cortex/hooks/use-patient-selection.ts
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* usePatientSelection Hook
|
||||||
|
*
|
||||||
|
* Handles patient selection flow: fetch FHIR data, map to DB format, update store.
|
||||||
|
* Extracted from ZoekenBlock for DRY compliance.
|
||||||
|
*
|
||||||
|
* Epic: E0.S4 (Patient Selectie - Refactor)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import { useToast } from '@/hooks/use-toast';
|
||||||
|
import { useCortexStore } from '@/stores/cortex-store';
|
||||||
|
import { safeFetch, getErrorInfo } from '@/lib/cortex/error-handler';
|
||||||
|
import { mapFhirToDbPatient, type Patient } from '@/lib/fhir/patient-mapper';
|
||||||
|
import type { PatientSearchResult } from './use-patient-search';
|
||||||
|
|
||||||
|
interface UsePatientSelectionOptions {
|
||||||
|
/** Callback after successful selection */
|
||||||
|
onSuccess?: (patient: Patient, patientName: string) => void;
|
||||||
|
/** Callback on error */
|
||||||
|
onError?: (error: Error) => void;
|
||||||
|
/** Add to recent actions (default: true) */
|
||||||
|
trackRecentAction?: boolean;
|
||||||
|
/** Show toast on success (default: true) */
|
||||||
|
showSuccessToast?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UsePatientSelectionReturn {
|
||||||
|
/** Select a patient by search result */
|
||||||
|
selectPatient: (patient: PatientSearchResult) => Promise<Patient | null>;
|
||||||
|
/** Whether a selection is in progress */
|
||||||
|
isSelecting: boolean;
|
||||||
|
/** ID of the patient currently being selected */
|
||||||
|
selectedId: string | null;
|
||||||
|
/** Clear selection state */
|
||||||
|
clearSelection: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePatientSelection(
|
||||||
|
options: UsePatientSelectionOptions = {}
|
||||||
|
): UsePatientSelectionReturn {
|
||||||
|
const {
|
||||||
|
onSuccess,
|
||||||
|
onError,
|
||||||
|
trackRecentAction = true,
|
||||||
|
showSuccessToast = true,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
const [isSelecting, setIsSelecting] = useState(false);
|
||||||
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const { setActivePatient, addRecentAction } = useCortexStore();
|
||||||
|
|
||||||
|
const selectPatient = useCallback(
|
||||||
|
async (patient: PatientSearchResult): Promise<Patient | null> => {
|
||||||
|
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);
|
||||||
|
|
||||||
|
// Track recent action
|
||||||
|
if (trackRecentAction) {
|
||||||
|
addRecentAction({
|
||||||
|
intent: 'zoeken',
|
||||||
|
label: `Patient geselecteerd: ${patient.name}`,
|
||||||
|
patientName: patient.name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show success toast
|
||||||
|
if (showSuccessToast) {
|
||||||
|
toast({
|
||||||
|
title: 'Patient geselecteerd',
|
||||||
|
description: `${patient.name} is nu actief`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call success callback
|
||||||
|
onSuccess?.(dbPatient, 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,
|
||||||
|
});
|
||||||
|
|
||||||
|
onError?.(error instanceof Error ? error : new Error(String(error)));
|
||||||
|
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
setIsSelecting(false);
|
||||||
|
setSelectedId(null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[setActivePatient, addRecentAction, toast, trackRecentAction, showSuccessToast, onSuccess, onError]
|
||||||
|
);
|
||||||
|
|
||||||
|
const clearSelection = useCallback(() => {
|
||||||
|
setIsSelecting(false);
|
||||||
|
setSelectedId(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
selectPatient,
|
||||||
|
isSelecting,
|
||||||
|
selectedId,
|
||||||
|
clearSelection,
|
||||||
|
};
|
||||||
|
}
|
||||||
158
lib/fhir/patient-mapper.ts
Normal file
158
lib/fhir/patient-mapper.ts
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
/**
|
||||||
|
* FHIR Patient Mapper
|
||||||
|
*
|
||||||
|
* Maps FHIR Patient resources to database Patient format.
|
||||||
|
* Extracted from ZoekenBlock for DRY compliance.
|
||||||
|
*
|
||||||
|
* Epic: E0.S3 (Patient Selectie - Refactor)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Database } from '@/lib/supabase/database.types';
|
||||||
|
|
||||||
|
export type Patient = Database['public']['Tables']['patients']['Row'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FHIR Patient resource structure (subset used in this app)
|
||||||
|
*/
|
||||||
|
export interface FhirPatient {
|
||||||
|
id: string;
|
||||||
|
resourceType?: 'Patient';
|
||||||
|
name?: Array<{
|
||||||
|
family?: string;
|
||||||
|
given?: string[];
|
||||||
|
prefix?: string[];
|
||||||
|
use?: string;
|
||||||
|
}>;
|
||||||
|
birthDate?: string;
|
||||||
|
gender?: string;
|
||||||
|
active?: boolean;
|
||||||
|
identifier?: Array<{
|
||||||
|
system?: string;
|
||||||
|
value?: string;
|
||||||
|
}>;
|
||||||
|
address?: Array<{
|
||||||
|
line?: string[];
|
||||||
|
city?: string;
|
||||||
|
postalCode?: string;
|
||||||
|
country?: string;
|
||||||
|
}>;
|
||||||
|
telecom?: Array<{
|
||||||
|
system?: string;
|
||||||
|
value?: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const GENDER_MAP: Record<string, Patient['gender']> = {
|
||||||
|
male: 'male',
|
||||||
|
female: 'female',
|
||||||
|
other: 'other',
|
||||||
|
unknown: 'unknown',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps a FHIR Patient resource to database Patient format
|
||||||
|
*
|
||||||
|
* @param fhir - FHIR Patient resource
|
||||||
|
* @returns Database Patient object
|
||||||
|
*/
|
||||||
|
export function mapFhirToDbPatient(fhir: FhirPatient): Patient {
|
||||||
|
const gender = GENDER_MAP[fhir.gender?.toLowerCase() || 'unknown'] || 'unknown';
|
||||||
|
const primaryName = fhir.name?.[0];
|
||||||
|
const primaryAddress = fhir.address?.[0];
|
||||||
|
|
||||||
|
// Extract identifiers
|
||||||
|
const bsn = fhir.identifier?.find(
|
||||||
|
(id) => id.system === 'http://fhir.nl/fhir/NamingSystem/bsn'
|
||||||
|
)?.value;
|
||||||
|
|
||||||
|
const clientNumber = fhir.identifier?.find(
|
||||||
|
(id) => id.system?.includes('client') || id.system?.includes('999.7.6')
|
||||||
|
)?.value;
|
||||||
|
|
||||||
|
// Extract telecom
|
||||||
|
const email = fhir.telecom?.find((t) => t.system === 'email')?.value;
|
||||||
|
const phone = fhir.telecom?.find((t) => t.system === 'phone')?.value;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: fhir.id,
|
||||||
|
name_family: primaryName?.family || '',
|
||||||
|
name_given: primaryName?.given || [],
|
||||||
|
name_prefix: primaryName?.prefix?.join(' ') || null,
|
||||||
|
name_use: primaryName?.use || null,
|
||||||
|
birth_date: fhir.birthDate || '',
|
||||||
|
gender,
|
||||||
|
active: fhir.active !== false,
|
||||||
|
identifier_bsn: bsn || null,
|
||||||
|
identifier_client_number: clientNumber || null,
|
||||||
|
// Address
|
||||||
|
address_line: primaryAddress?.line || null,
|
||||||
|
address_city: primaryAddress?.city || null,
|
||||||
|
address_postal_code: primaryAddress?.postalCode || null,
|
||||||
|
address_country: primaryAddress?.country || null,
|
||||||
|
// Telecom
|
||||||
|
telecom_email: email || null,
|
||||||
|
telecom_phone: phone || null,
|
||||||
|
// Null defaults for fields not in FHIR
|
||||||
|
status: null,
|
||||||
|
created_at: null,
|
||||||
|
updated_at: 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats a patient's full name from database format
|
||||||
|
*
|
||||||
|
* @param patient - Database Patient object
|
||||||
|
* @returns Formatted full name
|
||||||
|
*/
|
||||||
|
export function formatPatientName(patient: Patient): string {
|
||||||
|
const given = patient.name_given?.[0] || '';
|
||||||
|
const family = patient.name_family || '';
|
||||||
|
return `${given} ${family}`.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates patient age from birth date
|
||||||
|
*
|
||||||
|
* @param birthDate - Birth date string (ISO format)
|
||||||
|
* @returns Age in years, or null if invalid
|
||||||
|
*/
|
||||||
|
export function calculatePatientAge(birthDate: string | null): number | null {
|
||||||
|
if (!birthDate) return null;
|
||||||
|
|
||||||
|
const birth = new Date(birthDate);
|
||||||
|
if (isNaN(birth.getTime())) return null;
|
||||||
|
|
||||||
|
const today = new Date();
|
||||||
|
let age = today.getFullYear() - birth.getFullYear();
|
||||||
|
const monthDiff = today.getMonth() - birth.getMonth();
|
||||||
|
|
||||||
|
if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birth.getDate())) {
|
||||||
|
age--;
|
||||||
|
}
|
||||||
|
|
||||||
|
return age;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates initials from patient name
|
||||||
|
*
|
||||||
|
* @param name - Full name string
|
||||||
|
* @returns 2-character initials
|
||||||
|
*/
|
||||||
|
export function getPatientInitials(name: string): string {
|
||||||
|
return name
|
||||||
|
.split(' ')
|
||||||
|
.map((n) => n[0])
|
||||||
|
.join('')
|
||||||
|
.slice(0, 2)
|
||||||
|
.toUpperCase();
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user