@ampath/esm-reports-app 1.0.0-next.7 → 1.0.0-next.71

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 (258) hide show
  1. package/dist/197.js +1 -1
  2. package/dist/282.js +1 -1
  3. package/dist/282.js.map +1 -1
  4. package/dist/295.css +1 -0
  5. package/dist/295.css.map +1 -0
  6. package/dist/295.js +1 -0
  7. package/dist/295.js.map +1 -0
  8. package/dist/299.js +2 -0
  9. package/dist/299.js.LICENSE.txt +3 -0
  10. package/dist/299.js.map +1 -0
  11. package/dist/300.js +1 -1
  12. package/dist/311.js +1 -1
  13. package/dist/311.js.map +1 -1
  14. package/dist/335.js +1 -1
  15. package/dist/353.js +1 -1
  16. package/dist/353.js.map +1 -1
  17. package/dist/354.js +2 -0
  18. package/dist/354.js.LICENSE.txt +20 -0
  19. package/dist/354.js.map +1 -0
  20. package/dist/410.js +1 -0
  21. package/dist/410.js.map +1 -0
  22. package/dist/457.js +2 -0
  23. package/dist/457.js.LICENSE.txt +261 -0
  24. package/dist/457.js.map +1 -0
  25. package/dist/478.js +1 -1
  26. package/dist/478.js.map +1 -1
  27. package/dist/480.js +1 -0
  28. package/dist/540.js +1 -1
  29. package/dist/540.js.map +1 -1
  30. package/dist/55.js +1 -1
  31. package/dist/580.js +1 -0
  32. package/dist/580.js.map +1 -0
  33. package/dist/600.js +2 -0
  34. package/dist/600.js.LICENSE.txt +35 -0
  35. package/dist/600.js.map +1 -0
  36. package/dist/652.js +1 -1
  37. package/dist/781.js +1 -0
  38. package/dist/781.js.map +1 -0
  39. package/dist/919.css +1 -0
  40. package/dist/919.css.map +1 -0
  41. package/dist/919.js +1 -0
  42. package/dist/919.js.map +1 -0
  43. package/dist/961.js +1 -1
  44. package/dist/961.js.map +1 -1
  45. package/dist/99.js +1 -1
  46. package/dist/99.js.map +1 -0
  47. package/dist/ampath-esm-reports-app.js +1 -1
  48. package/dist/ampath-esm-reports-app.js.map +1 -1
  49. package/dist/routes.json +1 -1
  50. package/package.json +4 -2
  51. package/src/common/daily-summary-table.tsx +55 -0
  52. package/src/common/linelist-table/index.ts +3 -0
  53. package/src/common/linelist-table/linelist-columns.ts +48 -0
  54. package/src/common/linelist-table/linelist-table.component.tsx +442 -0
  55. package/src/common/linelist-table/linelist-table.scss +306 -0
  56. package/src/common/linelist-table/linelist-table.test.tsx +120 -0
  57. package/src/common/linelist-table/use-all-rows.ts +93 -0
  58. package/src/common/linelist-table/use-column-layout.ts +123 -0
  59. package/src/common/report-error/index.ts +1 -0
  60. package/src/common/report-error/show-report-error.ts +25 -0
  61. package/src/common/report-filters/date-range.ts +137 -0
  62. package/src/common/report-filters/index.ts +4 -0
  63. package/src/common/report-filters/report-filters.component.tsx +317 -99
  64. package/src/common/report-filters/report-filters.scss +255 -32
  65. package/src/common/report-filters/types.ts +79 -0
  66. package/src/common/report-filters/use-restore-period.ts +43 -0
  67. package/src/common/report-header/index.ts +1 -0
  68. package/src/common/report-header/use-location-details.ts +58 -0
  69. package/src/common/report-page/download-sheet-pdf.ts +59 -0
  70. package/src/common/report-page/index.ts +3 -0
  71. package/src/common/report-page/report-page.component.tsx +32 -0
  72. package/src/common/report-page/report-page.scss +45 -0
  73. package/src/common/report-page/use-page-scale.ts +60 -0
  74. package/src/common/report-placeholder/has-report-data.ts +15 -0
  75. package/src/common/report-placeholder/index.ts +3 -0
  76. package/src/common/report-placeholder/report-placeholder.component.tsx +81 -0
  77. package/src/common/report-placeholder/report-placeholder.scss +48 -0
  78. package/src/common/report-placeholder/use-generated-location.ts +62 -0
  79. package/src/common/report-register/index.ts +6 -0
  80. package/src/common/report-register/register-columns.ts +46 -0
  81. package/src/common/report-register/register-layout.component.tsx +265 -0
  82. package/src/common/report-register/register-layout.scss +150 -0
  83. package/src/common/report-register/register-table.scss +71 -0
  84. package/src/common/report-register/report-parents.ts +27 -0
  85. package/src/common/report-register/rows-to-csv.ts +89 -0
  86. package/src/common/report-register/use-location-name.ts +54 -0
  87. package/src/common/report-register/use-patient-list.ts +92 -0
  88. package/src/common/report-skeleton/index.ts +1 -0
  89. package/src/common/report-skeleton/report-skeleton.component.tsx +46 -0
  90. package/src/common/report-skeleton/report-skeleton.scss +29 -0
  91. package/src/config-schema.ts +9 -1
  92. package/src/dashboard/report-catalog.ts +173 -0
  93. package/src/dashboard/reports-dasboard.tsx +189 -18
  94. package/src/dashboard/reports-dashboard.module.scss +357 -14
  95. package/src/index.ts +13 -1
  96. package/src/registers/moh-240/moh-240-register.scss +41 -0
  97. package/src/registers/moh-240/moh-240.component.test.tsx +166 -0
  98. package/src/registers/moh-240/moh-240.component.tsx +164 -0
  99. package/src/registers/moh-362/moh-362-lab-register.component.tsx +392 -0
  100. package/src/registers/moh-362/moh-362-linkage-register.component.tsx +120 -0
  101. package/src/registers/moh-362/moh-362-register.scss +222 -0
  102. package/src/registers/moh-362/moh-362.component.tsx +99 -0
  103. package/src/registers/moh-362/moh-362.scss +11 -0
  104. package/src/registers/moh-362/moh-362.types.ts +97 -0
  105. package/src/registers/register-catalog.ts +96 -0
  106. package/src/registers/tb-lab-register/tb-lab-register-table.component.tsx +116 -0
  107. package/src/registers/tb-lab-register/tb-lab-register-table.scss +123 -0
  108. package/src/registers/tb-lab-register/tb-lab-register.columns.ts +164 -0
  109. package/src/registers/tb-lab-register/tb-lab-register.component.test.tsx +232 -0
  110. package/src/registers/tb-lab-register/tb-lab-register.component.tsx +153 -0
  111. package/src/reports/datatable-wrapper/datatable-wrapper.component.tsx +63 -48
  112. package/src/reports/moh-240/moh-240-header.component.tsx +62 -0
  113. package/src/reports/moh-240/moh-240.columns.ts +113 -0
  114. package/src/reports/moh-240/moh-240.component.tsx +72 -9
  115. package/src/reports/moh-240/moh-240.scss +40 -0
  116. package/src/reports/moh-240/sub-reports/moh-240-register.component.tsx +31 -37
  117. package/src/reports/moh-240/sub-reports/page-summary.component.tsx +143 -106
  118. package/src/reports/moh-505/moh-505-header.component.tsx +67 -0
  119. package/src/reports/moh-505/moh-505.component.tsx +441 -0
  120. package/src/reports/moh-505/moh-505.scss +117 -0
  121. package/src/reports/moh-705B/moh-204b-register.component.tsx +244 -0
  122. package/src/reports/moh-705B/moh-204b.columns.ts +39 -0
  123. package/src/reports/moh-705B/moh-705b.component.tsx +659 -0
  124. package/src/reports/moh-705B/moh-705b.scss +172 -0
  125. package/src/reports/moh-705a/moh-705a.component.tsx +661 -0
  126. package/src/reports/moh-705a/moh-705a.scss +172 -0
  127. package/src/reports/moh-705a/registers/moh-204a-register.component.tsx +329 -0
  128. package/src/reports/moh-705a/registers/moh-204a.columns.ts +57 -0
  129. package/src/reports/moh-705a/type.ts +6 -0
  130. package/src/reports/moh-706/moh-706-header.component.tsx +66 -0
  131. package/src/reports/moh-706/moh-706.component.tsx +150 -24
  132. package/src/reports/moh-706/moh-706.scss +119 -11
  133. package/src/reports/moh-706/sub-reports/bacteriology/bacteriology.component.tsx +54 -62
  134. package/src/reports/moh-706/sub-reports/blood-chemistry/blood-chemistry.component.tsx +228 -317
  135. package/src/reports/moh-706/sub-reports/drug-susceptibility-testing/drug-susceptibility-testing.component.tsx +31 -46
  136. package/src/reports/moh-706/sub-reports/haematology/haematology.component.tsx +47 -55
  137. package/src/reports/moh-706/sub-reports/histology-and-cytology/histology-and-cytology.component.tsx +43 -58
  138. package/src/reports/moh-706/sub-reports/parasitology/parasitology.component.tsx +31 -39
  139. package/src/reports/moh-706/sub-reports/serology/serology.component.tsx +26 -34
  140. package/src/reports/moh-706/sub-reports/specimen-referral-to-higher-levels/specimen-referral-to-higher-levels.component.tsx +9 -24
  141. package/src/reports/moh-706/sub-reports/urine-analysis/urine-analysis.component.tsx +43 -51
  142. package/src/reports/moh-710/moh-710.component.tsx +799 -632
  143. package/src/reports/moh-710/moh-710.scss +142 -21
  144. package/src/reports/moh-710/registers/moh-510/moh-510.columns.ts +40 -0
  145. package/src/reports/moh-710/registers/moh-510/moh-510.component.tsx +144 -0
  146. package/src/reports/moh-710/registers/moh-510/moh-510.scss +130 -0
  147. package/src/reports/moh-710/registers/moh-511-710-register.component.tsx +268 -0
  148. package/src/reports/moh-710/registers/moh-511-710.columns.ts +52 -0
  149. package/src/reports/moh-710/registers/type.ts +31 -0
  150. package/src/reports/moh-711/moh-711.component.tsx +138 -1269
  151. package/src/reports/moh-711/moh711.scss +135 -20
  152. package/src/reports/moh-711/registers/moh-333-register.component.tsx +637 -0
  153. package/src/reports/moh-711/registers/moh-333.columns.ts +119 -0
  154. package/src/reports/moh-711/registers/moh-405-register.component.tsx +520 -0
  155. package/src/reports/moh-711/registers/moh-405.columns.ts +120 -0
  156. package/src/reports/moh-711/registers/moh-406-register.component.tsx +530 -0
  157. package/src/reports/moh-711/registers/moh-406.columns.ts +133 -0
  158. package/src/reports/moh-711/registers/moh-510-register.component.tsx +55 -0
  159. package/src/reports/moh-711/registers/moh-511-register.component.tsx +270 -0
  160. package/src/reports/moh-711/registers/moh-511.columns.ts +59 -0
  161. package/src/reports/moh-711/registers/type.ts +192 -0
  162. package/src/reports/moh-711/sections/anc.component.tsx +159 -0
  163. package/src/reports/moh-711/sections/cervical-cancer.component.tsx +148 -0
  164. package/src/reports/moh-711/sections/chanis.component.tsx +366 -0
  165. package/src/reports/moh-711/sections/family-planning.component.tsx +221 -0
  166. package/src/reports/moh-711/sections/gbv.component.tsx +115 -0
  167. package/src/reports/moh-711/sections/maternity.component.tsx +321 -0
  168. package/src/reports/moh-711/sections/medical-social-work.component.tsx +73 -0
  169. package/src/reports/moh-711/sections/other.component.tsx +47 -0
  170. package/src/reports/moh-711/sections/physiotherapy.component.tsx +61 -0
  171. package/src/reports/moh-711/sections/pnc.component.tsx +127 -0
  172. package/src/reports/moh-711/sections/post-abortion.component.tsx +42 -0
  173. package/src/reports/moh-711/sections/rehabilitation.component.tsx +57 -0
  174. package/src/reports/moh-711/sections/report-compiled-by.component.tsx +50 -0
  175. package/src/reports/moh-711/sections/tb-screening.component.tsx +57 -0
  176. package/src/reports/moh-717/moh-717.component.tsx +149 -985
  177. package/src/reports/moh-717/moh717.scss +217 -21
  178. package/src/reports/moh-717/registers/nutrition-register.component.tsx +48 -0
  179. package/src/reports/moh-717/sections/finance.component.tsx +42 -0
  180. package/src/reports/moh-717/sections/inpatient.component.tsx +374 -0
  181. package/src/reports/moh-717/sections/maternity.component.tsx +126 -0
  182. package/src/reports/moh-717/sections/medical-records.component.tsx +35 -0
  183. package/src/reports/moh-717/sections/mortuary.component.tsx +43 -0
  184. package/src/reports/moh-717/sections/operations.component.tsx +53 -0
  185. package/src/reports/moh-717/sections/orthopaedic-trauma.component.tsx +98 -0
  186. package/src/reports/moh-717/sections/outpatient.component.tsx +597 -0
  187. package/src/reports/moh-717/sections/pharmacy.component.tsx +43 -0
  188. package/src/reports/moh-717/sections/preparedby.component.tsx +52 -0
  189. package/src/reports/moh-717/sections/special-services.component.tsx +114 -0
  190. package/src/reports/moh-731/linelist/moh-731-indicators.ts +1122 -0
  191. package/src/reports/moh-731/linelist/moh-731-linelist-sections.ts +472 -0
  192. package/src/reports/moh-731/linelist/moh-731-linelist.component.tsx +102 -0
  193. package/src/reports/moh-731/linelist/use-open-moh-731-linelist.ts +50 -0
  194. package/src/reports/moh-731/moh-731.component.tsx +266 -0
  195. package/src/reports/moh-731/moh-731.scss +499 -0
  196. package/src/reports/moh-731/registers/moh-366.columns.ts +38 -0
  197. package/src/reports/moh-731/registers/moh-366.component.tsx +471 -0
  198. package/src/reports/moh-731/registers/prep-register.component.tsx +284 -0
  199. package/src/reports/moh-731/registers/prep.columns.ts +34 -0
  200. package/src/reports/moh-731/sections/hiv-and-tb-treatment.component.tsx +1206 -0
  201. package/src/reports/moh-731/sections/hiv-testing-and-pre-exposure.component.tsx +727 -0
  202. package/src/reports/moh-731/sections/pmtct.component.tsx +572 -0
  203. package/src/reports/moh-731/summary/moh-731-summary.component.tsx +313 -0
  204. package/src/reports/moh-731/summary/moh-731-summary.config.ts +529 -0
  205. package/src/reports/moh-731/summary/moh-731-summary.scss +242 -0
  206. package/src/reports/moh-740/moh-740.component.scss +202 -0
  207. package/src/reports/moh-740/moh-740.component.tsx +1332 -0
  208. package/src/reports/moh-740/moh-740.resource.ts +54 -0
  209. package/src/reports/moh-740/registers/moh-222-daily-register/moh-222-daily-register.tsx +149 -0
  210. package/src/reports/moh-740/registers/moh-270-permanent-register/moh-270-permanent-register.tsx +123 -0
  211. package/src/reports/moh-740/registers/moh-740-patient-list.scss +0 -0
  212. package/src/reports/moh-740/registers/moh-740-patient-list.tsx +60 -0
  213. package/src/reports/moh-740/registers/moh-740.columns.ts +54 -0
  214. package/src/reports/moh-740/shared/data-cell/data-cell.scss +6 -0
  215. package/src/reports/moh-740/shared/data-cell/data-cell.tsx +23 -0
  216. package/src/reports/moh-740/shared/utils/indicator-register-map.ts +159 -0
  217. package/src/reports/moh-740/types/index.ts +223 -0
  218. package/src/reports/moh-745/moh-745.component.tsx +1021 -0
  219. package/src/reports/moh-745/moh-745.scss +132 -0
  220. package/src/reports/moh-745/registers/moh-412-register.component.tsx +249 -0
  221. package/src/reports/moh-745/registers/moh-412.columns.ts +52 -0
  222. package/src/reports/moh-745/registers/type.ts +181 -0
  223. package/src/reports/table-wrapper/table-row-mapper.component.tsx +57 -0
  224. package/src/reports/table-wrapper/table-wrapper.component.tsx +23 -14
  225. package/src/reports/table-wrapper/table-wrapper.scss +1 -16
  226. package/src/resources/moh-362.resource.ts +27 -0
  227. package/src/resources/moh-505.resource.ts +69 -0
  228. package/src/resources/moh-705.resource.ts +106 -0
  229. package/src/resources/moh-706.resource.ts +80 -0
  230. package/src/resources/moh-710.resource.ts +32 -9
  231. package/src/resources/moh-711.resource.ts +137 -9
  232. package/src/resources/moh-717.resource.ts +26 -9
  233. package/src/resources/moh-731.resource.ts +90 -0
  234. package/src/resources/moh-745.resource.ts +76 -0
  235. package/src/resources/patient-list-page.ts +65 -0
  236. package/src/root.component.tsx +59 -8
  237. package/src/root.scss +36 -0
  238. package/src/routes.json +3 -1
  239. package/src/utils/format-indicator.ts +39 -0
  240. package/src/utils/get-base-url.ts +39 -7
  241. package/src/utils/utils.ts +36 -0
  242. package/tsconfig.json +1 -1
  243. package/webpack.config.js +22 -1
  244. package/dist/138.js +0 -2
  245. package/dist/138.js.LICENSE.txt +0 -20
  246. package/dist/138.js.map +0 -1
  247. package/dist/184.js +0 -2
  248. package/dist/184.js.LICENSE.txt +0 -14
  249. package/dist/184.js.map +0 -1
  250. package/dist/205.js +0 -1
  251. package/dist/205.js.map +0 -1
  252. package/dist/711.js +0 -1
  253. package/dist/711.js.map +0 -1
  254. package/dist/812.js +0 -1
  255. package/dist/812.js.map +0 -1
  256. package/dist/ampath-esm-reports-app.js.buildmanifest.json +0 -539
  257. package/dist/main.js +0 -1
  258. package/dist/main.js.map +0 -1
@@ -0,0 +1,123 @@
1
+ @use '@carbon/layout';
2
+ @use '@openmrs/esm-styleguide/src/vars' as *;
3
+
4
+ /* The TB Laboratory Register sheet, drawn as on the printed template: black
5
+ rules, small type, a two-tier header over the appearance and microscopy
6
+ columns. It is a wide landscape sheet, so it scrolls sideways inside its
7
+ frame rather than widening the page. */
8
+
9
+ $ink: #000;
10
+ $rule: 1px solid $ink;
11
+ $text: 0.6875rem; // 11px body
12
+ $small: 0.625rem; // 10px codes
13
+
14
+ .sheet {
15
+ margin-top: layout.$spacing-05;
16
+ padding: layout.$spacing-05;
17
+ color: $ink;
18
+ font-size: $text;
19
+ line-height: 1.25;
20
+ background-color: $ui-02;
21
+ border: 1px solid $ui-03;
22
+ }
23
+
24
+ .runningHead {
25
+ display: grid;
26
+ grid-template-columns: 1fr auto 1fr;
27
+ gap: layout.$spacing-05;
28
+ font-size: $small;
29
+ font-weight: 700;
30
+
31
+ span:nth-child(2) {
32
+ text-align: center;
33
+ }
34
+
35
+ span:last-child {
36
+ text-align: right;
37
+ font-weight: 400;
38
+ }
39
+ }
40
+
41
+ .period {
42
+ margin-top: layout.$spacing-02;
43
+ font-size: $small;
44
+ text-align: right;
45
+ }
46
+
47
+ .tableScroll {
48
+ margin-top: layout.$spacing-03;
49
+ overflow-x: auto;
50
+ }
51
+
52
+ .register {
53
+ width: 100%;
54
+ min-width: 105rem;
55
+ border-collapse: collapse;
56
+
57
+ th,
58
+ td {
59
+ padding: layout.$spacing-02;
60
+ text-align: center;
61
+ vertical-align: middle;
62
+ border: $rule;
63
+ }
64
+
65
+ th {
66
+ font-weight: 400;
67
+ vertical-align: top;
68
+ }
69
+
70
+ /* a group header stays centred over its member columns */
71
+ thead tr:first-child th[colspan] {
72
+ font-weight: 700;
73
+ text-align: center;
74
+ vertical-align: middle;
75
+ }
76
+
77
+ td {
78
+ height: 1.125rem;
79
+ }
80
+ }
81
+
82
+ /* a column that stands alone across both header rows reads from the middle */
83
+ .tall {
84
+ vertical-align: middle !important;
85
+ }
86
+
87
+ .left {
88
+ text-align: left !important;
89
+ }
90
+
91
+ /* Page furniture around the sheet */
92
+
93
+ .toolbar {
94
+ display: flex;
95
+ align-items: center;
96
+ justify-content: flex-end;
97
+ gap: layout.$spacing-05;
98
+ margin-top: layout.$spacing-05;
99
+ }
100
+
101
+ .total {
102
+ margin-right: auto;
103
+ font-size: 0.8125rem;
104
+ color: $text-02;
105
+ }
106
+
107
+ .pager {
108
+ margin-top: layout.$spacing-04;
109
+ }
110
+
111
+ .empty {
112
+ display: flex;
113
+ flex-direction: column;
114
+ align-items: center;
115
+ gap: layout.$spacing-03;
116
+ margin-top: layout.$spacing-06;
117
+ padding: layout.$spacing-08 layout.$spacing-05;
118
+ text-align: center;
119
+ }
120
+
121
+ .emptyIcon {
122
+ fill: $text-03;
123
+ }
@@ -0,0 +1,164 @@
1
+ import dayjs from 'dayjs';
2
+
3
+ import { type RegisterColumn } from '../../common/report-register';
4
+
5
+ /**
6
+ * One row of the TB Laboratory Register: a lab-706 patient-list payload row for
7
+ * a TB test, fetched with the `total_tb_smear` indicator -- the one TB row
8
+ * filter the existing dataset carries (MOH 706 5.29). The columns the dataset
9
+ * does not carry yet (time in/out, ID/NEMIS, patient type, HIV status, TB-LAMP,
10
+ * LF-LAM, GeneXpert/Truenat) read blank until the ETL layer grows them.
11
+ */
12
+ export type TbLabRow = Record<string, any>;
13
+
14
+ /** Blank rather than "null" / "undefined" in a cell */
15
+ export const text = (value: unknown) => (value === null || value === undefined ? '' : String(value).trim());
16
+
17
+ /** DD/MM/YYYY, as the register prints it; values already in that shape pass through */
18
+ export function asRegisterDate(value: unknown): string {
19
+ const raw = text(value);
20
+ if (!raw) {
21
+ return '';
22
+ }
23
+ return /^\d{4}-\d{2}-\d{2}/.test(raw) ? dayjs(raw.slice(0, 10)).format('DD/MM/YYYY') : raw;
24
+ }
25
+
26
+ /** The date part of either an ISO datetime or an already-formatted DD/MM/YYYY, as YYYY-MM-DD */
27
+ const toIsoDate = (value: unknown): string => {
28
+ const raw = text(value);
29
+ if (/^\d{4}-\d{2}-\d{2}/.test(raw)) {
30
+ return raw.slice(0, 10);
31
+ }
32
+ const parts = raw.match(/^(\d{2})\/(\d{2})\/(\d{4})/);
33
+ return parts ? `${parts[3]}-${parts[2]}-${parts[1]}` : '';
34
+ };
35
+
36
+ /** Turnaround in whole calendar days, from sample collected to results dispatched */
37
+ export function tatDays(row: TbLabRow): string {
38
+ const from = toIsoDate(row.test_datetime);
39
+ const to = toIsoDate(row.date_results_dispatched);
40
+ if (!from || !to) {
41
+ return '';
42
+ }
43
+ const days = dayjs(to).diff(dayjs(from), 'day');
44
+ return days >= 0 ? String(days) : '';
45
+ }
46
+
47
+ /**
48
+ * Which sputum the row's sample is. The dataset does not carry the sample's
49
+ * timing yet, so it is read off the specimen label; rows that do not say print
50
+ * under Spot rather than being dropped, until the ETL carries the timing.
51
+ */
52
+ export function timingOf(row: TbLabRow): 'spot' | 'morning' {
53
+ return /morning/i.test(text(row.type_of_specimen)) ? 'morning' : 'spot';
54
+ }
55
+
56
+ /** The stain the microscopy used, as the template's Type ZN/FM column reads it */
57
+ export function stainType(row: TbLabRow): string {
58
+ const investigation = text(row.investigation_required).toLowerCase();
59
+ if (/fluor|\bfm\b/.test(investigation)) {
60
+ return 'FM';
61
+ }
62
+ if (/ziehl|\bzn\b/.test(investigation)) {
63
+ return 'ZN';
64
+ }
65
+ return '';
66
+ }
67
+
68
+ /** Which of the register's result columns the row's test result belongs in */
69
+ function resultKindOf(row: TbLabRow): 'microscopy' | 'tb_lamp' | 'lf_lam' | 'genexpert' {
70
+ const investigation = text(row.investigation_required).toLowerCase();
71
+ if (/lamp/.test(investigation)) {
72
+ return 'tb_lamp';
73
+ }
74
+ if (/\blam\b|lf.?lam|lateral flow/.test(investigation)) {
75
+ return 'lf_lam';
76
+ }
77
+ if (/xpert|genexp|truenat|mtb|rif/.test(investigation)) {
78
+ return 'genexpert';
79
+ }
80
+ return 'microscopy';
81
+ }
82
+
83
+ /** The row's result, placed in its own column: blank in the other result columns */
84
+ const resultIn = (kind: 'microscopy' | 'tb_lamp' | 'lf_lam' | 'genexpert') => (row: TbLabRow) =>
85
+ resultKindOf(row) === kind ? text(row.results) : '';
86
+
87
+ /** The sample appearance, under the sub-column its timing names */
88
+ const appearanceIn = (timing: 'spot' | 'morning') => (row: TbLabRow) =>
89
+ timingOf(row) === timing ? text(row.condition_of_specimen) : '';
90
+
91
+ /** The microscopy result, under the sub-column its timing names */
92
+ const microscopyIn = (timing: 'spot' | 'morning') => (row: TbLabRow) =>
93
+ resultKindOf(row) === 'microscopy' && timingOf(row) === timing ? text(row.results) : '';
94
+
95
+ export interface TbLabColumn {
96
+ /** The two-tier header group the column sits under, e.g. `Microscopy result` */
97
+ group?: string;
98
+ /** The column's own label, as the printed template reads it */
99
+ header: string;
100
+ value: (row: TbLabRow, index: number) => unknown;
101
+ /** Identifiers and phones stay text in the CSV */
102
+ text?: boolean;
103
+ /** Prose columns read left-aligned; the rest centre, as on the template */
104
+ align?: 'left';
105
+ }
106
+
107
+ /** The register's columns, in the order the printed template runs them */
108
+ export const TB_LAB_COLUMNS: TbLabColumn[] = [
109
+ { header: 'Lab S/No.', value: (row, index) => text(row.lab_no) || index + 1 },
110
+ { header: 'Date sample collected', value: (row) => asRegisterDate(row.test_datetime) },
111
+ { header: 'Date sample received', value: (row) => asRegisterDate(row.date_sample_received) },
112
+ /* not in the dataset yet: the ETL layer will carry it with the sample's timing */
113
+ { header: 'Time in', value: () => '' },
114
+ { header: 'Name (full)', value: (row) => text(row.full_name), align: 'left' },
115
+ { header: 'TB Reg. No. for follow ups / OPD No.', value: (row) => text(row.opd_ip_ref_no) },
116
+ { header: 'Sex M/F', value: (row) => text(row.sex) },
117
+ { header: 'Age (years)', value: (row) => text(row.age) },
118
+ /* not in the dataset yet: national ID / NEMIS / passport */
119
+ { header: "Patient's ID No./ NEMIS/ Passport", value: () => '' },
120
+ {
121
+ header: "Patient's physical address",
122
+ value: (row) => [text(row.village_estate_landmark), text(row.county_sub_county)].filter(Boolean).join(', '),
123
+ align: 'left',
124
+ },
125
+ { header: "Patient's/ guardian phone no.", value: (row) => text(row.telephone_number), text: true },
126
+ /* the dataset's referrals carry the from/to facility, which is what the
127
+ template's referring-facility column holds until a department is carried */
128
+ { header: 'Name of referring facility/ Department', value: (row) => text(row.referrals), align: 'left' },
129
+ /* not in the dataset yet: N / F2/F3/F5/F6 / O */
130
+ { header: 'Type of patient N, F2/F3/F5/F6, O', value: () => '' },
131
+ /* not in the dataset yet: Pos/Neg/ND/D */
132
+ { header: 'HIV status Pos/Neg/ND/D', value: () => '' },
133
+ { group: 'Sample appearance S / M / B', header: 'Spot', value: appearanceIn('spot') },
134
+ { group: 'Sample appearance S / M / B', header: 'Morning', value: appearanceIn('morning') },
135
+ { group: 'Microscopy result 0 = No AFB | actual no. | 1+ 2+ 3+', header: 'Type ZN/FM', value: stainType },
136
+ { group: 'Microscopy result 0 = No AFB | actual no. | 1+ 2+ 3+', header: 'Spot', value: microscopyIn('spot') },
137
+ {
138
+ group: 'Microscopy result 0 = No AFB | actual no. | 1+ 2+ 3+',
139
+ header: 'Morning',
140
+ value: microscopyIn('morning'),
141
+ },
142
+ { header: 'TB-LAMP', value: resultIn('tb_lamp') },
143
+ { header: 'LF-LAM', value: resultIn('lf_lam') },
144
+ { header: 'GeneXpert/ Truenat', value: resultIn('genexpert') },
145
+ { header: 'Date tested', value: (row) => asRegisterDate(row.date_sample_analysed) },
146
+ { header: 'Date results dispatched', value: (row) => asRegisterDate(row.date_results_dispatched) },
147
+ /* not in the dataset yet */
148
+ { header: 'Time out (results dispatch)', value: () => '' },
149
+ { header: 'TAT (days)', value: tatDays },
150
+ { header: "Laboratory officer's name", value: (row) => text(row.testing_officer), align: 'left' },
151
+ { header: 'Remarks (date & place sample referred etc.)', value: (row) => text(row.comments), align: 'left' },
152
+ ];
153
+
154
+ /**
155
+ * The register as exportable CSV columns: the two-tier header flattened into
156
+ * one unambiguous label per column, so the file reads without the grouping.
157
+ */
158
+ export function tbLabCsvColumns(): RegisterColumn[] {
159
+ return TB_LAB_COLUMNS.map((column) => ({
160
+ header: column.group ? `${column.group} - ${column.header}` : column.header,
161
+ value: column.value,
162
+ text: column.text,
163
+ }));
164
+ }
@@ -0,0 +1,232 @@
1
+ import React from 'react';
2
+ import { render, screen, waitFor, within } from '@testing-library/react';
3
+ import userEvent from '@testing-library/user-event';
4
+ import { MemoryRouter } from 'react-router-dom';
5
+
6
+ import TbLabRegister from './tb-lab-register.component';
7
+ import { TB_LAB_COLUMNS, tbLabCsvColumns } from './tb-lab-register.columns';
8
+ import { downloadCsv } from '../../common/report-register';
9
+ import { getMoh706PatientList } from '../../resources/moh-706.resource';
10
+
11
+ jest.mock('../../common/report-register', () => ({
12
+ ...jest.requireActual('../../common/report-register'),
13
+ downloadCsv: jest.fn(),
14
+ }));
15
+
16
+ jest.mock('../../resources/moh-706.resource', () => ({
17
+ getMoh706PatientList: jest.fn(),
18
+ }));
19
+
20
+ jest.mock('@openmrs/esm-framework', () => ({
21
+ useSession: () => ({ sessionLocation: { uuid: 'loc-uuid', display: 'Test Facility' } }),
22
+ openmrsFetch: jest.fn().mockResolvedValue({ ok: false }),
23
+ showSnackbar: jest.fn(),
24
+ }));
25
+
26
+ /* The filter bar is Carbon-heavy and its period picking is not what this page
27
+ is about, so it is stood in by a button that generates a fixed period. The
28
+ restore-from-URL hook stays real, as it is part of the page's behaviour. */
29
+ jest.mock('../../common/report-filters', () => {
30
+ const actual = jest.requireActual('../../common/report-filters');
31
+ return {
32
+ ...actual,
33
+ ReportFiltersComponent: ({
34
+ onGenerate,
35
+ }: {
36
+ onGenerate: (period: { startDate: string; endDate: string; label?: string }) => void;
37
+ }) => (
38
+ <button onClick={() => onGenerate({ startDate: '2026-10-01', endDate: '2026-10-02', label: 'period' })}>
39
+ generate
40
+ </button>
41
+ ),
42
+ };
43
+ });
44
+
45
+ /** A TB smear row as the lab-706 patient list returns it: a spot sputum, ZN stain */
46
+ const aRow = (overrides: Record<string, unknown> = {}) => ({
47
+ lab_no: 'L-0007',
48
+ test_datetime: '2026-10-01T09:00:00.000Z',
49
+ date_sample_received: '2026-10-01',
50
+ full_name: 'Jane Akinyi',
51
+ opd_ip_ref_no: 'OPD-12',
52
+ sex: 'F',
53
+ age: 34,
54
+ village_estate_landmark: 'Mama Ngina',
55
+ county_sub_county: 'Kisumu East',
56
+ telephone_number: '0700123456',
57
+ referrals: 'Kisumu County Hospital',
58
+ type_of_specimen: 'Spot',
59
+ condition_of_specimen: 'B',
60
+ investigation_required: 'Ziehl-Neelsen stain',
61
+ results: '1+',
62
+ date_sample_analysed: '2026-10-02',
63
+ date_results_dispatched: '2026-10-03',
64
+ testing_officer: 'J. Otieno',
65
+ comments: '',
66
+ ...overrides,
67
+ });
68
+
69
+ const renderRegister = () =>
70
+ render(
71
+ <MemoryRouter>
72
+ <TbLabRegister />
73
+ </MemoryRouter>,
74
+ );
75
+
76
+ const page = (results: Array<Record<string, unknown>>, total = results.length) => ({
77
+ results,
78
+ total,
79
+ isTotalExact: true,
80
+ });
81
+
82
+ const cellValues = (row: HTMLTableRowElement) =>
83
+ within(row)
84
+ .getAllByRole('cell')
85
+ .map((cell) => cell.textContent);
86
+
87
+ describe('TbLabRegister', () => {
88
+ beforeEach(() => {
89
+ jest.mocked(downloadCsv).mockClear();
90
+ jest.mocked(getMoh706PatientList).mockReset();
91
+ });
92
+
93
+ it('asks for the register to be generated before showing anything', () => {
94
+ renderRegister();
95
+
96
+ expect(screen.getByText('No report generated yet')).toBeInTheDocument();
97
+ expect(getMoh706PatientList).not.toHaveBeenCalled();
98
+ });
99
+
100
+ it('fetches the whole TB workload and lays the template out', async () => {
101
+ jest
102
+ .mocked(getMoh706PatientList)
103
+ .mockResolvedValue(
104
+ page([
105
+ aRow(),
106
+ aRow({
107
+ type_of_specimen: 'Morning',
108
+ investigation_required: 'Fluorescent microscopy',
109
+ results: 'No AFB seen',
110
+ condition_of_specimen: 'S',
111
+ }),
112
+ ]),
113
+ );
114
+
115
+ const { container } = renderRegister();
116
+ await userEvent.click(screen.getByRole('button', { name: 'generate' }));
117
+
118
+ await waitFor(() => expect(screen.getByText('2 records')).toBeInTheDocument());
119
+
120
+ /* the register's own row filter: every TB smear of the period */
121
+ expect(getMoh706PatientList).toHaveBeenCalledWith(
122
+ expect.objectContaining({
123
+ locationUuids: 'loc-uuid',
124
+ startDate: '2026-10-01',
125
+ endDate: '2026-10-02',
126
+ indicators: 'total_tb_smear',
127
+ }),
128
+ );
129
+
130
+ const table = within(container.querySelector('table') as HTMLTableElement);
131
+ const headerRows = within(table.getAllByRole('rowgroup')[0]).getAllByRole('row');
132
+ expect(headerRows).toHaveLength(2);
133
+
134
+ /* the top tier: the standalone columns, with the two groups sitting
135
+ between the columns they span */
136
+ const tallLabels = TB_LAB_COLUMNS.filter((column) => !column.group).map((column) => column.header);
137
+ const groups = [...new Set(TB_LAB_COLUMNS.filter((column) => column.group).map((column) => column.group!))];
138
+ const topCells = within(headerRows[0])
139
+ .getAllByRole('columnheader')
140
+ .map((cell) => cell.textContent);
141
+ expect(topCells).toEqual([...tallLabels.slice(0, 14), ...groups, ...tallLabels.slice(14)]);
142
+ /* the bottom tier: the five sub-columns under the groups */
143
+ expect(
144
+ within(headerRows[1])
145
+ .getAllByRole('columnheader')
146
+ .map((cell) => cell.textContent),
147
+ ).toEqual(['Spot', 'Morning', 'Type ZN/FM', 'Spot', 'Morning']);
148
+
149
+ const bodyRows = within(table.getAllByRole('rowgroup')[1]).getAllByRole('row');
150
+ /* the spot row: appearance and result under Spot, the stain read off the test, two-day TAT */
151
+ expect(cellValues(bodyRows[0] as HTMLTableRowElement)).toEqual([
152
+ 'L-0007',
153
+ '01/10/2026',
154
+ '01/10/2026',
155
+ '',
156
+ 'Jane Akinyi',
157
+ 'OPD-12',
158
+ 'F',
159
+ '34',
160
+ '',
161
+ 'Mama Ngina, Kisumu East',
162
+ '0700123456',
163
+ 'Kisumu County Hospital',
164
+ '',
165
+ '',
166
+ 'B',
167
+ '',
168
+ 'ZN',
169
+ '1+',
170
+ '',
171
+ '',
172
+ '',
173
+ '',
174
+ '02/10/2026',
175
+ '03/10/2026',
176
+ '',
177
+ '2',
178
+ 'J. Otieno',
179
+ '',
180
+ ]);
181
+ /* the morning row: appearance and result under Morning, stain FM */
182
+ expect(cellValues(bodyRows[1] as HTMLTableRowElement)[15]).toBe('S');
183
+ expect(cellValues(bodyRows[1] as HTMLTableRowElement)[16]).toBe('FM');
184
+ expect(cellValues(bodyRows[1] as HTMLTableRowElement)[18]).toBe('No AFB seen');
185
+ });
186
+
187
+ it('exports every page with the two-tier header flattened into one label per column', async () => {
188
+ jest.mocked(getMoh706PatientList).mockResolvedValue(page([aRow()]));
189
+
190
+ renderRegister();
191
+ await userEvent.click(screen.getByRole('button', { name: 'generate' }));
192
+ await waitFor(() => expect(screen.getByRole('button', { name: /Export CSV/ })).toBeInTheDocument());
193
+
194
+ await userEvent.click(screen.getByRole('button', { name: /Export CSV/ }));
195
+
196
+ await waitFor(() => expect(jest.mocked(downloadCsv)).toHaveBeenCalledTimes(1));
197
+ const [csv, filename] = jest.mocked(downloadCsv).mock.calls[0];
198
+ const lines = (csv as string).split('\r\n');
199
+ /* labels holding commas come out quoted, as CSV requires */
200
+ expect(lines[0]).toBe(
201
+ tbLabCsvColumns()
202
+ .map((column) => (column.header.includes(',') ? `"${column.header}"` : column.header))
203
+ .join(','),
204
+ );
205
+ /* phones and lab numbers survive Excel as text */
206
+ expect(lines[1]).toContain('="0700123456"');
207
+ expect(filename).toBe('tb-laboratory-register-2026-10-01-2026-10-02.csv');
208
+ });
209
+
210
+ it('pagers past the first page when the period holds more rows than one page', async () => {
211
+ jest.mocked(getMoh706PatientList).mockResolvedValue(
212
+ page(
213
+ Array.from({ length: 50 }, () => aRow()),
214
+ 120,
215
+ ),
216
+ );
217
+
218
+ renderRegister();
219
+ await userEvent.click(screen.getByRole('button', { name: 'generate' }));
220
+
221
+ await waitFor(() => expect(screen.getByText(/120 items/)).toBeInTheDocument());
222
+ });
223
+
224
+ it('falls back to the placeholder when the register cannot be fetched', async () => {
225
+ jest.mocked(getMoh706PatientList).mockRejectedValue(new Error('etl down'));
226
+
227
+ renderRegister();
228
+ await userEvent.click(screen.getByRole('button', { name: 'generate' }));
229
+
230
+ await waitFor(() => expect(screen.getByText('No report generated yet')).toBeInTheDocument());
231
+ });
232
+ });
@@ -0,0 +1,153 @@
1
+ import React, { useState } from 'react';
2
+ import { Button, Layer, Pagination } from '@carbon/react';
3
+ import { Document, Download } from '@carbon/react/icons';
4
+ import { useSession } from '@openmrs/esm-framework';
5
+
6
+ import { ReportFiltersComponent, type ReportPeriod, useRestorePeriod } from '../../common/report-filters';
7
+ import { showReportError } from '../../common/report-error';
8
+ import { ReportPlaceholder, useGeneratedLocation } from '../../common/report-placeholder';
9
+ import { ReportSkeleton } from '../../common/report-skeleton';
10
+ import { columnsToCsv, downloadCsv, usePatientList } from '../../common/report-register';
11
+ import { getMoh706PatientList } from '../../resources/moh-706.resource';
12
+ import TbLabRegisterTable from './tb-lab-register-table.component';
13
+ import { tbLabCsvColumns, type TbLabRow } from './tb-lab-register.columns';
14
+ import styles from './tb-lab-register-table.scss';
15
+
16
+ /**
17
+ * TB Laboratory Register, opened from the landing page's Registers tab. It
18
+ * shares the reports' filter bar and renders the printed template -- the
19
+ * two-tier header over the appearance and microscopy columns -- from the
20
+ * existing lab-706 dataset: rows come back filtered by the `total_tb_smear`
21
+ * indicator (the MOH 706 5.29 filter) until the ETL layer carries the rest of
22
+ * the TB workload, and the columns the dataset does not carry yet read blank.
23
+ */
24
+ const TbLabRegister: React.FC = () => {
25
+ const [period, setPeriod] = useState<{ startDate: string; endDate: string }>({ startDate: '', endDate: '' });
26
+ const [isExporting, setIsExporting] = useState(false);
27
+ /* A fetch that failed leaves the page on the placeholder rather than showing
28
+ an empty register, so the two are told apart here. */
29
+ const [hasError, setHasError] = useState(false);
30
+
31
+ const session = useSession();
32
+ const locationUuids = session?.sessionLocation?.uuid ?? '';
33
+ /* Only a register for the current location counts */
34
+ const { isForCurrentLocation, hasLocationChanged, markGenerated } = useGeneratedLocation('tbLabRegisterLocation');
35
+ const isGenerated = Boolean(period.startDate && period.endDate) && isForCurrentLocation && !hasError;
36
+
37
+ const { rows, total, isTotalExact, isLoading, page, pageSize, onPageChange, fetchAll } = usePatientList(
38
+ ({ startIndex, limit }) => {
39
+ /* Nothing is fetched before a period is chosen: the mount-time load and
40
+ an unset session land here rather than querying the endpoint's defaults. */
41
+ if (!period.startDate || !period.endDate) {
42
+ return Promise.resolve({ results: [], total: 0, isTotalExact: true });
43
+ }
44
+
45
+ return getMoh706PatientList({
46
+ locationUuids,
47
+ startDate: period.startDate,
48
+ endDate: period.endDate,
49
+ indicators: 'total_tb_smear',
50
+ startIndex,
51
+ limit,
52
+ }).then(
53
+ (result) => {
54
+ setHasError(false);
55
+ return result;
56
+ },
57
+ (error) => {
58
+ setHasError(true);
59
+ throw error;
60
+ },
61
+ );
62
+ },
63
+ [locationUuids, period.startDate, period.endDate],
64
+ );
65
+
66
+ const generate = ({ startDate, endDate }: ReportPeriod) => {
67
+ setHasError(false);
68
+ setPeriod({ startDate, endDate });
69
+ markGenerated(locationUuids);
70
+ };
71
+
72
+ useRestorePeriod(generate);
73
+
74
+ /* Exports every page, not the one on screen: with pagination the DOM only
75
+ holds one page, and the file must match the printed register. */
76
+ const exportCsv = async () => {
77
+ setIsExporting(true);
78
+
79
+ try {
80
+ const all: TbLabRow[] = await fetchAll();
81
+ const slug = ['tb-laboratory-register', period.startDate, period.endDate].filter(Boolean).join('-').toLowerCase();
82
+ downloadCsv(columnsToCsv(all, tbLabCsvColumns()), `${slug}.csv`);
83
+ } catch (error) {
84
+ showReportError('the register export', error);
85
+ } finally {
86
+ setIsExporting(false);
87
+ }
88
+ };
89
+
90
+ return (
91
+ <>
92
+ <ReportFiltersComponent
93
+ reportName="TB Laboratory Register"
94
+ mode="both"
95
+ onGenerate={generate}
96
+ isLoading={isLoading}
97
+ isReportGenerated={isGenerated}
98
+ generatedPeriod={period}
99
+ defaultTab="custom"
100
+ backLink={{ label: 'All registers', to: '/?tab=registers' }}
101
+ />
102
+ {isGenerated && isLoading && rows.length === 0 && <ReportSkeleton />}
103
+ {isGenerated && !isLoading && (
104
+ <>
105
+ <div className={styles.toolbar}>
106
+ <span className={styles.total}>
107
+ {total.toLocaleString()}
108
+ {isTotalExact ? '' : '+'} records
109
+ </span>
110
+ <Button kind="tertiary" size="sm" renderIcon={Download} onClick={exportCsv} disabled={isExporting}>
111
+ {isExporting ? 'Preparing…' : 'Export CSV'}
112
+ </Button>
113
+ </div>
114
+ {rows.length > 0 ? (
115
+ <>
116
+ <TbLabRegisterTable
117
+ rows={rows}
118
+ facility={session?.sessionLocation?.display ?? ''}
119
+ startDate={period.startDate}
120
+ endDate={period.endDate}
121
+ />
122
+ {typeof total === 'number' && total > pageSize && (
123
+ <Pagination
124
+ className={styles.pager}
125
+ page={page}
126
+ pageSize={pageSize}
127
+ pageSizes={[25, 50, 100, 200]}
128
+ totalItems={total}
129
+ size="sm"
130
+ onChange={onPageChange}
131
+ />
132
+ )}
133
+ </>
134
+ ) : (
135
+ <Layer className={styles.empty}>
136
+ <Document size={32} className={styles.emptyIcon} />
137
+ <h4>No records to list</h4>
138
+ <p>No TB laboratory entries matched the selected period and facility.</p>
139
+ </Layer>
140
+ )}
141
+ </>
142
+ )}
143
+ {!isGenerated && (
144
+ <ReportPlaceholder
145
+ reportName="TB laboratory register"
146
+ isLocationChanged={Boolean(period.startDate) && hasLocationChanged}
147
+ />
148
+ )}
149
+ </>
150
+ );
151
+ };
152
+
153
+ export default TbLabRegister;