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:
@@ -3,342 +3,121 @@
|
||||
/**
|
||||
* Zoeken Block
|
||||
*
|
||||
* Block voor het zoeken naar patiënten.
|
||||
* E3.S4: Volledige implementatie met input, resultaten en selectie naar store.
|
||||
* Block voor het zoeken naar patienten.
|
||||
* Refactored to use extracted hooks and components (E0 - DRY).
|
||||
*
|
||||
* Epic: E3.S4 (Original), E0 (Refactor)
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useCortexStore } from '@/stores/cortex-store';
|
||||
import { BlockContainer } from './block-container';
|
||||
import type { BlockPrefillData } from '@/stores/cortex-store';
|
||||
import { BLOCK_CONFIGS } from '@/lib/cortex/types';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Loader2, Search, User, Check } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { safeFetch, getErrorInfo } from '@/lib/cortex/error-handler';
|
||||
import { Search, User } from 'lucide-react';
|
||||
|
||||
// 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 {
|
||||
prefill?: BlockPrefillData;
|
||||
}
|
||||
|
||||
interface PatientSearchResult {
|
||||
id: string;
|
||||
name: string;
|
||||
birthDate: string;
|
||||
identifier_bsn?: string;
|
||||
identifier_client_number?: string;
|
||||
matchScore: number;
|
||||
}
|
||||
|
||||
export function ZoekenBlock({ prefill }: ZoekenBlockProps) {
|
||||
const config = BLOCK_CONFIGS.zoeken;
|
||||
const { closeBlock, setActivePatient, addRecentAction, 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 { closeBlock, openArtifact } = useCortexStore();
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Patient search function
|
||||
const searchPatients = useCallback(async (query: string) => {
|
||||
if (query.length < 2) {
|
||||
setPatients([]);
|
||||
return;
|
||||
}
|
||||
// Get prefill query from various sources
|
||||
const prefillQuery = prefill?.patientName || prefill?.query || '';
|
||||
|
||||
setIsSearching(true);
|
||||
try {
|
||||
const response = await safeFetch(
|
||||
`/api/patients/search?q=${encodeURIComponent(query)}&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]);
|
||||
// Use extracted hooks
|
||||
const { query, setQuery, results, isSearching, searchPatients } = usePatientSearch({
|
||||
initialQuery: prefillQuery,
|
||||
limit: 10,
|
||||
});
|
||||
|
||||
// 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
|
||||
toast({
|
||||
title: 'Patiënt geselecteerd',
|
||||
description: `${patient.name} is nu actief`,
|
||||
});
|
||||
|
||||
// Close legacy block (v2) and open dashboard artifact (v3)
|
||||
const { selectPatient, selectedId } = usePatientSelection({
|
||||
onSuccess: (dbPatient, patientName) => {
|
||||
// Close block and open patient dashboard
|
||||
closeBlock();
|
||||
openArtifact({
|
||||
type: 'patient-dashboard',
|
||||
title: `Dashboard - ${patient.name}`,
|
||||
title: `Dashboard - ${patientName}`,
|
||||
prefill: {
|
||||
patientId: patient.id,
|
||||
patientName: patient.name,
|
||||
patientId: dbPatient.id,
|
||||
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 => {
|
||||
let display = patient.name;
|
||||
if (patient.birthDate) {
|
||||
const birthYear = new Date(patient.birthDate).getFullYear();
|
||||
const currentYear = new Date().getFullYear();
|
||||
const age = currentYear - birthYear;
|
||||
display += ` (${age} jaar)`;
|
||||
// Auto-search on prefill
|
||||
useEffect(() => {
|
||||
if (prefillQuery && prefillQuery.length >= 2) {
|
||||
searchPatients(prefillQuery);
|
||||
}
|
||||
return display;
|
||||
};
|
||||
}, [prefillQuery, searchPatients]);
|
||||
|
||||
const showResults = query.length >= 2;
|
||||
|
||||
return (
|
||||
<BlockContainer title={config.title} size={config.size}>
|
||||
<div className="space-y-4">
|
||||
{/* Search Input */}
|
||||
<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}>
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-400" />
|
||||
<Input
|
||||
id="patient-search"
|
||||
type="text"
|
||||
placeholder="Typ naam, BSN of clientnummer..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
className="pl-9"
|
||||
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>
|
||||
|
||||
{/* Search Results */}
|
||||
{searchQuery.length >= 2 && (
|
||||
{showResults && (
|
||||
<div className="space-y-2">
|
||||
{isSearching ? (
|
||||
<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">Zoeken...</span>
|
||||
</div>
|
||||
) : patients.length > 0 ? (
|
||||
<PatientListLoading />
|
||||
) : results.length > 0 ? (
|
||||
<div className="space-y-1 max-h-96 overflow-y-auto">
|
||||
{patients.map((patient) => {
|
||||
const isSelected = selectedPatientId === patient.id;
|
||||
return (
|
||||
<button
|
||||
key={patient.id}
|
||||
type="button"
|
||||
onClick={() => handleSelectPatient(patient)}
|
||||
disabled={isSelected}
|
||||
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>
|
||||
);
|
||||
})}
|
||||
{results.map((patient) => (
|
||||
<PatientListItem
|
||||
key={patient.id}
|
||||
patient={patient}
|
||||
isLoading={selectedId === patient.id}
|
||||
onClick={() => selectPatient(patient)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-slate-400">
|
||||
<User className="h-8 w-8 mb-2 opacity-50" />
|
||||
<p className="text-sm">Geen patiënten gevonden</p>
|
||||
<p className="text-xs mt-1">Probeer een andere zoekterm</p>
|
||||
</div>
|
||||
<PatientListEmpty
|
||||
message="Geen patienten gevonden"
|
||||
submessage="Probeer een andere zoekterm"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty State */}
|
||||
{searchQuery.length < 2 && (
|
||||
{/* Empty State - waiting for input */}
|
||||
{!showResults && (
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user