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:
colinislit
2026-01-03 11:40:23 +01:00
parent 7b9de4ada2
commit 983807d833
9 changed files with 2011 additions and 281 deletions

158
lib/fhir/patient-mapper.ts Normal file
View 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();
}