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:
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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user