@palladium-ethiopia/esm-reports-app 5.4.2-pre.200
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.
- package/.turbo/turbo-build.log +7 -0
- package/dist/117.js +1 -0
- package/dist/152.js +1 -0
- package/dist/152.js.map +1 -0
- package/dist/209.js +1 -0
- package/dist/209.js.map +1 -0
- package/dist/256.js +1 -0
- package/dist/256.js.map +1 -0
- package/dist/383.js +43 -0
- package/dist/383.js.map +1 -0
- package/dist/442.js +1 -0
- package/dist/442.js.map +1 -0
- package/dist/466.js +1 -0
- package/dist/466.js.map +1 -0
- package/dist/61.js +1 -0
- package/dist/61.js.map +1 -0
- package/dist/689.js +1 -0
- package/dist/689.js.map +1 -0
- package/dist/697.js +1 -0
- package/dist/697.js.map +1 -0
- package/dist/712.js +1 -0
- package/dist/712.js.map +1 -0
- package/dist/743.js +1 -0
- package/dist/743.js.map +1 -0
- package/dist/771.js +1 -0
- package/dist/771.js.map +1 -0
- package/dist/789.js +1 -0
- package/dist/789.js.map +1 -0
- package/dist/806.js +1 -0
- package/dist/908.js +15 -0
- package/dist/908.js.map +1 -0
- package/dist/esm-reports-app.js +6 -0
- package/dist/esm-reports-app.js.buildmanifest.json +544 -0
- package/dist/esm-reports-app.js.map +1 -0
- package/dist/main.js +6 -0
- package/dist/main.js.map +1 -0
- package/dist/routes.json +1 -0
- package/jest.config.js +3 -0
- package/package.json +55 -0
- package/rspack.config.js +1 -0
- package/src/api/report-request.ts +118 -0
- package/src/api/reports.resource.ts +70 -0
- package/src/config-schema.ts +75 -0
- package/src/declarations.d.ts +2 -0
- package/src/index.ts +17 -0
- package/src/left-nav/reports-left-nav.component.scss +23 -0
- package/src/left-nav/reports-left-nav.component.tsx +77 -0
- package/src/report/info-landing.component.scss +31 -0
- package/src/report/info-landing.component.tsx +23 -0
- package/src/report/report-results.component.scss +26 -0
- package/src/report/report-results.component.tsx +131 -0
- package/src/report/report-runner.component.scss +44 -0
- package/src/report/report-runner.component.tsx +220 -0
- package/src/root.component.tsx +24 -0
- package/src/root.scss +11 -0
- package/src/routes.json +15 -0
- package/translations/am.json +20 -0
- package/translations/en.json +20 -0
- package/tsconfig.json +4 -0
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
@use '@carbon/layout';
|
|
2
|
+
@use '@carbon/type';
|
|
3
|
+
|
|
4
|
+
.sideNav {
|
|
5
|
+
border-right: 1px solid var(--cds-border-subtle, #e0e0e0);
|
|
6
|
+
// The fixed SideNav starts at the top of the viewport, behind the 3rem app
|
|
7
|
+
// header, which clips the "Reports" heading. Offset it below the navbar to
|
|
8
|
+
// match the framework's own left-nav placement.
|
|
9
|
+
top: var(--omrs-navbar-height, 3rem);
|
|
10
|
+
height: calc(100% - var(--omrs-navbar-height, 3rem));
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
.heading {
|
|
14
|
+
@include type.type-style('heading-compact-02');
|
|
15
|
+
padding: layout.$spacing-05 layout.$spacing-05 layout.$spacing-03;
|
|
16
|
+
margin: 0;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
.status {
|
|
20
|
+
padding: layout.$spacing-03 layout.$spacing-05;
|
|
21
|
+
color: var(--cds-text-secondary, #525252);
|
|
22
|
+
@include type.type-style('body-compact-01');
|
|
23
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { useTranslation } from 'react-i18next';
|
|
3
|
+
import { useLocation, useNavigate } from 'react-router-dom';
|
|
4
|
+
import { SideNav, SideNavItems, SideNavMenu, SideNavMenuItem, SideNavLink, InlineLoading } from '@carbon/react';
|
|
5
|
+
import { useGroupedReports } from '../api/reports.resource';
|
|
6
|
+
import styles from './reports-left-nav.component.scss';
|
|
7
|
+
|
|
8
|
+
const ReportsLeftNav: React.FC = () => {
|
|
9
|
+
const { t } = useTranslation();
|
|
10
|
+
const navigate = useNavigate();
|
|
11
|
+
const location = useLocation();
|
|
12
|
+
const { groupedReports, isLoading, error } = useGroupedReports();
|
|
13
|
+
|
|
14
|
+
const currentUuid = location.pathname.replace(/^\//, '');
|
|
15
|
+
|
|
16
|
+
const categories = Object.keys(groupedReports);
|
|
17
|
+
const hasAnyReport = categories.some((c) => (groupedReports[c]?.length ?? 0) > 0);
|
|
18
|
+
|
|
19
|
+
return (
|
|
20
|
+
<SideNav
|
|
21
|
+
isFixedNav
|
|
22
|
+
expanded
|
|
23
|
+
isChildOfHeader={false}
|
|
24
|
+
aria-label={t('ethiopiaEmrReports', 'EthiopiaEMR Reports')}
|
|
25
|
+
className={styles.sideNav}>
|
|
26
|
+
<SideNavItems>
|
|
27
|
+
<h4 className={styles.heading}>{t('reports', 'Reports')}</h4>
|
|
28
|
+
|
|
29
|
+
{isLoading && (
|
|
30
|
+
<div className={styles.status}>
|
|
31
|
+
<InlineLoading description={t('loading', 'Loading…')} />
|
|
32
|
+
</div>
|
|
33
|
+
)}
|
|
34
|
+
|
|
35
|
+
{error && <div className={styles.status}>{t('errorLoadingReports', 'Could not load the report list.')}</div>}
|
|
36
|
+
|
|
37
|
+
{!isLoading &&
|
|
38
|
+
!error &&
|
|
39
|
+
categories.map((category) => {
|
|
40
|
+
const reports = groupedReports[category] ?? [];
|
|
41
|
+
if (reports.length === 0) {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
// Expand the group that contains the currently selected report (default: first).
|
|
45
|
+
const containsActive = reports.some((r) => r.uuid === currentUuid);
|
|
46
|
+
return (
|
|
47
|
+
<SideNavMenu
|
|
48
|
+
key={category}
|
|
49
|
+
title={category}
|
|
50
|
+
defaultExpanded={containsActive || category === categories[0]}>
|
|
51
|
+
{reports.map((report) => (
|
|
52
|
+
<SideNavMenuItem
|
|
53
|
+
key={report.uuid}
|
|
54
|
+
isActive={report.uuid === currentUuid}
|
|
55
|
+
href="#"
|
|
56
|
+
onClick={(e: React.MouseEvent) => {
|
|
57
|
+
e.preventDefault();
|
|
58
|
+
navigate(`/${report.uuid}`);
|
|
59
|
+
}}>
|
|
60
|
+
{report.name}
|
|
61
|
+
</SideNavMenuItem>
|
|
62
|
+
))}
|
|
63
|
+
</SideNavMenu>
|
|
64
|
+
);
|
|
65
|
+
})}
|
|
66
|
+
|
|
67
|
+
{!isLoading && !error && !hasAnyReport && (
|
|
68
|
+
<SideNavLink href="#" onClick={(e: React.MouseEvent) => e.preventDefault()}>
|
|
69
|
+
{t('noRows', 'No rows.')}
|
|
70
|
+
</SideNavLink>
|
|
71
|
+
)}
|
|
72
|
+
</SideNavItems>
|
|
73
|
+
</SideNav>
|
|
74
|
+
);
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
export default ReportsLeftNav;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
@use '@carbon/layout';
|
|
2
|
+
@use '@carbon/type';
|
|
3
|
+
|
|
4
|
+
.wrapper {
|
|
5
|
+
display: flex;
|
|
6
|
+
align-items: center;
|
|
7
|
+
justify-content: center;
|
|
8
|
+
padding: layout.$spacing-09;
|
|
9
|
+
min-height: 60vh;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
.tile {
|
|
13
|
+
max-width: 28rem;
|
|
14
|
+
text-align: center;
|
|
15
|
+
padding: layout.$spacing-09 layout.$spacing-07;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
.icon {
|
|
19
|
+
color: var(--cds-icon-secondary, #6f6f6f);
|
|
20
|
+
margin-bottom: layout.$spacing-05;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
.title {
|
|
24
|
+
@include type.type-style('heading-03');
|
|
25
|
+
margin-bottom: layout.$spacing-03;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
.body {
|
|
29
|
+
@include type.type-style('body-01');
|
|
30
|
+
color: var(--cds-text-secondary, #525252);
|
|
31
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { useTranslation } from 'react-i18next';
|
|
3
|
+
import { Tile } from '@carbon/react';
|
|
4
|
+
import { DocumentMultiple_01 } from '@carbon/react/icons';
|
|
5
|
+
import styles from './info-landing.component.scss';
|
|
6
|
+
|
|
7
|
+
const InfoLanding: React.FC = () => {
|
|
8
|
+
const { t } = useTranslation();
|
|
9
|
+
|
|
10
|
+
return (
|
|
11
|
+
<div className={styles.wrapper}>
|
|
12
|
+
<Tile className={styles.tile}>
|
|
13
|
+
<DocumentMultiple_01 size={48} className={styles.icon} />
|
|
14
|
+
<h3 className={styles.title}>{t('selectAReport', 'Select a report')}</h3>
|
|
15
|
+
<p className={styles.body}>
|
|
16
|
+
{t('selectAReportToBegin', 'Select a report from the menu on the left to get started.')}
|
|
17
|
+
</p>
|
|
18
|
+
</Tile>
|
|
19
|
+
</div>
|
|
20
|
+
);
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export default InfoLanding;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
@use '@carbon/layout';
|
|
2
|
+
@use '@carbon/type';
|
|
3
|
+
|
|
4
|
+
.results {
|
|
5
|
+
margin-top: layout.$spacing-05;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
.dataset {
|
|
9
|
+
margin-bottom: layout.$spacing-06;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
.dsTitle {
|
|
13
|
+
@include type.type-style('heading-compact-02');
|
|
14
|
+
margin-bottom: layout.$spacing-03;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
.scroll {
|
|
18
|
+
overflow-x: auto;
|
|
19
|
+
width: 100%;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
.empty {
|
|
23
|
+
@include type.type-style('body-compact-01');
|
|
24
|
+
color: var(--cds-text-secondary, #525252);
|
|
25
|
+
padding: layout.$spacing-03 0;
|
|
26
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import React 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
|
+
} from '@carbon/react';
|
|
13
|
+
import { type ReportDataSet } from '../api/report-request';
|
|
14
|
+
import styles from './report-results.component.scss';
|
|
15
|
+
|
|
16
|
+
interface ReportResultsProps {
|
|
17
|
+
reportUuid: string;
|
|
18
|
+
dataSets: Array<ReportDataSet>;
|
|
19
|
+
columnOrderByUuid: Record<string, Array<string>>;
|
|
20
|
+
hiddenDatasets: Array<string>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Renders run-report results, one table per (visible) dataset. Columns are
|
|
25
|
+
* ordered using the configured per-report column order, with any unlisted columns
|
|
26
|
+
* appended afterwards — porting the legacy GSP's orderColumns()/HIDDEN_DATASETS.
|
|
27
|
+
*/
|
|
28
|
+
const ReportResults: React.FC<ReportResultsProps> = ({ reportUuid, dataSets, columnOrderByUuid, hiddenDatasets }) => {
|
|
29
|
+
const { t } = useTranslation();
|
|
30
|
+
const hidden = new Set(hiddenDatasets);
|
|
31
|
+
const visible = dataSets.filter((ds) => !hidden.has(ds.name));
|
|
32
|
+
|
|
33
|
+
if (visible.length === 0) {
|
|
34
|
+
return <p className={styles.empty}>{t('noData', 'No data returned.')}</p>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return (
|
|
38
|
+
<div className={styles.results}>
|
|
39
|
+
{visible.map((ds, idx) => {
|
|
40
|
+
if (ds.rows.length === 0) {
|
|
41
|
+
return (
|
|
42
|
+
<div key={`${ds.name}-${idx}`}>
|
|
43
|
+
<h4 className={styles.dsTitle}>{ds.name}</h4>
|
|
44
|
+
<p className={styles.empty}>{t('noRows', 'No rows.')}</p>
|
|
45
|
+
</div>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const columns = orderColumns(reportUuid, Object.keys(ds.rows[0]), columnOrderByUuid);
|
|
50
|
+
const headers = columns.map((c) => ({ key: c, header: c }));
|
|
51
|
+
const tableRows = ds.rows.map((row, rIdx) => {
|
|
52
|
+
const r: Record<string, string> = { id: String(rIdx) };
|
|
53
|
+
columns.forEach((c) => {
|
|
54
|
+
const v = row[c];
|
|
55
|
+
r[c] = v === null || v === undefined ? '-' : String(v);
|
|
56
|
+
});
|
|
57
|
+
return r;
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<div key={`${ds.name}-${idx}`} className={styles.dataset}>
|
|
62
|
+
<h4 className={styles.dsTitle}>{ds.name}</h4>
|
|
63
|
+
<DataTable
|
|
64
|
+
rows={tableRows as Array<{ id: string } & Record<string, string>>}
|
|
65
|
+
headers={headers}
|
|
66
|
+
size="sm"
|
|
67
|
+
useZebraStyles>
|
|
68
|
+
{({ rows, headers: hdrs, getHeaderProps, getTableProps }) => (
|
|
69
|
+
<TableContainer>
|
|
70
|
+
<div className={styles.scroll}>
|
|
71
|
+
<Table {...getTableProps()}>
|
|
72
|
+
<TableHead>
|
|
73
|
+
<TableRow>
|
|
74
|
+
{hdrs.map((header) => {
|
|
75
|
+
const { key, ...rest } = getHeaderProps({ header });
|
|
76
|
+
return (
|
|
77
|
+
<TableHeader key={header.key} {...rest}>
|
|
78
|
+
{header.header}
|
|
79
|
+
</TableHeader>
|
|
80
|
+
);
|
|
81
|
+
})}
|
|
82
|
+
</TableRow>
|
|
83
|
+
</TableHead>
|
|
84
|
+
<TableBody>
|
|
85
|
+
{rows.map((row) => (
|
|
86
|
+
<TableRow key={row.id}>
|
|
87
|
+
{row.cells.map((cell) => (
|
|
88
|
+
<TableCell key={cell.id}>{String(cell.value)}</TableCell>
|
|
89
|
+
))}
|
|
90
|
+
</TableRow>
|
|
91
|
+
))}
|
|
92
|
+
</TableBody>
|
|
93
|
+
</Table>
|
|
94
|
+
</div>
|
|
95
|
+
</TableContainer>
|
|
96
|
+
)}
|
|
97
|
+
</DataTable>
|
|
98
|
+
</div>
|
|
99
|
+
);
|
|
100
|
+
})}
|
|
101
|
+
</div>
|
|
102
|
+
);
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
function orderColumns(
|
|
106
|
+
uuid: string,
|
|
107
|
+
availableCols: Array<string>,
|
|
108
|
+
columnOrderByUuid: Record<string, Array<string>>,
|
|
109
|
+
): Array<string> {
|
|
110
|
+
const preferred = columnOrderByUuid?.[uuid];
|
|
111
|
+
if (!preferred) {
|
|
112
|
+
return availableCols;
|
|
113
|
+
}
|
|
114
|
+
const present = new Set(availableCols);
|
|
115
|
+
const ordered: Array<string> = [];
|
|
116
|
+
preferred.forEach((c) => {
|
|
117
|
+
if (present.has(c)) {
|
|
118
|
+
ordered.push(c);
|
|
119
|
+
present.delete(c);
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
// append any data columns not in the preferred list
|
|
123
|
+
availableCols.forEach((c) => {
|
|
124
|
+
if (present.has(c)) {
|
|
125
|
+
ordered.push(c);
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
return ordered;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export default ReportResults;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
@use '@carbon/layout';
|
|
2
|
+
@use '@carbon/type';
|
|
3
|
+
|
|
4
|
+
.container {
|
|
5
|
+
padding: layout.$spacing-05 layout.$spacing-06;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
.title {
|
|
9
|
+
@include type.type-style('heading-04');
|
|
10
|
+
margin-bottom: layout.$spacing-02;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
.description {
|
|
14
|
+
@include type.type-style('body-01');
|
|
15
|
+
color: var(--cds-text-secondary, #525252);
|
|
16
|
+
margin-bottom: layout.$spacing-05;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
.formPanel {
|
|
20
|
+
padding: layout.$spacing-05;
|
|
21
|
+
margin-bottom: layout.$spacing-05;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
.fields {
|
|
25
|
+
display: flex;
|
|
26
|
+
flex-wrap: wrap;
|
|
27
|
+
gap: layout.$spacing-05;
|
|
28
|
+
margin-bottom: layout.$spacing-05;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
.field {
|
|
32
|
+
min-width: 12rem;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
.actions {
|
|
36
|
+
display: flex;
|
|
37
|
+
flex-wrap: wrap;
|
|
38
|
+
gap: layout.$spacing-03;
|
|
39
|
+
align-items: center;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
.status {
|
|
43
|
+
margin-top: layout.$spacing-04;
|
|
44
|
+
}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
2
|
+
import { useTranslation } from 'react-i18next';
|
|
3
|
+
import { useParams } from 'react-router-dom';
|
|
4
|
+
import {
|
|
5
|
+
Button,
|
|
6
|
+
DatePicker,
|
|
7
|
+
DatePickerInput,
|
|
8
|
+
InlineLoading,
|
|
9
|
+
InlineNotification,
|
|
10
|
+
Layer,
|
|
11
|
+
TextInput,
|
|
12
|
+
} from '@carbon/react';
|
|
13
|
+
import { useConfig } from '@openmrs/esm-framework';
|
|
14
|
+
import { type EthiopiaReportsConfig } from '../config-schema';
|
|
15
|
+
import { useReportDefinition } from '../api/reports.resource';
|
|
16
|
+
import { runReport, downloadReportDesign, type ReportDataSet } from '../api/report-request';
|
|
17
|
+
import ReportResults from './report-results.component';
|
|
18
|
+
import styles from './report-runner.component.scss';
|
|
19
|
+
|
|
20
|
+
const ReportRunner: React.FC = () => {
|
|
21
|
+
const { t } = useTranslation();
|
|
22
|
+
const { reportUuid } = useParams<{ reportUuid: string }>();
|
|
23
|
+
const config = useConfig<EthiopiaReportsConfig>();
|
|
24
|
+
const { reportDefinition, isLoading, error } = useReportDefinition(reportUuid);
|
|
25
|
+
|
|
26
|
+
const [paramValues, setParamValues] = useState<Record<string, string>>({});
|
|
27
|
+
const [results, setResults] = useState<Array<ReportDataSet> | null>(null);
|
|
28
|
+
const [running, setRunning] = useState(false);
|
|
29
|
+
const [downloadingUuid, setDownloadingUuid] = useState<string | null>(null);
|
|
30
|
+
const [status, setStatus] = useState<{ text: string; kind: 'success' | 'error' } | null>(null);
|
|
31
|
+
const downloadAbortRef = useRef<AbortController | null>(null);
|
|
32
|
+
|
|
33
|
+
// Abort any in-flight download poll when the component unmounts or the report changes.
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
return () => {
|
|
36
|
+
downloadAbortRef.current?.abort();
|
|
37
|
+
};
|
|
38
|
+
}, [reportUuid]);
|
|
39
|
+
|
|
40
|
+
const params = useMemo(() => reportDefinition?.parameters ?? [], [reportDefinition]);
|
|
41
|
+
|
|
42
|
+
const allFilled = useMemo(
|
|
43
|
+
() => params.every((p) => paramValues[p.name] && paramValues[p.name].length > 0),
|
|
44
|
+
[params, paramValues],
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
const setParam = useCallback((name: string, value: string) => {
|
|
48
|
+
setParamValues((prev) => ({ ...prev, [name]: value }));
|
|
49
|
+
}, []);
|
|
50
|
+
|
|
51
|
+
const handleRun = useCallback(async () => {
|
|
52
|
+
if (!reportUuid || !allFilled) {
|
|
53
|
+
setStatus({ text: t('fillAllFields', 'Please fill in all required fields.'), kind: 'error' });
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
setRunning(true);
|
|
57
|
+
setStatus({ text: t('running', 'Running report, please wait…'), kind: 'success' });
|
|
58
|
+
try {
|
|
59
|
+
const dataSets = await runReport(reportUuid, paramValues);
|
|
60
|
+
setResults(dataSets);
|
|
61
|
+
setStatus({ text: t('reportCompleted', 'Report completed successfully.'), kind: 'success' });
|
|
62
|
+
} catch (e) {
|
|
63
|
+
setStatus({
|
|
64
|
+
text: (e as Error)?.message ?? t('reportFailed', 'Report evaluation failed on the server.'),
|
|
65
|
+
kind: 'error',
|
|
66
|
+
});
|
|
67
|
+
} finally {
|
|
68
|
+
setRunning(false);
|
|
69
|
+
}
|
|
70
|
+
}, [reportUuid, allFilled, paramValues, t]);
|
|
71
|
+
|
|
72
|
+
const handleDownload = useCallback(
|
|
73
|
+
async (designUuid: string) => {
|
|
74
|
+
if (!reportUuid || !allFilled) {
|
|
75
|
+
setStatus({ text: t('fillAllFields', 'Please fill in all required fields.'), kind: 'error' });
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
downloadAbortRef.current?.abort();
|
|
79
|
+
const controller = new AbortController();
|
|
80
|
+
downloadAbortRef.current = controller;
|
|
81
|
+
setDownloadingUuid(designUuid);
|
|
82
|
+
setStatus({ text: t('generatingDownload', 'Generating download, please wait…'), kind: 'success' });
|
|
83
|
+
try {
|
|
84
|
+
await downloadReportDesign(reportUuid, designUuid, paramValues, controller.signal);
|
|
85
|
+
setStatus({ text: t('downloadReady', 'Download ready.'), kind: 'success' });
|
|
86
|
+
} catch (e) {
|
|
87
|
+
if ((e as Error)?.name === 'AbortError') {
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
setStatus({ text: (e as Error)?.message ?? t('downloadFailed', 'Download failed.'), kind: 'error' });
|
|
91
|
+
} finally {
|
|
92
|
+
setDownloadingUuid(null);
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
[reportUuid, allFilled, paramValues, t],
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
if (isLoading) {
|
|
99
|
+
return (
|
|
100
|
+
<div className={styles.container}>
|
|
101
|
+
<InlineLoading description={t('loading', 'Loading…')} />
|
|
102
|
+
</div>
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (error || !reportDefinition) {
|
|
107
|
+
return (
|
|
108
|
+
<div className={styles.container}>
|
|
109
|
+
<InlineNotification
|
|
110
|
+
kind="error"
|
|
111
|
+
lowContrast
|
|
112
|
+
hideCloseButton
|
|
113
|
+
title={t('errorLoadingReport', 'Could not load this report.')}
|
|
114
|
+
subtitle={(error as Error)?.message ?? ''}
|
|
115
|
+
/>
|
|
116
|
+
</div>
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return (
|
|
121
|
+
<div className={styles.container}>
|
|
122
|
+
<h2 className={styles.title}>{reportDefinition.name}</h2>
|
|
123
|
+
{reportDefinition.description && <p className={styles.description}>{reportDefinition.description}</p>}
|
|
124
|
+
|
|
125
|
+
<Layer className={styles.formPanel}>
|
|
126
|
+
<div className={styles.fields}>
|
|
127
|
+
{params.map((param) =>
|
|
128
|
+
param.type === 'date' ? (
|
|
129
|
+
<DatePicker
|
|
130
|
+
key={param.name}
|
|
131
|
+
datePickerType="single"
|
|
132
|
+
dateFormat="Y-m-d"
|
|
133
|
+
className={styles.field}
|
|
134
|
+
onChange={(dates: Array<Date>) => {
|
|
135
|
+
// Carbon's single DatePicker fires onChange with an empty array when it
|
|
136
|
+
// closes without a (re)selection — e.g. when focus moves to the other
|
|
137
|
+
// date field. Treat that as "no change" so it doesn't clobber a value the
|
|
138
|
+
// user already picked. An explicit clear is handled via the input's
|
|
139
|
+
// onChange below.
|
|
140
|
+
const d = dates?.[0];
|
|
141
|
+
if (d) {
|
|
142
|
+
setParam(param.name, formatIsoDate(d));
|
|
143
|
+
}
|
|
144
|
+
}}>
|
|
145
|
+
<DatePickerInput
|
|
146
|
+
id={`param-${param.name}`}
|
|
147
|
+
labelText={param.label}
|
|
148
|
+
placeholder="yyyy-mm-dd"
|
|
149
|
+
size="md"
|
|
150
|
+
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
|
151
|
+
if (!e.target.value) {
|
|
152
|
+
setParam(param.name, '');
|
|
153
|
+
}
|
|
154
|
+
}}
|
|
155
|
+
/>
|
|
156
|
+
</DatePicker>
|
|
157
|
+
) : (
|
|
158
|
+
<TextInput
|
|
159
|
+
key={param.name}
|
|
160
|
+
id={`param-${param.name}`}
|
|
161
|
+
labelText={param.label}
|
|
162
|
+
size="md"
|
|
163
|
+
className={styles.field}
|
|
164
|
+
value={paramValues[param.name] ?? ''}
|
|
165
|
+
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setParam(param.name, e.target.value)}
|
|
166
|
+
/>
|
|
167
|
+
),
|
|
168
|
+
)}
|
|
169
|
+
</div>
|
|
170
|
+
|
|
171
|
+
<div className={styles.actions}>
|
|
172
|
+
<Button kind="primary" disabled={running || !allFilled} onClick={handleRun}>
|
|
173
|
+
{running ? <InlineLoading description={t('running', 'Running…')} /> : t('runReport', 'Run Report')}
|
|
174
|
+
</Button>
|
|
175
|
+
{reportDefinition.designs.map((design) => (
|
|
176
|
+
<Button
|
|
177
|
+
key={design.uuid}
|
|
178
|
+
kind="tertiary"
|
|
179
|
+
disabled={downloadingUuid !== null}
|
|
180
|
+
onClick={() => handleDownload(design.uuid)}>
|
|
181
|
+
{downloadingUuid === design.uuid ? (
|
|
182
|
+
<InlineLoading description={t('generatingDownload', 'Generating…')} />
|
|
183
|
+
) : (
|
|
184
|
+
`${t('download', 'Download')} ${design.name}`
|
|
185
|
+
)}
|
|
186
|
+
</Button>
|
|
187
|
+
))}
|
|
188
|
+
</div>
|
|
189
|
+
|
|
190
|
+
{status && (
|
|
191
|
+
<InlineNotification
|
|
192
|
+
className={styles.status}
|
|
193
|
+
kind={status.kind}
|
|
194
|
+
lowContrast
|
|
195
|
+
hideCloseButton
|
|
196
|
+
title={status.text}
|
|
197
|
+
/>
|
|
198
|
+
)}
|
|
199
|
+
</Layer>
|
|
200
|
+
|
|
201
|
+
{results && (
|
|
202
|
+
<ReportResults
|
|
203
|
+
reportUuid={reportUuid}
|
|
204
|
+
dataSets={results}
|
|
205
|
+
columnOrderByUuid={config.columnOrderByUuid}
|
|
206
|
+
hiddenDatasets={config.hiddenDatasets}
|
|
207
|
+
/>
|
|
208
|
+
)}
|
|
209
|
+
</div>
|
|
210
|
+
);
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
function formatIsoDate(date: Date): string {
|
|
214
|
+
const y = date.getFullYear();
|
|
215
|
+
const m = String(date.getMonth() + 1).padStart(2, '0');
|
|
216
|
+
const d = String(date.getDate()).padStart(2, '0');
|
|
217
|
+
return `${y}-${m}-${d}`;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export default ReportRunner;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { BrowserRouter, Route, Routes } from 'react-router-dom';
|
|
3
|
+
import ReportsLeftNav from './left-nav/reports-left-nav.component';
|
|
4
|
+
import InfoLanding from './report/info-landing.component';
|
|
5
|
+
import ReportRunner from './report/report-runner.component';
|
|
6
|
+
import styles from './root.scss';
|
|
7
|
+
|
|
8
|
+
const Root: React.FC = () => {
|
|
9
|
+
const spaBasePath = `${window.getOpenmrsSpaBase()}reports`;
|
|
10
|
+
|
|
11
|
+
return (
|
|
12
|
+
<BrowserRouter basename={spaBasePath}>
|
|
13
|
+
<ReportsLeftNav />
|
|
14
|
+
<main className={styles.container}>
|
|
15
|
+
<Routes>
|
|
16
|
+
<Route path="/" element={<InfoLanding />} />
|
|
17
|
+
<Route path="/:reportUuid" element={<ReportRunner />} />
|
|
18
|
+
</Routes>
|
|
19
|
+
</main>
|
|
20
|
+
</BrowserRouter>
|
|
21
|
+
);
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export default Root;
|
package/src/root.scss
ADDED
package/src/routes.json
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json.openmrs.org/routes.schema.json",
|
|
3
|
+
"backendDependencies": {
|
|
4
|
+
"ethiopiaemrreports": ">=1.0.0",
|
|
5
|
+
"reporting": ">=1.27.0",
|
|
6
|
+
"reportingrest": ">=1.15.0"
|
|
7
|
+
},
|
|
8
|
+
"pages": [
|
|
9
|
+
{
|
|
10
|
+
"component": "root",
|
|
11
|
+
"route": "reports"
|
|
12
|
+
}
|
|
13
|
+
],
|
|
14
|
+
"extensions": []
|
|
15
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"download": "Download",
|
|
3
|
+
"downloadFailed": "Download failed.",
|
|
4
|
+
"downloadReady": "Download ready.",
|
|
5
|
+
"errorLoadingReport": "Could not load this report.",
|
|
6
|
+
"errorLoadingReports": "Could not load the report list.",
|
|
7
|
+
"ethiopiaEmrReports": "EthiopiaEMR Reports",
|
|
8
|
+
"fillAllFields": "Please fill in all required fields.",
|
|
9
|
+
"generatingDownload": "Generating…",
|
|
10
|
+
"loading": "Loading…",
|
|
11
|
+
"noData": "No data returned.",
|
|
12
|
+
"noRows": "No rows.",
|
|
13
|
+
"reportCompleted": "Report completed successfully.",
|
|
14
|
+
"reportFailed": "Report evaluation failed on the server.",
|
|
15
|
+
"reports": "Reports",
|
|
16
|
+
"running": "Running…",
|
|
17
|
+
"runReport": "Run Report",
|
|
18
|
+
"selectAReport": "Select a report",
|
|
19
|
+
"selectAReportToBegin": "Select a report from the menu on the left to get started."
|
|
20
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"download": "Download",
|
|
3
|
+
"downloadFailed": "Download failed.",
|
|
4
|
+
"downloadReady": "Download ready.",
|
|
5
|
+
"errorLoadingReport": "Could not load this report.",
|
|
6
|
+
"errorLoadingReports": "Could not load the report list.",
|
|
7
|
+
"ethiopiaEmrReports": "EthiopiaEMR Reports",
|
|
8
|
+
"fillAllFields": "Please fill in all required fields.",
|
|
9
|
+
"generatingDownload": "Generating download, please wait…",
|
|
10
|
+
"loading": "Loading…",
|
|
11
|
+
"noData": "No data returned.",
|
|
12
|
+
"noRows": "No rows.",
|
|
13
|
+
"reportCompleted": "Report completed successfully.",
|
|
14
|
+
"reportFailed": "Report evaluation failed on the server.",
|
|
15
|
+
"reports": "Reports",
|
|
16
|
+
"running": "Running report, please wait…",
|
|
17
|
+
"runReport": "Run Report",
|
|
18
|
+
"selectAReport": "Select a report",
|
|
19
|
+
"selectAReportToBegin": "Select a report from the menu on the left to get started."
|
|
20
|
+
}
|
package/tsconfig.json
ADDED