@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,134 @@
1
+ import React from 'react';
2
+ import { render, screen } from '@testing-library/react';
3
+ import userEvent from '@testing-library/user-event';
4
+ import '@testing-library/jest-dom';
5
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
6
+ import { launchWorkspace2 } from '@openmrs/esm-framework';
7
+ import LinkedParticipant from './linked-participant.component';
8
+ import { usePcsDependants, usePcsParticipant } from '../resources/pcs.resource';
9
+ import { type PcsParticipant } from '../types';
10
+
11
+ vi.mock('@openmrs/esm-framework', async (importOriginal) => ({
12
+ ...(await importOriginal<typeof import('@openmrs/esm-framework')>()),
13
+ launchWorkspace2: vi.fn(),
14
+ showModal: vi.fn(),
15
+ useConfig: () => ({
16
+ pcsAttributeTypes: { pbidsEnrollmentStatus: 'pbids-attr', cardseEnrollmentStatus: 'cardse-attr' },
17
+ }),
18
+ }));
19
+
20
+ vi.mock('react-i18next', () => ({
21
+ useTranslation: () => ({ t: (_key: string, fallback: string) => fallback }),
22
+ }));
23
+
24
+ vi.mock('../resources/pcs.resource', async (importOriginal) => ({
25
+ ...(await importOriginal<typeof import('../resources/pcs.resource')>()),
26
+ usePcsParticipant: vi.fn(),
27
+ usePcsDependants: vi.fn(),
28
+ }));
29
+
30
+ vi.mock('../resources/link-participant.resource', () => ({
31
+ useSyncStudyAttributes: () => ({ syncNow: vi.fn() }),
32
+ }));
33
+
34
+ vi.mock('./use-study-sync-snackbars', () => ({
35
+ useStudySyncSnackbars: () => ({ onSynced: vi.fn(), onSyncError: vi.fn() }),
36
+ }));
37
+
38
+ // The panels are exercised by their own tests; here they would only add mocking noise.
39
+ vi.mock('./participant-details.component', () => ({ default: () => null }));
40
+ vi.mock('./dependants-list.component', () => ({ default: () => null }));
41
+
42
+ const mockUsePcsParticipant = vi.mocked(usePcsParticipant);
43
+ const mockUsePcsDependants = vi.mocked(usePcsDependants);
44
+
45
+ const participant = { individualId: '901-1-1-2', firstName: 'JANE', lastName: 'ODONGO' } as PcsParticipant;
46
+
47
+ const subject = { name: 'Jane Odongo', phoneNumber: '0712345678', hiePatient: { id: 'hie-1' } } as any;
48
+
49
+ const renderBanner = () =>
50
+ render(
51
+ <LinkedParticipant
52
+ subject={subject}
53
+ studyParticipantId="901-1-1-2"
54
+ localPatient={{ uuid: 'mother-uuid' }}
55
+ onDelinked={vi.fn()}
56
+ />,
57
+ );
58
+
59
+ beforeEach(() => {
60
+ vi.clearAllMocks();
61
+ mockUsePcsParticipant.mockReturnValue({ participant, isLoading: false, error: null, mutate: vi.fn() } as any);
62
+ mockUsePcsDependants.mockReturnValue({
63
+ dependants: [],
64
+ totalCount: 0,
65
+ isLoading: false,
66
+ error: null,
67
+ mutate: vi.fn(),
68
+ } as any);
69
+ });
70
+
71
+ describe('LinkedParticipant dependant questions', () => {
72
+ it("launches the add-dependant workspace with the mother's individual ID", async () => {
73
+ renderBanner();
74
+
75
+ await userEvent.click(screen.getByRole('button', { name: /Dependant not in HIE and PCS\?/ }));
76
+
77
+ // The feature's only entry point — a drifted workspace name fails silently in the browser.
78
+ expect(launchWorkspace2).toHaveBeenCalledWith(
79
+ 'pcs-add-dependant-workspace-form',
80
+ expect.objectContaining({ motherIndividualId: '901-1-1-2' }),
81
+ );
82
+ });
83
+
84
+ it('revalidates the dependants list once the child is created', async () => {
85
+ const mutateDependants = vi.fn();
86
+ mockUsePcsDependants.mockReturnValue({
87
+ dependants: [],
88
+ totalCount: 0,
89
+ isLoading: false,
90
+ error: null,
91
+ mutate: mutateDependants,
92
+ } as any);
93
+
94
+ renderBanner();
95
+ await userEvent.click(screen.getByRole('button', { name: /Dependant not in HIE and PCS\?/ }));
96
+
97
+ // Without this the new child does not appear until the page is refreshed.
98
+ vi.mocked(launchWorkspace2).mock.calls[0][1].onCreated();
99
+ expect(mutateDependants).toHaveBeenCalled();
100
+ });
101
+
102
+ it('holds the question closed until the participant has loaded', () => {
103
+ mockUsePcsParticipant.mockReturnValue({ participant: null, isLoading: true, error: null, mutate: vi.fn() } as any);
104
+
105
+ renderBanner();
106
+
107
+ // The handler reads participant.individualId, so an early click would throw.
108
+ expect(screen.getByRole('button', { name: /Dependant not in HIE and PCS\?/ })).toBeDisabled();
109
+ });
110
+
111
+ it("launches the HIE-dependant workspace with the mother's record and ID", async () => {
112
+ renderBanner();
113
+
114
+ await userEvent.click(screen.getByRole('button', { name: /Dependant in HIE and not PCS\?/ }));
115
+
116
+ // hiePatient is the candidate list's only source — without it the workspace shows empty.
117
+ expect(launchWorkspace2).toHaveBeenCalledWith(
118
+ 'pcs-link-hie-dependant-workspace-form',
119
+ expect.objectContaining({
120
+ motherIndividualId: '901-1-1-2',
121
+ hiePatient: subject.hiePatient,
122
+ parentPhoneNumber: '0712345678',
123
+ }),
124
+ );
125
+ });
126
+
127
+ it('holds the HIE question closed until the participant has loaded', () => {
128
+ mockUsePcsParticipant.mockReturnValue({ participant: null, isLoading: true, error: null, mutate: vi.fn() } as any);
129
+
130
+ renderBanner();
131
+
132
+ expect(screen.getByRole('button', { name: /Dependant in HIE and not PCS\?/ })).toBeDisabled();
133
+ });
134
+ });
@@ -0,0 +1,142 @@
1
+ import React from 'react';
2
+ import { Button, InlineLoading } from '@carbon/react';
3
+ import { ArrowRight, Renew, Unlink } from '@carbon/react/icons';
4
+ import { launchWorkspace2, showModal, showSnackbar, useConfig } from '@openmrs/esm-framework';
5
+ import { useTranslation } from 'react-i18next';
6
+ import { type PcsConfig } from '../config-schema';
7
+ import styles from '../pcs.scss';
8
+ import { formatParticipantName, usePcsDependants, usePcsParticipant } from '../resources/pcs.resource';
9
+ import { useSyncStudyAttributes } from '../resources/link-participant.resource';
10
+ import { useStudySyncSnackbars } from './use-study-sync-snackbars';
11
+ import { type PcsSearchSubject } from '../types';
12
+ import LinkedParticipantBody from './linked-participant-body.component';
13
+
14
+ interface LinkedParticipantProps {
15
+ subject: PcsSearchSubject;
16
+ /** The study participant ID already stamped on the patient — the key into PCS. */
17
+ studyParticipantId: string;
18
+ localPatient: any;
19
+ onDelinked: () => void;
20
+ }
21
+
22
+ const LinkedParticipant: React.FC<LinkedParticipantProps> = ({
23
+ subject,
24
+ studyParticipantId,
25
+ localPatient,
26
+ onDelinked,
27
+ }) => {
28
+ const { t } = useTranslation();
29
+ const { pcsAttributeTypes } = useConfig<PcsConfig>();
30
+ const { participant, isLoading, error, mutate } = usePcsParticipant(studyParticipantId);
31
+ // Same key as the list inside the panel, so SWR issues one request and the tab can show a
32
+ // real count without anyone opening it.
33
+ const {
34
+ totalCount: dependantCount,
35
+ isLoading: isLoadingDependants,
36
+ mutate: mutateDependants,
37
+ } = usePcsDependants(participant?.individualId ?? null);
38
+
39
+ // PCS owns these two flags, so every pull by id reconciles the patient record with them.
40
+ const { syncNow } = useSyncStudyAttributes({
41
+ participant,
42
+ localPatient,
43
+ pbidsEnrollmentAttributeType: pcsAttributeTypes.pbidsEnrollmentStatus,
44
+ cardseEnrollmentAttributeType: pcsAttributeTypes.cardseEnrollmentStatus,
45
+ ...useStudySyncSnackbars(participant ? formatParticipantName(participant) : ''),
46
+ });
47
+
48
+ const refresh = async () => {
49
+ await mutate();
50
+ // Bypasses the sync guard: the PCS values may be identical while the patient's own
51
+ // attributes have drifted, and Refresh should mean "reconcile now".
52
+ syncNow();
53
+ };
54
+
55
+ const openAddDependant = () => {
56
+ launchWorkspace2('pcs-add-dependant-workspace-form', {
57
+ motherIndividualId: participant!.individualId,
58
+ // The new child is a participant with this mother, so the same key that feeds the tab
59
+ // count and the list picks them up.
60
+ onCreated: () => mutateDependants(),
61
+ });
62
+ };
63
+
64
+ const openLinkHieDependant = () => {
65
+ launchWorkspace2('pcs-link-hie-dependant-workspace-form', {
66
+ motherIndividualId: participant!.individualId,
67
+ hiePatient: subject.hiePatient,
68
+ parentPhoneNumber: subject.phoneNumber ?? undefined,
69
+ // The child becomes a participant with this mother, so the key behind the tab count and
70
+ // the list picks her up.
71
+ onLinked: () => mutateDependants(),
72
+ });
73
+ };
74
+
75
+ const openDelinkModal = () => {
76
+ const dispose = showModal('pcs-delink-participant-modal', {
77
+ closeModal: () => dispose(),
78
+ localPatient,
79
+ studyParticipantId,
80
+ participantName: participant ? formatParticipantName(participant) : undefined,
81
+ onDelinked,
82
+ });
83
+ };
84
+
85
+ return (
86
+ <>
87
+ <div className={styles.pcsLinkedBanner}>
88
+ <span className={styles.pcsLinkedTitle}>{t('linkedToPcs', 'Linked to PCS')}</span>
89
+ <span className={styles.pcsLinkedSubtitle}>
90
+ {t('linkedAs', '{{patientName}} is linked as {{individualId}}', {
91
+ patientName: subject.name,
92
+ individualId: studyParticipantId,
93
+ })}
94
+ </span>
95
+ <div className={styles.pcsLinkedActions}>
96
+ <Button kind="ghost" size="sm" renderIcon={Renew} disabled={isLoading} onClick={refresh}>
97
+ {isLoading ? <InlineLoading description={t('refreshing', 'Refreshing...')} /> : t('refresh', 'Refresh')}
98
+ </Button>
99
+ <Button kind="danger--ghost" size="sm" renderIcon={Unlink} onClick={openDelinkModal}>
100
+ {t('unlink', 'Unlink')}
101
+ </Button>
102
+ </div>
103
+
104
+ {/* The Dependants tab lists PCS rows, so by construction it cannot reach a child PCS has
105
+ never heard of. These two questions are exactly those cases, named by the situation at
106
+ the desk rather than by what the flow does. */}
107
+ <div className={styles.pcsLinkedQuestions}>
108
+ <Button
109
+ kind="ghost"
110
+ size="sm"
111
+ renderIcon={ArrowRight}
112
+ // Needs the mother's individualId, which only arrives with the participant.
113
+ disabled={!participant}
114
+ onClick={openAddDependant}>
115
+ {t('dependantNotInHieAndPcs', 'Dependant not in HIE and PCS?')}
116
+ </Button>
117
+ <Button
118
+ kind="ghost"
119
+ size="sm"
120
+ renderIcon={ArrowRight}
121
+ // Same guard as above — the handler reads participant.individualId.
122
+ disabled={!participant}
123
+ onClick={openLinkHieDependant}>
124
+ {t('dependantInHieNotPcs', 'Dependant in HIE and not PCS?')}
125
+ </Button>
126
+ </div>
127
+ </div>
128
+
129
+ <LinkedParticipantBody
130
+ subject={subject}
131
+ participant={participant}
132
+ studyParticipantId={studyParticipantId}
133
+ isLoading={isLoading}
134
+ error={error}
135
+ dependantCount={dependantCount}
136
+ isLoadingDependants={isLoadingDependants}
137
+ />
138
+ </>
139
+ );
140
+ };
141
+
142
+ export default LinkedParticipant;
@@ -0,0 +1,89 @@
1
+ import React from 'react';
2
+ import { Tag } from '@carbon/react';
3
+ import { GenderFemale, GenderMale } from '@carbon/react/icons';
4
+ import { age, formatDate, parseDate } from '@openmrs/esm-framework';
5
+ import { useTranslation } from 'react-i18next';
6
+ import styles from '../pcs.scss';
7
+ import { formatCompoundHeadName, formatMotherName, formatParticipantName } from '../resources/pcs.resource';
8
+ import { type PcsParticipant } from '../types';
9
+
10
+ interface ParticipantDetailsProps {
11
+ participant: PcsParticipant;
12
+ }
13
+
14
+ export const GENDER_ICONS = {
15
+ F: <GenderFemale />,
16
+ M: <GenderMale />,
17
+ } as const;
18
+
19
+ export const SEX_LABELS: Record<PcsParticipant['sex'], string> = { F: 'Female', M: 'Male' };
20
+
21
+ /** The linked participant's full PCS record. Read-only. */
22
+ const ParticipantDetails: React.FC<ParticipantDetailsProps> = ({ participant }) => {
23
+ const { t } = useTranslation();
24
+
25
+ const row = (label: string, value?: string | null) => (
26
+ <div className={styles.pcsRow} key={label}>
27
+ <span className={styles.pcsFieldLabel}>{label}:</span>
28
+ <span>{value || '--'}</span>
29
+ </div>
30
+ );
31
+
32
+ const contacts = participant.contacts?.length ? participant.contacts : [{}];
33
+
34
+ return (
35
+ <div className={styles.pcsTile}>
36
+ <div className={styles.pcsRow}>
37
+ <span className={styles.pcsName}>{formatParticipantName(participant)}</span>
38
+ </div>
39
+
40
+ <div className={styles.pcsRow}>
41
+ <span className={styles.genderIcon}>
42
+ {GENDER_ICONS[participant.sex]}
43
+ <span>{SEX_LABELS[participant.sex]}</span>
44
+ </span>
45
+ {participant.dateOfBirth && (
46
+ <>
47
+ <span className={styles.separator}>&middot;</span>
48
+ <span>{age(participant.dateOfBirth)}</span>
49
+ <span className={styles.separator}>&middot;</span>
50
+ <span>{formatDate(parseDate(participant.dateOfBirth))}</span>
51
+ </>
52
+ )}
53
+ </div>
54
+
55
+ <div className={styles.pcsRow}>
56
+ {participant.pbidsEnrolled && (
57
+ <Tag className={styles.enrollmentTag} type="green" size="sm">
58
+ {t('pbidsEnrolled', 'PBIDS enrolled')}
59
+ </Tag>
60
+ )}
61
+ {participant.cardse && (
62
+ <Tag className={styles.enrollmentTag} type="purple" size="sm">
63
+ {t('cardse', 'CARDSE')}
64
+ </Tag>
65
+ )}
66
+ </div>
67
+
68
+ {row(t('individualId', 'Individual ID'), participant.individualId)}
69
+ {participant.mother && row(t('mother', 'Mother'), formatMotherName(participant.mother))}
70
+ {row(
71
+ t('compound', 'Compound'),
72
+ `${participant.compound.compoundId} · ${formatCompoundHeadName(participant.compound)}`,
73
+ )}
74
+ {row(t('village', 'Village'), `${participant.village.name} (${participant.village.code})`)}
75
+
76
+ <div className={styles.pcsContacts}>
77
+ {contacts.map((contact, index) => (
78
+ <div key={`contact-${index}`}>
79
+ {row(t('phone', 'Phone'), contact.phone)}
80
+ {row(t('nationalId', 'National ID'), contact.nationalId)}
81
+ {row(t('email', 'Email'), contact.email)}
82
+ </div>
83
+ ))}
84
+ </div>
85
+ </div>
86
+ );
87
+ };
88
+
89
+ export default ParticipantDetails;
@@ -0,0 +1,41 @@
1
+ import { showSnackbar } from '@openmrs/esm-framework';
2
+ import { useTranslation } from 'react-i18next';
3
+ import { type StudyAttributeFlag } from '../resources/link-participant.resource';
4
+
5
+ /**
6
+ * The snackbars `useSyncStudyAttributes` fires, shared by the mother's banner and each linked
7
+ * dependant row so the two can't drift apart.
8
+ *
9
+ * The participant's name is in the subtitle because several rows can sync at once — an
10
+ * unattributed "Updated from PCS: PBIDS enrollment" says nothing about which child changed.
11
+ *
12
+ * `useSyncStudyAttributes` keeps its callbacks in a ref refreshed each render, so these do not
13
+ * need to be referentially stable.
14
+ */
15
+ export function useStudySyncSnackbars(participantName: string) {
16
+ const { t } = useTranslation();
17
+
18
+ const flagLabels: Record<StudyAttributeFlag, string> = {
19
+ pbids: t('pbidsEnrollment', 'PBIDS enrollment'),
20
+ cardse: t('cardseEnrollment', 'CARDSE enrollment'),
21
+ };
22
+
23
+ return {
24
+ onSynced: (changed: Array<StudyAttributeFlag>) =>
25
+ showSnackbar({
26
+ title: t('studyAttributesUpdated', 'Study attributes updated'),
27
+ subtitle: t('studyAttributesUpdatedSubtitle', 'Updated {{name}} from PCS: {{fields}}', {
28
+ name: participantName,
29
+ fields: changed.map((flag) => flagLabels[flag]).join(', '),
30
+ }),
31
+ kind: 'success',
32
+ isLowContrast: true,
33
+ }),
34
+ onSyncError: (syncError: any) =>
35
+ showSnackbar({
36
+ title: t('studyAttributesSyncFailed', 'Could not sync study attributes'),
37
+ subtitle: syncError?.responseBody?.error?.message ?? syncError?.message,
38
+ kind: 'error',
39
+ }),
40
+ };
41
+ }
@@ -0,0 +1,96 @@
1
+ import React, { useState } from 'react';
2
+ import { useTranslation } from 'react-i18next';
3
+ import { Button, InlineLoading, InlineNotification, ModalBody, ModalFooter, ModalHeader } from '@carbon/react';
4
+ import { showSnackbar, useConfig } from '@openmrs/esm-framework';
5
+
6
+ import { type PcsConfig } from '../config-schema';
7
+ import { delinkParticipant } from '../resources/link-participant.resource';
8
+
9
+ interface DelinkParticipantModalProps {
10
+ closeModal: () => void;
11
+ localPatient: any;
12
+ studyParticipantId: string;
13
+ participantName?: string;
14
+ onDelinked?: () => void;
15
+ }
16
+
17
+ const DelinkParticipantModal: React.FC<DelinkParticipantModalProps> = ({
18
+ closeModal,
19
+ localPatient,
20
+ studyParticipantId,
21
+ participantName,
22
+ onDelinked,
23
+ }) => {
24
+ const { t } = useTranslation();
25
+ const { pcsIdentifiers, pcsAttributeTypes } = useConfig<PcsConfig>();
26
+ const [isDelinking, setIsDelinking] = useState(false);
27
+ const [error, setError] = useState<string | null>(null);
28
+
29
+ const handleDelink = async () => {
30
+ setError(null);
31
+ setIsDelinking(true);
32
+ try {
33
+ await delinkParticipant({
34
+ localPatient,
35
+ studyParticipantIdentifierType: pcsIdentifiers.studyParticipantID,
36
+ pbidsEnrollmentAttributeType: pcsAttributeTypes.pbidsEnrollmentStatus,
37
+ cardseEnrollmentAttributeType: pcsAttributeTypes.cardseEnrollmentStatus,
38
+ });
39
+ showSnackbar({
40
+ title: t('participantUnlinked', 'Records unlinked'),
41
+ subtitle: t('participantUnlinkedSubtitle', 'The study participant ID and study attributes were removed.'),
42
+ kind: 'success',
43
+ isLowContrast: true,
44
+ });
45
+ onDelinked?.();
46
+ closeModal();
47
+ } catch (e: any) {
48
+ setError(
49
+ e?.responseBody?.error?.message ??
50
+ e?.message ??
51
+ t('participantUnlinkFailedSubtitle', 'The patient record could not be updated.'),
52
+ );
53
+ } finally {
54
+ setIsDelinking(false);
55
+ }
56
+ };
57
+
58
+ return (
59
+ <>
60
+ <ModalHeader closeModal={closeModal} title={t('unlinkFromPcs', 'Unlink from PCS')} />
61
+ <ModalBody>
62
+ <p>
63
+ {t('unlinkConfirmation', 'Remove the link between this patient and PCS participant {{individualId}}?', {
64
+ individualId: participantName ? `${studyParticipantId} (${participantName})` : studyParticipantId,
65
+ })}
66
+ </p>
67
+ <p>
68
+ {t(
69
+ 'unlinkWhatIsRemoved',
70
+ 'The study participant ID and both enrollment status attributes will be removed from the patient record.',
71
+ )}
72
+ </p>
73
+
74
+ {error && (
75
+ <InlineNotification
76
+ kind="error"
77
+ lowContrast
78
+ hideCloseButton
79
+ title={t('participantUnlinkFailed', 'Could not unlink records')}
80
+ subtitle={error}
81
+ />
82
+ )}
83
+ </ModalBody>
84
+ <ModalFooter>
85
+ <Button kind="secondary" onClick={closeModal} disabled={isDelinking}>
86
+ {t('cancel', 'Cancel')}
87
+ </Button>
88
+ <Button kind="danger" onClick={handleDelink} disabled={isDelinking}>
89
+ {isDelinking ? <InlineLoading description={t('unlinking', 'Unlinking...')} /> : t('unlink', 'Unlink')}
90
+ </Button>
91
+ </ModalFooter>
92
+ </>
93
+ );
94
+ };
95
+
96
+ export default DelinkParticipantModal;