@ampath/esm-reports-app 1.0.0-next.69 → 1.0.0-next.70

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.
@@ -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;
@@ -26,6 +26,7 @@ import PrepRegisterComponent from './reports/moh-731/registers/prep-register.com
26
26
  import Moh731Linelist from './reports/moh-731/linelist/moh-731-linelist.component';
27
27
  import Moh362Register from './registers/moh-362/moh-362.component';
28
28
  import Moh240LabRegister from './registers/moh-240/moh-240.component';
29
+ import TbLabRegister from './registers/tb-lab-register/tb-lab-register.component';
29
30
  import styles from './root.scss';
30
31
  import Moh510RegisterComponent from './reports/moh-710/registers/moh-510/moh-510.component';
31
32
  import Moh222DailyRegister from './reports/moh-740/registers/moh-222-daily-register/moh-222-daily-register';
@@ -67,6 +68,7 @@ const RootComponent: React.FC = () => {
67
68
  <Route path="/moh-222" element={<Moh222DailyRegister />} />
68
69
  <Route path="/moh-270" element={<Moh270DailyRegister />} />
69
70
  <Route path="/moh-240-register" element={<Moh240LabRegister />} />
71
+ <Route path="/tb-lab-register" element={<TbLabRegister />} />
70
72
  </Routes>
71
73
  </div>
72
74
  </BrowserRouter>