@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,244 @@
1
+ import React, { useMemo, useState } from 'react';
2
+ import { useTranslation } from 'react-i18next';
3
+ import {
4
+ Button,
5
+ InlineLoading,
6
+ InlineNotification,
7
+ ModalBody,
8
+ ModalFooter,
9
+ ModalHeader,
10
+ RadioButton,
11
+ RadioButtonGroup,
12
+ StructuredListBody,
13
+ StructuredListCell,
14
+ StructuredListRow,
15
+ StructuredListWrapper,
16
+ } from '@carbon/react';
17
+ import { showSnackbar, useConfig } from '@openmrs/esm-framework';
18
+
19
+ import { type PcsConfig } from '../config-schema';
20
+ import { useIdentifierTypeUuids } from '../resources/use-identifier-type-uuids';
21
+ import { getDependentsFromContacts } from '../resources/hie-contacts';
22
+ import { formatParticipantName, getPrimaryContact } from '../resources/pcs.resource';
23
+ import {
24
+ createAndLinkFromParticipant,
25
+ linkDependantToParticipant,
26
+ useHieDependantLinkState,
27
+ } from '../resources/link-dependant.resource';
28
+ import { type PcsParticipant } from '../types';
29
+ import styles from './link-participant.scss';
30
+
31
+ interface LinkDependantModalProps {
32
+ closeModal: () => void;
33
+ /** The PCS dependant being linked to. */
34
+ participant: PcsParticipant;
35
+ /** The mother's HIE record — her `contact` array is the candidate list. */
36
+ hiePatient?: fhir.Patient;
37
+ parentPhoneNumber?: string;
38
+ onLinked?: () => void;
39
+ }
40
+
41
+ /** Sentinel for "PCS knows this child, the HIE does not". */
42
+ const PCS_DETAILS_OPTION = '__pcs_details__';
43
+
44
+ const LinkDependantModal: React.FC<LinkDependantModalProps> = ({
45
+ closeModal,
46
+ participant,
47
+ hiePatient,
48
+ parentPhoneNumber,
49
+ onLinked,
50
+ }) => {
51
+ const { t } = useTranslation();
52
+ const uuids = useIdentifierTypeUuids();
53
+ const { pcsIdentifiers, pcsAttributeTypes, nationalIdUUID, phoneAttributeTypeUUID } = useConfig<PcsConfig>();
54
+ const [selectedId, setSelectedId] = useState<string | null>(null);
55
+ const [isLinking, setIsLinking] = useState(false);
56
+ const [linkError, setLinkError] = useState<string | null>(null);
57
+
58
+ // Read straight off the record already in hand — no request.
59
+ const candidates = useMemo(() => (hiePatient ? getDependentsFromContacts(hiePatient as any) : []), [hiePatient]);
60
+ const isFromPcsDetails = selectedId === PCS_DETAILS_OPTION;
61
+ const selected = candidates.find((candidate) => candidate.id === selectedId);
62
+ const yesNo = (flag: boolean) => (flag ? t('yes', 'Yes') : t('no', 'No'));
63
+ const primaryContact = getPrimaryContact(participant);
64
+
65
+ // A candidate already linked to some participant must not be offered: linking them again
66
+ // would re-point that child from one participant to another.
67
+ const { linkedById, isChecking } = useHieDependantLinkState(candidates, [
68
+ pcsIdentifiers.studyParticipantID,
69
+ pcsIdentifiers.studyTemporaryParticipantID,
70
+ ]);
71
+
72
+ const handleLink = async () => {
73
+ if (!selected && !isFromPcsDetails) {
74
+ return;
75
+ }
76
+ setLinkError(null);
77
+ setIsLinking(true);
78
+ try {
79
+ const studyTypes = {
80
+ studyParticipantIdentifierType: pcsIdentifiers.studyParticipantID,
81
+ pbidsEnrollmentAttributeType: pcsAttributeTypes.pbidsEnrollmentStatus,
82
+ cardseEnrollmentAttributeType: pcsAttributeTypes.cardseEnrollmentStatus,
83
+ };
84
+
85
+ if (isFromPcsDetails) {
86
+ // The participant already holds a permanent individualId, so this stamps that —
87
+ // nothing temporary is minted and PCS keeps one record for the child.
88
+ await createAndLinkFromParticipant({ participant, nationalIdUUID, phoneAttributeTypeUUID, ...studyTypes });
89
+ } else {
90
+ await linkDependantToParticipant({
91
+ dependant: selected!,
92
+ parentPhoneNumber,
93
+ participant,
94
+ ...studyTypes,
95
+ uuids,
96
+ });
97
+ }
98
+ showSnackbar({
99
+ title: t('participantLinked', 'Records linked'),
100
+ subtitle: t('participantLinkedSubtitle', 'The patient is now linked to PCS participant {{individualId}}.', {
101
+ individualId: participant.individualId,
102
+ }),
103
+ kind: 'success',
104
+ isLowContrast: true,
105
+ });
106
+ onLinked?.();
107
+ closeModal();
108
+ } catch (e: any) {
109
+ // A study ID already held by another patient comes back from OpenMRS as a duplicate
110
+ // identifier error — worth showing verbatim.
111
+ setLinkError(
112
+ e?.responseBody?.error?.message ??
113
+ e?.message ??
114
+ t('participantLinkFailedSubtitle', 'The patient record could not be updated.'),
115
+ );
116
+ } finally {
117
+ setIsLinking(false);
118
+ }
119
+ };
120
+
121
+ return (
122
+ <>
123
+ <ModalHeader closeModal={closeModal} title={t('linkDependantToPcs', 'Link dependant to PCS')} />
124
+ <ModalBody>
125
+ <div className={styles.comparison}>
126
+ <div className={styles.side}>
127
+ <span className={styles.sideLabel}>{t('pcsParticipant', 'PCS participant')}</span>
128
+ <span className={styles.sideValue}>{formatParticipantName(participant)}</span>
129
+ </div>
130
+ <div className={styles.side}>
131
+ <span className={styles.sideLabel}>{t('individualId', 'Individual ID')}</span>
132
+ <span className={styles.sideValue}>{participant.individualId}</span>
133
+ </div>
134
+ </div>
135
+
136
+ <p className={styles.willWrite}>{t('mothersDependantsInHie', "Mother's dependants in the HIE")}</p>
137
+
138
+ {candidates.length === 0 && (
139
+ <p className={styles.note}>{t('noHieDependants', 'The HIE record lists no dependants for this mother.')}</p>
140
+ )}
141
+
142
+ {/* The PCS-details option is always offered, including when the mother has no HIE
143
+ contacts at all — which is exactly the case it exists for. */}
144
+ <RadioButtonGroup
145
+ name="hieDependant"
146
+ orientation="vertical"
147
+ valueSelected={selectedId ?? undefined}
148
+ onChange={(value) => setSelectedId(String(value))}>
149
+ {candidates.map((candidate) => {
150
+ const linkedTo = linkedById[candidate.id];
151
+
152
+ return (
153
+ <RadioButton
154
+ key={candidate.id}
155
+ value={candidate.id}
156
+ // Disabled while the check runs too, so nothing can be picked in the window
157
+ // before its state is known.
158
+ disabled={isChecking || Boolean(linkedTo)}
159
+ labelText={`${candidate.name} · ${candidate.relationship} · ${candidate.gender}${
160
+ candidate.birthDate && candidate.birthDate !== 'Unknown' ? ` · ${candidate.birthDate}` : ''
161
+ }${
162
+ linkedTo
163
+ ? ` — ${t('alreadyLinkedTo', 'already linked to {{individualId}}', { individualId: linkedTo })}`
164
+ : ''
165
+ }`}
166
+ />
167
+ );
168
+ })}
169
+ <RadioButton
170
+ value={PCS_DETAILS_OPTION}
171
+ labelText={t('notInHie', 'Not in the HIE — create from PCS details')}
172
+ />
173
+ </RadioButtonGroup>
174
+
175
+ {isFromPcsDetails && (
176
+ <p className={styles.note}>
177
+ {t(
178
+ 'willCreateFromPcsDetails',
179
+ 'A patient will be created as {{name}} · {{sex}}{{dateOfBirth}}{{contact}}.',
180
+ {
181
+ name: formatParticipantName(participant),
182
+ sex: participant.sex,
183
+ dateOfBirth: participant.dateOfBirth ? ` · ${participant.dateOfBirth}` : '',
184
+ contact: primaryContact?.nationalId ? ` · ${primaryContact.nationalId}` : '',
185
+ },
186
+ )}
187
+ </p>
188
+ )}
189
+
190
+ {(selected || isFromPcsDetails) && (
191
+ <>
192
+ <p className={styles.willWrite}>
193
+ {t('willBeWritten', 'The following will be written to the patient record')}
194
+ </p>
195
+ <StructuredListWrapper isCondensed selection={false}>
196
+ <StructuredListBody>
197
+ {(
198
+ [
199
+ [t('studyParticipantId', 'Study participant ID'), participant.individualId],
200
+ [t('pbidsEnrollment', 'PBIDS enrollment'), yesNo(participant.pbidsEnrolled)],
201
+ [t('cardseEnrollment', 'CARDSE enrollment'), yesNo(participant.cardse)],
202
+ ] as Array<[string, string]>
203
+ ).map(([label, value]) => (
204
+ <StructuredListRow key={label}>
205
+ <StructuredListCell>{label}</StructuredListCell>
206
+ <StructuredListCell className={styles.value}>{value}</StructuredListCell>
207
+ </StructuredListRow>
208
+ ))}
209
+ </StructuredListBody>
210
+ </StructuredListWrapper>
211
+ </>
212
+ )}
213
+
214
+ {linkError && (
215
+ <InlineNotification
216
+ className={styles.notification}
217
+ kind="error"
218
+ lowContrast
219
+ hideCloseButton
220
+ title={t('participantLinkFailed', 'Could not link records')}
221
+ subtitle={linkError}
222
+ />
223
+ )}
224
+ </ModalBody>
225
+ <ModalFooter>
226
+ <Button kind="secondary" onClick={closeModal} disabled={isLinking}>
227
+ {t('cancel', 'Cancel')}
228
+ </Button>
229
+ <Button kind="primary" onClick={handleLink} disabled={isLinking || (!selected && !isFromPcsDetails)}>
230
+ {isLinking ? (
231
+ <InlineLoading description={t('linkingRecords', 'Linking records...')} />
232
+ ) : isFromPcsDetails ? (
233
+ // This branch creates the patient before linking, so say so before it's pressed.
234
+ t('createAndLinkRecords', 'Create and link records')
235
+ ) : (
236
+ t('linkRecords', 'Link records')
237
+ )}
238
+ </Button>
239
+ </ModalFooter>
240
+ </>
241
+ );
242
+ };
243
+
244
+ export default LinkDependantModal;
@@ -0,0 +1,143 @@
1
+ import React, { useState } from 'react';
2
+ import { useTranslation } from 'react-i18next';
3
+ import {
4
+ Button,
5
+ InlineLoading,
6
+ InlineNotification,
7
+ ModalBody,
8
+ ModalFooter,
9
+ ModalHeader,
10
+ StructuredListBody,
11
+ StructuredListCell,
12
+ StructuredListRow,
13
+ StructuredListWrapper,
14
+ } from '@carbon/react';
15
+ import { showSnackbar, useConfig } from '@openmrs/esm-framework';
16
+
17
+ import { type PcsConfig } from '../config-schema';
18
+ import { useIdentifierTypeUuids } from '../resources/use-identifier-type-uuids';
19
+ import { formatParticipantName } from '../resources/pcs.resource';
20
+ import { linkParticipantToPatient } from '../resources/link-participant.resource';
21
+ import { type PcsParticipant, type PcsSearchSubject } from '../types';
22
+ import styles from './link-participant.scss';
23
+
24
+ interface LinkParticipantModalProps {
25
+ closeModal: () => void;
26
+ subject: PcsSearchSubject;
27
+ participant: PcsParticipant;
28
+ onLinked?: () => void;
29
+ }
30
+
31
+ const LinkParticipantModal: React.FC<LinkParticipantModalProps> = ({ closeModal, subject, participant, onLinked }) => {
32
+ const { t } = useTranslation();
33
+ const uuids = useIdentifierTypeUuids();
34
+ const { pcsIdentifiers, pcsAttributeTypes } = useConfig<PcsConfig>();
35
+ const [isLinking, setIsLinking] = useState(false);
36
+ const [error, setError] = useState<string | null>(null);
37
+
38
+ // Written as booleans; shown as Yes/No because that is what a person reads.
39
+ const yesNo = (flag: boolean) => (flag ? t('yes', 'Yes') : t('no', 'No'));
40
+
41
+ const rows: Array<[string, string]> = [
42
+ [t('studyParticipantId', 'Study participant ID'), participant.individualId],
43
+ [t('pbidsEnrollment', 'PBIDS enrollment'), yesNo(participant.pbidsEnrolled)],
44
+ [t('cardseEnrollment', 'CARDSE enrollment'), yesNo(participant.cardse)],
45
+ ];
46
+
47
+ const handleLink = async () => {
48
+ setError(null);
49
+ setIsLinking(true);
50
+ try {
51
+ await linkParticipantToPatient({
52
+ subject,
53
+ participant,
54
+ studyParticipantIdentifierType: pcsIdentifiers.studyParticipantID,
55
+ pbidsEnrollmentAttributeType: pcsAttributeTypes.pbidsEnrollmentStatus,
56
+ cardseEnrollmentAttributeType: pcsAttributeTypes.cardseEnrollmentStatus,
57
+ uuids,
58
+ });
59
+ showSnackbar({
60
+ title: t('participantLinked', 'Records linked'),
61
+ subtitle: t('participantLinkedSubtitle', 'The patient is now linked to PCS participant {{individualId}}.', {
62
+ individualId: participant.individualId,
63
+ }),
64
+ kind: 'success',
65
+ isLowContrast: true,
66
+ });
67
+ onLinked?.();
68
+ closeModal();
69
+ } catch (e: any) {
70
+ // Surface the server's own wording — a duplicate study ID or a concept-format
71
+ // attribute type both come back with a message worth reading.
72
+ const message =
73
+ e?.responseBody?.error?.message ??
74
+ e?.message ??
75
+ t('participantLinkFailedSubtitle', 'The patient record could not be updated.');
76
+ setError(message);
77
+ } finally {
78
+ setIsLinking(false);
79
+ }
80
+ };
81
+
82
+ return (
83
+ <>
84
+ <ModalHeader closeModal={closeModal} title={t('linkToPcsParticipant', 'Link to PCS participant')} />
85
+ <ModalBody>
86
+ <div className={styles.comparison}>
87
+ <div className={styles.side}>
88
+ <span className={styles.sideLabel}>{t('emrPatient', 'EMR patient')}</span>
89
+ <span className={styles.sideValue}>{subject.name}</span>
90
+ </div>
91
+ <div className={styles.side}>
92
+ <span className={styles.sideLabel}>{t('pcsParticipant', 'PCS participant')}</span>
93
+ <span className={styles.sideValue}>{formatParticipantName(participant)}</span>
94
+ </div>
95
+ </div>
96
+
97
+ <p className={styles.willWrite}>{t('willBeWritten', 'The following will be written to the patient record')}</p>
98
+
99
+ <StructuredListWrapper isCondensed selection={false}>
100
+ <StructuredListBody>
101
+ {rows.map(([label, value]) => (
102
+ <StructuredListRow key={label}>
103
+ <StructuredListCell>{label}</StructuredListCell>
104
+ <StructuredListCell className={styles.value}>{value}</StructuredListCell>
105
+ </StructuredListRow>
106
+ ))}
107
+ </StructuredListBody>
108
+ </StructuredListWrapper>
109
+
110
+ {subject.source === 'hie' && (
111
+ <p className={styles.note}>
112
+ {t('patientWillBeCreated', 'This patient is not registered here yet and will be created first.')}
113
+ </p>
114
+ )}
115
+
116
+ {error && (
117
+ <InlineNotification
118
+ className={styles.notification}
119
+ kind="error"
120
+ lowContrast
121
+ hideCloseButton
122
+ title={t('participantLinkFailed', 'Could not link records')}
123
+ subtitle={error}
124
+ />
125
+ )}
126
+ </ModalBody>
127
+ <ModalFooter>
128
+ <Button kind="secondary" onClick={closeModal} disabled={isLinking}>
129
+ {t('cancel', 'Cancel')}
130
+ </Button>
131
+ <Button kind="primary" onClick={handleLink} disabled={isLinking}>
132
+ {isLinking ? (
133
+ <InlineLoading description={t('linkingRecords', 'Linking records...')} />
134
+ ) : (
135
+ t('linkRecords', 'Link records')
136
+ )}
137
+ </Button>
138
+ </ModalFooter>
139
+ </>
140
+ );
141
+ };
142
+
143
+ export default LinkParticipantModal;
@@ -0,0 +1,48 @@
1
+ @use '@carbon/layout';
2
+ @use '@carbon/type';
3
+ @use '@carbon/colors';
4
+
5
+ .comparison {
6
+ display: flex;
7
+ flex-wrap: wrap;
8
+ gap: layout.$spacing-05;
9
+ margin-bottom: layout.$spacing-05;
10
+ }
11
+
12
+ .side {
13
+ display: flex;
14
+ flex-direction: column;
15
+ gap: layout.$spacing-02;
16
+ min-width: 0;
17
+ }
18
+
19
+ .sideLabel {
20
+ @include type.type-style('label-01');
21
+ color: colors.$gray-70;
22
+ }
23
+
24
+ .sideValue {
25
+ @include type.type-style('heading-compact-01');
26
+ color: colors.$gray-100;
27
+ }
28
+
29
+ .willWrite {
30
+ @include type.type-style('label-01');
31
+ color: colors.$gray-70;
32
+ margin-bottom: layout.$spacing-03;
33
+ }
34
+
35
+ .value {
36
+ font-weight: 600;
37
+ }
38
+
39
+ .note {
40
+ @include type.type-style('label-01');
41
+ color: colors.$gray-70;
42
+ margin-top: layout.$spacing-04;
43
+ }
44
+
45
+ .notification {
46
+ margin-top: layout.$spacing-04;
47
+ max-width: 100%;
48
+ }
package/src/pcs.scss ADDED
@@ -0,0 +1,241 @@
1
+ @use '@carbon/layout';
2
+ @use '@carbon/type';
3
+ @use '@carbon/colors';
4
+ @use '@openmrs/esm-styleguide/src/vars' as *;
5
+
6
+ .pcsColumn {
7
+ width: 100%;
8
+ min-width: 0;
9
+ margin-top: layout.$spacing-05;
10
+ display: flex;
11
+ flex-direction: column;
12
+ gap: layout.$spacing-04;
13
+ background-color: colors.$white;
14
+ border: 1px solid colors.$gray-20;
15
+ border-radius: 4px;
16
+ padding: layout.$spacing-04;
17
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
18
+
19
+ :global(.omrs-breakpoint-lt-desktop) & {
20
+ margin-top: layout.$spacing-04;
21
+ padding: layout.$spacing-03;
22
+ }
23
+
24
+ :global(.omrs-breakpoint-lt-tablet) & {
25
+ padding: layout.$spacing-02;
26
+ border-radius: 2px;
27
+ }
28
+ }
29
+
30
+ .pcsHeader {
31
+ padding-bottom: layout.$spacing-02;
32
+ }
33
+
34
+ .pcsTitle {
35
+ @include type.type-style('heading-compact-01');
36
+ color: colors.$gray-100;
37
+ display: block;
38
+ }
39
+
40
+ .pcsFilters {
41
+ display: flex;
42
+ flex-direction: column;
43
+ gap: layout.$spacing-03;
44
+ padding-bottom: layout.$spacing-03;
45
+ }
46
+
47
+ .pcsFilterRow {
48
+ display: flex;
49
+ align-items: flex-end;
50
+ gap: layout.$spacing-02;
51
+ }
52
+
53
+ .pcsFilterTags {
54
+ display: flex;
55
+ flex-wrap: wrap;
56
+ gap: layout.$spacing-02;
57
+ }
58
+
59
+ .pcsFilterActions {
60
+ display: flex;
61
+ justify-content: flex-end;
62
+ gap: layout.$spacing-02;
63
+ }
64
+
65
+ .pcsFilterHint {
66
+ @include type.type-style('label-01');
67
+ color: colors.$red-60;
68
+ }
69
+
70
+ .pcsResults {
71
+ display: flex;
72
+ flex-direction: column;
73
+ gap: layout.$spacing-03;
74
+ }
75
+
76
+ .pcsResultsCount {
77
+ @include type.type-style('label-01');
78
+ color: colors.$gray-70;
79
+ }
80
+
81
+ .pcsLinkedBanner {
82
+ display: flex;
83
+ flex-direction: column;
84
+ gap: layout.$spacing-02;
85
+ padding: layout.$spacing-03;
86
+ background-color: colors.$green-10;
87
+ border-left: 3px solid colors.$green-50;
88
+ border-radius: 4px;
89
+ }
90
+
91
+ .pcsLinkedTitle {
92
+ @include type.type-style('heading-compact-01');
93
+ color: colors.$gray-100;
94
+ }
95
+
96
+ .pcsLinkedSubtitle {
97
+ @include type.type-style('label-01');
98
+ color: colors.$gray-70;
99
+ }
100
+
101
+ .pcsLinkedActions {
102
+ display: flex;
103
+ flex-wrap: wrap;
104
+ gap: layout.$spacing-02;
105
+ margin-top: layout.$spacing-02;
106
+ }
107
+
108
+ /* Same dashed rule as .pcsContacts, so the banner has one divider style rather than two. */
109
+ .pcsLinkedQuestions {
110
+ display: flex;
111
+ flex-direction: column;
112
+ align-items: flex-start;
113
+ gap: layout.$spacing-01;
114
+ margin-top: layout.$spacing-02;
115
+ padding-top: layout.$spacing-02;
116
+ border-top: 1px dashed colors.$gray-20;
117
+ }
118
+
119
+ .pcsContacts {
120
+ display: flex;
121
+ flex-direction: column;
122
+ gap: layout.$spacing-03;
123
+ padding-top: layout.$spacing-02;
124
+ border-top: 1px dashed colors.$gray-20;
125
+ }
126
+
127
+ .pcsLinkError {
128
+ display: flex;
129
+ flex-direction: column;
130
+ gap: layout.$spacing-02;
131
+ padding: layout.$spacing-04;
132
+ text-align: center;
133
+ }
134
+
135
+ .pcsTile {
136
+ background-color: colors.$gray-10;
137
+ border: 1px solid colors.$gray-20;
138
+ border-left: 3px solid colors.$gray-40;
139
+ border-radius: 4px;
140
+ padding: layout.$spacing-03;
141
+ display: flex;
142
+ flex-direction: column;
143
+ gap: layout.$spacing-02;
144
+ position: relative;
145
+ }
146
+
147
+ .pcsName {
148
+ @include type.type-style('heading-compact-01');
149
+ color: colors.$gray-100;
150
+ }
151
+
152
+ .pcsRow {
153
+ @include type.type-style('label-01');
154
+ color: colors.$gray-70;
155
+ display: flex;
156
+ flex-wrap: wrap;
157
+ align-items: center;
158
+ gap: layout.$spacing-02;
159
+ }
160
+
161
+ .pcsTileActions {
162
+ display: flex;
163
+ justify-content: flex-end;
164
+ margin-top: layout.$spacing-02;
165
+ padding-top: layout.$spacing-02;
166
+ border-top: 1px solid colors.$gray-20;
167
+ }
168
+
169
+ .pcsHousehold {
170
+ @include type.type-style('label-01');
171
+ color: colors.$gray-70;
172
+ padding-top: layout.$spacing-02;
173
+ }
174
+
175
+ .pcsFieldLabel {
176
+ color: colors.$gray-60;
177
+ }
178
+
179
+ .separator {
180
+ color: colors.$gray-40;
181
+ }
182
+
183
+ .genderIcon {
184
+ display: inline-flex;
185
+ align-items: center;
186
+ gap: layout.$spacing-02;
187
+
188
+ svg {
189
+ fill: colors.$gray-70;
190
+ }
191
+ }
192
+
193
+ .matchTag {
194
+ position: absolute;
195
+ top: layout.$spacing-03;
196
+ right: layout.$spacing-03;
197
+ }
198
+
199
+ /* In-flow tags. Zeroing Carbon's own tag margin leaves the parent row's `gap` as the single
200
+ source of spacing, so several can sit side by side. */
201
+ .enrollmentTag {
202
+ margin: 0;
203
+ }
204
+
205
+ .pcsLoading {
206
+ display: flex;
207
+ flex-direction: column;
208
+ gap: layout.$spacing-03;
209
+ }
210
+
211
+ .pcsSkeletonTile {
212
+ border: 1px solid colors.$gray-20;
213
+ border-radius: 4px;
214
+ padding: layout.$spacing-03;
215
+ }
216
+
217
+ .pcsEmptyState {
218
+ display: flex;
219
+ flex-direction: column;
220
+ align-items: center;
221
+ justify-content: center;
222
+ text-align: center;
223
+ min-height: 12rem;
224
+ row-gap: layout.$spacing-02;
225
+ padding: layout.$spacing-04;
226
+
227
+ svg {
228
+ width: 140px;
229
+ height: 70px;
230
+ }
231
+
232
+ .pcsEmptyTitle {
233
+ @include type.type-style('heading-compact-01');
234
+ color: colors.$cool-gray-70;
235
+ }
236
+
237
+ .pcsEmptySubtitle {
238
+ @include type.type-style('label-01');
239
+ color: colors.$cool-gray-70;
240
+ }
241
+ }