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:
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