@digihmis/esm-patient-registration-app 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (146) hide show
  1. package/README.md +4 -0
  2. package/dist/117.js +1 -0
  3. package/dist/117.js.map +1 -0
  4. package/dist/131.js +1 -0
  5. package/dist/131.js.map +1 -0
  6. package/dist/132.js +1 -0
  7. package/dist/132.js.map +1 -0
  8. package/dist/152.js +1 -0
  9. package/dist/152.js.map +1 -0
  10. package/dist/209.js +1 -0
  11. package/dist/209.js.map +1 -0
  12. package/dist/247.js +1 -0
  13. package/dist/247.js.map +1 -0
  14. package/dist/28.js +1 -0
  15. package/dist/28.js.map +1 -0
  16. package/dist/317.js +1 -0
  17. package/dist/317.js.map +1 -0
  18. package/dist/349.js +1 -0
  19. package/dist/349.js.map +1 -0
  20. package/dist/371.js +1 -0
  21. package/dist/371.js.map +1 -0
  22. package/dist/378.js +1 -0
  23. package/dist/378.js.map +1 -0
  24. package/dist/442.js +1 -0
  25. package/dist/442.js.map +1 -0
  26. package/dist/45.js +1 -0
  27. package/dist/45.js.map +1 -0
  28. package/dist/459.js +12 -0
  29. package/dist/459.js.map +1 -0
  30. package/dist/466.js +1 -0
  31. package/dist/466.js.map +1 -0
  32. package/dist/508.js +1 -0
  33. package/dist/508.js.map +1 -0
  34. package/dist/61.js +1 -0
  35. package/dist/61.js.map +1 -0
  36. package/dist/640.js +1 -0
  37. package/dist/640.js.map +1 -0
  38. package/dist/689.js +1 -0
  39. package/dist/689.js.map +1 -0
  40. package/dist/709.js +1 -0
  41. package/dist/709.js.map +1 -0
  42. package/dist/712.js +1 -0
  43. package/dist/712.js.map +1 -0
  44. package/dist/720.js +1 -0
  45. package/dist/720.js.map +1 -0
  46. package/dist/742.js +1 -0
  47. package/dist/742.js.map +1 -0
  48. package/dist/771.js +1 -0
  49. package/dist/771.js.map +1 -0
  50. package/dist/806.js +1 -0
  51. package/dist/806.js.map +1 -0
  52. package/dist/912.js +1 -0
  53. package/dist/912.js.map +1 -0
  54. package/dist/913.js +1 -0
  55. package/dist/913.js.map +1 -0
  56. package/dist/940.js +1 -0
  57. package/dist/940.js.map +1 -0
  58. package/dist/957.js +15 -0
  59. package/dist/957.js.map +1 -0
  60. package/dist/digihmis-esm-patient-registration-app.js +6 -0
  61. package/dist/digihmis-esm-patient-registration-app.js.buildmanifest.json +827 -0
  62. package/dist/digihmis-esm-patient-registration-app.js.map +1 -0
  63. package/dist/main.js +17 -0
  64. package/dist/main.js.map +1 -0
  65. package/dist/routes.json +1 -0
  66. package/jest.config.js +26 -0
  67. package/package.json +51 -0
  68. package/rspack.config.js +1 -0
  69. package/src/active-visit-table/active-visit-table.component.tsx +240 -0
  70. package/src/active-visit-table/active-visits.resource.ts +338 -0
  71. package/src/active-visit-table/active-visits.scss +211 -0
  72. package/src/active-visit-table/empty-data-illustration.component.tsx +39 -0
  73. package/src/checkin-form/base-visit-type.component.tsx +102 -0
  74. package/src/checkin-form/base-visit-type.scss +75 -0
  75. package/src/checkin-form/checkin-form.scss +167 -0
  76. package/src/checkin-form/checkin-form.workspace.tsx +703 -0
  77. package/src/checkin-form/hooks/useDefaultFacilityLocation.ts +15 -0
  78. package/src/checkin-form/hooks/useDefaultVisitLocation.ts +24 -0
  79. package/src/checkin-form/hooks/useOfflineVisitType.ts +18 -0
  80. package/src/checkin-form/hooks/usePatientHistory.ts +40 -0
  81. package/src/checkin-form/hooks/useRecommendedVisitTypes.ts +34 -0
  82. package/src/checkin-form/hooks/useVisitAttributeType.ts +102 -0
  83. package/src/checkin-form/location-selector.component.tsx +86 -0
  84. package/src/checkin-form/recommended-visit-type.component.tsx +32 -0
  85. package/src/checkin-form/visit-attribute-type.component.tsx +256 -0
  86. package/src/checkin-form/visit-attribute-type.scss +5 -0
  87. package/src/checkin-form/visit-date-time.component.tsx +201 -0
  88. package/src/checkin-form/visit-form.resource.ts +308 -0
  89. package/src/components/custom-card/compact-patient-banner.component.tsx +70 -0
  90. package/src/components/custom-card/compact-patient-banner.scss +34 -0
  91. package/src/components/custom-card/custom-loader.component.tsx +22 -0
  92. package/src/components/custom-card/patient-search-context.ts +30 -0
  93. package/src/components/custom-input/autosuggest/autosuggest.component.tsx +213 -0
  94. package/src/components/custom-input/autosuggest/autosuggest.scss +158 -0
  95. package/src/components/custom-input/autosuggest/autosuggest.test.tsx +168 -0
  96. package/src/components/custom-patient-banner/custom-patient-banner.component.tsx +39 -0
  97. package/src/components/custom-patient-banner/custom-patient-banner.resource.ts +32 -0
  98. package/src/components/custom-patient-banner/custom-patient-banner.scss +26 -0
  99. package/src/config-schema.ts +511 -0
  100. package/src/constant.ts +6 -0
  101. package/src/constants/index.ts +1 -0
  102. package/src/dashboard.meta.ts +8 -0
  103. package/src/declarations.d.ts +6 -0
  104. package/src/empty-svg/empty-svg.component.tsx +565 -0
  105. package/src/icon-wrapper/icon-wrapper.extension.tsx +17 -0
  106. package/src/identifier-workspace/identifier-configure-form.workspace.tsx +278 -0
  107. package/src/identifier-workspace/identifier-configure.scss +106 -0
  108. package/src/identifier-workspace/identifier.resource.ts +90 -0
  109. package/src/index.ts +33 -0
  110. package/src/left-panel/left-panel.component.tsx +54 -0
  111. package/src/left-panel/left-panel.scss +13 -0
  112. package/src/page-header/page-header.component.tsx +35 -0
  113. package/src/page-header/page-header.scss +76 -0
  114. package/src/patient-tags/deceased-patient-tag.extension.tsx +41 -0
  115. package/src/patient-tags/deceased-patient-tag.scss +15 -0
  116. package/src/patient-tags/patient-tags.resource.ts +30 -0
  117. package/src/patient-tags/visit-tag.extension.tsx +43 -0
  118. package/src/patient-tags/visit-tag.scss +19 -0
  119. package/src/registration-form/form-manager.ts +449 -0
  120. package/src/registration-form/registration-form.component.tsx +1565 -0
  121. package/src/registration-form/registration-form.resource.ts +264 -0
  122. package/src/registration-form/registration-form.scss +282 -0
  123. package/src/routes.json +62 -0
  124. package/src/search-bar/search-bar.component.tsx +285 -0
  125. package/src/search-bar/search-bar.resource.ts +274 -0
  126. package/src/search-bar/search-bar.scss +427 -0
  127. package/src/search-dashboard/search-dashboard.component.tsx +17 -0
  128. package/src/search-dashboard/search-dashboard.scss +13 -0
  129. package/src/setup-tests.ts +1 -0
  130. package/src/type/index.ts +463 -0
  131. package/src/utils/fhir-mapper.ts +73 -0
  132. package/src/visit-summary/visit-summary.component.tsx +32 -0
  133. package/src/visit-summary/visit-summary.resource.ts +56 -0
  134. package/src/visit-summary/visit-summary.scss +74 -0
  135. package/translations/am.json +112 -0
  136. package/translations/ar.json +120 -0
  137. package/translations/en.json +112 -0
  138. package/translations/es.json +114 -0
  139. package/translations/fr.json +114 -0
  140. package/translations/he.json +114 -0
  141. package/translations/km.json +111 -0
  142. package/translations/pt.json +114 -0
  143. package/translations/sw.json +112 -0
  144. package/translations/vi.json +111 -0
  145. package/translations/zh.json +111 -0
  146. package/tsconfig.json +5 -0
@@ -0,0 +1,1565 @@
1
+ import React, { useState, useMemo, useCallback, useEffect } from 'react';
2
+ import {
3
+ Button,
4
+ TextInput,
5
+ ComboBox,
6
+ ContentSwitcher,
7
+ Switch,
8
+ TextArea,
9
+ Layer,
10
+ Select,
11
+ SelectItem,
12
+ InlineNotification,
13
+ NotificationActionButton,
14
+ SkeletonText,
15
+ RadioButtonGroup,
16
+ RadioButton,
17
+ Tag,
18
+ } from '@carbon/react';
19
+ import {
20
+ navigate,
21
+ OpenmrsDatePicker,
22
+ useConfig,
23
+ showSnackbar,
24
+ openmrsFetch,
25
+ restBaseUrl,
26
+ type FetchResponse,
27
+ UserHasAccess,
28
+ isDesktop,
29
+ useLayoutType,
30
+ launchWorkspace,
31
+ useSession,
32
+ ExtensionSlot,
33
+ } from '@openmrs/esm-framework';
34
+ import useSWRImmutable from 'swr/immutable';
35
+ import { useTranslation } from 'react-i18next';
36
+ import { z } from 'zod';
37
+ import { useForm, Controller } from 'react-hook-form';
38
+ import { zodResolver } from '@hookform/resolvers/zod';
39
+ import styles from './registration-form.scss';
40
+ import { TrashCan, ArrowLeft, ArrowRight } from '@carbon/react/icons';
41
+ import {
42
+ getCounties,
43
+ getSubCountiesByCounty,
44
+ getAllWardsInCounty,
45
+ type County,
46
+ type SubCounty,
47
+ type Ward,
48
+ } from 'osm-kenya-boundaries';
49
+ import { RegistrationConfig } from '../config-schema';
50
+ import { Autosuggest } from '../components/custom-input/autosuggest/autosuggest.component';
51
+ import { ConceptAnswers, PatientUuidMapType, FormValues, PatientIdentifierType } from '../type';
52
+ import { fetchPatientIdentifierTypesWithSources } from '../identifier-workspace/identifier.resource';
53
+ import { FormManager, SavePatientTransactionManager } from './form-manager';
54
+ import { v4 as uuidv4 } from 'uuid';
55
+ import { fetchPerson, useConceptAnswers } from './registration-form.resource';
56
+
57
+ interface RelationshipType {
58
+ display: string;
59
+ uuid: string;
60
+ direction: string;
61
+ }
62
+
63
+ interface RelationshipValue {
64
+ relatedPersonUuid: string;
65
+ relatedPersonName?: string;
66
+ relationshipType?: string;
67
+ initialrelationshipTypeValue?: string;
68
+ action?: 'ADD' | 'UPDATE' | 'DELETE';
69
+ uuid?: string;
70
+ }
71
+
72
+ // Function to create dynamic validation schema
73
+ const createRegistrationSchema = (maritalStatusRequired: boolean = false) => {
74
+ const baseSchema = {
75
+ isNameKnown: z.boolean().default(true),
76
+ firstName: z.string().optional(),
77
+ middleName: z.string().max(50, 'Middle name must be less than 50 characters').optional(),
78
+ familyName: z.string().optional(),
79
+ gender: z.string().min(1, 'Gender is required'),
80
+ isDobKnown: z.boolean().default(true),
81
+ birthdate: z.date().optional(),
82
+ yearsEstimated: z.number().min(0, 'Years must be 0 or greater').max(120, 'Years must be less than 120').optional(),
83
+ monthsEstimated: z
84
+ .number()
85
+ .min(0, 'Months must be 0 or greater')
86
+ .max(11, 'Months must be between 0 and 11')
87
+ .optional(),
88
+ county: z.string().min(1, 'County is required'),
89
+ countyUuid: z.string().optional(),
90
+ subCounty: z.string().min(1, 'Sub county is required'),
91
+ subCountyUuid: z.string().optional(),
92
+ ward: z.string().min(1, 'Ward is required'),
93
+ wardUuid: z.string().optional(),
94
+ phoneNumber: z
95
+ .string()
96
+ .regex(/^(\+254|0)[17]\d{8}$/, 'Please enter a valid Kenyan phone number (e.g., 0712345678 or +254712345678)')
97
+ .optional()
98
+ .or(z.literal('')),
99
+ email: z.string().email('Please enter a valid email address').optional().or(z.literal('')),
100
+ isDead: z.boolean().default(false),
101
+ deathDate: z.date().optional(),
102
+ deathCause: z.string().optional(),
103
+ nonCodedCauseOfDeath: z.string().optional(),
104
+ identifiers: z.record(z.string().optional()).optional(),
105
+ };
106
+
107
+ const schemaWithMaritalStatus = {
108
+ ...baseSchema,
109
+ maritalStatus: maritalStatusRequired ? z.string().min(1, 'Marital status is required') : z.string().optional(),
110
+ };
111
+
112
+ return z.object(schemaWithMaritalStatus).superRefine((data, ctx) => {
113
+ if (data.isNameKnown) {
114
+ if (!data.firstName || data.firstName.trim() === '') {
115
+ ctx.addIssue({
116
+ code: z.ZodIssueCode.custom,
117
+ message: 'First name is required',
118
+ path: ['firstName'],
119
+ });
120
+ } else if (data.firstName.length > 50) {
121
+ ctx.addIssue({
122
+ code: z.ZodIssueCode.custom,
123
+ message: 'First name must be less than 50 characters',
124
+ path: ['firstName'],
125
+ });
126
+ }
127
+
128
+ if (!data.familyName || data.familyName.trim() === '') {
129
+ ctx.addIssue({
130
+ code: z.ZodIssueCode.custom,
131
+ message: 'Family name is required',
132
+ path: ['familyName'],
133
+ });
134
+ } else if (data.familyName.length > 50) {
135
+ ctx.addIssue({
136
+ code: z.ZodIssueCode.custom,
137
+ message: 'Family name must be less than 50 characters',
138
+ path: ['familyName'],
139
+ });
140
+ }
141
+ }
142
+
143
+ if (data.isDobKnown && !data.birthdate) {
144
+ ctx.addIssue({
145
+ code: z.ZodIssueCode.custom,
146
+ message: 'Birthdate is required when date of birth is known',
147
+ path: ['birthdate'],
148
+ });
149
+ }
150
+
151
+ if (!data.isDobKnown && !data.yearsEstimated && !data.monthsEstimated) {
152
+ ctx.addIssue({
153
+ code: z.ZodIssueCode.custom,
154
+ message: 'Either years or months must be provided',
155
+ path: ['yearsEstimated'],
156
+ });
157
+ }
158
+
159
+ if (data.birthdate && data.birthdate > new Date()) {
160
+ ctx.addIssue({
161
+ code: z.ZodIssueCode.custom,
162
+ message: 'Birthdate cannot be in the future',
163
+ path: ['birthdate'],
164
+ });
165
+ }
166
+
167
+ if (data.isDead && !data.deathCause) {
168
+ ctx.addIssue({
169
+ code: z.ZodIssueCode.custom,
170
+ message: 'Cause of death is required when patient is marked as deceased',
171
+ path: ['deathCause'],
172
+ });
173
+ }
174
+
175
+ if (data.isDead && !data.deathDate) {
176
+ ctx.addIssue({
177
+ code: z.ZodIssueCode.custom,
178
+ message: 'Death date is required when patient is marked as deceased',
179
+ path: ['deathDate'],
180
+ });
181
+ }
182
+
183
+ // Validate that at least one identifier has a value (recommended but not required)
184
+ if (data.identifiers) {
185
+ const hasAtLeastOneIdentifier = Object.values(data.identifiers).some((value) => value && value.trim() !== '');
186
+
187
+ if (!hasAtLeastOneIdentifier) {
188
+ // Add a warning but don't block submission
189
+ // This will be shown as an inline notification instead
190
+ }
191
+ }
192
+ });
193
+ };
194
+
195
+ type RegistrationFormData = z.infer<ReturnType<typeof createRegistrationSchema>>;
196
+
197
+ interface RelationshipTypeResponse {
198
+ uuid: string;
199
+ display: string;
200
+ displayAIsToB?: string;
201
+ displayBIsToA?: string;
202
+ }
203
+
204
+ function useRelationshipTypes() {
205
+ const { data, error, isLoading } = useSWRImmutable<FetchResponse<{ results: RelationshipTypeResponse[] }>, Error>(
206
+ `${restBaseUrl}/relationshiptype?v=full`,
207
+ openmrsFetch,
208
+ );
209
+
210
+ if (error) {
211
+ showSnackbar({
212
+ title: error.name,
213
+ subtitle: error.message,
214
+ kind: 'error',
215
+ });
216
+ }
217
+
218
+ return {
219
+ data: data?.data?.results,
220
+ isLoading,
221
+ error,
222
+ };
223
+ }
224
+
225
+ interface RelationshipViewProps {
226
+ relationship: RelationshipValue;
227
+ index: number;
228
+ displayRelationshipTypes: RelationshipType[];
229
+ onUpdate: (index: number, field: string, value: any) => void;
230
+ onDelete: (index: number) => void;
231
+ onRestore: (index: number) => void;
232
+ }
233
+
234
+ const RelationshipView: React.FC<RelationshipViewProps> = ({
235
+ relationship,
236
+ index,
237
+ displayRelationshipTypes,
238
+ onUpdate,
239
+ onDelete,
240
+ onRestore,
241
+ }) => {
242
+ const { t } = useTranslation();
243
+ const [isInvalid, setIsInvalid] = useState(false);
244
+ const [hasInteracted, setHasInteracted] = useState(false);
245
+ const newRelationship = !relationship.uuid;
246
+
247
+ const searchPerson = useCallback(
248
+ async (query: string) => {
249
+ const abortController = new AbortController();
250
+ try {
251
+ const results = await fetchPerson(query, abortController);
252
+ return results;
253
+ } catch (error) {
254
+ console.error(`[RelationshipView ${index}] Error searching for person:`, error);
255
+ return [];
256
+ }
257
+ },
258
+ [index],
259
+ );
260
+
261
+ const handleRelationshipTypeChange = useCallback(
262
+ (event) => {
263
+ const { target } = event;
264
+ const value = target.options[target.selectedIndex].value;
265
+ onUpdate(index, 'relationshipType', value);
266
+ if (!relationship?.action) {
267
+ onUpdate(index, 'action', 'UPDATE');
268
+ }
269
+ },
270
+ [index, relationship?.action, onUpdate],
271
+ );
272
+
273
+ const handleSuggestionSelected = useCallback(
274
+ (fieldId: string, value: string | undefined) => {
275
+ setHasInteracted(true);
276
+ if (value) {
277
+ setIsInvalid(false);
278
+ onUpdate(index, 'relatedPersonUuid', value);
279
+ if (!relationship?.action) {
280
+ onUpdate(index, 'action', 'UPDATE');
281
+ }
282
+ } else if (value === '') {
283
+ setIsInvalid(true);
284
+ onUpdate(index, 'relatedPersonUuid', '');
285
+ }
286
+ },
287
+ [index, relationship?.action, onUpdate],
288
+ );
289
+
290
+ return relationship.action !== 'DELETE' ? (
291
+ <div>
292
+ <div>
293
+ <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
294
+ <h4 style={{ fontSize: '1rem', fontWeight: 600 }}>{t('relationshipPlaceholder', 'Relationship')}</h4>
295
+ <Button
296
+ kind="ghost"
297
+ iconDescription={t('deleteRelationshipTooltipText', 'Delete')}
298
+ hasIconOnly
299
+ onClick={() => onDelete(index)}>
300
+ <TrashCan size={16} />
301
+ </Button>
302
+ </div>
303
+ <div style={{ marginBottom: '1rem' }}>
304
+ {newRelationship ? (
305
+ <Autosuggest
306
+ id={`relationships[${index}].relatedPersonUuid`}
307
+ labelText={t('relativeFullNameLabelText', 'Full name')}
308
+ placeholder={t('relativeNamePlaceholder', 'Firstname Familyname')}
309
+ defaultValue={relationship.relatedPersonName}
310
+ onSuggestionSelected={handleSuggestionSelected}
311
+ invalid={hasInteracted && isInvalid}
312
+ invalidText={t('relationshipPersonMustExist', 'Related person must be an existing person')}
313
+ getSearchResults={searchPerson}
314
+ getDisplayValue={(item) => item.display}
315
+ getFieldValue={(item) => item.uuid}
316
+ />
317
+ ) : (
318
+ <>
319
+ <span style={{ fontSize: '0.75rem', fontWeight: 600 }}>
320
+ {t('relativeFullNameLabelText', 'Full name')}
321
+ </span>
322
+ <p style={{ marginTop: '0.5rem' }}>{relationship.relatedPersonName}</p>
323
+ </>
324
+ )}
325
+ </div>
326
+ </div>
327
+ <div style={{ marginBottom: '1rem' }}>
328
+ <Layer>
329
+ <Select
330
+ id={`relationship-type-${index}`}
331
+ labelText={t('relationship', 'Relationship')}
332
+ onChange={handleRelationshipTypeChange}
333
+ name={`relationships[${index}].relationshipType`}
334
+ value={relationship?.relationshipType || 'placeholder-item'}>
335
+ <SelectItem
336
+ disabled
337
+ hidden
338
+ value="placeholder-item"
339
+ text={t('relationshipToPatient', 'Relationship to patient')}
340
+ />
341
+ {displayRelationshipTypes && displayRelationshipTypes.length > 0 ? (
342
+ displayRelationshipTypes.map((relationshipType, idx) => (
343
+ <SelectItem
344
+ text={relationshipType.display}
345
+ value={`${relationshipType.uuid}/${relationshipType.direction}`}
346
+ key={`relationship-${relationshipType.uuid}-${relationshipType.direction}-${idx}`}
347
+ />
348
+ ))
349
+ ) : (
350
+ <SelectItem disabled value="no-options" text={t('noOptions', 'No options available')} />
351
+ )}
352
+ </Select>
353
+ </Layer>
354
+ </div>
355
+ </div>
356
+ ) : (
357
+ <InlineNotification
358
+ kind="info"
359
+ title={t('relationshipRemovedText', 'Relationship removed')}
360
+ actions={
361
+ <NotificationActionButton onClick={() => onRestore(index)}>
362
+ {t('restoreRelationshipActionButton', 'Undo')}
363
+ </NotificationActionButton>
364
+ }
365
+ />
366
+ );
367
+ };
368
+
369
+ export const RegistrationForm: React.FC = () => {
370
+ const { t } = useTranslation();
371
+ const layout = useLayoutType();
372
+ const session = useSession();
373
+ const [isNameKnown, setIsNameKnown] = useState(true);
374
+ const [isDobKnown, setIsDobKnown] = useState(true);
375
+ const [isDead, setIsDead] = useState(false);
376
+ const [selectedCounty, setSelectedCounty] = useState<County | null>(null);
377
+ const [selectedSubCounty, setSelectedSubCounty] = useState<SubCounty | null>(null);
378
+ const [selectedWard, setSelectedWard] = useState<Ward | null>(null);
379
+
380
+ const [allIdentifierTypes, setAllIdentifierTypes] = useState<Array<PatientIdentifierType>>([]);
381
+ const [selectedIdentifierUuids, setSelectedIdentifierUuids] = useState<Array<string>>([]);
382
+ const [isLoadingIdentifiers, setIsLoadingIdentifiers] = useState(true);
383
+ const [showIdentifierWarning, setShowIdentifierWarning] = useState(false);
384
+
385
+ const config = useConfig<RegistrationConfig>();
386
+ const { fieldConfigurations, freeTextFieldConceptUuid, defaultPatientIdentifierTypes, fieldDefinitions } = config;
387
+ const conceptUuid = fieldConfigurations?.causeOfDeath?.conceptUuid;
388
+ const fieldConfigs = fieldConfigurations?.gender;
389
+
390
+ const maritalStatusField = fieldDefinitions?.find((fieldDefinition) => fieldDefinition.id === 'maritalStatus');
391
+ const maritalStatusOptions: Array<ConceptAnswers> =
392
+ maritalStatusField?.customConceptAnswers?.map((concept) => ({
393
+ uuid: concept?.uuid,
394
+ display: concept?.label,
395
+ })) ?? [];
396
+
397
+ const [relationships, setRelationships] = useState<RelationshipValue[]>([]);
398
+ const [displayRelationshipTypes, setDisplayRelationshipTypes] = useState<RelationshipType[]>([]);
399
+
400
+ const {
401
+ data: relationshipTypesData,
402
+ isLoading: isLoadingRelationshipTypes,
403
+ error: errorLoadingRelationshipTypes,
404
+ } = useRelationshipTypes();
405
+
406
+ useEffect(() => {
407
+ const loadIdentifierTypes = async () => {
408
+ try {
409
+ setIsLoadingIdentifiers(true);
410
+ const types = await fetchPatientIdentifierTypesWithSources();
411
+ setAllIdentifierTypes(types);
412
+
413
+ if (defaultPatientIdentifierTypes && defaultPatientIdentifierTypes.length > 0) {
414
+ setSelectedIdentifierUuids(defaultPatientIdentifierTypes);
415
+ }
416
+ } catch (error) {
417
+ showSnackbar({
418
+ title: t('errorLoadingIdentifiers', 'Error loading identifiers'),
419
+ kind: 'error',
420
+ });
421
+ } finally {
422
+ setIsLoadingIdentifiers(false);
423
+ }
424
+ };
425
+
426
+ loadIdentifierTypes();
427
+ }, [t, defaultPatientIdentifierTypes]);
428
+
429
+ const selectedIdentifierTypes = useMemo(() => {
430
+ return allIdentifierTypes.filter((type) => selectedIdentifierUuids.includes(type.uuid));
431
+ }, [allIdentifierTypes, selectedIdentifierUuids]);
432
+
433
+ useEffect(() => {
434
+ if (relationshipTypesData && Array.isArray(relationshipTypesData)) {
435
+ const tmp: RelationshipType[] = [];
436
+
437
+ relationshipTypesData.forEach((type) => {
438
+ let displayA = type.displayAIsToB;
439
+ let displayB = type.displayBIsToA;
440
+
441
+ if (!displayA || !displayB) {
442
+ const displayParts = type.display.split('/');
443
+ displayA = displayA || displayParts[0]?.trim() || type.display;
444
+ displayB = displayB || displayParts[1]?.trim() || type.display;
445
+ }
446
+
447
+ const aIsToB: RelationshipType = {
448
+ display: displayA,
449
+ uuid: type.uuid,
450
+ direction: 'aIsToB',
451
+ };
452
+
453
+ const bIsToA: RelationshipType = {
454
+ display: displayB,
455
+ uuid: type.uuid,
456
+ direction: 'bIsToA',
457
+ };
458
+
459
+ if (displayA === displayB) {
460
+ tmp.push(aIsToB);
461
+ } else if (displayB.toLowerCase() === 'patient') {
462
+ tmp.push(aIsToB, {
463
+ display: `Patient (${displayA})`,
464
+ uuid: type.uuid,
465
+ direction: 'bIsToA',
466
+ });
467
+ } else {
468
+ tmp.push(aIsToB, bIsToA);
469
+ }
470
+ });
471
+
472
+ setDisplayRelationshipTypes(tmp);
473
+ }
474
+ }, [relationshipTypesData]);
475
+
476
+ const handleAddRelationship = useCallback(() => {
477
+ setRelationships((prev) => [
478
+ ...prev,
479
+ {
480
+ relatedPersonUuid: '',
481
+ action: 'ADD',
482
+ },
483
+ ]);
484
+ }, []);
485
+
486
+ const handleUpdateRelationship = useCallback((index: number, field: string, value: any) => {
487
+ setRelationships((prev) => {
488
+ const updated = [...prev];
489
+ updated[index] = {
490
+ ...updated[index],
491
+ [field]: value,
492
+ };
493
+ return updated;
494
+ });
495
+ }, []);
496
+
497
+ const handleDeleteRelationship = useCallback((index: number) => {
498
+ setRelationships((prev) => {
499
+ const updated = [...prev];
500
+ if (updated[index].action === 'ADD') {
501
+ return updated.filter((_, i) => i !== index);
502
+ } else {
503
+ updated[index] = {
504
+ ...updated[index],
505
+ action: 'DELETE',
506
+ };
507
+ return updated;
508
+ }
509
+ });
510
+ }, []);
511
+
512
+ const handleRestoreRelationship = useCallback((index: number) => {
513
+ setRelationships((prev) => {
514
+ const updated = [...prev];
515
+ updated[index] = {
516
+ ...updated[index],
517
+ action: undefined,
518
+ relationshipType: updated[index].initialrelationshipTypeValue,
519
+ };
520
+ return updated;
521
+ });
522
+ }, []);
523
+
524
+ const shouldFetchConcepts = conceptUuid && typeof conceptUuid === 'string' && conceptUuid.trim() !== '';
525
+
526
+ const {
527
+ data: conceptAnswers,
528
+ isLoading: isLoadingConceptAnswers,
529
+ error: errorLoadingConceptAnswers,
530
+ } = useConceptAnswers(shouldFetchConcepts ? conceptUuid : null);
531
+
532
+ const answers = useMemo(() => {
533
+ if (isLoadingConceptAnswers) {
534
+ return [];
535
+ }
536
+ if (conceptAnswers && Array.isArray(conceptAnswers)) {
537
+ return conceptAnswers.map((answer) => ({
538
+ uuid: answer.uuid,
539
+ display: answer.display,
540
+ label: answer.display,
541
+ }));
542
+ }
543
+ return [];
544
+ }, [conceptAnswers, isLoadingConceptAnswers]);
545
+
546
+ const counties = useMemo(() => getCounties(), []);
547
+
548
+ const subCounties = useMemo(() => {
549
+ if (!selectedCounty) {
550
+ return [];
551
+ }
552
+ return getSubCountiesByCounty(selectedCounty.name);
553
+ }, [selectedCounty]);
554
+
555
+ const wards = useMemo(() => {
556
+ if (!selectedCounty) {
557
+ return [];
558
+ }
559
+ if (!selectedSubCounty) {
560
+ return getAllWardsInCounty(selectedCounty.name);
561
+ }
562
+ const allCountyWards = getAllWardsInCounty(selectedCounty.name);
563
+ return allCountyWards.filter(
564
+ (ward) =>
565
+ ward.constituency.toLowerCase().includes(selectedSubCounty.name.toLowerCase()) ||
566
+ selectedSubCounty.name.toLowerCase().includes(ward.constituency.toLowerCase()),
567
+ );
568
+ }, [selectedCounty, selectedSubCounty]);
569
+
570
+ const {
571
+ control,
572
+ handleSubmit,
573
+ formState: { errors, isSubmitting },
574
+ watch,
575
+ setValue,
576
+ trigger,
577
+ } = useForm<RegistrationFormData>({
578
+ resolver: zodResolver(createRegistrationSchema(maritalStatusField?.validation.required || false)),
579
+ defaultValues: {
580
+ isNameKnown: true,
581
+ isDobKnown: true,
582
+ firstName: '',
583
+ middleName: '',
584
+ familyName: '',
585
+ gender: '',
586
+ maritalStatus: '',
587
+ yearsEstimated: undefined,
588
+ monthsEstimated: undefined,
589
+ county: '',
590
+ countyUuid: '',
591
+ subCounty: '',
592
+ subCountyUuid: '',
593
+ ward: '',
594
+ wardUuid: '',
595
+ phoneNumber: '',
596
+ email: '',
597
+ isDead: false,
598
+ deathDate: undefined,
599
+ deathCause: '',
600
+ nonCodedCauseOfDeath: '',
601
+ identifiers: {},
602
+ },
603
+ });
604
+
605
+ const identifiers = useMemo(() => watch('identifiers') || {}, [watch]);
606
+
607
+ useEffect(() => {
608
+ const hasAtLeastOneIdentifier = Object.values(identifiers).some(
609
+ (value) => value && typeof value === 'string' && value.trim() !== '',
610
+ );
611
+ setShowIdentifierWarning(!hasAtLeastOneIdentifier && selectedIdentifierTypes.length > 0);
612
+ }, [identifiers, selectedIdentifierTypes.length]);
613
+
614
+ const handleConfigureIdentifiers = useCallback(() => {
615
+ launchWorkspace('configure-identifiers', {
616
+ currentIdentifiers: selectedIdentifierUuids,
617
+ onSave: (selected: Array<string>) => {
618
+ setSelectedIdentifierUuids(selected);
619
+ const currentIdentifiers = { ...identifiers };
620
+ const newIdentifiers = {};
621
+
622
+ selected.forEach((uuid) => {
623
+ const identifierType = allIdentifierTypes.find((t) => t.uuid === uuid);
624
+ if (identifierType) {
625
+ newIdentifiers[identifierType.fieldName] = currentIdentifiers[identifierType.fieldName] || '';
626
+ }
627
+ });
628
+
629
+ setValue('identifiers', newIdentifiers);
630
+
631
+ showSnackbar({
632
+ title: t('identifiersUpdated', 'Identifiers updated'),
633
+ subtitle: t('identifiersConfigured', '{{count}} identifier(s) configured', { count: selected.length }),
634
+ kind: 'success',
635
+ });
636
+ },
637
+ });
638
+ }, [selectedIdentifierUuids, allIdentifierTypes, identifiers, setValue, t]);
639
+
640
+ const handleBackToSearch = () => {
641
+ navigate({ to: window.getOpenmrsSpaBase() + 'home/registration' });
642
+ };
643
+
644
+ const onSubmit = async (data: RegistrationFormData) => {
645
+ try {
646
+ const incompleteRelationships = relationships.filter(
647
+ (rel) => rel.action !== 'DELETE' && (!rel.relatedPersonUuid || !rel.relationshipType),
648
+ );
649
+
650
+ if (incompleteRelationships.length > 0) {
651
+ showSnackbar({
652
+ title: t('validationError', 'Validation Error'),
653
+ subtitle: t('incompleteRelationships', 'Please complete all relationship fields'),
654
+ kind: 'error',
655
+ });
656
+ return;
657
+ }
658
+
659
+ let calculatedBirthdate = data.birthdate;
660
+ let birthdateEstimated = !data.isDobKnown;
661
+
662
+ if (!data.isDobKnown && (data.yearsEstimated || data.monthsEstimated)) {
663
+ const today = new Date();
664
+ const years = data.yearsEstimated || 0;
665
+ const months = data.monthsEstimated || 0;
666
+ calculatedBirthdate = new Date(today.getFullYear() - years, today.getMonth() - months, today.getDate());
667
+ }
668
+
669
+ const patientUuid = uuidv4();
670
+
671
+ const patientUuidMap: PatientUuidMapType = {
672
+ preferredNameUuid: uuidv4(),
673
+ additionalNameUuid: data.isNameKnown ? undefined : uuidv4(),
674
+ };
675
+
676
+ const identifiersForFormManager = {};
677
+ for (const identifierType of selectedIdentifierTypes) {
678
+ const fieldName = identifierType.fieldName;
679
+ const autoGeneration = identifierType.identifierSources?.some(
680
+ (source) => source?.autoGenerationOption?.automaticGenerationEnabled === true,
681
+ );
682
+ const manualEntryEnabled = identifierType.identifierSources?.some(
683
+ (source) => source?.autoGenerationOption?.manualEntryEnabled === true,
684
+ );
685
+
686
+ const selectedSource = identifierType.identifierSources?.[0];
687
+
688
+ identifiersForFormManager[fieldName] = {
689
+ identifierTypeUuid: identifierType.uuid,
690
+ identifierValue: data.identifiers?.[fieldName] || '',
691
+ identifierUuid: uuidv4(),
692
+ selectedSource: selectedSource,
693
+ preferred: identifierType.required || false,
694
+ autoGeneration: autoGeneration,
695
+ initialValue: '',
696
+ identifierName: identifierType.name,
697
+ };
698
+ }
699
+
700
+ const attributes = {};
701
+ if (data.phoneNumber) {
702
+ attributes['b2c38640-2603-4629-aebd-3b54f33f1e3a'] = data.phoneNumber;
703
+ }
704
+
705
+ const obs = {};
706
+ if (data.maritalStatus && maritalStatusField?.uuid) {
707
+ obs[maritalStatusField.uuid] = data.maritalStatus;
708
+ }
709
+
710
+ const address = {
711
+ countyDistrict: data.county,
712
+ stateProvince: data.subCounty,
713
+ address4: data.ward,
714
+ preferred: true,
715
+ };
716
+
717
+ const formValues: FormValues = {
718
+ patientUuid: patientUuid,
719
+ givenName: data.firstName || config?.name?.defaultUnknownGivenName || 'UNKNOWN',
720
+ middleName: data.middleName || '',
721
+ familyName: data.familyName || config?.name?.defaultUnknownFamilyName || 'UNKNOWN',
722
+ addNameInLocalLanguage: false,
723
+ additionalGivenName: '',
724
+ additionalMiddleName: '',
725
+ additionalFamilyName: '',
726
+ gender: data.gender.toLowerCase(),
727
+ birthdate: calculatedBirthdate,
728
+ birthdateEstimated: birthdateEstimated,
729
+ address: { ...address, preferred: address.preferred.toString() },
730
+ attributes: attributes,
731
+ identifiers: identifiersForFormManager,
732
+ relationships: relationships.map((rel) => ({
733
+ relatedPersonUuid: rel.relatedPersonUuid,
734
+ relationshipType: rel.relationshipType,
735
+ uuid: rel.uuid,
736
+ action: rel.action,
737
+ })),
738
+ obs: obs,
739
+ isDead: data.isDead,
740
+ deathDate: data.deathDate,
741
+ deathTime: '',
742
+ deathTimeFormat: 'AM',
743
+ deathCause: data.deathCause,
744
+ nonCodedCauseOfDeath: data.nonCodedCauseOfDeath,
745
+ telephoneNumber: data.phoneNumber || '',
746
+ yearsEstimated: data.yearsEstimated || 0,
747
+ monthsEstimated: data.monthsEstimated || 0,
748
+ };
749
+
750
+ const currentLocation = session?.sessionLocation?.uuid;
751
+
752
+ const savePatientTransactionManager = new SavePatientTransactionManager();
753
+
754
+ const savedPatientUuid = await FormManager.savePatientFormOnline(
755
+ true,
756
+ formValues,
757
+ patientUuidMap,
758
+ {},
759
+ {
760
+ imageData: '',
761
+ dateTime: '',
762
+ },
763
+ currentLocation,
764
+ {},
765
+ session,
766
+ config,
767
+ savePatientTransactionManager,
768
+ );
769
+
770
+ // Fetch the newly created patient data
771
+ const patientResponse = await openmrsFetch(`${restBaseUrl}/patient/${savedPatientUuid}?v=full`);
772
+ const patient = patientResponse.data;
773
+
774
+ showSnackbar({
775
+ title: t('success', 'Success'),
776
+ subtitle: t('patientRegistered', 'Patient registered successfully'),
777
+ kind: 'success',
778
+ });
779
+
780
+ navigate({ to: `${window.getOpenmrsSpaBase()}home/registration` });
781
+
782
+ launchWorkspace('check-in-form', {
783
+ patientUuid: savedPatientUuid,
784
+ patient: patient,
785
+ openedFrom: 'registration',
786
+ showPatientHeader: true,
787
+ });
788
+ } catch (error) {
789
+ console.error('Submission error:', error);
790
+
791
+ let errorMessage = t('submissionFailed', 'Failed to submit form');
792
+
793
+ if (error?.responseBody) {
794
+ const responseBody = error.responseBody;
795
+
796
+ if (responseBody.error?.globalErrors && responseBody.error.globalErrors.length > 0) {
797
+ errorMessage = responseBody.error.globalErrors[0].message;
798
+ } else if (responseBody.error?.fieldErrors && Object.keys(responseBody.error.fieldErrors).length > 0) {
799
+ const fieldErrors = responseBody.error.fieldErrors;
800
+ const firstField = Object.keys(fieldErrors)[0];
801
+ errorMessage = `${firstField}: ${fieldErrors[firstField][0].message}`;
802
+ } else if (responseBody.error?.message) {
803
+ errorMessage = responseBody.error.message;
804
+ }
805
+ } else if (error?.message) {
806
+ errorMessage = error.message;
807
+ }
808
+
809
+ showSnackbar({
810
+ title: t('error', 'Error'),
811
+ subtitle: errorMessage,
812
+ kind: 'error',
813
+ });
814
+ }
815
+ };
816
+
817
+ const handleNameKnownChange = (value: boolean) => {
818
+ setIsNameKnown(value);
819
+ setValue('isNameKnown', value);
820
+ if (!value) {
821
+ setValue('firstName', config?.name?.defaultUnknownGivenName || 'UNKNOWN');
822
+ setValue('middleName', '');
823
+ setValue('familyName', config?.name?.defaultUnknownFamilyName || 'UNKNOWN');
824
+ } else {
825
+ setValue('firstName', '');
826
+ setValue('middleName', '');
827
+ setValue('familyName', '');
828
+ }
829
+ };
830
+
831
+ const handleDobKnownChange = (value: boolean) => {
832
+ setIsDobKnown(value);
833
+ setValue('isDobKnown', value);
834
+ if (value) {
835
+ setValue('yearsEstimated', undefined);
836
+ setValue('monthsEstimated', undefined);
837
+ } else {
838
+ setValue('birthdate', undefined);
839
+ }
840
+ };
841
+
842
+ const handleIsDeadChange = (value: boolean) => {
843
+ setIsDead(value);
844
+ setValue('isDead', value);
845
+ if (!value) {
846
+ setValue('deathDate', undefined);
847
+ setValue('deathCause', '');
848
+ setValue('nonCodedCauseOfDeath', '');
849
+ }
850
+ };
851
+
852
+ const today = new Date();
853
+
854
+ return (
855
+ <div className={styles.FormContainer}>
856
+ <div className={styles.FormHeader}>
857
+ <button
858
+ className={styles.BackButton}
859
+ onClick={handleBackToSearch}
860
+ type="button"
861
+ aria-label={t('goBack', 'Go back')}>
862
+ <ArrowLeft size={20} />
863
+ <span className={styles.BackButtonText}>{t('back', 'Back')}</span>
864
+ </button>
865
+
866
+ <div className={styles.TitleSection}>
867
+ <h1 className={styles.FormTitle}>{t('registerAPatient', 'Register a Patient')}</h1>
868
+ <p className={styles.FormSubtitle}>
869
+ {t('allFieldsRequiredText', 'All fields are required unless marked optional')}
870
+ </p>
871
+ </div>
872
+ </div>
873
+
874
+ <form onSubmit={handleSubmit(onSubmit)}>
875
+ <div className={styles.FormContent}>
876
+ <section className={styles.PatientDetailsSection}>
877
+ <h2 className={styles.SectionTitle}>{t('basicInformation', '1. Basic Information')}</h2>
878
+
879
+ <div className={styles.halfWidthInDesktopView}>
880
+ <UserHasAccess privilege={['Get Identifier Types', 'Add Patient Identifiers']}>
881
+ <div className={styles.identifierLabelText}>
882
+ <span className={styles.label01}>
883
+ {t('idFieldLabelText', 'Identifiers')}
884
+ <span className={styles.required}>*</span>
885
+ </span>
886
+ <Button
887
+ kind="ghost"
888
+ className={styles.configureIdentifiersButton}
889
+ onClick={handleConfigureIdentifiers}
890
+ size={isDesktop(layout) ? 'sm' : 'md'}
891
+ disabled={isLoadingIdentifiers}>
892
+ {t('configure', 'Configure')} <ArrowRight className={styles.arrowRightIcon} size={16} />
893
+ </Button>
894
+ </div>
895
+ </UserHasAccess>
896
+ </div>
897
+
898
+ {isLoadingIdentifiers ? (
899
+ <SkeletonText />
900
+ ) : (
901
+ <div>
902
+ {selectedIdentifierTypes.length === 0 ? (
903
+ <p className={styles.noIdentifiersConfiguredText}>
904
+ {t('noIdentifiersConfigured', 'No identifiers configured. Click Configure to add identifiers.')}
905
+ </p>
906
+ ) : (
907
+ <>
908
+ {/* {showIdentifierWarning && (
909
+ <InlineNotification
910
+ kind="warning"
911
+ lowContrast
912
+ hideCloseButton
913
+ subtitle={t(
914
+ 'identifierRecommendation',
915
+ 'It is recommended to enter at least one identifier for the patient.',
916
+ )}
917
+ style={{ marginBottom: '1rem' }}
918
+ />
919
+ )} */}
920
+ {selectedIdentifierTypes.map((identifierType) => {
921
+ const autoGeneration =
922
+ identifierType.identifierSources?.some(
923
+ (source) => source?.autoGenerationOption?.automaticGenerationEnabled === true,
924
+ ) ?? false;
925
+
926
+ const manualEntryEnabled =
927
+ identifierType.identifierSources?.some(
928
+ (source) => source?.autoGenerationOption?.manualEntryEnabled === true,
929
+ ) ?? false;
930
+
931
+ const isAutoGeneratedOnly = autoGeneration && !manualEntryEnabled;
932
+
933
+ return (
934
+ <div key={identifierType.fieldName} style={{ marginBottom: '1rem' }}>
935
+ {isAutoGeneratedOnly ? (
936
+ <div>
937
+ <div
938
+ style={{
939
+ display: 'flex',
940
+ alignItems: 'center',
941
+ gap: '0.5rem',
942
+ marginBottom: '0.5rem',
943
+ }}>
944
+ <span
945
+ className={styles.label02}
946
+ style={{ fontWeight: 'bold', color: 'var(--cds-text-secondary)' }}>
947
+ {identifierType.name}
948
+ {identifierType.required && <span className={styles.required}>*</span>}
949
+ </span>
950
+ <Tag size="md" type="blue">
951
+ {t('autoGenerated', 'Auto-generated')}
952
+ </Tag>
953
+ </div>
954
+ <div
955
+ style={{
956
+ fontSize: '0.75rem',
957
+ color: 'var(--cds-text-secondary)',
958
+ fontStyle: 'italic',
959
+ }}>
960
+ {t(
961
+ 'identifierAutoGenerateInfo',
962
+ 'This identifier will be automatically generated when you save the patient.',
963
+ )}
964
+ </div>
965
+ </div>
966
+ ) : (
967
+ <Controller
968
+ name={`identifiers.${identifierType.fieldName}`}
969
+ control={control}
970
+ render={({ field }) => (
971
+ <TextInput
972
+ {...field}
973
+ id={identifierType.fieldName}
974
+ labelText={
975
+ <div
976
+ style={{
977
+ display: 'flex',
978
+ alignItems: 'center',
979
+ gap: '0.5rem',
980
+ flexWrap: 'wrap',
981
+ }}>
982
+ <span>
983
+ {identifierType.name}
984
+ {identifierType.required && <span className={styles.required}>*</span>}
985
+ </span>
986
+ {autoGeneration && (
987
+ <Tag size="md" type="blue">
988
+ {t('autoGenerated', 'Auto-generated')}
989
+ </Tag>
990
+ )}
991
+ </div>
992
+ }
993
+ placeholder={`${t('enter', 'Enter')} ${identifierType.name}`}
994
+ className={styles.inputField}
995
+ />
996
+ )}
997
+ />
998
+ )}
999
+ </div>
1000
+ );
1001
+ })}
1002
+ </>
1003
+ )}
1004
+ </div>
1005
+ )}
1006
+ <div className={styles.FormField}>
1007
+ <div className={styles.dobContentSwitcherLabel}>
1008
+ <span className={styles.label01}>{t('patientNameKnown', "Is the patient's name known?")}</span>
1009
+ </div>
1010
+ <ContentSwitcher
1011
+ className={styles.contentSwitcher}
1012
+ selectedIndex={isNameKnown ? 0 : 1}
1013
+ onChange={(e) => handleNameKnownChange(e.name === 'known')}>
1014
+ <Switch name="known" text={t('yes', 'Yes')} />
1015
+ <Switch name="unknown" text={t('no', 'No')} />
1016
+ </ContentSwitcher>
1017
+ </div>
1018
+
1019
+ {isNameKnown && (
1020
+ <div className={styles.FormField}>
1021
+ <span className={styles.label01}>
1022
+ {t('patientName', 'Patient Name')}
1023
+ <span className={styles.required}>*</span>
1024
+ </span>
1025
+ <div className={styles.nameFieldsContainer}>
1026
+ <Controller
1027
+ name="firstName"
1028
+ control={control}
1029
+ render={({ field }) => (
1030
+ <TextInput
1031
+ {...field}
1032
+ id="firstName"
1033
+ labelText={t('firstName', 'First Name')}
1034
+ placeholder={t('firstName', 'First Name')}
1035
+ className={styles.inputField}
1036
+ invalid={!!errors.firstName}
1037
+ invalidText={errors.firstName?.message}
1038
+ />
1039
+ )}
1040
+ />
1041
+ <Controller
1042
+ name="middleName"
1043
+ control={control}
1044
+ render={({ field }) => (
1045
+ <TextInput
1046
+ {...field}
1047
+ id="middleName"
1048
+ labelText={t('middleName', 'Middle Name')}
1049
+ placeholder={t('middleName', 'Middle Name')}
1050
+ className={styles.inputField}
1051
+ invalid={!!errors.middleName}
1052
+ invalidText={errors.middleName?.message}
1053
+ />
1054
+ )}
1055
+ />
1056
+ <Controller
1057
+ name="familyName"
1058
+ control={control}
1059
+ render={({ field }) => (
1060
+ <TextInput
1061
+ {...field}
1062
+ id="familyName"
1063
+ labelText={t('familyName', 'Family Name')}
1064
+ placeholder={t('familyName', 'Family Name')}
1065
+ className={styles.inputField}
1066
+ invalid={!!errors.familyName}
1067
+ invalidText={errors.familyName?.message}
1068
+ />
1069
+ )}
1070
+ />
1071
+ </div>
1072
+ </div>
1073
+ )}
1074
+
1075
+ {!isNameKnown && (
1076
+ <div className={styles.FormField}>
1077
+ <InlineNotification
1078
+ kind="info"
1079
+ lowContrast
1080
+ hideCloseButton
1081
+ title={t('unknownPatientName', 'Unknown Patient')}
1082
+ subtitle={t(
1083
+ 'unknownPatientNameDescription',
1084
+ 'Default names will be used for this patient: {{givenName}} {{familyName}}',
1085
+ {
1086
+ givenName: config?.name?.defaultUnknownGivenName || 'UNKNOWN',
1087
+ familyName: config?.name?.defaultUnknownFamilyName || 'UNKNOWN',
1088
+ },
1089
+ )}
1090
+ />
1091
+ </div>
1092
+ )}
1093
+
1094
+ <div className={styles.FormField}>
1095
+ <Controller
1096
+ name="gender"
1097
+ control={control}
1098
+ render={({ field }) => (
1099
+ <RadioButtonGroup
1100
+ name="gender"
1101
+ legendText={
1102
+ <>
1103
+ <span className={styles.label01}>
1104
+ {t('genderLabelText', 'Gender')}
1105
+ <span className={styles.required}>*</span>
1106
+ </span>
1107
+ </>
1108
+ }
1109
+ orientation="vertical"
1110
+ onChange={(value) => {
1111
+ field.onChange(value);
1112
+ trigger('gender');
1113
+ }}
1114
+ valueSelected={field.value}
1115
+ invalid={!!errors.gender}
1116
+ invalidText={errors.gender?.message}>
1117
+ {fieldConfigs?.map((option) => (
1118
+ <RadioButton
1119
+ id={`gender-option-${option.value}`}
1120
+ key={option.label ?? option.value}
1121
+ labelText={t(option.label ?? option.value, option.label ?? option.value)}
1122
+ value={option.value}
1123
+ className={styles.radioButtonOption}
1124
+ />
1125
+ ))}
1126
+ </RadioButtonGroup>
1127
+ )}
1128
+ />
1129
+ </div>
1130
+
1131
+ {maritalStatusField && (
1132
+ <div className={styles.FormField}>
1133
+ <Controller
1134
+ name="maritalStatus"
1135
+ control={control}
1136
+ render={({ field }) => (
1137
+ <ComboBox
1138
+ id="maritalStatus-combobox"
1139
+ className={styles.fullWidthCombobox}
1140
+ items={maritalStatusOptions}
1141
+ itemToString={(item) => (item ? item.display : '')}
1142
+ selectedItem={maritalStatusOptions.find((option) => option.uuid === field.value) || null}
1143
+ onChange={({ selectedItem }) => {
1144
+ field.onChange(selectedItem?.uuid || '');
1145
+ }}
1146
+ titleText={
1147
+ <>
1148
+ <span className={styles.label01}>
1149
+ {t('maritalStatusLabelText', 'Marital Status')}
1150
+ {maritalStatusField?.validation.required && <span className={styles.required}>*</span>}
1151
+ </span>
1152
+ </>
1153
+ }
1154
+ placeholder={t('selectAnOption', 'Select an option')}
1155
+ invalid={!!errors.maritalStatus}
1156
+ invalidText={errors.maritalStatus?.message}
1157
+ />
1158
+ )}
1159
+ />
1160
+ </div>
1161
+ )}
1162
+
1163
+ <div className={styles.FormField}>
1164
+ <span className={styles.label01}>
1165
+ {t('birth', 'Birth')}
1166
+ <span className={styles.required}>*</span>
1167
+ </span>
1168
+ <div className={styles.FormField}>
1169
+ <div className={styles.dobContentSwitcherLabel}>
1170
+ <span className={styles.label01}>{t('dateOfBirthKnown', 'Date of Birth Known?')}</span>
1171
+ </div>
1172
+ <ContentSwitcher
1173
+ className={styles.contentSwitcher}
1174
+ selectedIndex={isDobKnown ? 0 : 1}
1175
+ onChange={(e) => handleDobKnownChange(e.name === 'known')}>
1176
+ <Switch name="known" text={t('yes', 'Yes')} />
1177
+ <Switch name="unknown" text={t('no', 'No')} />
1178
+ </ContentSwitcher>
1179
+ </div>
1180
+
1181
+ {isDobKnown ? (
1182
+ <div className={styles.dobField}>
1183
+ <Controller
1184
+ name="birthdate"
1185
+ control={control}
1186
+ render={({ field }) => (
1187
+ <OpenmrsDatePicker
1188
+ id="birthdate"
1189
+ data-testid="birthdate"
1190
+ {...field}
1191
+ onChange={(date) => {
1192
+ field.onChange(date);
1193
+ trigger('birthdate');
1194
+ }}
1195
+ maxDate={today}
1196
+ labelText={t('dateOfBirthLabelText', 'Date of birth')}
1197
+ isInvalid={!!errors.birthdate}
1198
+ invalidText={errors.birthdate?.message}
1199
+ value={field.value}
1200
+ />
1201
+ )}
1202
+ />
1203
+ </div>
1204
+ ) : (
1205
+ <div className={styles.grid}>
1206
+ <div className={styles.dobField}>
1207
+ <Controller
1208
+ name="yearsEstimated"
1209
+ control={control}
1210
+ render={({ field }) => (
1211
+ <TextInput
1212
+ {...field}
1213
+ id="yearsEstimated"
1214
+ type="number"
1215
+ onChange={(e) => {
1216
+ const value = e.target.value ? parseInt(e.target.value, 10) : undefined;
1217
+ field.onChange(value);
1218
+ }}
1219
+ value={field.value ?? ''}
1220
+ labelText={t('estimatedAgeInYearsLabelText', 'Estimated age in years')}
1221
+ invalid={!!errors.yearsEstimated}
1222
+ invalidText={errors.yearsEstimated?.message}
1223
+ min={0}
1224
+ />
1225
+ )}
1226
+ />
1227
+ </div>
1228
+ <div className={styles.dobField}>
1229
+ <Controller
1230
+ name="monthsEstimated"
1231
+ control={control}
1232
+ render={({ field }) => (
1233
+ <TextInput
1234
+ {...field}
1235
+ id="monthsEstimated"
1236
+ type="number"
1237
+ onChange={(e) => {
1238
+ const value = e.target.value ? parseInt(e.target.value, 10) : undefined;
1239
+ field.onChange(value);
1240
+ }}
1241
+ value={field.value ?? ''}
1242
+ labelText={t('estimatedAgeInMonthsLabelText', 'Estimated age in months')}
1243
+ invalid={!!errors.monthsEstimated}
1244
+ invalidText={errors.monthsEstimated?.message}
1245
+ min={0}
1246
+ max={11}
1247
+ />
1248
+ )}
1249
+ />
1250
+ </div>
1251
+ </div>
1252
+ )}
1253
+ </div>
1254
+ </section>
1255
+
1256
+ <section className={styles.ContactDetailsSection}>
1257
+ <h2 className={styles.SectionTitle}>{t('contactDetails', '2. Contact Details')}</h2>
1258
+
1259
+ <div className={styles.FormField}>
1260
+ <span className={styles.label01}>
1261
+ {t('address', 'Address')}
1262
+ <span className={styles.required}>*</span>
1263
+ </span>
1264
+
1265
+ <div className={styles.addressFieldsContainer}>
1266
+ <Controller
1267
+ name="county"
1268
+ control={control}
1269
+ render={({ field }) => (
1270
+ <ComboBox
1271
+ id="county-combobox"
1272
+ items={counties}
1273
+ itemToString={(item) => (item ? item.name : '')}
1274
+ selectedItem={selectedCounty}
1275
+ onChange={({ selectedItem }) => {
1276
+ setSelectedCounty(selectedItem);
1277
+ setSelectedSubCounty(null);
1278
+ setSelectedWard(null);
1279
+ field.onChange(selectedItem?.name || '');
1280
+ setValue('countyUuid', selectedItem?.code || '');
1281
+ setValue('subCounty', '');
1282
+ setValue('subCountyUuid', '');
1283
+ setValue('ward', '');
1284
+ setValue('wardUuid', '');
1285
+ trigger('county');
1286
+ }}
1287
+ titleText={t('county', 'County')}
1288
+ placeholder={t('selectCounty', 'Select a county')}
1289
+ invalid={!!errors.county}
1290
+ invalidText={errors.county?.message}
1291
+ />
1292
+ )}
1293
+ />
1294
+ <Controller
1295
+ name="subCounty"
1296
+ control={control}
1297
+ render={({ field }) => (
1298
+ <ComboBox
1299
+ id="subcounty-combobox"
1300
+ items={subCounties}
1301
+ itemToString={(item) => (item ? item.name : '')}
1302
+ selectedItem={selectedSubCounty}
1303
+ onChange={({ selectedItem }) => {
1304
+ setSelectedSubCounty(selectedItem);
1305
+ setSelectedWard(null);
1306
+ field.onChange(selectedItem?.name || '');
1307
+ setValue('subCountyUuid', selectedItem?.code || '');
1308
+ setValue('ward', '');
1309
+ setValue('wardUuid', '');
1310
+ trigger('subCounty');
1311
+ }}
1312
+ titleText={t('subCounty', 'Sub county')}
1313
+ placeholder={t('selectSubCounty', 'Select a sub county')}
1314
+ disabled={!selectedCounty}
1315
+ invalid={!!errors.subCounty}
1316
+ invalidText={errors.subCounty?.message}
1317
+ />
1318
+ )}
1319
+ />
1320
+ <Controller
1321
+ name="ward"
1322
+ control={control}
1323
+ render={({ field }) => (
1324
+ <ComboBox
1325
+ id="ward-combobox"
1326
+ items={wards}
1327
+ itemToString={(item) => (item ? item.name : '')}
1328
+ selectedItem={selectedWard}
1329
+ onChange={({ selectedItem }) => {
1330
+ setSelectedWard(selectedItem);
1331
+ field.onChange(selectedItem?.name || '');
1332
+ setValue('wardUuid', selectedItem?.code || '');
1333
+ trigger('ward');
1334
+ }}
1335
+ titleText={t('ward', 'Ward')}
1336
+ placeholder={t('selectWard', 'Select a ward')}
1337
+ disabled={!selectedCounty}
1338
+ invalid={!!errors.ward}
1339
+ invalidText={errors.ward?.message}
1340
+ />
1341
+ )}
1342
+ />
1343
+ </div>
1344
+ </div>
1345
+
1346
+ <div className={styles.contactFieldsContainer}>
1347
+ <Controller
1348
+ name="phoneNumber"
1349
+ control={control}
1350
+ render={({ field }) => (
1351
+ <TextInput
1352
+ {...field}
1353
+ id="phoneNumber"
1354
+ labelText={
1355
+ <>
1356
+ {t('phoneNumber', 'Phone Number')}
1357
+ <span className={styles.optional}> ({t('optional', 'Optional')})</span>
1358
+ </>
1359
+ }
1360
+ placeholder={t('phoneNumberPlaceholder', 'e.g., 0712345678')}
1361
+ className={styles.inputField}
1362
+ invalid={!!errors.phoneNumber}
1363
+ invalidText={errors.phoneNumber?.message}
1364
+ />
1365
+ )}
1366
+ />
1367
+ <Controller
1368
+ name="email"
1369
+ control={control}
1370
+ render={({ field }) => (
1371
+ <TextInput
1372
+ {...field}
1373
+ id="email"
1374
+ type="email"
1375
+ labelText={
1376
+ <>
1377
+ {t('email', 'Email Address')}
1378
+ <span className={styles.optional}> ({t('optional', 'Optional')})</span>
1379
+ </>
1380
+ }
1381
+ placeholder={t('emailPlaceholder', 'joedoe@email.com')}
1382
+ className={styles.inputField}
1383
+ invalid={!!errors.email}
1384
+ invalidText={errors.email?.message}
1385
+ />
1386
+ )}
1387
+ />
1388
+ </div>
1389
+ </section>
1390
+
1391
+ <section className={styles.IsDeadSection}>
1392
+ <h2 className={styles.SectionTitle}>{t('deathInformation', '3. Death Information')}</h2>
1393
+
1394
+ <div className={styles.FormField}>
1395
+ <div className={styles.dobContentSwitcherLabel}>
1396
+ <span className={styles.label01}>{t('isPatientDeceased', 'Is the patient deceased?')}</span>
1397
+ </div>
1398
+ <ContentSwitcher
1399
+ className={styles.contentSwitcher}
1400
+ selectedIndex={isDead ? 0 : 1}
1401
+ onChange={(e) => handleIsDeadChange(e.name === 'dead')}>
1402
+ <Switch name="dead" text={t('yes', 'Yes')} />
1403
+ <Switch name="alive" text={t('no', 'No')} />
1404
+ </ContentSwitcher>
1405
+ </div>
1406
+
1407
+ {isDead && (
1408
+ <>
1409
+ <div className={styles.FormField}>
1410
+ <Controller
1411
+ name="deathDate"
1412
+ control={control}
1413
+ render={({ field }) => (
1414
+ <OpenmrsDatePicker
1415
+ id="deathDate"
1416
+ {...field}
1417
+ onChange={(date) => {
1418
+ field.onChange(date);
1419
+ trigger('deathDate');
1420
+ }}
1421
+ maxDate={today}
1422
+ labelText={
1423
+ <>
1424
+ {t('deathDate', 'Date of Death')}
1425
+ <span className={styles.required}>*</span>
1426
+ </>
1427
+ }
1428
+ isInvalid={!!errors.deathDate}
1429
+ invalidText={errors.deathDate?.message}
1430
+ value={field.value}
1431
+ />
1432
+ )}
1433
+ />
1434
+ </div>
1435
+ <div className={styles.FormField}>
1436
+ <Controller
1437
+ name="deathCause"
1438
+ control={control}
1439
+ render={({ field }) => (
1440
+ <ComboBox
1441
+ id="deathCause-combobox"
1442
+ items={answers}
1443
+ itemToString={(item) => (item ? item.display : '')}
1444
+ selectedItem={answers.find((answer) => answer.uuid === field.value) || null}
1445
+ onChange={({ selectedItem }) => {
1446
+ field.onChange(selectedItem?.uuid || '');
1447
+ if (selectedItem?.uuid !== freeTextFieldConceptUuid) {
1448
+ setValue('nonCodedCauseOfDeath', '');
1449
+ }
1450
+ trigger('deathCause');
1451
+ }}
1452
+ titleText={
1453
+ <>
1454
+ {t('causeOfDeathInputLabel', 'Cause of Death')}
1455
+ <span className={styles.required}>*</span>
1456
+ </>
1457
+ }
1458
+ placeholder={
1459
+ isLoadingConceptAnswers ? t('loading', 'Loading...') : t('selectAnOption', 'Select an option')
1460
+ }
1461
+ disabled={isLoadingConceptAnswers || answers.length === 0}
1462
+ invalid={!!errors.deathCause}
1463
+ invalidText={errors.deathCause?.message}
1464
+ className={styles.causeOfDeathField}
1465
+ />
1466
+ )}
1467
+ />
1468
+
1469
+ {errorLoadingConceptAnswers && (
1470
+ <InlineNotification
1471
+ kind="error"
1472
+ title={t('error', 'Error')}
1473
+ subtitle={t('failedToLoadCauseOfDeath', 'Failed to load cause of death options')}
1474
+ />
1475
+ )}
1476
+
1477
+ {!isLoadingConceptAnswers && answers.length === 0 && !errorLoadingConceptAnswers && (
1478
+ <div className={styles.warningText}>
1479
+ {t(
1480
+ 'noCauseOfDeathOptions',
1481
+ 'No cause of death options available. Please configure the concept UUID.',
1482
+ )}
1483
+ </div>
1484
+ )}
1485
+ </div>
1486
+
1487
+ {watch('deathCause') === freeTextFieldConceptUuid && (
1488
+ <div className={styles.FormField}>
1489
+ <Controller
1490
+ name="nonCodedCauseOfDeath"
1491
+ control={control}
1492
+ render={({ field }) => (
1493
+ <TextArea
1494
+ {...field}
1495
+ id="nonCodedCauseOfDeath"
1496
+ labelText={
1497
+ <>
1498
+ {t('nonCodedCauseOfDeath', 'Non-coded cause of death')}
1499
+ <span className={styles.required}>*</span>
1500
+ </>
1501
+ }
1502
+ placeholder={t('enterNonCodedCauseOfDeath', 'Enter non-coded cause of death')}
1503
+ className={styles.inputField}
1504
+ invalid={!!errors.nonCodedCauseOfDeath}
1505
+ invalidText={errors.nonCodedCauseOfDeath?.message}
1506
+ rows={4}
1507
+ />
1508
+ )}
1509
+ />
1510
+ </div>
1511
+ )}
1512
+ </>
1513
+ )}
1514
+ </section>
1515
+
1516
+ <section className={styles.RelationshipSection}>
1517
+ <h2 className={styles.SectionTitle}>{t('relationshipDetails', '4. Relationship')}</h2>
1518
+ {isLoadingRelationshipTypes ? (
1519
+ <div aria-label="Loading relationships section">
1520
+ <SkeletonText />
1521
+ </div>
1522
+ ) : errorLoadingRelationshipTypes ? (
1523
+ <InlineNotification
1524
+ kind="error"
1525
+ title={t('error', 'Error')}
1526
+ subtitle={t('failedToLoadRelationshipTypes', 'Failed to load relationship types')}
1527
+ />
1528
+ ) : (
1529
+ <div>
1530
+ {relationships && relationships.length > 0
1531
+ ? relationships.map((relationship: RelationshipValue, index) => (
1532
+ <div key={index} style={{ marginBottom: '1rem' }}>
1533
+ <RelationshipView
1534
+ relationship={relationship}
1535
+ index={index}
1536
+ displayRelationshipTypes={displayRelationshipTypes}
1537
+ onUpdate={handleUpdateRelationship}
1538
+ onDelete={handleDeleteRelationship}
1539
+ onRestore={handleRestoreRelationship}
1540
+ />
1541
+ </div>
1542
+ ))
1543
+ : null}
1544
+ <div style={{ marginTop: '1rem' }}>
1545
+ <Button kind="ghost" onClick={handleAddRelationship}>
1546
+ {t('addRelationshipButtonText', 'Add Relationship')}
1547
+ </Button>
1548
+ </div>
1549
+ </div>
1550
+ )}
1551
+ </section>
1552
+
1553
+ <div className={styles.FormActions}>
1554
+ <Button kind="secondary" onClick={handleBackToSearch}>
1555
+ {t('cancel', 'Cancel')}
1556
+ </Button>
1557
+ <Button type="submit" disabled={isSubmitting}>
1558
+ {isSubmitting ? t('submitting', 'Submitting...') : t('registerPatient', 'Register Patient')}
1559
+ </Button>
1560
+ </div>
1561
+ </div>
1562
+ </form>
1563
+ </div>
1564
+ );
1565
+ };