@kenyaemr/esm-reports-app 5.4.4-pre.483
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/1072.js +1 -0
- package/dist/1072.js.map +1 -0
- package/dist/1148.js +1 -0
- package/dist/1148.js.map +1 -0
- package/dist/1278.js +1 -0
- package/dist/1278.js.map +1 -0
- package/dist/1442.js +1 -0
- package/dist/1442.js.map +1 -0
- package/dist/15.js +1 -0
- package/dist/15.js.map +1 -0
- package/dist/1620.js +1 -0
- package/dist/1620.js.map +1 -0
- package/dist/1789.js +1 -0
- package/dist/1789.js.map +1 -0
- package/dist/1831.js +1 -0
- package/dist/1831.js.map +1 -0
- package/dist/1844.js +1 -0
- package/dist/1844.js.map +1 -0
- package/dist/1854.js +1 -0
- package/dist/1854.js.map +1 -0
- package/dist/1919.js +1 -0
- package/dist/1919.js.map +1 -0
- package/dist/1946.js +1 -0
- package/dist/1946.js.map +1 -0
- package/dist/2146.js +1 -0
- package/dist/2146.js.map +1 -0
- package/dist/2152.js +1 -0
- package/dist/2152.js.map +1 -0
- package/dist/2202.js +1 -0
- package/dist/2202.js.map +1 -0
- package/dist/2394.js +1 -0
- package/dist/2394.js.map +1 -0
- package/dist/2534.js +1 -0
- package/dist/2534.js.map +1 -0
- package/dist/2537.js +1 -0
- package/dist/2537.js.map +1 -0
- package/dist/2556.js +1 -0
- package/dist/2556.js.map +1 -0
- package/dist/2648.js +1 -0
- package/dist/2648.js.map +1 -0
- package/dist/2842.js +1 -0
- package/dist/2842.js.map +1 -0
- package/dist/2916.js +1 -0
- package/dist/2916.js.map +1 -0
- package/dist/2924.js +1 -0
- package/dist/2924.js.map +1 -0
- package/dist/2947.js +1 -0
- package/dist/2947.js.map +1 -0
- package/dist/3032.js +1 -0
- package/dist/3032.js.map +1 -0
- package/dist/310.js +1 -0
- package/dist/310.js.map +1 -0
- package/dist/3285.js +1 -0
- package/dist/3285.js.map +1 -0
- package/dist/3335.js +1 -0
- package/dist/3335.js.map +1 -0
- package/dist/335.js +1 -0
- package/dist/335.js.map +1 -0
- package/dist/3378.js +1 -0
- package/dist/3378.js.map +1 -0
- package/dist/3504.js +1 -0
- package/dist/3504.js.map +1 -0
- package/dist/356.js +1 -0
- package/dist/356.js.map +1 -0
- package/dist/3569.js +1 -0
- package/dist/3569.js.map +1 -0
- package/dist/3723.js +1 -0
- package/dist/3723.js.map +1 -0
- package/dist/3745.js +1 -0
- package/dist/3745.js.map +1 -0
- package/dist/3813.js +1 -0
- package/dist/3813.js.map +1 -0
- package/dist/393.js +6 -0
- package/dist/393.js.map +1 -0
- package/dist/3953.js +1 -0
- package/dist/3953.js.map +1 -0
- package/dist/3989.js +1 -0
- package/dist/3989.js.map +1 -0
- package/dist/3997.js +1 -0
- package/dist/3997.js.map +1 -0
- package/dist/4178.js +1 -0
- package/dist/4178.js.map +1 -0
- package/dist/4487.js +11 -0
- package/dist/4487.js.map +1 -0
- package/dist/4588.js +1 -0
- package/dist/4588.js.map +1 -0
- package/dist/4637.js +1 -0
- package/dist/4637.js.map +1 -0
- package/dist/466.js +1 -0
- package/dist/466.js.map +1 -0
- package/dist/4817.js +1 -0
- package/dist/4817.js.map +1 -0
- package/dist/4962.js +1 -0
- package/dist/4962.js.map +1 -0
- package/dist/500.js +1 -0
- package/dist/500.js.map +1 -0
- package/dist/5055.js +1 -0
- package/dist/5055.js.map +1 -0
- package/dist/5086.js +1 -0
- package/dist/5086.js.map +1 -0
- package/dist/510.js +1 -0
- package/dist/510.js.map +1 -0
- package/dist/5117.js +1 -0
- package/dist/5117.js.map +1 -0
- package/dist/5279.js +1 -0
- package/dist/5279.js.map +1 -0
- package/dist/5366.js +1 -0
- package/dist/5366.js.map +1 -0
- package/dist/5598.js +1 -0
- package/dist/5598.js.map +1 -0
- package/dist/5676.js +1 -0
- package/dist/5676.js.map +1 -0
- package/dist/5731.js +5 -0
- package/dist/5731.js.map +1 -0
- package/dist/5792.js +1 -0
- package/dist/5792.js.map +1 -0
- package/dist/5809.js +1 -0
- package/dist/5809.js.map +1 -0
- package/dist/5825.js +1 -0
- package/dist/5825.js.map +1 -0
- package/dist/5932.js +1 -0
- package/dist/5932.js.map +1 -0
- package/dist/5941.js +1 -0
- package/dist/5941.js.map +1 -0
- package/dist/5967.js +1 -0
- package/dist/5967.js.map +1 -0
- package/dist/6104.js +1 -0
- package/dist/6104.js.map +1 -0
- package/dist/6273.js +1 -0
- package/dist/6273.js.map +1 -0
- package/dist/6337.js +1 -0
- package/dist/6337.js.map +1 -0
- package/dist/6357.js +1 -0
- package/dist/6357.js.map +1 -0
- package/dist/6427.js +1 -0
- package/dist/6427.js.map +1 -0
- package/dist/6478.js +7 -0
- package/dist/6478.js.map +1 -0
- package/dist/6508.js +1 -0
- package/dist/6508.js.map +1 -0
- package/dist/6561.js +1 -0
- package/dist/6561.js.map +1 -0
- package/dist/6567.js +1 -0
- package/dist/6567.js.map +1 -0
- package/dist/6634.js +1 -0
- package/dist/6634.js.map +1 -0
- package/dist/689.js +1 -0
- package/dist/689.js.map +1 -0
- package/dist/6998.js +1 -0
- package/dist/6998.js.map +1 -0
- package/dist/7022.js +1 -0
- package/dist/7022.js.map +1 -0
- package/dist/7085.js +1 -0
- package/dist/7085.js.map +1 -0
- package/dist/7209.js +1 -0
- package/dist/7209.js.map +1 -0
- package/dist/7360.js +1 -0
- package/dist/7360.js.map +1 -0
- package/dist/740.js +1 -0
- package/dist/740.js.map +1 -0
- package/dist/7454.js +1 -0
- package/dist/7454.js.map +1 -0
- package/dist/7478.js +1 -0
- package/dist/7478.js.map +1 -0
- package/dist/7782.js +1 -0
- package/dist/7782.js.map +1 -0
- package/dist/781.js +1 -0
- package/dist/781.js.map +1 -0
- package/dist/8028.js +1 -0
- package/dist/8028.js.map +1 -0
- package/dist/8196.js +1 -0
- package/dist/8196.js.map +1 -0
- package/dist/820.js +1 -0
- package/dist/820.js.map +1 -0
- package/dist/8240.js +1 -0
- package/dist/8240.js.map +1 -0
- package/dist/8472.js +1 -0
- package/dist/8472.js.map +1 -0
- package/dist/8607.js +1 -0
- package/dist/8607.js.map +1 -0
- package/dist/8762.js +1 -0
- package/dist/8762.js.map +1 -0
- package/dist/8924.js +1 -0
- package/dist/8924.js.map +1 -0
- package/dist/9011.js +1 -0
- package/dist/9011.js.map +1 -0
- package/dist/9060.js +1 -0
- package/dist/9060.js.map +1 -0
- package/dist/9061.js +1 -0
- package/dist/9061.js.map +1 -0
- package/dist/9087.js +1 -0
- package/dist/9087.js.map +1 -0
- package/dist/9168.js +1 -0
- package/dist/9168.js.map +1 -0
- package/dist/9425.js +1 -0
- package/dist/9520.js +1 -0
- package/dist/9520.js.map +1 -0
- package/dist/9547.js +1 -0
- package/dist/9547.js.map +1 -0
- package/dist/9612.js +1 -0
- package/dist/9612.js.map +1 -0
- package/dist/9771.js +1 -0
- package/dist/9771.js.map +1 -0
- package/dist/9774.js +1 -0
- package/dist/9774.js.map +1 -0
- package/dist/9806.js +1 -0
- package/dist/9806.js.map +1 -0
- package/dist/9885.js +1 -0
- package/dist/9885.js.map +1 -0
- package/dist/992.js +1 -0
- package/dist/992.js.map +1 -0
- package/dist/kenyaemr-esm-reports-app.js +6 -0
- package/dist/kenyaemr-esm-reports-app.js.buildmanifest.json +2960 -0
- package/dist/kenyaemr-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/package.json +49 -0
- package/rspack.config.js +1 -0
- package/src/components/group-reports.component.tsx +98 -0
- package/src/components/group-reports.scss +13 -0
- package/src/components/header/header.component.tsx +20 -0
- package/src/components/header/header.scss +5 -0
- package/src/components/report-download-menu/report-download-menu.component.tsx +71 -0
- package/src/components/report-history/report-history.component.tsx +334 -0
- package/src/components/report-history/report-history.scss +175 -0
- package/src/components/report-table/report-panel.component.tsx +20 -0
- package/src/components/report-table/report-table.component.tsx +162 -0
- package/src/components/report-table/report-table.scss +10 -0
- package/src/components/report-view/report-view.component.tsx +183 -0
- package/src/components/report-view/report-view.scss +111 -0
- package/src/components/utils.tsx +65 -0
- package/src/config-schema.ts +1 -0
- package/src/declarations.d.ts +2 -0
- package/src/hooks/useReportData.tsx +20 -0
- package/src/hooks/useReportDefinition.tsx +33 -0
- package/src/hooks/useReportRequests.tsx +26 -0
- package/src/hooks/useReports.tsx +30 -0
- package/src/index.ts +20 -0
- package/src/root.component.tsx +24 -0
- package/src/routes.json +40 -0
- package/src/types/index.ts +96 -0
- package/src/usePrintPreview.tsx +17 -0
- package/src/workspaces/report-request.workspace.scss +61 -0
- package/src/workspaces/report-request.workspaces.tsx +266 -0
- package/translations/am.json +49 -0
- package/translations/en.json +49 -0
- package/translations/fr.json +50 -0
- package/translations/sw.json +49 -0
- package/tsconfig.json +5 -0
- package/webpack.config.js +1 -0
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import React, { useMemo, useState } from 'react';
|
|
2
|
+
import { useNavigate, useParams } from 'react-router-dom';
|
|
3
|
+
import { useTranslation } from 'react-i18next';
|
|
4
|
+
import { Pagination, SkeletonText, Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@carbon/react';
|
|
5
|
+
import { ErrorState, formatDate, formatDatetime, parseDate } from '@openmrs/esm-framework';
|
|
6
|
+
|
|
7
|
+
import { useReportData } from '../../hooks/useReportData';
|
|
8
|
+
import { type ReportDataSet } from '../../types';
|
|
9
|
+
import ReportDownloadMenu from '../report-download-menu/report-download-menu.component';
|
|
10
|
+
import styles from './report-view.scss';
|
|
11
|
+
import dayjs from 'dayjs';
|
|
12
|
+
|
|
13
|
+
const PAGE_SIZE = 20;
|
|
14
|
+
|
|
15
|
+
const renderValue = (value: unknown): string => {
|
|
16
|
+
if (value === null || value === undefined || value === '') {
|
|
17
|
+
return '--';
|
|
18
|
+
}
|
|
19
|
+
if (typeof value === 'object') {
|
|
20
|
+
const record = value as Record<string, unknown>;
|
|
21
|
+
return String(record.display ?? record.value ?? record.name ?? JSON.stringify(value));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (typeof value === 'number') {
|
|
25
|
+
const unixConvertedDate = dayjs(value).toDate();
|
|
26
|
+
return formatDate(unixConvertedDate, { noToday: true, time: false });
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return String(value);
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const RowDataSetTable: React.FC<{ dataSet: ReportDataSet }> = ({ dataSet }) => {
|
|
33
|
+
const { t } = useTranslation();
|
|
34
|
+
const [page, setPage] = useState(1);
|
|
35
|
+
const [pageSize, setPageSize] = useState(PAGE_SIZE);
|
|
36
|
+
|
|
37
|
+
const pagedRows = useMemo(
|
|
38
|
+
() => dataSet.rows.slice((page - 1) * pageSize, page * pageSize),
|
|
39
|
+
[dataSet.rows, page, pageSize],
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<>
|
|
44
|
+
<div className={styles.tableWrapper}>
|
|
45
|
+
<Table size="sm" useZebraStyles>
|
|
46
|
+
<TableHead>
|
|
47
|
+
<TableRow>
|
|
48
|
+
{dataSet.columns.map((column) => (
|
|
49
|
+
<TableHeader key={column.name}>{column.label || column.name}</TableHeader>
|
|
50
|
+
))}
|
|
51
|
+
</TableRow>
|
|
52
|
+
</TableHead>
|
|
53
|
+
<TableBody>
|
|
54
|
+
{pagedRows.map((row, rowIndex) => (
|
|
55
|
+
<TableRow key={rowIndex}>
|
|
56
|
+
{dataSet.columns.map((column) => (
|
|
57
|
+
<TableCell key={column.name}>{renderValue(row[column.name])}</TableCell>
|
|
58
|
+
))}
|
|
59
|
+
</TableRow>
|
|
60
|
+
))}
|
|
61
|
+
</TableBody>
|
|
62
|
+
</Table>
|
|
63
|
+
</div>
|
|
64
|
+
{dataSet.rows.length > PAGE_SIZE && (
|
|
65
|
+
<Pagination
|
|
66
|
+
page={page}
|
|
67
|
+
pageSize={pageSize}
|
|
68
|
+
pageSizes={[20, 50, 100]}
|
|
69
|
+
totalItems={dataSet.rows.length}
|
|
70
|
+
itemRangeText={(min, max, total) =>
|
|
71
|
+
t('minMaxItems', '{{min}}-{{max}} of {{total}} items', { min, max, total })
|
|
72
|
+
}
|
|
73
|
+
onChange={({ page: newPage, pageSize: newPageSize }) => {
|
|
74
|
+
setPage(newPage);
|
|
75
|
+
setPageSize(newPageSize);
|
|
76
|
+
}}
|
|
77
|
+
/>
|
|
78
|
+
)}
|
|
79
|
+
</>
|
|
80
|
+
);
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const IndicatorDataSetTable: React.FC<{ dataSet: ReportDataSet }> = ({ dataSet }) => {
|
|
84
|
+
const { t } = useTranslation();
|
|
85
|
+
const values = dataSet.values ?? {};
|
|
86
|
+
|
|
87
|
+
return (
|
|
88
|
+
<div className={`${styles.tableWrapper} ${styles.indicatorTable}`}>
|
|
89
|
+
<Table size="sm" useZebraStyles>
|
|
90
|
+
<TableHead>
|
|
91
|
+
<TableRow>
|
|
92
|
+
<TableHeader>{t('indicator', 'Indicator')}</TableHeader>
|
|
93
|
+
<TableHeader>{t('value', 'Value')}</TableHeader>
|
|
94
|
+
</TableRow>
|
|
95
|
+
</TableHead>
|
|
96
|
+
<TableBody>
|
|
97
|
+
{dataSet.columns.map((column) => (
|
|
98
|
+
<TableRow key={column.name}>
|
|
99
|
+
<TableCell>{column.label || column.name}</TableCell>
|
|
100
|
+
<TableCell>{renderValue(values[column.name])}</TableCell>
|
|
101
|
+
</TableRow>
|
|
102
|
+
))}
|
|
103
|
+
</TableBody>
|
|
104
|
+
</Table>
|
|
105
|
+
</div>
|
|
106
|
+
);
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const ReportView: React.FC = () => {
|
|
110
|
+
const { t } = useTranslation();
|
|
111
|
+
const navigate = useNavigate();
|
|
112
|
+
const { reportUuid = '', requestId = '' } = useParams();
|
|
113
|
+
const { reportData, isLoading, error } = useReportData(requestId);
|
|
114
|
+
|
|
115
|
+
if (isLoading) {
|
|
116
|
+
return (
|
|
117
|
+
<div className={styles.loadingState}>
|
|
118
|
+
<SkeletonText heading width="40%" />
|
|
119
|
+
<SkeletonText paragraph lineCount={5} />
|
|
120
|
+
</div>
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (error || !reportData) {
|
|
125
|
+
return (
|
|
126
|
+
<div className={styles.errorState}>
|
|
127
|
+
<ErrorState error={error} headerTitle={t('reportData', 'Report data')} />
|
|
128
|
+
</div>
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const { request, definition, parameters, dataSets } = reportData;
|
|
133
|
+
|
|
134
|
+
return (
|
|
135
|
+
<div className={styles.container}>
|
|
136
|
+
<div className={styles.breadcrumb}>
|
|
137
|
+
<button type="button" className={styles.backButton} onClick={() => navigate(`/report/${reportUuid}`)}>
|
|
138
|
+
{t('reportsHistory', 'Report History')}
|
|
139
|
+
</button>
|
|
140
|
+
<span>/</span>
|
|
141
|
+
<span>{t('reportData', 'Report data')}</span>
|
|
142
|
+
</div>
|
|
143
|
+
|
|
144
|
+
<div className={styles.titleRow}>
|
|
145
|
+
<div>
|
|
146
|
+
<h2 className={styles.title}>{definition?.name}</h2>
|
|
147
|
+
{request?.requestDate && (
|
|
148
|
+
<p className={styles.subtitle}>
|
|
149
|
+
{t('generatedOn', 'Generated on {{date}}', {
|
|
150
|
+
date: formatDatetime(parseDate(request.requestDate), { mode: 'standard', noToday: true }),
|
|
151
|
+
})}
|
|
152
|
+
{request?.requestedBy?.display ? ` · ${request.requestedBy.display}` : ''}
|
|
153
|
+
</p>
|
|
154
|
+
)}
|
|
155
|
+
</div>
|
|
156
|
+
{request && <ReportDownloadMenu request={request} reportName={definition?.name} />}
|
|
157
|
+
</div>
|
|
158
|
+
|
|
159
|
+
{parameters && Object.keys(parameters).length > 0 && (
|
|
160
|
+
<div className={styles.parameters}>
|
|
161
|
+
{Object.entries(parameters).map(([name, value]) => (
|
|
162
|
+
<div key={name} className={styles.parameter}>
|
|
163
|
+
<span className={styles.parameterLabel}>{name}</span>
|
|
164
|
+
<span className={styles.parameterValue}>{renderValue(value)}</span>
|
|
165
|
+
</div>
|
|
166
|
+
))}
|
|
167
|
+
</div>
|
|
168
|
+
)}
|
|
169
|
+
|
|
170
|
+
{Object.values(dataSets ?? {}).map((dataSet) => {
|
|
171
|
+
const isIndicator = (!dataSet.rows || dataSet.rows.length === 0) && dataSet.values;
|
|
172
|
+
return (
|
|
173
|
+
<div key={dataSet.key} className={styles.dataSet}>
|
|
174
|
+
<h3 className={styles.dataSetTitle}>{dataSet.name || dataSet.key}</h3>
|
|
175
|
+
{isIndicator ? <IndicatorDataSetTable dataSet={dataSet} /> : <RowDataSetTable dataSet={dataSet} />}
|
|
176
|
+
</div>
|
|
177
|
+
);
|
|
178
|
+
})}
|
|
179
|
+
</div>
|
|
180
|
+
);
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
export default ReportView;
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
@use '@carbon/colors';
|
|
2
|
+
@use '@carbon/layout';
|
|
3
|
+
@use '@carbon/type';
|
|
4
|
+
|
|
5
|
+
.container {
|
|
6
|
+
display: flex;
|
|
7
|
+
flex-direction: column;
|
|
8
|
+
gap: layout.$spacing-05;
|
|
9
|
+
background-color: colors.$white;
|
|
10
|
+
padding: layout.$spacing-05;
|
|
11
|
+
margin: layout.$spacing-02;
|
|
12
|
+
border: 1px solid colors.$gray-10;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
.breadcrumb {
|
|
16
|
+
display: flex;
|
|
17
|
+
align-items: center;
|
|
18
|
+
gap: layout.$spacing-03;
|
|
19
|
+
@include type.type-style('body-compact-01');
|
|
20
|
+
color: colors.$gray-70;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
.backButton {
|
|
24
|
+
display: inline-flex;
|
|
25
|
+
align-items: center;
|
|
26
|
+
gap: layout.$spacing-02;
|
|
27
|
+
background: none;
|
|
28
|
+
border: none;
|
|
29
|
+
cursor: pointer;
|
|
30
|
+
padding: 0;
|
|
31
|
+
color: colors.$blue-60;
|
|
32
|
+
@include type.type-style('body-compact-01');
|
|
33
|
+
|
|
34
|
+
&:hover {
|
|
35
|
+
text-decoration: underline;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
.titleRow {
|
|
40
|
+
display: flex;
|
|
41
|
+
align-items: flex-start;
|
|
42
|
+
justify-content: space-between;
|
|
43
|
+
gap: layout.$spacing-05;
|
|
44
|
+
padding-bottom: layout.$spacing-03;
|
|
45
|
+
border-bottom: 1px solid colors.$gray-20;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
.title {
|
|
49
|
+
@include type.type-style('heading-05');
|
|
50
|
+
color: colors.$gray-100;
|
|
51
|
+
margin: 0;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
.subtitle {
|
|
55
|
+
@include type.type-style('body-01');
|
|
56
|
+
color: colors.$gray-70;
|
|
57
|
+
margin-top: layout.$spacing-02;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
.parameters {
|
|
61
|
+
display: flex;
|
|
62
|
+
flex-wrap: wrap;
|
|
63
|
+
gap: layout.$spacing-03 layout.$spacing-06;
|
|
64
|
+
padding: layout.$spacing-04 0;
|
|
65
|
+
border-bottom: 1px solid colors.$gray-20;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
.parameter {
|
|
69
|
+
display: flex;
|
|
70
|
+
flex-direction: column;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
.parameterLabel {
|
|
74
|
+
@include type.type-style('label-01');
|
|
75
|
+
text-transform: uppercase;
|
|
76
|
+
letter-spacing: 0.04em;
|
|
77
|
+
color: colors.$gray-70;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
.parameterValue {
|
|
81
|
+
@include type.type-style('body-compact-01');
|
|
82
|
+
color: colors.$gray-100;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
.dataSet {
|
|
86
|
+
display: flex;
|
|
87
|
+
flex-direction: column;
|
|
88
|
+
gap: layout.$spacing-03;
|
|
89
|
+
border:1px solid colors.$gray-10;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
.dataSetTitle {
|
|
93
|
+
@include type.type-style('heading-03');
|
|
94
|
+
color: colors.$gray-100;
|
|
95
|
+
margin-top: layout.$spacing-05;
|
|
96
|
+
margin-left: layout.$spacing-05;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
.tableWrapper {
|
|
100
|
+
overflow-x: auto;
|
|
101
|
+
border: 1px solid colors.$gray-20;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
.indicatorTable {
|
|
105
|
+
width: 100%;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
.errorState,
|
|
109
|
+
.loadingState {
|
|
110
|
+
padding: layout.$spacing-07;
|
|
111
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import dayjs from 'dayjs';
|
|
2
|
+
import React, { type ReactElement } from 'react';
|
|
3
|
+
import { Csv, DocumentPdf, Json, Xls, Xml, type CarbonIconType } from '@carbon/react/icons';
|
|
4
|
+
import { openmrsFetch } from '@openmrs/esm-framework';
|
|
5
|
+
import type { DownloadFormats } from '../types';
|
|
6
|
+
|
|
7
|
+
const UNIX_TIMESTAMP_SECONDS_PATTERN = /^\d{10}$/;
|
|
8
|
+
|
|
9
|
+
export const isUnixTimestampString = (value: string): boolean => {
|
|
10
|
+
const trimmedValue = value.trim();
|
|
11
|
+
const timestamp = Number(trimmedValue);
|
|
12
|
+
|
|
13
|
+
return (
|
|
14
|
+
UNIX_TIMESTAMP_SECONDS_PATTERN.test(trimmedValue) &&
|
|
15
|
+
Number.isSafeInteger(timestamp) &&
|
|
16
|
+
dayjs.unix(timestamp).isValid()
|
|
17
|
+
);
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const downloadFormatIconMap = {
|
|
21
|
+
pdf: DocumentPdf,
|
|
22
|
+
csv: Csv,
|
|
23
|
+
excel: Xls,
|
|
24
|
+
json: Json,
|
|
25
|
+
adx: Xml,
|
|
26
|
+
} as const satisfies Record<DownloadFormats, CarbonIconType>;
|
|
27
|
+
|
|
28
|
+
export const getDownloadFormatIcon = (downloadFormat: DownloadFormats, size = 20): ReactElement => {
|
|
29
|
+
const Icon = downloadFormatIconMap[downloadFormat];
|
|
30
|
+
return <Icon size={size} />;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export const downloadFormatLabel: Record<DownloadFormats, string> = {
|
|
34
|
+
pdf: 'PDF',
|
|
35
|
+
csv: 'CSV',
|
|
36
|
+
excel: 'Excel',
|
|
37
|
+
json: 'JSON',
|
|
38
|
+
adx: 'ADX',
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const parseFilename = (contentDisposition: string | null): string | null => {
|
|
42
|
+
if (!contentDisposition) {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
const match = /filename\*?=(?:UTF-8'')?"?([^";]+)"?/i.exec(contentDisposition);
|
|
46
|
+
return match ? decodeURIComponent(match[1]) : null;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Fetches a completed report request export and triggers a browser download.
|
|
51
|
+
* The backend serves the file as an attachment, so the response body is read as a
|
|
52
|
+
* blob rather than parsed as JSON.
|
|
53
|
+
*/
|
|
54
|
+
export const downloadReportRequestFile = async (downloadUrl: string, fallbackFilename: string): Promise<void> => {
|
|
55
|
+
const response = await openmrsFetch(downloadUrl, { headers: { Accept: '*/*' } });
|
|
56
|
+
const blob = await response.blob();
|
|
57
|
+
const objectUrl = URL.createObjectURL(blob);
|
|
58
|
+
const anchor = document.createElement('a');
|
|
59
|
+
anchor.href = objectUrl;
|
|
60
|
+
anchor.download = parseFilename(response.headers?.get('content-disposition')) ?? fallbackFilename;
|
|
61
|
+
document.body.appendChild(anchor);
|
|
62
|
+
anchor.click();
|
|
63
|
+
anchor.remove();
|
|
64
|
+
URL.revokeObjectURL(objectUrl);
|
|
65
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const configSchema = {};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { openmrsFetch, restBaseUrl } from '@openmrs/esm-framework';
|
|
2
|
+
import useSWR from 'swr';
|
|
3
|
+
import { type ReportData } from '../types';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* useReportData
|
|
7
|
+
* Loads the rendered data (datasets, columns, rows) for a completed report request.
|
|
8
|
+
*/
|
|
9
|
+
export const useReportData = (requestId?: string | number) => {
|
|
10
|
+
const url = requestId ? `${restBaseUrl}/kenyaemr/reportRequests/${requestId}/data` : null;
|
|
11
|
+
|
|
12
|
+
const { data, isLoading, error, mutate } = useSWR<{ data: ReportData }>(url, openmrsFetch);
|
|
13
|
+
|
|
14
|
+
return {
|
|
15
|
+
reportData: data?.data ?? null,
|
|
16
|
+
isLoading,
|
|
17
|
+
error,
|
|
18
|
+
mutate,
|
|
19
|
+
};
|
|
20
|
+
};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { openmrsFetch, restBaseUrl } from '@openmrs/esm-framework';
|
|
2
|
+
import useSWRImmutable from 'swr/immutable';
|
|
3
|
+
import { type ReportWithDefinition } from '../types';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* useReportDefinition
|
|
7
|
+
* Fetches a single KenyaEMR report together with its definition and parameters.
|
|
8
|
+
*/
|
|
9
|
+
export const useReportDefinition = (reportUuid: string) => {
|
|
10
|
+
const url = reportUuid ? `${restBaseUrl}/kenyaemr/reports/${reportUuid}` : null;
|
|
11
|
+
|
|
12
|
+
const { data, isLoading, error, mutate } = useSWRImmutable<{ data: ReportWithDefinition }>(url, openmrsFetch);
|
|
13
|
+
|
|
14
|
+
return {
|
|
15
|
+
report: data?.data ?? null,
|
|
16
|
+
parameters: data?.data?.definition?.parameters ?? [],
|
|
17
|
+
isLoading,
|
|
18
|
+
error,
|
|
19
|
+
mutate,
|
|
20
|
+
};
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* requestReport
|
|
25
|
+
* Queues a report request with the supplied parameter values.
|
|
26
|
+
*/
|
|
27
|
+
export const requestReport = (reportUuid: string, parameterValues: Record<string, unknown>) => {
|
|
28
|
+
return openmrsFetch(`${restBaseUrl}/kenyaemr/reports/${reportUuid}/requests`, {
|
|
29
|
+
method: 'POST',
|
|
30
|
+
headers: { 'Content-Type': 'application/json' },
|
|
31
|
+
body: parameterValues,
|
|
32
|
+
});
|
|
33
|
+
};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { openmrsFetch, restBaseUrl } from '@openmrs/esm-framework';
|
|
2
|
+
import useSWR from 'swr';
|
|
3
|
+
import { type ReportRequest } from '../types';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* useReportRequests
|
|
7
|
+
* Fetches the execution history (report requests) for a given report.
|
|
8
|
+
* Polls while there are in-flight requests so running rows update automatically.
|
|
9
|
+
*/
|
|
10
|
+
export const useReportRequests = (reportUuid: string) => {
|
|
11
|
+
const url = reportUuid ? `${restBaseUrl}/kenyaemr/reportRequests?reportUuid=${reportUuid}` : null;
|
|
12
|
+
|
|
13
|
+
const { data, isLoading, isValidating, error, mutate } = useSWR<{ data: { results: Array<ReportRequest> } }>(
|
|
14
|
+
url,
|
|
15
|
+
openmrsFetch,
|
|
16
|
+
{ refreshInterval: 10000 },
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
return {
|
|
20
|
+
requests: data?.data?.results ?? [],
|
|
21
|
+
isLoading,
|
|
22
|
+
isValidating,
|
|
23
|
+
error,
|
|
24
|
+
mutate,
|
|
25
|
+
};
|
|
26
|
+
};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { openmrsFetch, restBaseUrl } from '@openmrs/esm-framework';
|
|
2
|
+
import useSWRImmutable from 'swr/immutable';
|
|
3
|
+
import { type ReportCategory, type ReportDescriptor, type ReportsResponse } from '../types';
|
|
4
|
+
|
|
5
|
+
const byName = (a: ReportDescriptor, b: ReportDescriptor) => a.name.localeCompare(b.name);
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* useGroupedReports
|
|
9
|
+
* Fetches grouped KenyaEMR reports with each category's reports sorted alphabetically.
|
|
10
|
+
*/
|
|
11
|
+
const useGroupedReports = () => {
|
|
12
|
+
const url = `${restBaseUrl}/kenyaemr/reports/grouped`;
|
|
13
|
+
|
|
14
|
+
const { data, isLoading, error, mutate } = useSWRImmutable<{ data: ReportsResponse }>(url, openmrsFetch);
|
|
15
|
+
|
|
16
|
+
const reports: Array<ReportCategory> = (data?.data.results ?? []).map((group) => ({
|
|
17
|
+
...group,
|
|
18
|
+
indicator: [...(group.indicator ?? [])].sort(byName),
|
|
19
|
+
patientFollowUpReports: [...(group.patientFollowUpReports ?? [])].sort(byName),
|
|
20
|
+
}));
|
|
21
|
+
|
|
22
|
+
return {
|
|
23
|
+
reports,
|
|
24
|
+
isLoading,
|
|
25
|
+
error,
|
|
26
|
+
mutate,
|
|
27
|
+
};
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export default useGroupedReports;
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { defineConfigSchema, getAsyncLifecycle } from '@openmrs/esm-framework';
|
|
2
|
+
import { configSchema } from './config-schema';
|
|
3
|
+
|
|
4
|
+
const moduleName = '@kenyaemr/esm-esm-reports-app';
|
|
5
|
+
|
|
6
|
+
const options = {
|
|
7
|
+
featureName: 'esm-reports-app',
|
|
8
|
+
moduleName,
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export const importTranslation = require.context('../translations', false, /.json$/, 'lazy');
|
|
12
|
+
export const reportsDashboard = getAsyncLifecycle(() => import('./root.component'), options);
|
|
13
|
+
export const reportRequestWorkspaces = getAsyncLifecycle(
|
|
14
|
+
() => import('./workspaces/report-request.workspaces'),
|
|
15
|
+
options,
|
|
16
|
+
);
|
|
17
|
+
|
|
18
|
+
export function startupApp() {
|
|
19
|
+
defineConfigSchema(moduleName, configSchema);
|
|
20
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Routes, BrowserRouter, Route } from 'react-router-dom';
|
|
3
|
+
import Header from './components/header/header.component';
|
|
4
|
+
import GroupReports from './components/group-reports.component';
|
|
5
|
+
import ReportHistory from './components/report-history/report-history.component';
|
|
6
|
+
import ReportView from './components/report-view/report-view.component';
|
|
7
|
+
|
|
8
|
+
const ReportDashboard = () => {
|
|
9
|
+
const spaBasePath = `${(globalThis as any).spaBase}/reporting`;
|
|
10
|
+
return (
|
|
11
|
+
<main>
|
|
12
|
+
<BrowserRouter basename={spaBasePath}>
|
|
13
|
+
<Header />
|
|
14
|
+
<Routes>
|
|
15
|
+
<Route path="/" element={<GroupReports />} />
|
|
16
|
+
<Route path="/report/:reportUuid" element={<ReportHistory />} />
|
|
17
|
+
<Route path="/report/:reportUuid/requests/:requestId" element={<ReportView />} />
|
|
18
|
+
</Routes>
|
|
19
|
+
</BrowserRouter>
|
|
20
|
+
</main>
|
|
21
|
+
);
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export default ReportDashboard;
|
package/src/routes.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json.openmrs.org/routes.schema.json",
|
|
3
|
+
"backendDependencies": {
|
|
4
|
+
"kenyaemr": "^19.0.0"
|
|
5
|
+
},
|
|
6
|
+
"pages": [
|
|
7
|
+
{
|
|
8
|
+
"component": "reportsDashboard",
|
|
9
|
+
"route": "reporting"
|
|
10
|
+
}
|
|
11
|
+
],
|
|
12
|
+
"workspaces2": [
|
|
13
|
+
{
|
|
14
|
+
"name":"report-request-workspace",
|
|
15
|
+
"component":"reportRequestWorkspaces",
|
|
16
|
+
"window":"reporting-window"
|
|
17
|
+
}
|
|
18
|
+
],
|
|
19
|
+
"workspaceWindows2": [
|
|
20
|
+
{
|
|
21
|
+
"name" : "reporting-window",
|
|
22
|
+
"group" : "reporting-group",
|
|
23
|
+
"width": "wider",
|
|
24
|
+
"canMaximize":true
|
|
25
|
+
}
|
|
26
|
+
],
|
|
27
|
+
"workspaceGroups2": [
|
|
28
|
+
{
|
|
29
|
+
"name":"reporting-group",
|
|
30
|
+
"closeable":true,
|
|
31
|
+
"scopePattern":"reporting"
|
|
32
|
+
}
|
|
33
|
+
],
|
|
34
|
+
"extensions": [
|
|
35
|
+
|
|
36
|
+
],
|
|
37
|
+
"modals": [
|
|
38
|
+
|
|
39
|
+
]
|
|
40
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
export type ReportDescriptor = {
|
|
2
|
+
uuid: string;
|
|
3
|
+
name: string;
|
|
4
|
+
description: string | null;
|
|
5
|
+
type: 'IndicatorReportDescriptor' | 'HybridReportDescriptor' | 'CalculationReportDescriptor';
|
|
6
|
+
indicator: boolean;
|
|
7
|
+
hybrid: boolean;
|
|
8
|
+
template?: string | null;
|
|
9
|
+
repeatingSection?: string | null;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export type ReportCategory = {
|
|
13
|
+
name: string;
|
|
14
|
+
indicator: Array<ReportDescriptor>;
|
|
15
|
+
patientFollowUpReports: Array<ReportDescriptor>;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export type ReportsResponse = {
|
|
19
|
+
appId: string;
|
|
20
|
+
results: Array<ReportCategory>;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type ReportParameterType =
|
|
24
|
+
| 'java.util.Date'
|
|
25
|
+
| 'java.lang.String'
|
|
26
|
+
| 'java.lang.Integer'
|
|
27
|
+
| 'java.lang.Long'
|
|
28
|
+
| 'java.lang.Double'
|
|
29
|
+
| 'java.lang.Boolean';
|
|
30
|
+
|
|
31
|
+
export interface ReportParameter {
|
|
32
|
+
name: string;
|
|
33
|
+
label: string;
|
|
34
|
+
type: ReportParameterType | string;
|
|
35
|
+
defaultValue: string | number | boolean | null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface ReportDefinition {
|
|
39
|
+
uuid: string;
|
|
40
|
+
name: string;
|
|
41
|
+
description: string;
|
|
42
|
+
parameters: Array<ReportParameter>;
|
|
43
|
+
dataSets: Array<string>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export type ReportWithDefinition = ReportDescriptor & {
|
|
47
|
+
requestUrl: string;
|
|
48
|
+
requestsUrl: string;
|
|
49
|
+
definition: ReportDefinition | null;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export type ReportRequestStatus = 'REQUESTED' | 'SAVED' | 'SCHEDULED' | 'PROCESSING' | 'COMPLETED' | 'FAILED' | string;
|
|
53
|
+
|
|
54
|
+
export interface ReportRequest {
|
|
55
|
+
id: number;
|
|
56
|
+
uuid: string;
|
|
57
|
+
status: ReportRequestStatus;
|
|
58
|
+
requestDate: string;
|
|
59
|
+
evaluateStartDatetime: string | null;
|
|
60
|
+
evaluateCompleteDatetime: string | null;
|
|
61
|
+
priority: string;
|
|
62
|
+
parameters: Record<string, unknown>;
|
|
63
|
+
requestedBy?: {
|
|
64
|
+
uuid: string;
|
|
65
|
+
display: string;
|
|
66
|
+
};
|
|
67
|
+
report?: {
|
|
68
|
+
uuid: string;
|
|
69
|
+
name: string;
|
|
70
|
+
description: string | null;
|
|
71
|
+
};
|
|
72
|
+
downloadUrls: Partial<Record<DownloadFormats, string>>;
|
|
73
|
+
downloadFormats: Array<DownloadFormats>;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export type DownloadFormats = 'pdf' | 'csv' | 'json' | 'excel' | 'adx';
|
|
77
|
+
|
|
78
|
+
export interface ReportDataSetColumn {
|
|
79
|
+
name: string;
|
|
80
|
+
label: string;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface ReportDataSet {
|
|
84
|
+
key: string;
|
|
85
|
+
name: string;
|
|
86
|
+
columns: Array<ReportDataSetColumn>;
|
|
87
|
+
rows: Array<Record<string, unknown>>;
|
|
88
|
+
values?: Record<string, unknown>;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface ReportData {
|
|
92
|
+
request: ReportRequest;
|
|
93
|
+
definition: ReportDefinition;
|
|
94
|
+
parameters: Record<string, unknown>;
|
|
95
|
+
dataSets: Record<string, ReportDataSet>;
|
|
96
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import useSWR from 'swr';
|
|
2
|
+
|
|
3
|
+
const fetcher = async (url: string) => {
|
|
4
|
+
const res = await fetch(url, {
|
|
5
|
+
headers: {
|
|
6
|
+
'Content-Type': 'application/pdf',
|
|
7
|
+
},
|
|
8
|
+
});
|
|
9
|
+
const fileData = await res.arrayBuffer();
|
|
10
|
+
const blob = new Blob([fileData], { type: 'application/pdf' });
|
|
11
|
+
return URL.createObjectURL(blob);
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export const usePrintPreview = (url: string) => {
|
|
15
|
+
const { data, isLoading, error, mutate } = useSWR<String>(url, fetcher);
|
|
16
|
+
return { data, isLoading, error, mutate };
|
|
17
|
+
};
|