@pih/esm-commons-app 2.1.1-pre.15 → 2.1.1-pre.154

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 (136) hide show
  1. package/.turbo/turbo-build.log +5 -35
  2. package/dist/117.js +1 -0
  3. package/dist/117.js.map +1 -0
  4. package/dist/125.js +1 -0
  5. package/dist/125.js.map +1 -0
  6. package/dist/163.js +1 -0
  7. package/dist/163.js.map +1 -0
  8. package/dist/240.js +1 -0
  9. package/dist/240.js.map +1 -0
  10. package/dist/294.js +1 -0
  11. package/dist/294.js.map +1 -0
  12. package/dist/347.js +1 -0
  13. package/dist/347.js.map +1 -0
  14. package/dist/349.js +1 -0
  15. package/dist/349.js.map +1 -0
  16. package/dist/378.js +1 -0
  17. package/dist/378.js.map +1 -0
  18. package/dist/395.js +33 -0
  19. package/dist/395.js.map +1 -0
  20. package/dist/466.js +1 -0
  21. package/dist/466.js.map +1 -0
  22. package/dist/499.js +1 -0
  23. package/dist/499.js.map +1 -0
  24. package/dist/508.js +1 -0
  25. package/dist/508.js.map +1 -0
  26. package/dist/61.js +1 -0
  27. package/dist/61.js.map +1 -0
  28. package/dist/646.js +17 -0
  29. package/dist/646.js.map +1 -0
  30. package/dist/664.js +1 -0
  31. package/dist/664.js.map +1 -0
  32. package/dist/689.js +1 -0
  33. package/dist/689.js.map +1 -0
  34. package/dist/701.js +1 -0
  35. package/dist/701.js.map +1 -0
  36. package/dist/712.js +1 -0
  37. package/dist/712.js.map +1 -0
  38. package/dist/72.js +1 -0
  39. package/dist/72.js.map +1 -0
  40. package/dist/743.js +1 -0
  41. package/dist/743.js.map +1 -0
  42. package/dist/771.js +1 -0
  43. package/dist/771.js.map +1 -0
  44. package/dist/830.js +1 -0
  45. package/dist/830.js.map +1 -0
  46. package/dist/846.js +11 -0
  47. package/dist/846.js.map +1 -0
  48. package/dist/852.js +1 -0
  49. package/dist/852.js.map +1 -0
  50. package/dist/855.js +1 -0
  51. package/dist/855.js.map +1 -0
  52. package/dist/859.js +1 -0
  53. package/dist/859.js.map +1 -0
  54. package/dist/867.js +1 -0
  55. package/dist/867.js.map +1 -0
  56. package/dist/912.js +1 -0
  57. package/dist/912.js.map +1 -0
  58. package/dist/913.js +1 -0
  59. package/dist/913.js.map +1 -0
  60. package/dist/959.js +1 -0
  61. package/dist/959.js.map +1 -0
  62. package/dist/985.js +1 -0
  63. package/dist/985.js.map +1 -0
  64. package/dist/989.js +1 -0
  65. package/dist/989.js.map +1 -0
  66. package/dist/main.js +3 -1
  67. package/dist/main.js.map +1 -1
  68. package/dist/pih-esm-commons-app.js +3 -1
  69. package/dist/pih-esm-commons-app.js.buildmanifest.json +642 -107
  70. package/dist/pih-esm-commons-app.js.map +1 -1
  71. package/dist/routes.json +1 -1
  72. package/package.json +7 -10
  73. package/rspack.config.js +1 -0
  74. package/src/config-schema.ts +85 -0
  75. package/src/declarations.d.ts +2 -0
  76. package/src/dispensing/prescription-label-print-action.extension.test.tsx +47 -0
  77. package/src/dispensing/prescription-label-print-action.extension.tsx +45 -0
  78. package/src/dispensing/prescription-label-print.modal.test.tsx +125 -0
  79. package/src/dispensing/prescription-label-print.modal.tsx +139 -0
  80. package/src/dispensing/prescription-label-print.types.ts +16 -0
  81. package/src/dispensing/zebra-label-print-action.extension.tsx +66 -0
  82. package/src/dispensing/zebra-label-print.resource.ts +70 -0
  83. package/src/dispensing/zebra-pdf-print.ts +108 -0
  84. package/src/hooks/useConceptReferenceRanges.ts +128 -0
  85. package/src/hooks/useEncounters.ts +22 -0
  86. package/src/hooks/useVisitAttribute.ts +37 -0
  87. package/src/index.ts +49 -12
  88. package/src/routes.json +76 -11
  89. package/src/service-queues-app/maternal-triage-form.workspace.tsx +111 -0
  90. package/src/service-queues-app/maternal-triage-queue-actions.extension.tsx +132 -0
  91. package/src/service-queues-app/maternal-triage-queue-actions.resource.ts +84 -0
  92. package/src/service-queues-app/maternal-trial-queue-actions.scss +14 -0
  93. package/src/service-queues-app/types.ts +58 -0
  94. package/src/ward-app/VitalsTable.tsx +361 -0
  95. package/src/ward-app/inborn-outborn-tag-row.extension.tsx +79 -0
  96. package/src/ward-app/inborn-outborn-tag-row.scss +57 -0
  97. package/src/ward-app/o2-iframe.component.tsx +13 -4
  98. package/src/ward-app/o2-pregnancy-infant-dashboard.extension.tsx +29 -10
  99. package/src/ward-app/o2-pregnancy-infant-dashboard.scss +5 -0
  100. package/src/ward-app/o2-visit-summary-action-button.component.tsx +17 -0
  101. package/src/ward-app/o2-visit-summary-workspace.component.tsx +104 -26
  102. package/src/ward-app/o2-vital-signs-action-button.component.tsx +17 -0
  103. package/src/ward-app/o2-vital-signs-workspace.component.tsx +65 -0
  104. package/src/ward-app/types.ts +7 -4
  105. package/translations/en.json +32 -3
  106. package/translations/es.json +15 -1
  107. package/translations/fr.json +15 -1
  108. package/dist/138.js +0 -1
  109. package/dist/138.js.map +0 -1
  110. package/dist/291.js +0 -1
  111. package/dist/291.js.map +0 -1
  112. package/dist/300.js +0 -1
  113. package/dist/372.js +0 -1
  114. package/dist/372.js.map +0 -1
  115. package/dist/41.js +0 -2
  116. package/dist/41.js.LICENSE.txt +0 -9
  117. package/dist/41.js.map +0 -1
  118. package/dist/411.js +0 -2
  119. package/dist/411.js.LICENSE.txt +0 -5
  120. package/dist/411.js.map +0 -1
  121. package/dist/449.js +0 -2
  122. package/dist/449.js.LICENSE.txt +0 -9
  123. package/dist/449.js.map +0 -1
  124. package/dist/55.js +0 -1
  125. package/dist/641.js +0 -1
  126. package/dist/641.js.map +0 -1
  127. package/dist/660.js +0 -1
  128. package/dist/660.js.map +0 -1
  129. package/dist/831.js +0 -2
  130. package/dist/831.js.LICENSE.txt +0 -5
  131. package/dist/831.js.map +0 -1
  132. package/dist/90.js +0 -1
  133. package/dist/90.js.map +0 -1
  134. package/dist/99.js +0 -1
  135. package/src/ward-app/o2-visit-summary-action-button.extension.tsx +0 -18
  136. package/webpack.config.js +0 -1
@@ -0,0 +1,361 @@
1
+ import React, { useMemo, useState } from 'react';
2
+ import { useTranslation } from 'react-i18next';
3
+ import {
4
+ DataTable,
5
+ Table,
6
+ TableBody,
7
+ TableCell,
8
+ TableContainer,
9
+ TableHead,
10
+ TableHeader,
11
+ TableRow,
12
+ Button,
13
+ } from '@carbon/react';
14
+ import { restBaseUrl, useConfig, useOpenmrsFetchAll, usePagination, usePatient } from '@openmrs/esm-framework';
15
+ import { Pagination, NumericObservation } from '@openmrs/esm-styleguide';
16
+ import {
17
+ useConceptReferenceRanges,
18
+ calculateInterpretation,
19
+ type ObservationInterpretation,
20
+ } from '../hooks/useConceptReferenceRanges';
21
+ import dayjs from 'dayjs';
22
+
23
+ interface VitalsTableProps {
24
+ patientUuid: string;
25
+ visitUuid: string;
26
+ }
27
+
28
+ interface Observation {
29
+ uuid: string;
30
+ concept: {
31
+ uuid: string;
32
+ name: {
33
+ display: string;
34
+ };
35
+ };
36
+ value: number | string | object | null;
37
+ }
38
+
39
+ interface EncounterProvider {
40
+ provider: {
41
+ person: {
42
+ display: string;
43
+ };
44
+ };
45
+ }
46
+
47
+ interface Encounter {
48
+ uuid: string;
49
+ display: string;
50
+ encounterDatetime: string;
51
+ encounterProviders?: EncounterProvider[];
52
+ obs: Observation[];
53
+ }
54
+
55
+ interface EncounterResponse {
56
+ results: Encounter[];
57
+ }
58
+
59
+ const customRepresentation =
60
+ 'custom:(uuid,display,encounterDatetime,encounterProviders:(provider:(person:(display))),obs:(uuid,concept:(uuid,name:(display)),value))';
61
+
62
+ const VitalsTable: React.FC<VitalsTableProps> = ({ patientUuid, visitUuid }) => {
63
+ const { concepts: externalConcepts } = useConfig<{ concepts: Record<string, string> }>({
64
+ externalModuleName: '@openmrs/esm-patient-vitals-app',
65
+ });
66
+ const { concepts: localConcepts } = useConfig<{
67
+ concepts: { hemoglobinUuid: string; glucoseUuid: string; fhrUuid: string; secondFhrUuid: string };
68
+ }>();
69
+ const vitalsConcepts = {
70
+ systolic: externalConcepts.systolicBloodPressureUuid,
71
+ diastolic: externalConcepts.diastolicBloodPressureUuid,
72
+ pulse: externalConcepts.pulseUuid,
73
+ temperature: externalConcepts.temperatureUuid,
74
+ oxygenSaturation: externalConcepts.oxygenSaturationUuid,
75
+ respiratoryRate: externalConcepts.respiratoryRateUuid,
76
+ hemoglobin: localConcepts.hemoglobinUuid,
77
+ glucose: localConcepts.glucoseUuid,
78
+ fhr: localConcepts.fhrUuid,
79
+ secondFhr: localConcepts.secondFhrUuid,
80
+ };
81
+ const { referenceRangeMap } = useConceptReferenceRanges(patientUuid, Object.values(vitalsConcepts));
82
+ const { t } = useTranslation();
83
+ const [showAll, setShowAll] = useState(false);
84
+ const { patient } = usePatient(patientUuid);
85
+
86
+ // Check if patient is less than 30 days old
87
+ const isNewborn = useMemo(() => {
88
+ if (!patient?.birthDate) return false;
89
+ const birthDate = dayjs(patient.birthDate);
90
+ const ageInDays = dayjs().diff(birthDate, 'day');
91
+ return ageInDays < 30;
92
+ }, [patient?.birthDate]);
93
+
94
+ const interpretationLabels: Record<ObservationInterpretation, string> = {
95
+ normal: t('normal', 'Normal'),
96
+ high: t('high', 'High'),
97
+ critically_high: t('criticallyHigh', 'Critically high'),
98
+ off_scale_high: t('offScaleHigh', 'Off scale high'),
99
+ low: t('low', 'Low'),
100
+ critically_low: t('criticallyLow', 'Critically low'),
101
+ off_scale_low: t('offScaleLow', 'Off scale low'),
102
+ };
103
+
104
+ interface VitalCellData {
105
+ value: number | string | object | null;
106
+ interpretation?: ObservationInterpretation;
107
+ conceptUuid?: string;
108
+ }
109
+
110
+ interface VitalRowData {
111
+ id: string;
112
+ date: string;
113
+ bp: { systolic?: VitalCellData; diastolic?: VitalCellData };
114
+ pulse?: VitalCellData;
115
+ temperature?: VitalCellData;
116
+ oxygenSaturation?: VitalCellData;
117
+ respiratoryRate?: VitalCellData;
118
+ hemoglobin?: VitalCellData;
119
+ glucose?: VitalCellData;
120
+ fhr?: VitalCellData;
121
+ secondFhr?: VitalCellData;
122
+ provider?: string;
123
+ }
124
+
125
+ type VitalColumnKey = Exclude<keyof VitalRowData, 'id' | 'date' | 'provider'>;
126
+
127
+ const createVitalCellData = (value: number | string | object | null, conceptUuid?: string): VitalCellData => {
128
+ if (value === null || value === undefined) {
129
+ return { value: null };
130
+ }
131
+
132
+ const numericValue = typeof value === 'object' ? undefined : value;
133
+ const interpretation = conceptUuid
134
+ ? calculateInterpretation(numericValue, referenceRangeMap[conceptUuid])
135
+ : undefined;
136
+
137
+ return {
138
+ value,
139
+ interpretation,
140
+ conceptUuid,
141
+ };
142
+ };
143
+
144
+ const vitalColumns: Array<{ key: VitalColumnKey; label: string; conceptUuid?: string }> = useMemo(() => {
145
+ let columns = [
146
+ { key: 'bp', label: t('bloodPressure', 'BP') },
147
+ { key: 'pulse', label: t('pulse', 'HR'), conceptUuid: vitalsConcepts.pulse },
148
+ { key: 'temperature', label: t('temperature', 'Temp'), conceptUuid: vitalsConcepts.temperature },
149
+ { key: 'respiratoryRate', label: t('respiratoryRate', 'RR'), conceptUuid: vitalsConcepts.respiratoryRate },
150
+ { key: 'oxygenSaturation', label: t('oxygenSaturation', 'O2 Sat'), conceptUuid: vitalsConcepts.oxygenSaturation },
151
+ { key: 'glucose', label: t('glucose', 'Glucose'), conceptUuid: vitalsConcepts.glucose },
152
+ ] as Array<{ key: VitalColumnKey; label: string; conceptUuid?: string }>;
153
+
154
+ // Only include hemoglobin, fhr, and secondFhr for patients 30 days or older
155
+ if (!isNewborn) {
156
+ columns.push(
157
+ { key: 'hemoglobin', label: t('hemoglobin', 'Hb'), conceptUuid: vitalsConcepts.hemoglobin },
158
+ { key: 'fhr', label: t('fetalHeartRate', 'FHR'), conceptUuid: vitalsConcepts.fhr },
159
+ { key: 'secondFhr', label: t('secondFetalHeartRate', '2nd FHR'), conceptUuid: vitalsConcepts.secondFhr },
160
+ );
161
+ }
162
+
163
+ return columns;
164
+ }, [isNewborn, vitalsConcepts, t]);
165
+
166
+ const renderVitalCell = (
167
+ cellData: VitalCellData | { systolic?: VitalCellData; diastolic?: VitalCellData } | undefined,
168
+ ) => {
169
+ if (!cellData) {
170
+ return <span>—</span>;
171
+ }
172
+
173
+ // Handle BP which is an object with systolic and diastolic
174
+ if ('systolic' in cellData) {
175
+ const bpData = cellData as { systolic?: VitalCellData; diastolic?: VitalCellData };
176
+ return (
177
+ <div style={{ display: 'flex', gap: '0', alignItems: 'center' }}>
178
+ {bpData.systolic && (
179
+ <NumericObservation
180
+ value={
181
+ bpData.systolic.value !== null && bpData.systolic.value !== undefined
182
+ ? String(bpData.systolic.value)
183
+ : ''
184
+ }
185
+ interpretation={bpData.systolic.interpretation}
186
+ conceptUuid={bpData.systolic.conceptUuid}
187
+ variant="cell"
188
+ patientUuid={patientUuid}
189
+ />
190
+ )}
191
+ <span style={{ margin: '0 -0.25rem' }}>/</span>
192
+ {bpData.diastolic && (
193
+ <NumericObservation
194
+ value={
195
+ bpData.diastolic.value !== null && bpData.diastolic.value !== undefined
196
+ ? String(bpData.diastolic.value)
197
+ : ''
198
+ }
199
+ interpretation={bpData.diastolic.interpretation}
200
+ conceptUuid={bpData.diastolic.conceptUuid}
201
+ variant="cell"
202
+ patientUuid={patientUuid}
203
+ />
204
+ )}
205
+ </div>
206
+ );
207
+ }
208
+
209
+ // Handle regular vitals
210
+ const vitalData = cellData as VitalCellData;
211
+ if (vitalData.value === null || vitalData.value === undefined) {
212
+ return <span>—</span>;
213
+ }
214
+
215
+ return (
216
+ <NumericObservation
217
+ value={String(vitalData.value)}
218
+ interpretation={vitalData.interpretation}
219
+ conceptUuid={vitalData.conceptUuid}
220
+ variant="cell"
221
+ patientUuid={patientUuid}
222
+ />
223
+ );
224
+ };
225
+
226
+ const createRowData = (encounter: Encounter): VitalRowData => {
227
+ const vitalsMap = encounter.obs.reduce(
228
+ (acc, obs) => {
229
+ acc[obs.concept.uuid] = obs.value;
230
+ return acc;
231
+ },
232
+ {} as Record<string, number | string | object | null>,
233
+ );
234
+
235
+ const providerName = encounter.encounterProviders?.[0]?.provider?.person?.display || '—';
236
+
237
+ return {
238
+ id: encounter.uuid,
239
+ date: dayjs(encounter.encounterDatetime).format('DD/MM/YYYY HH:mm'),
240
+ bp: {
241
+ systolic: createVitalCellData(vitalsMap[vitalsConcepts.systolic]),
242
+ diastolic: createVitalCellData(vitalsMap[vitalsConcepts.diastolic]),
243
+ },
244
+ pulse: createVitalCellData(vitalsMap[vitalsConcepts.pulse]),
245
+ temperature: createVitalCellData(vitalsMap[vitalsConcepts.temperature]),
246
+ oxygenSaturation: createVitalCellData(
247
+ vitalsMap[vitalsConcepts.oxygenSaturation],
248
+ vitalsConcepts.oxygenSaturation,
249
+ ),
250
+ respiratoryRate: createVitalCellData(vitalsMap[vitalsConcepts.respiratoryRate]),
251
+ hemoglobin: createVitalCellData(vitalsMap[vitalsConcepts.hemoglobin]),
252
+ glucose: createVitalCellData(vitalsMap[vitalsConcepts.glucose]),
253
+ fhr: createVitalCellData(vitalsMap[vitalsConcepts.fhr]),
254
+ secondFhr: createVitalCellData(vitalsMap[vitalsConcepts.secondFhr]),
255
+ provider: providerName,
256
+ };
257
+ };
258
+
259
+ const { data: vitalsData, error } = useOpenmrsFetchAll<Encounter>(
260
+ `${restBaseUrl}/encounter?patient=${patientUuid}&visit=${visitUuid}&order=desc&v=${customRepresentation}`,
261
+ );
262
+
263
+ const encounters: Encounter[] = vitalsData || [];
264
+
265
+ // Filter encounters to only include those with vitals observations
266
+ const encountersWithVitals = encounters.filter((encounter) => {
267
+ return encounter.obs.some((obs) => Object.values(vitalsConcepts).includes(obs.concept.uuid));
268
+ });
269
+
270
+ // Process each encounter to extract vital signs
271
+ const tableRows = useMemo(
272
+ () => encountersWithVitals.map(createRowData),
273
+ [encountersWithVitals, vitalsConcepts, referenceRangeMap],
274
+ );
275
+
276
+ const pageSize = 5;
277
+ const { results: paginatedVitals, currentPage, goTo } = usePagination(tableRows, pageSize);
278
+
279
+ const displayedRows = showAll ? tableRows : paginatedVitals;
280
+
281
+ if (error) {
282
+ return <div>{t('errorLoadingVitals', 'Error loading vitals')}</div>;
283
+ }
284
+
285
+ if (!vitalsData) {
286
+ return <div>{t('loadingVitals', 'Loading vitals...')}</div>;
287
+ }
288
+
289
+ const tableHeaders = [
290
+ { key: 'date', header: t('date', 'Date') },
291
+ ...vitalColumns.map((col) => ({ key: col.key, header: col.label })),
292
+ { key: 'provider', header: t('provider', 'Provider') },
293
+ ];
294
+
295
+ return (
296
+ <>
297
+ <DataTable headers={tableHeaders} rows={displayedRows} size="sm" useZebraStyles>
298
+ {({ rows, headers, getTableProps, getHeaderProps, getRowProps }) => (
299
+ <TableContainer>
300
+ <Table {...getTableProps()}>
301
+ <TableHead>
302
+ <TableRow>
303
+ {headers.map((header) => (
304
+ <TableHeader {...getHeaderProps({ header })} key={header.key}>
305
+ {header.header}
306
+ </TableHeader>
307
+ ))}
308
+ </TableRow>
309
+ </TableHead>
310
+ <TableBody>
311
+ {rows.map((row) => {
312
+ const rowData = displayedRows.find((r) => r.id === row.id) as VitalRowData | undefined;
313
+
314
+ return (
315
+ <TableRow {...getRowProps({ row })} key={row.id}>
316
+ {row.cells.map((cell) => {
317
+ if (cell.info.header === 'date' || cell.info.header === 'provider') {
318
+ return <TableCell key={cell.id}>{cell.value}</TableCell>;
319
+ }
320
+
321
+ const columnKey = cell.info.header as VitalColumnKey;
322
+ const cellValue = rowData?.[columnKey];
323
+
324
+ return (
325
+ <TableCell key={cell.id} style={{ padding: 0 }}>
326
+ {renderVitalCell(cellValue)}
327
+ </TableCell>
328
+ );
329
+ })}
330
+ </TableRow>
331
+ );
332
+ })}
333
+ </TableBody>
334
+ </Table>
335
+ </TableContainer>
336
+ )}
337
+ </DataTable>
338
+ <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: '1rem' }}>
339
+ <div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginLeft: '1rem' }}>
340
+ <span style={{ fontSize: '0.875rem', color: '#6f6f6f' }}>
341
+ {displayedRows.length} / {tableRows.length} {t('items', 'items')}
342
+ </span>
343
+ <Button kind="ghost" size="sm" onClick={() => setShowAll(!showAll)}>
344
+ {showAll ? t('showPaginated', 'Show Paginated') : t('showAll', 'Show All')}
345
+ </Button>
346
+ </div>
347
+ {!showAll && (
348
+ <Pagination
349
+ pageNumber={currentPage}
350
+ totalItems={tableRows.length}
351
+ currentItems={paginatedVitals.length}
352
+ pageSize={pageSize}
353
+ onPageNumberChange={({ page }) => goTo(page)}
354
+ />
355
+ )}
356
+ </div>
357
+ </>
358
+ );
359
+ };
360
+
361
+ export default VitalsTable;
@@ -0,0 +1,79 @@
1
+ import React from 'react';
2
+ import classNames from 'classnames';
3
+ import { useParams } from 'react-router-dom';
4
+ import { type Visit, useSession } from '@openmrs/esm-framework';
5
+ import { useTranslation } from 'react-i18next';
6
+ import { useVisitAttribute } from '../hooks/useVisitAttribute';
7
+ import styles from './inborn-outborn-tag-row.scss';
8
+
9
+ /**
10
+ * Stable, non-module class on the badge so card headers can reserve space for it via a `:has()`
11
+ * sibling rule in openmrs-esm-patient-management (see ward-patient-card.scss /
12
+ * admission-request-card.scss). This class is the cross-package contract between the extension slot
13
+ * and the host card layout — keep it in sync with the selectors there.
14
+ */
15
+ const BADGE_MARKER_CLASS = 'ward-inborn-outborn-badge';
16
+
17
+ /**
18
+ * UUID of the visit attribute type that records whether the patient was born in this
19
+ * facility (Inborn) or referred from elsewhere (Outborn).
20
+ */
21
+ const INBORN_OUTBORN_ATTRIBUTE_TYPE_UUID = '86f716fc-5e26-4eb1-9484-46370cff28f0';
22
+
23
+ /**
24
+ * UUID of the NICU ward location. The Inborn/Outborn badge is only relevant on the NICU ward, so the
25
+ * extension renders nothing on any other ward.
26
+ */
27
+ const NICU_LOCATION_UUID = '0ce2f6fb-6850-11ee-ab8d-0242ac120002';
28
+
29
+ interface InbornOutbornTagRowProps {
30
+ /** The active visit, supplied by the extension slot's state. */
31
+ visit: Visit;
32
+ }
33
+
34
+ /**
35
+ * InbornOutbornTagRow displays a tag indicating the patient's place of birth relative to this
36
+ * facility, based on a boolean visit attribute on the active visit. The patient is considered
37
+ * "Inborn" (born here) only when the attribute's value is `true`; in every other case (the value is
38
+ * false, or the visit has no such attribute) they default to "Outborn" (referred in).
39
+ *
40
+ * This is a PIH-specific extension slotted into the maternal ward cards of
41
+ * openmrs-esm-patient-management via `maternal-ward-patient-card-header-slot`. The default visit
42
+ * representation used by the ward hooks does not include attribute values, so the value is fetched
43
+ * on demand via {@link useVisitAttribute}. Following the NICU patient card design, the badge renders
44
+ * as a rounded pill pinned to the top-right corner of the card: blue for Inborn, purple for Outborn.
45
+ * The badge is scoped to the NICU ward only (see {@link NICU_LOCATION_UUID}).
46
+ */
47
+ const InbornOutbornTagRow: React.FC<InbornOutbornTagRowProps> = ({ visit }) => {
48
+ const { t } = useTranslation();
49
+ const { value, isLoading } = useVisitAttribute(visit?.uuid, INBORN_OUTBORN_ATTRIBUTE_TYPE_UUID);
50
+ // Resolve the ward being viewed the same way esm-ward-app does: the location UUID comes from the
51
+ // ward route (`.../ward/:locationUuid`), falling back to the session location when unset.
52
+ const { locationUuid } = useParams();
53
+ const { sessionLocation } = useSession();
54
+ const wardLocationUuid = locationUuid ?? sessionLocation?.uuid;
55
+
56
+ // Only show the badge on the NICU ward.
57
+ if (wardLocationUuid !== NICU_LOCATION_UUID) {
58
+ return null;
59
+ }
60
+
61
+ // Avoid flashing a (default) Outborn badge before the attribute value has loaded.
62
+ if (isLoading) {
63
+ return null;
64
+ }
65
+
66
+ const isInborn = value === true;
67
+
68
+ return (
69
+ <div className={classNames(styles.inbornOutbornBadge, BADGE_MARKER_CLASS)}>
70
+ {isInborn ? (
71
+ <span className={classNames(styles.badge, styles.inborn)}>{t('inborn', 'Inborn')}</span>
72
+ ) : (
73
+ <span className={classNames(styles.badge, styles.outborn)}>{t('outborn', 'Outborn')}</span>
74
+ )}
75
+ </div>
76
+ );
77
+ };
78
+
79
+ export default InbornOutbornTagRow;
@@ -0,0 +1,57 @@
1
+ @use '@carbon/layout';
2
+
3
+ /**
4
+ * The extension slot wrapper and its inner `Extension` wrapper are both hardcoded to
5
+ * `position: relative` by @openmrs/esm-framework, which would otherwise trap the absolutely
6
+ * positioned badge inside that small in-flow wrapper box instead of the card. `display: contents`
7
+ * removes those wrapper boxes from layout so the badge anchors to the `position: relative` card and
8
+ * pins to its top-right corner. (Sibling/`:has()` selectors still match — `display: contents` only
9
+ * affects box generation, not the DOM tree.)
10
+ */
11
+ :global {
12
+ [data-extension-slot-name='maternal-ward-patient-card-header-slot'],
13
+ [data-extension-slot-name='maternal-ward-patient-card-header-slot'] [data-extension-id] {
14
+ display: contents;
15
+ }
16
+ }
17
+
18
+ /**
19
+ * Inborn/Outborn origin badge, rendered as a rounded pill pinned to the top-right corner of the
20
+ * patient card (per the NICU patient card design). The card container is `position: relative`, so
21
+ * the badge positions against the whole card regardless of where it sits in the DOM (including when
22
+ * it is injected through an extension slot).
23
+ */
24
+ .inbornOutbornBadge {
25
+ position: absolute;
26
+ top: 0.875rem; // 14px, matches the design
27
+ right: 0.875rem;
28
+ z-index: 2; // sit above the card's clickable overlay (::before, z-index 1)
29
+ margin: 0 !important; // neutralize blanket child margins applied by some card containers
30
+ pointer-events: none; // let clicks fall through to the underlying card button
31
+ }
32
+
33
+ // The origin badge, rendered as the design's fully-rounded pill.
34
+ .badge {
35
+ display: inline-flex;
36
+ align-items: center;
37
+ height: 1.5rem; // 24px
38
+ min-height: 1.5rem;
39
+ padding: 0 0.75rem; // 12px
40
+ border-radius: 62.5rem;
41
+ font-size: 0.75rem; // 12px, matches Carbon tag
42
+ font-weight: 600;
43
+ line-height: 1;
44
+ white-space: nowrap;
45
+ }
46
+
47
+ // Inborn: blue (matches the former Carbon `blue` tag colors).
48
+ .inborn {
49
+ background-color: #d0e2ff;
50
+ color: #0043ce;
51
+ }
52
+
53
+ // Outborn: purple (matches the former Carbon `purple` tag colors).
54
+ .outborn {
55
+ background-color: #e8daff;
56
+ color: #6929c4;
57
+ }
@@ -1,9 +1,9 @@
1
1
  import { ArrowLeft } from '@carbon/react/icons';
2
2
 
3
- import React, { useCallback, useMemo, useRef, useState } from 'react';
4
- import styles from './o2-iframe.scss';
5
3
  import { IconButton, InlineLoading } from '@carbon/react';
4
+ import React, { useMemo, useRef, useState } from 'react';
6
5
  import { useTranslation } from 'react-i18next';
6
+ import styles from './o2-iframe.scss';
7
7
 
8
8
  interface O2IFrame {
9
9
  src: string;
@@ -16,9 +16,14 @@ interface O2IFrame {
16
16
  * a list of css selectors defining elements to be made un-clickable within the iframe
17
17
  */
18
18
  elementsToDisable?: string[];
19
+
20
+ /**
21
+ * JavaScript code to inject into the iframe to run
22
+ */
23
+ customJavaScript?: string;
19
24
  }
20
25
 
21
- const O2IFrame: React.FC<O2IFrame> = ({ src, elementsToHide, elementsToDisable }) => {
26
+ const O2IFrame: React.FC<O2IFrame> = ({ src, elementsToHide, elementsToDisable, customJavaScript = '' }) => {
22
27
  const iframeRef = useRef<HTMLIFrameElement>();
23
28
  const [isIframeLoading, setIsIframeLoading] = useState(true);
24
29
  const [isGoingBack, setIsGoingBack] = useState(false);
@@ -32,7 +37,7 @@ const O2IFrame: React.FC<O2IFrame> = ({ src, elementsToHide, elementsToDisable }
32
37
 
33
38
  return `@media screen {
34
39
  ${toHide}
35
- ${toDisable}
40
+ ${toDisable}
36
41
  }`;
37
42
  }, [elementsToHide, elementsToDisable]);
38
43
 
@@ -48,6 +53,10 @@ const O2IFrame: React.FC<O2IFrame> = ({ src, elementsToHide, elementsToDisable }
48
53
  const styleTag = contentDocument.createElement('style');
49
54
  styleTag.innerHTML = customCss;
50
55
  contentDocument.head.appendChild(styleTag);
56
+
57
+ const scriptTag = contentDocument.createElement('script');
58
+ scriptTag.innerHTML = customJavaScript;
59
+ contentDocument.body.appendChild(scriptTag);
51
60
  };
52
61
 
53
62
  const goBack = () => {
@@ -3,6 +3,7 @@ import React from 'react';
3
3
  import { useTranslation } from 'react-i18next';
4
4
  import { useActivePatientEnrollment } from '../hooks/useProgramEnrollment';
5
5
  import O2IFrame from './o2-iframe.component';
6
+ import styles from './o2-pregnancy-infant-dashboard.scss';
6
7
 
7
8
  /**
8
9
  * Extension to display either the O2 pregnancy program or infant program dashboard,
@@ -30,6 +31,9 @@ const O2PregnancyInfantProgramDashboard: React.FC<{ patientUuid: string }> = ({
30
31
  '.pregnancy\\.dashboard\\.ancFollowupEncounters',
31
32
  '.pregnancy\\.dashboard\\.riskFactors a.right',
32
33
  '.pregnancy\\.dashboard\\.temperature a.right',
34
+ '.pregnancy\\.dashboard\\.postpartumDailyProgressEncounters',
35
+ '.pregnancy\\.dashboard\\.laborAndDeliverySummaryEncounters',
36
+ '.pregnancy\\.dashboard\\.maternalAdmissionEncounters',
33
37
  '.patient-location',
34
38
  '.pregnancy\\.dashboard\\.currentEnrollment',
35
39
  '.infant\\.dashboard\\.newbornAssesmentEncounters',
@@ -48,19 +52,34 @@ const O2PregnancyInfantProgramDashboard: React.FC<{ patientUuid: string }> = ({
48
52
  if (isLoading) {
49
53
  return <InlineLoading />;
50
54
  } else if (inInfantProgram) {
51
- const src = `${window.openmrsBase}/coreapps/clinicianfacing/patient.page?patientId=${patientUuid}&dashboard=${infantProgramUuid}`;
52
- return <O2IFrame key={patientUuid} {...{ src, elementsToDisable, elementsToHide }} />;
55
+ const infantProgramUrl = `${window.openmrsBase}/coreapps/clinicianfacing/patient.page?patientId=${patientUuid}&dashboard=${infantProgramUuid}`;
56
+ return (
57
+ <>
58
+ <div className={styles.patientChartLinkContainer}>
59
+ <a href={infantProgramUrl}>{t('fullInfantDashboard', 'Full Infant Dashboard')}</a>
60
+ </div>
61
+ <O2IFrame key={patientUuid} {...{ src: infantProgramUrl, elementsToDisable, elementsToHide }} />
62
+ </>
63
+ );
53
64
  } else if (inPregnancyProgram) {
54
- const src = `${window.openmrsBase}/coreapps/clinicianfacing/patient.page?patientId=${patientUuid}&dashboard=${pregnancyProgramUuid}`;
55
- return <O2IFrame key={patientUuid} {...{ src, elementsToDisable, elementsToHide }} />;
65
+ const pregnancyProgramUrl = `${window.openmrsBase}/coreapps/clinicianfacing/patient.page?patientId=${patientUuid}&dashboard=${pregnancyProgramUuid}`;
66
+ return (
67
+ <>
68
+ <div className={styles.patientChartLinkContainer}>
69
+ <a href={pregnancyProgramUrl}>{t('fullPregnancyDashboard', 'Full Pregnancy Dashboard')}</a>
70
+ </div>
71
+ <O2IFrame key={patientUuid} {...{ src: pregnancyProgramUrl, elementsToDisable, elementsToHide }} />
72
+ </>
73
+ );
56
74
  } else {
75
+ const patientChartUrl = `${window.openmrsBase}/coreapps/clinicianfacing/patient.page?patientId=${patientUuid}`;
57
76
  return (
58
- <div>
59
- {t(
60
- 'patientNotEnrolledInInfantOrPregnancyProgram',
61
- 'Patient not enrolled in either Infant or Pregnancy Program',
62
- )}
63
- </div>
77
+ <>
78
+ <div className={styles.patientChartLinkContainer}>
79
+ <a href={patientChartUrl}>{t('fullPatientChart', 'Full Patient Chart')}</a>
80
+ </div>
81
+ <O2IFrame key={patientUuid} {...{ src: patientChartUrl, elementsToDisable, elementsToHide }} />
82
+ </>
64
83
  );
65
84
  }
66
85
  };
@@ -0,0 +1,5 @@
1
+ @use '@carbon/layout';
2
+
3
+ .patientChartLinkContainer {
4
+ padding: layout.$spacing-05;
5
+ }
@@ -0,0 +1,17 @@
1
+ import { ActionMenuButton2, CalendarHeatMapIcon } from '@openmrs/esm-framework';
2
+ import React from 'react';
3
+ import { useTranslation } from 'react-i18next';
4
+
5
+ export default function WardPatientActionButton() {
6
+ const { t } = useTranslation();
7
+
8
+ return (
9
+ <ActionMenuButton2
10
+ icon={(props) => <CalendarHeatMapIcon {...props} />}
11
+ label={t('visitSummary', 'Visit summary')}
12
+ workspaceToLaunch={{
13
+ workspaceName: 'o2-visit-summary-workspace',
14
+ }}
15
+ />
16
+ );
17
+ }