@kenyaemr/esm-pcs-app 5.4.5-pre.1092

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 (286) hide show
  1. package/.turbo/turbo-build.log +6 -0
  2. package/dist/1072.js +1 -0
  3. package/dist/1072.js.map +1 -0
  4. package/dist/1102.js +1 -0
  5. package/dist/1102.js.map +1 -0
  6. package/dist/1105.js +1 -0
  7. package/dist/1105.js.map +1 -0
  8. package/dist/1148.js +1 -0
  9. package/dist/1148.js.map +1 -0
  10. package/dist/1278.js +1 -0
  11. package/dist/1278.js.map +1 -0
  12. package/dist/1442.js +1 -0
  13. package/dist/1442.js.map +1 -0
  14. package/dist/15.js +1 -0
  15. package/dist/15.js.map +1 -0
  16. package/dist/1620.js +1 -0
  17. package/dist/1620.js.map +1 -0
  18. package/dist/1789.js +1 -0
  19. package/dist/1789.js.map +1 -0
  20. package/dist/1818.js +1 -0
  21. package/dist/1818.js.map +1 -0
  22. package/dist/1831.js +1 -0
  23. package/dist/1831.js.map +1 -0
  24. package/dist/1844.js +1 -0
  25. package/dist/1844.js.map +1 -0
  26. package/dist/1854.js +1 -0
  27. package/dist/1854.js.map +1 -0
  28. package/dist/1946.js +1 -0
  29. package/dist/1946.js.map +1 -0
  30. package/dist/2007.js +1 -0
  31. package/dist/2007.js.map +1 -0
  32. package/dist/2115.js +1 -0
  33. package/dist/2115.js.map +1 -0
  34. package/dist/2152.js +1 -0
  35. package/dist/2152.js.map +1 -0
  36. package/dist/2202.js +1 -0
  37. package/dist/2202.js.map +1 -0
  38. package/dist/2394.js +1 -0
  39. package/dist/2394.js.map +1 -0
  40. package/dist/2530.js +1 -0
  41. package/dist/2530.js.map +1 -0
  42. package/dist/2534.js +1 -0
  43. package/dist/2534.js.map +1 -0
  44. package/dist/2537.js +1 -0
  45. package/dist/2537.js.map +1 -0
  46. package/dist/2556.js +1 -0
  47. package/dist/2556.js.map +1 -0
  48. package/dist/2648.js +1 -0
  49. package/dist/2648.js.map +1 -0
  50. package/dist/2795.js +1 -0
  51. package/dist/2795.js.map +1 -0
  52. package/dist/2827.js +1 -0
  53. package/dist/2827.js.map +1 -0
  54. package/dist/2916.js +1 -0
  55. package/dist/2916.js.map +1 -0
  56. package/dist/2924.js +1 -0
  57. package/dist/2924.js.map +1 -0
  58. package/dist/2947.js +1 -0
  59. package/dist/2947.js.map +1 -0
  60. package/dist/3022.js +1 -0
  61. package/dist/3022.js.map +1 -0
  62. package/dist/3285.js +1 -0
  63. package/dist/3285.js.map +1 -0
  64. package/dist/3335.js +1 -0
  65. package/dist/3335.js.map +1 -0
  66. package/dist/3378.js +1 -0
  67. package/dist/3378.js.map +1 -0
  68. package/dist/3381.js +1 -0
  69. package/dist/3381.js.map +1 -0
  70. package/dist/3504.js +1 -0
  71. package/dist/3504.js.map +1 -0
  72. package/dist/3530.js +1 -0
  73. package/dist/3530.js.map +1 -0
  74. package/dist/356.js +1 -0
  75. package/dist/356.js.map +1 -0
  76. package/dist/3569.js +1 -0
  77. package/dist/3569.js.map +1 -0
  78. package/dist/3723.js +1 -0
  79. package/dist/3723.js.map +1 -0
  80. package/dist/3745.js +1 -0
  81. package/dist/3745.js.map +1 -0
  82. package/dist/3813.js +1 -0
  83. package/dist/3813.js.map +1 -0
  84. package/dist/393.js +6 -0
  85. package/dist/393.js.map +1 -0
  86. package/dist/3953.js +1 -0
  87. package/dist/3953.js.map +1 -0
  88. package/dist/3989.js +1 -0
  89. package/dist/3989.js.map +1 -0
  90. package/dist/412.js +1 -0
  91. package/dist/412.js.map +1 -0
  92. package/dist/4178.js +1 -0
  93. package/dist/4178.js.map +1 -0
  94. package/dist/4413.js +1 -0
  95. package/dist/4413.js.map +1 -0
  96. package/dist/4487.js +11 -0
  97. package/dist/4487.js.map +1 -0
  98. package/dist/4537.js +1 -0
  99. package/dist/4537.js.map +1 -0
  100. package/dist/4588.js +1 -0
  101. package/dist/4588.js.map +1 -0
  102. package/dist/4637.js +1 -0
  103. package/dist/4637.js.map +1 -0
  104. package/dist/4650.js +1 -0
  105. package/dist/4650.js.map +1 -0
  106. package/dist/466.js +1 -0
  107. package/dist/466.js.map +1 -0
  108. package/dist/4817.js +1 -0
  109. package/dist/4817.js.map +1 -0
  110. package/dist/4818.js +1 -0
  111. package/dist/4818.js.map +1 -0
  112. package/dist/500.js +1 -0
  113. package/dist/500.js.map +1 -0
  114. package/dist/5086.js +1 -0
  115. package/dist/5086.js.map +1 -0
  116. package/dist/5117.js +1 -0
  117. package/dist/5117.js.map +1 -0
  118. package/dist/5279.js +1 -0
  119. package/dist/5279.js.map +1 -0
  120. package/dist/5366.js +1 -0
  121. package/dist/5366.js.map +1 -0
  122. package/dist/5598.js +1 -0
  123. package/dist/5598.js.map +1 -0
  124. package/dist/5676.js +1 -0
  125. package/dist/5676.js.map +1 -0
  126. package/dist/5731.js +5 -0
  127. package/dist/5731.js.map +1 -0
  128. package/dist/5792.js +1 -0
  129. package/dist/5792.js.map +1 -0
  130. package/dist/5825.js +1 -0
  131. package/dist/5825.js.map +1 -0
  132. package/dist/5932.js +1 -0
  133. package/dist/5932.js.map +1 -0
  134. package/dist/5941.js +1 -0
  135. package/dist/5941.js.map +1 -0
  136. package/dist/5967.js +1 -0
  137. package/dist/5967.js.map +1 -0
  138. package/dist/6104.js +1 -0
  139. package/dist/6104.js.map +1 -0
  140. package/dist/624.js +1 -0
  141. package/dist/624.js.map +1 -0
  142. package/dist/6259.js +1 -0
  143. package/dist/6259.js.map +1 -0
  144. package/dist/6273.js +1 -0
  145. package/dist/6273.js.map +1 -0
  146. package/dist/6337.js +1 -0
  147. package/dist/6337.js.map +1 -0
  148. package/dist/6427.js +1 -0
  149. package/dist/6427.js.map +1 -0
  150. package/dist/6478.js +7 -0
  151. package/dist/6478.js.map +1 -0
  152. package/dist/6508.js +1 -0
  153. package/dist/6508.js.map +1 -0
  154. package/dist/6561.js +1 -0
  155. package/dist/6561.js.map +1 -0
  156. package/dist/6566.js +1 -0
  157. package/dist/6566.js.map +1 -0
  158. package/dist/6567.js +1 -0
  159. package/dist/6567.js.map +1 -0
  160. package/dist/6634.js +1 -0
  161. package/dist/6634.js.map +1 -0
  162. package/dist/7022.js +1 -0
  163. package/dist/7022.js.map +1 -0
  164. package/dist/7085.js +1 -0
  165. package/dist/7085.js.map +1 -0
  166. package/dist/7209.js +1 -0
  167. package/dist/7209.js.map +1 -0
  168. package/dist/7291.js +1 -0
  169. package/dist/7291.js.map +1 -0
  170. package/dist/7360.js +1 -0
  171. package/dist/7360.js.map +1 -0
  172. package/dist/7453.js +1 -0
  173. package/dist/7453.js.map +1 -0
  174. package/dist/7454.js +1 -0
  175. package/dist/7454.js.map +1 -0
  176. package/dist/7478.js +1 -0
  177. package/dist/7478.js.map +1 -0
  178. package/dist/7778.js +1 -0
  179. package/dist/7778.js.map +1 -0
  180. package/dist/7782.js +1 -0
  181. package/dist/7782.js.map +1 -0
  182. package/dist/8028.js +1 -0
  183. package/dist/8028.js.map +1 -0
  184. package/dist/8031.js +1 -0
  185. package/dist/8031.js.map +1 -0
  186. package/dist/8196.js +1 -0
  187. package/dist/8196.js.map +1 -0
  188. package/dist/820.js +1 -0
  189. package/dist/820.js.map +1 -0
  190. package/dist/8240.js +1 -0
  191. package/dist/8240.js.map +1 -0
  192. package/dist/8472.js +1 -0
  193. package/dist/8472.js.map +1 -0
  194. package/dist/8607.js +1 -0
  195. package/dist/8607.js.map +1 -0
  196. package/dist/8793.js +1 -0
  197. package/dist/8793.js.map +1 -0
  198. package/dist/8924.js +1 -0
  199. package/dist/8924.js.map +1 -0
  200. package/dist/8990.js +1 -0
  201. package/dist/8990.js.map +1 -0
  202. package/dist/9060.js +1 -0
  203. package/dist/9060.js.map +1 -0
  204. package/dist/9061.js +1 -0
  205. package/dist/9061.js.map +1 -0
  206. package/dist/9087.js +1 -0
  207. package/dist/9087.js.map +1 -0
  208. package/dist/9165.js +1 -0
  209. package/dist/9165.js.map +1 -0
  210. package/dist/9274.js +1 -0
  211. package/dist/9274.js.map +1 -0
  212. package/dist/9425.js +1 -0
  213. package/dist/9520.js +1 -0
  214. package/dist/9520.js.map +1 -0
  215. package/dist/9547.js +1 -0
  216. package/dist/9547.js.map +1 -0
  217. package/dist/9612.js +1 -0
  218. package/dist/9612.js.map +1 -0
  219. package/dist/9771.js +1 -0
  220. package/dist/9771.js.map +1 -0
  221. package/dist/9774.js +1 -0
  222. package/dist/9774.js.map +1 -0
  223. package/dist/9806.js +1 -0
  224. package/dist/9806.js.map +1 -0
  225. package/dist/9885.js +1 -0
  226. package/dist/9885.js.map +1 -0
  227. package/dist/992.js +1 -0
  228. package/dist/992.js.map +1 -0
  229. package/dist/9941.js +1 -0
  230. package/dist/9941.js.map +1 -0
  231. package/dist/997.js +1 -0
  232. package/dist/997.js.map +1 -0
  233. package/dist/kenyaemr-esm-pcs-app.js +3 -0
  234. package/dist/kenyaemr-esm-pcs-app.js.buildmanifest.json +3173 -0
  235. package/dist/kenyaemr-esm-pcs-app.js.map +1 -0
  236. package/dist/main.js +3 -0
  237. package/dist/main.js.map +1 -0
  238. package/dist/routes.json +1 -0
  239. package/package.json +50 -0
  240. package/rspack.config.js +1 -0
  241. package/src/add-dependant/add-dependant.scss +29 -0
  242. package/src/add-dependant/add-dependant.workspace.test.ts +65 -0
  243. package/src/add-dependant/add-dependant.workspace.tsx +249 -0
  244. package/src/config-schema.ts +67 -0
  245. package/src/declarations.d.ts +2 -0
  246. package/src/empty-svg.component.tsx +565 -0
  247. package/src/index.ts +34 -0
  248. package/src/link-hie-dependant/link-hie-dependant.scss +55 -0
  249. package/src/link-hie-dependant/link-hie-dependant.workspace.tsx +207 -0
  250. package/src/linked/dependant-row.component.test.tsx +134 -0
  251. package/src/linked/dependant-row.component.tsx +122 -0
  252. package/src/linked/dependants-list.component.tsx +60 -0
  253. package/src/linked/linked-participant-body.component.tsx +82 -0
  254. package/src/linked/linked-participant.component.test.tsx +134 -0
  255. package/src/linked/linked-participant.component.tsx +142 -0
  256. package/src/linked/participant-details.component.tsx +89 -0
  257. package/src/linked/use-study-sync-snackbars.ts +41 -0
  258. package/src/modals/delink-participant.modal.tsx +96 -0
  259. package/src/modals/link-dependant.modal.tsx +244 -0
  260. package/src/modals/link-participant.modal.tsx +143 -0
  261. package/src/modals/link-participant.scss +48 -0
  262. package/src/pcs.scss +241 -0
  263. package/src/resources/hie-contacts.ts +61 -0
  264. package/src/resources/identifiers.ts +52 -0
  265. package/src/resources/link-dependant.resource.test.ts +445 -0
  266. package/src/resources/link-dependant.resource.ts +268 -0
  267. package/src/resources/link-participant.resource.test.ts +336 -0
  268. package/src/resources/link-participant.resource.ts +452 -0
  269. package/src/resources/patient-create.resource.ts +296 -0
  270. package/src/resources/patient-search.resource.ts +106 -0
  271. package/src/resources/pcs.resource.test.ts +36 -0
  272. package/src/resources/pcs.resource.ts +182 -0
  273. package/src/resources/use-identifier-type-uuids.ts +38 -0
  274. package/src/routes.json +54 -0
  275. package/src/search/participant-results.component.tsx +96 -0
  276. package/src/search/participant-tile.component.tsx +133 -0
  277. package/src/search/pcs-empty-state.component.tsx +31 -0
  278. package/src/search/pcs-results-skeleton.component.tsx +21 -0
  279. package/src/search/pcs-search.component.test.tsx +169 -0
  280. package/src/search/pcs-search.component.tsx +228 -0
  281. package/src/types/index.ts +140 -0
  282. package/translations/am.json +120 -0
  283. package/translations/en.json +120 -0
  284. package/translations/fr.json +122 -0
  285. package/translations/sw.json +120 -0
  286. package/tsconfig.json +5 -0
@@ -0,0 +1,207 @@
1
+ import React, { useEffect, useMemo, useState } from 'react';
2
+ import { useTranslation } from 'react-i18next';
3
+ import { Controller, useForm } from 'react-hook-form';
4
+ import { zodResolver } from '@hookform/resolvers/zod';
5
+ import { z } from 'zod';
6
+ import {
7
+ Button,
8
+ ButtonSet,
9
+ Form,
10
+ InlineLoading,
11
+ InlineNotification,
12
+ RadioButton,
13
+ RadioButtonGroup,
14
+ } from '@carbon/react';
15
+ import { showSnackbar, useConfig, Workspace2, type Workspace2DefinitionProps } from '@openmrs/esm-framework';
16
+
17
+ import { type PcsConfig } from '../config-schema';
18
+ import { useIdentifierTypeUuids } from '../resources/use-identifier-type-uuids';
19
+ import { getDependentsFromContacts } from '../resources/hie-contacts';
20
+ import { linkHieDependantWithTemporaryId, useHieDependantLinkState } from '../resources/link-dependant.resource';
21
+ import styles from './link-hie-dependant.scss';
22
+
23
+ export interface LinkHieDependantWorkspaceProps {
24
+ /** The mother's PCS individual ID — the new participant is created against it. */
25
+ motherIndividualId: string;
26
+ /** The mother's HIE record; her `contact` array is the candidate list. */
27
+ hiePatient?: fhir.Patient;
28
+ parentPhoneNumber?: string;
29
+ onLinked?: () => void;
30
+ }
31
+
32
+ const schema = z.object({
33
+ dependantId: z.string().min(1),
34
+ });
35
+
36
+ type LinkHieDependantFormData = z.infer<typeof schema>;
37
+
38
+ const LinkHieDependantWorkspace: React.FC<Workspace2DefinitionProps<LinkHieDependantWorkspaceProps, {}, {}>> = ({
39
+ closeWorkspace,
40
+ workspaceProps,
41
+ }) => {
42
+ const { t } = useTranslation();
43
+ const uuids = useIdentifierTypeUuids();
44
+
45
+ const { pcsIdentifiers } = useConfig<PcsConfig>();
46
+ const { motherIndividualId, hiePatient, parentPhoneNumber, onLinked } = workspaceProps ?? {};
47
+ const [submitError, setSubmitError] = useState<string | null>(null);
48
+
49
+ // Read straight off the record already in hand — no request.
50
+ const candidates = useMemo(() => (hiePatient ? getDependentsFromContacts(hiePatient as any) : []), [hiePatient]);
51
+
52
+ // A child already holding either study identifier is in PCS, so this flow does not apply to
53
+ // her — she is shown with the ID she holds rather than hidden, so an operator hunting for her
54
+ // can tell "already linked" apart from "not in the HIE at all".
55
+ const { linkedById, isChecking } = useHieDependantLinkState(candidates, [
56
+ pcsIdentifiers.studyParticipantID,
57
+ pcsIdentifiers.studyTemporaryParticipantID,
58
+ ]);
59
+
60
+ const {
61
+ control,
62
+ handleSubmit,
63
+ formState: { isSubmitting },
64
+ } = useForm<LinkHieDependantFormData>({
65
+ resolver: zodResolver(schema),
66
+ defaultValues: { dependantId: '' },
67
+ });
68
+
69
+ const onSubmit = async ({ dependantId }: LinkHieDependantFormData) => {
70
+ const dependant = candidates.find((candidate) => candidate.id === dependantId);
71
+ if (!dependant || !motherIndividualId) {
72
+ return;
73
+ }
74
+
75
+ setSubmitError(null);
76
+ try {
77
+ const { participant } = await linkHieDependantWithTemporaryId({
78
+ dependant,
79
+ parentPhoneNumber,
80
+ motherIndividualId,
81
+ uuids,
82
+ });
83
+
84
+ showSnackbar({
85
+ title: t('dependantLinkedToPcs', 'Dependant added to PCS'),
86
+ subtitle: participant?.individualId
87
+ ? t('dependantAddedSubtitle', 'PCS issued {{individualId}} for this dependant.', {
88
+ individualId: participant.individualId,
89
+ })
90
+ : undefined,
91
+ kind: 'success',
92
+ isLowContrast: true,
93
+ });
94
+
95
+ onLinked?.();
96
+ closeWorkspace();
97
+ } catch (e: any) {
98
+ // A child linked elsewhere since the list rendered comes back as a 409 — worth showing
99
+ // the module's own wording rather than a generic failure.
100
+ setSubmitError(
101
+ e?.responseBody?.error?.message ??
102
+ e?.message ??
103
+ t('dependantLinkToPcsFailed', 'The dependant could not be added to PCS.'),
104
+ );
105
+ }
106
+ };
107
+
108
+ if (!workspaceProps) {
109
+ return null;
110
+ }
111
+
112
+ return (
113
+ <Workspace2 title={t('dependantInHieNotPcs', 'Dependant in HIE and not PCS?')}>
114
+ <Form onSubmit={handleSubmit(onSubmit)} className={styles.workspaceForm}>
115
+ <div className={styles.workspaceContent}>
116
+ {candidates.length === 0 ? (
117
+ <div className={styles.emptyState}>
118
+ <p className={styles.emptyTitle}>
119
+ {t('noHieDependants', 'The HIE record lists no dependants for this mother.')}
120
+ </p>
121
+ <p className={styles.emptySubtitle}>
122
+ {t(
123
+ 'noHieDependantsSubtitle',
124
+ 'Use "Dependant not in HIE and PCS?" to register the child from their own details.',
125
+ )}
126
+ </p>
127
+ </div>
128
+ ) : (
129
+ <>
130
+ <p className={styles.prompt}>
131
+ {t('chooseDependantToAdd', 'Choose the dependant to add to PCS. One at a time.')}
132
+ </p>
133
+
134
+ <Controller
135
+ name="dependantId"
136
+ control={control}
137
+ render={({ field: { onChange, value } }) => (
138
+ <RadioButtonGroup
139
+ name="hieDependant"
140
+ orientation="vertical"
141
+ legendText={t('mothersDependantsInHie', "Mother's dependants in the HIE")}
142
+ valueSelected={value ?? ''}
143
+ // Load-bearing: without it Carbon's internal state ignores a programmatic
144
+ // change to the selected value.
145
+ key={value ?? 'empty'}
146
+ onChange={(selected) => onChange(String(selected))}>
147
+ {candidates.map((candidate) => {
148
+ const linkedTo = linkedById[candidate.id];
149
+
150
+ return (
151
+ <RadioButton
152
+ key={candidate.id}
153
+ value={candidate.id}
154
+ // Disabled while the check runs too, so nothing can be picked in the
155
+ // window before its state is known.
156
+ disabled={isChecking || Boolean(linkedTo)}
157
+ labelText={`${candidate.name} · ${candidate.relationship} · ${candidate.gender}${
158
+ candidate.birthDate && candidate.birthDate !== 'Unknown' ? ` · ${candidate.birthDate}` : ''
159
+ }${
160
+ linkedTo
161
+ ? ` — ${t('alreadyLinkedTo', 'already linked to {{individualId}}', {
162
+ individualId: linkedTo,
163
+ })}`
164
+ : ''
165
+ }`}
166
+ />
167
+ );
168
+ })}
169
+ </RadioButtonGroup>
170
+ )}
171
+ />
172
+
173
+ {isChecking && (
174
+ <InlineLoading
175
+ className={styles.checking}
176
+ description={t('checkingPcsLinks', 'Checking which dependants are already in PCS...')}
177
+ />
178
+ )}
179
+ </>
180
+ )}
181
+
182
+ {submitError && (
183
+ <InlineNotification
184
+ className={styles.notification}
185
+ kind="error"
186
+ lowContrast
187
+ hideCloseButton
188
+ title={t('dependantLinkToPcsFailedTitle', 'Could not add dependant to PCS')}
189
+ subtitle={submitError}
190
+ />
191
+ )}
192
+ </div>
193
+
194
+ <ButtonSet className={styles.workspaceButtonSet}>
195
+ <Button kind="secondary" onClick={() => closeWorkspace()}>
196
+ {t('cancel', 'Cancel')}
197
+ </Button>
198
+ <Button kind="primary" type="submit" disabled={isSubmitting || candidates.length === 0}>
199
+ {isSubmitting ? <InlineLoading description={t('addingDependant', 'Adding...')} /> : t('save', 'Save')}
200
+ </Button>
201
+ </ButtonSet>
202
+ </Form>
203
+ </Workspace2>
204
+ );
205
+ };
206
+
207
+ export default LinkHieDependantWorkspace;
@@ -0,0 +1,134 @@
1
+ import React from 'react';
2
+ import { render, screen } from '@testing-library/react';
3
+ import '@testing-library/jest-dom';
4
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
5
+ import DependantRow from './dependant-row.component';
6
+ import { useLinkedPatientForParticipant, useSyncStudyAttributes } from '../resources/link-participant.resource';
7
+ import { type PcsParticipant } from '../types';
8
+
9
+ vi.mock('@openmrs/esm-framework', async (importOriginal) => ({
10
+ ...(await importOriginal<typeof import('@openmrs/esm-framework')>()),
11
+ age: () => '14 yrs',
12
+ useConfig: () => ({
13
+ pcsIdentifiers: { studyParticipantID: 'study-id-type', studyTemporaryParticipantID: 'temp-id-type' },
14
+ pcsAttributeTypes: { pbidsEnrollmentStatus: 'pbids-attr', cardseEnrollmentStatus: 'cardse-attr' },
15
+ }),
16
+ }));
17
+
18
+ vi.mock('react-i18next', () => ({
19
+ useTranslation: () => ({ t: (_key: string, fallback: string) => fallback }),
20
+ }));
21
+
22
+ vi.mock('../resources/link-participant.resource', () => ({
23
+ useLinkedPatientForParticipant: vi.fn(),
24
+ useSyncStudyAttributes: vi.fn(),
25
+ }));
26
+
27
+ vi.mock('./use-study-sync-snackbars', () => ({
28
+ useStudySyncSnackbars: () => ({ onSynced: vi.fn(), onSyncError: vi.fn() }),
29
+ }));
30
+
31
+ vi.mock('../resources/pcs.resource', () => ({ formatParticipantName: () => 'DENNIS OMONDI ODONGO' }));
32
+
33
+ const mockUseLinkedPatientForParticipant = vi.mocked(useLinkedPatientForParticipant);
34
+ const mockUseSyncStudyAttributes = vi.mocked(useSyncStudyAttributes);
35
+
36
+ const dependant = {
37
+ individualId: '901-1-1-3',
38
+ firstName: 'DENNIS',
39
+ middleName: 'OMONDI',
40
+ lastName: 'ODONGO',
41
+ sex: 'M',
42
+ dateOfBirth: '2011-06-12',
43
+ pbidsEnrolled: true,
44
+ cardse: false,
45
+ mother: null,
46
+ compound: { compoundId: '901-1', headIndividualId: '901-1-1-1', headFirstName: 'JOHN', headLastName: 'ODONGO' },
47
+ village: { code: '901', name: 'TEST ABUYA' },
48
+ contacts: [],
49
+ matchedOn: null,
50
+ matchType: null,
51
+ } as PcsParticipant;
52
+
53
+ beforeEach(() => vi.clearAllMocks());
54
+
55
+ describe('DependantRow', () => {
56
+ it('reports the patient a dependant is already linked to', () => {
57
+ mockUseLinkedPatientForParticipant.mockReturnValue({
58
+ linkedPatient: { uuid: 'p1', person: { personName: { display: 'Dennis Odongo' } } },
59
+ isLoading: false,
60
+ error: undefined,
61
+ mutate: vi.fn(),
62
+ } as any);
63
+
64
+ render(<DependantRow dependant={dependant} />);
65
+
66
+ expect(screen.getByText('Linked')).toBeInTheDocument();
67
+ expect(screen.getByText('Dennis Odongo')).toBeInTheDocument();
68
+ // A linked row offers the undo, not the link.
69
+ expect(screen.getByRole('button', { name: /Unlink/ })).toBeInTheDocument();
70
+ expect(screen.queryByRole('button', { name: 'Link dependant' })).not.toBeInTheDocument();
71
+ });
72
+
73
+ it("reconciles a linked dependant's enrolment attributes in the background", () => {
74
+ const linkedPatient = { uuid: 'p1', person: { personName: { display: 'Dennis Odongo' } } };
75
+ mockUseLinkedPatientForParticipant.mockReturnValue({
76
+ linkedPatient,
77
+ isLoading: false,
78
+ error: undefined,
79
+ mutate: vi.fn(),
80
+ } as any);
81
+
82
+ render(<DependantRow dependant={dependant} />);
83
+
84
+ expect(mockUseSyncStudyAttributes).toHaveBeenCalledWith(
85
+ expect.objectContaining({ participant: dependant, localPatient: linkedPatient }),
86
+ );
87
+ });
88
+
89
+ it('leaves an unlinked dependant with nothing to sync', () => {
90
+ mockUseLinkedPatientForParticipant.mockReturnValue({
91
+ linkedPatient: null,
92
+ isLoading: false,
93
+ error: undefined,
94
+ mutate: vi.fn(),
95
+ } as any);
96
+
97
+ render(<DependantRow dependant={dependant} />);
98
+
99
+ // A null patient is what makes the hook a no-op, so unlinked rows cost no request.
100
+ expect(mockUseSyncStudyAttributes).toHaveBeenCalledWith(expect.objectContaining({ localPatient: null }));
101
+ });
102
+
103
+ it('offers to create a dependant nobody is linked to', () => {
104
+ mockUseLinkedPatientForParticipant.mockReturnValue({
105
+ linkedPatient: null,
106
+ isLoading: false,
107
+ error: undefined,
108
+ mutate: vi.fn(),
109
+ } as any);
110
+
111
+ render(<DependantRow dependant={dependant} />);
112
+
113
+ expect(screen.getByRole('button', { name: 'Link dependant' })).toBeInTheDocument();
114
+ expect(screen.queryByRole('button', { name: /Unlink/ })).not.toBeInTheDocument();
115
+ expect(screen.queryByText('Linked')).not.toBeInTheDocument();
116
+ });
117
+
118
+ it('shows neither while the lookup is still in flight', () => {
119
+ mockUseLinkedPatientForParticipant.mockReturnValue({
120
+ linkedPatient: null,
121
+ isLoading: true,
122
+ error: undefined,
123
+ mutate: vi.fn(),
124
+ } as any);
125
+
126
+ render(<DependantRow dependant={dependant} />);
127
+
128
+ // Flashing the action before the answer arrives invites a misclick on a child who is
129
+ // already registered.
130
+ expect(screen.queryByRole('button', { name: 'Link dependant' })).not.toBeInTheDocument();
131
+ expect(screen.queryByRole('button', { name: /Unlink/ })).not.toBeInTheDocument();
132
+ expect(screen.queryByText('Linked')).not.toBeInTheDocument();
133
+ });
134
+ });
@@ -0,0 +1,122 @@
1
+ import React from 'react';
2
+ import { Button, Tag } from '@carbon/react';
3
+ import { Link as LinkIcon, Unlink } from '@carbon/react/icons';
4
+ import { age, showModal, useConfig } from '@openmrs/esm-framework';
5
+ import { useTranslation } from 'react-i18next';
6
+ import styles from '../pcs.scss';
7
+ import { type PcsConfig } from '../config-schema';
8
+ import { formatParticipantName } from '../resources/pcs.resource';
9
+ import { useLinkedPatientForParticipant, useSyncStudyAttributes } from '../resources/link-participant.resource';
10
+ import { useStudySyncSnackbars } from './use-study-sync-snackbars';
11
+ import { type PcsParticipant } from '../types';
12
+ import { GENDER_ICONS, SEX_LABELS } from './participant-details.component';
13
+
14
+ interface DependantRowProps {
15
+ dependant: PcsParticipant;
16
+ /** The mother's HIE record — the modal picks her dependants out of its contacts. */
17
+ hiePatient?: fhir.Patient;
18
+ parentPhoneNumber?: string;
19
+ }
20
+
21
+ const getPatientName = (localPatient: any) =>
22
+ localPatient?.person?.personName?.display || localPatient?.display || localPatient?.person?.display;
23
+
24
+ /** One of the mother's PCS children, with whether they are already a patient here. */
25
+ const DependantRow: React.FC<DependantRowProps> = ({ dependant, hiePatient, parentPhoneNumber }) => {
26
+ const { t } = useTranslation();
27
+ const { pcsIdentifiers, pcsAttributeTypes } = useConfig<PcsConfig>();
28
+
29
+ // Either type counts: a child linked through "Not in PCS" holds the temporary one, and the
30
+ // module makes that id the participant's INDIVIDID, so they come back in this list too.
31
+ const { linkedPatient, isLoading, mutate } = useLinkedPatientForParticipant(dependant.individualId, [
32
+ pcsIdentifiers.studyParticipantID,
33
+ pcsIdentifiers.studyTemporaryParticipantID,
34
+ ]);
35
+
36
+ // Same reconciliation the mother's banner does, so a linked child's flags don't go stale.
37
+ // Called unconditionally, as hooks must be — the hook no-ops while `linkedPatient` is null,
38
+ // which is what keeps unlinked rows free.
39
+ useSyncStudyAttributes({
40
+ participant: dependant,
41
+ localPatient: linkedPatient,
42
+ pbidsEnrollmentAttributeType: pcsAttributeTypes.pbidsEnrollmentStatus,
43
+ cardseEnrollmentAttributeType: pcsAttributeTypes.cardseEnrollmentStatus,
44
+ ...useStudySyncSnackbars(formatParticipantName(dependant)),
45
+ });
46
+
47
+ const openLinkModal = () => {
48
+ const dispose = showModal('pcs-link-dependant-modal', {
49
+ closeModal: () => dispose(),
50
+ participant: dependant,
51
+ hiePatient,
52
+ parentPhoneNumber,
53
+ onLinked: () => mutate(),
54
+ });
55
+ };
56
+
57
+ const openUnlinkModal = () => {
58
+ const dispose = showModal('pcs-delink-participant-modal', {
59
+ closeModal: () => dispose(),
60
+ localPatient: linkedPatient,
61
+ studyParticipantId: dependant.individualId,
62
+ participantName: formatParticipantName(dependant),
63
+ onDelinked: () => mutate(),
64
+ });
65
+ };
66
+
67
+ return (
68
+ <div className={styles.pcsTile}>
69
+ <div className={styles.pcsRow}>
70
+ <span className={styles.pcsName}>{formatParticipantName(dependant)}</span>
71
+ {linkedPatient && (
72
+ <Tag className={styles.enrollmentTag} type="green" size="sm">
73
+ {t('linked', 'Linked')}
74
+ </Tag>
75
+ )}
76
+ </div>
77
+
78
+ <div className={styles.pcsRow}>
79
+ <span className={styles.genderIcon}>
80
+ {GENDER_ICONS[dependant.sex]}
81
+ <span>{SEX_LABELS[dependant.sex]}</span>
82
+ </span>
83
+ {dependant.dateOfBirth && (
84
+ <>
85
+ <span className={styles.separator}>&middot;</span>
86
+ <span>{age(dependant.dateOfBirth)}</span>
87
+ </>
88
+ )}
89
+ </div>
90
+
91
+ <div className={styles.pcsRow}>
92
+ <span className={styles.pcsFieldLabel}>{t('individualId', 'Individual ID')}:</span>
93
+ <span>{dependant.individualId}</span>
94
+ </div>
95
+
96
+ {linkedPatient && (
97
+ <div className={styles.pcsRow}>
98
+ <span className={styles.pcsFieldLabel}>{t('emrPatient', 'EMR patient')}:</span>
99
+ <span>{getPatientName(linkedPatient) || '--'}</span>
100
+ </div>
101
+ )}
102
+
103
+ {/* Nothing is rendered while the lookup is in flight: showing the link action first
104
+ would flash it on every row and invite a misclick on a child already registered. */}
105
+ {!isLoading && (
106
+ <div className={styles.pcsTileActions}>
107
+ {linkedPatient ? (
108
+ <Button kind="danger--ghost" size="sm" renderIcon={Unlink} onClick={openUnlinkModal}>
109
+ {t('unlink', 'Unlink')}
110
+ </Button>
111
+ ) : (
112
+ <Button kind="tertiary" size="sm" renderIcon={LinkIcon} onClick={openLinkModal}>
113
+ {t('linkDependant', 'Link dependant')}
114
+ </Button>
115
+ )}
116
+ </div>
117
+ )}
118
+ </div>
119
+ );
120
+ };
121
+
122
+ export default DependantRow;
@@ -0,0 +1,60 @@
1
+ import React from 'react';
2
+ import { SkeletonText } from '@carbon/react';
3
+ import { ErrorState } from '@openmrs/esm-framework';
4
+ import { useTranslation } from 'react-i18next';
5
+ import styles from '../pcs.scss';
6
+ import { usePcsDependants } from '../resources/pcs.resource';
7
+ import DependantRow from './dependant-row.component';
8
+
9
+ interface DependantsListProps {
10
+ /** The linked participant — dependants are those whose mother is this individual. */
11
+ motherIndividualId: string;
12
+ /** The mother's HIE record, passed down so a row can offer to link one of her contacts. */
13
+ hiePatient?: fhir.Patient;
14
+ parentPhoneNumber?: string;
15
+ }
16
+
17
+ /** The linked client's children in PCS. Display-only. */
18
+ const DependantsList: React.FC<DependantsListProps> = ({ motherIndividualId, hiePatient, parentPhoneNumber }) => {
19
+ const { t } = useTranslation();
20
+ // SWR dedupes this with the identical call in the parent that supplies the tab count.
21
+ const { dependants, isLoading, error } = usePcsDependants(motherIndividualId);
22
+
23
+ if (isLoading) {
24
+ return (
25
+ <div className={styles.pcsSkeletonTile}>
26
+ <SkeletonText paragraph lineCount={3} />
27
+ </div>
28
+ );
29
+ }
30
+
31
+ if (error) {
32
+ return <ErrorState error={error} headerTitle={t('errorLoadingDependants', 'Error loading dependants')} />;
33
+ }
34
+
35
+ if (dependants.length === 0) {
36
+ return (
37
+ <div className={styles.pcsLinkError}>
38
+ <p className={styles.pcsEmptyTitle}>{t('noDependants', 'No dependants')}</p>
39
+ <p className={styles.pcsEmptySubtitle}>
40
+ {t('noDependantsSubtitle', 'PCS has no participants recorded with this mother.')}
41
+ </p>
42
+ </div>
43
+ );
44
+ }
45
+
46
+ return (
47
+ <div className={styles.pcsResults}>
48
+ {dependants.map((dependant) => (
49
+ <DependantRow
50
+ key={dependant.individualId}
51
+ dependant={dependant}
52
+ hiePatient={hiePatient}
53
+ parentPhoneNumber={parentPhoneNumber}
54
+ />
55
+ ))}
56
+ </div>
57
+ );
58
+ };
59
+
60
+ export default DependantsList;
@@ -0,0 +1,82 @@
1
+ import React from 'react';
2
+ import { SkeletonText, Tab, TabList, TabPanel, TabPanels, Tabs } from '@carbon/react';
3
+ import { useTranslation } from 'react-i18next';
4
+ import styles from '../pcs.scss';
5
+ import { type PcsParticipant, type PcsSearchSubject } from '../types';
6
+ import DependantsList from './dependants-list.component';
7
+ import ParticipantDetails from './participant-details.component';
8
+
9
+ interface LinkedParticipantBodyProps {
10
+ subject: PcsSearchSubject;
11
+ participant: PcsParticipant | null;
12
+ /** The study participant ID stamped on the patient — shown when PCS cannot resolve it. */
13
+ studyParticipantId: string;
14
+ isLoading: boolean;
15
+ error: unknown;
16
+ dependantCount: number;
17
+ isLoadingDependants: boolean;
18
+ }
19
+
20
+ /** Everything below the linked banner: loading, unresolvable, or the participant's tabs. */
21
+ const LinkedParticipantBody: React.FC<LinkedParticipantBodyProps> = ({
22
+ subject,
23
+ participant,
24
+ studyParticipantId,
25
+ isLoading,
26
+ error,
27
+ dependantCount,
28
+ isLoadingDependants,
29
+ }) => {
30
+ const { t } = useTranslation();
31
+
32
+ if (isLoading) {
33
+ return (
34
+ <div className={styles.pcsSkeletonTile}>
35
+ <SkeletonText heading width="60%" />
36
+ <SkeletonText paragraph lineCount={5} />
37
+ </div>
38
+ );
39
+ }
40
+
41
+ // A PCS record that cannot be resolved must not strand the patient — the unlink action stays
42
+ // available above, so a stale or mistyped study ID can always be removed.
43
+ if (error || !participant) {
44
+ return (
45
+ <div className={styles.pcsLinkError}>
46
+ <p className={styles.pcsEmptyTitle}>{t('pcsParticipantUnavailable', 'Participant could not be loaded')}</p>
47
+ <p className={styles.pcsEmptySubtitle}>
48
+ {t('pcsParticipantUnavailableSubtitle', 'PCS did not return a record for {{individualId}}.', {
49
+ individualId: studyParticipantId,
50
+ })}
51
+ </p>
52
+ </div>
53
+ );
54
+ }
55
+
56
+ return (
57
+ <Tabs>
58
+ <TabList aria-label={t('pcsParticipantTabs', 'PCS participant')} contained>
59
+ <Tab>{t('details', 'Details')}</Tab>
60
+ <Tab>
61
+ {isLoadingDependants
62
+ ? t('dependants', 'Dependants')
63
+ : t('dependantsWithCount', 'Dependants ({{count}})', { count: dependantCount })}
64
+ </Tab>
65
+ </TabList>
66
+ <TabPanels>
67
+ <TabPanel>
68
+ <ParticipantDetails participant={participant} />
69
+ </TabPanel>
70
+ <TabPanel>
71
+ <DependantsList
72
+ motherIndividualId={participant.individualId}
73
+ hiePatient={subject.hiePatient}
74
+ parentPhoneNumber={subject.phoneNumber ?? undefined}
75
+ />
76
+ </TabPanel>
77
+ </TabPanels>
78
+ </Tabs>
79
+ );
80
+ };
81
+
82
+ export default LinkedParticipantBody;