@digihmis/esm-procedures-app 1.0.1
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/package.json +51 -0
- package/rspack.config.js +1 -0
- package/src/config-schema.ts +31 -0
- package/src/dashboard.meta.ts +8 -0
- package/src/declarations.d.ts +6 -0
- package/src/index.ts +27 -0
- package/src/page-header/page-header.component.tsx +12 -0
- package/src/procedures-tab/procedures-orders-table/procedures-orders-table.component.tsx +229 -0
- package/src/procedures-tab/procedures-orders-table/procedures-orders-table.scss +78 -0
- package/src/procedures-tab/procedures-tab.component.tsx +183 -0
- package/src/procedures-tab/procedures-tab.scss +19 -0
- package/src/procedures-worklist/procedures-worklist.component.tsx +129 -0
- package/src/procedures-worklist/procedures-worklist.scss +166 -0
- package/src/procedures.resource.ts +163 -0
- package/src/report-workspace/report.workspace.scss +48 -0
- package/src/report-workspace/report.workspace.tsx +168 -0
- package/src/routes.json +35 -0
- package/translations/en.json +62 -0
- package/tsconfig.json +5 -0
package/package.json
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@digihmis/esm-procedures-app",
|
|
3
|
+
"version": "1.0.1",
|
|
4
|
+
"description": "Procedures microfrontend for the OpenMRS SPA",
|
|
5
|
+
"browser": "dist/digihmis-esm-procedures-app.js",
|
|
6
|
+
"main": "src/index.ts",
|
|
7
|
+
"source": true,
|
|
8
|
+
"license": "MPL-2.0",
|
|
9
|
+
"homepage": "https://github.com/its-kios09/DigiHMIS-apps#readme",
|
|
10
|
+
"scripts": {
|
|
11
|
+
"analyze": "rspack --mode=production --env analyze=true",
|
|
12
|
+
"build": "rspack --mode=production",
|
|
13
|
+
"coverage": "yarn test --coverage",
|
|
14
|
+
"debug": "npm run serve",
|
|
15
|
+
"extract-translations": "i18next 'src/**/*.component.tsx' 'src/index.ts' --config ../../tools/i18next-parser.config.js",
|
|
16
|
+
"lint": "eslint src --ext ts,tsx",
|
|
17
|
+
"serve": "rspack serve --mode=development",
|
|
18
|
+
"start": "openmrs develop",
|
|
19
|
+
"test": "cross-env TZ=UTC jest --config jest.config.js --verbose false --passWithNoTests",
|
|
20
|
+
"test:watch": "cross-env TZ=UTC jest --watch --config jest.config.js",
|
|
21
|
+
"typescript": "tsc"
|
|
22
|
+
},
|
|
23
|
+
"browserslist": [
|
|
24
|
+
"extends browserslist-config-openmrs"
|
|
25
|
+
],
|
|
26
|
+
"keywords": [
|
|
27
|
+
"openmrs"
|
|
28
|
+
],
|
|
29
|
+
"publishConfig": {
|
|
30
|
+
"access": "public"
|
|
31
|
+
},
|
|
32
|
+
"repository": {
|
|
33
|
+
"type": "git",
|
|
34
|
+
"url": "git+https://github.com/its-kios09/DigiHMIS-apps.git#readme"
|
|
35
|
+
},
|
|
36
|
+
"bugs": {
|
|
37
|
+
"url": "https://github.com/its-kios09/DigiHMIS-apps/issues"
|
|
38
|
+
},
|
|
39
|
+
"dependencies": {
|
|
40
|
+
"@digihmis/esm-commons-lib": "1.0.0",
|
|
41
|
+
"lodash-es": "^4.17.15"
|
|
42
|
+
},
|
|
43
|
+
"peerDependencies": {
|
|
44
|
+
"@carbon/react": "1.x",
|
|
45
|
+
"@openmrs/esm-framework": "8.x",
|
|
46
|
+
"react": "^18.1.0",
|
|
47
|
+
"react-i18next": "16.x",
|
|
48
|
+
"react-router-dom": "6.x",
|
|
49
|
+
"swr": "2.x"
|
|
50
|
+
}
|
|
51
|
+
}
|
package/rspack.config.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
module.exports = require('../../tools/rs-pack-code-spliting');
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { Type, validators, type ConfigSchema } from '@openmrs/esm-framework';
|
|
2
|
+
|
|
3
|
+
export const configSchema: ConfigSchema = {
|
|
4
|
+
proceduresIcon: {
|
|
5
|
+
src: {
|
|
6
|
+
_type: Type.String,
|
|
7
|
+
_default: '/openmrs/spa/procedure.svg',
|
|
8
|
+
_description:
|
|
9
|
+
'The path or URL to the logo image. If set to an empty string, the default OpenMRS SVG sprite will be used.',
|
|
10
|
+
_validators: [validators.isUrl],
|
|
11
|
+
},
|
|
12
|
+
alt: {
|
|
13
|
+
_type: Type.String,
|
|
14
|
+
_default: 'Procedures',
|
|
15
|
+
_description: 'The alternative text for the logo image.',
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
proceduresQueueUuid: {
|
|
19
|
+
_type: Type.UUID,
|
|
20
|
+
_description: 'The UUID of the procedures queue, used to scope the provider clock-in session and metrics.',
|
|
21
|
+
_default: '7c9e1d2f-8b6a-4c3e-9f1b-0d2e3f4a5b6c',
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export type ConfigObject = {
|
|
26
|
+
proceduresIcon: {
|
|
27
|
+
src: string;
|
|
28
|
+
alt?: string;
|
|
29
|
+
};
|
|
30
|
+
proceduresQueueUuid: string;
|
|
31
|
+
};
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { defineConfigSchema, getAsyncLifecycle, getSyncLifecycle } from '@openmrs/esm-framework';
|
|
2
|
+
import { configSchema } from './config-schema';
|
|
3
|
+
import { createDashboardLink } from '@digihmis/esm-commons-lib';
|
|
4
|
+
import { dashboardMeta } from './dashboard.meta';
|
|
5
|
+
import ImagingWorklist from './procedures-worklist/procedures-worklist.component';
|
|
6
|
+
|
|
7
|
+
const moduleName = '@digihmis/esm-procedures-app';
|
|
8
|
+
|
|
9
|
+
const options = {
|
|
10
|
+
featureName: 'procedures',
|
|
11
|
+
moduleName,
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export const importTranslation = require.context('../translations', false, /.json$/, 'lazy');
|
|
15
|
+
|
|
16
|
+
// The dashboard link shown in the home left-nav (procedures-dashboard-slot).
|
|
17
|
+
export const leftPanelLink = getSyncLifecycle(createDashboardLink(dashboardMeta), options);
|
|
18
|
+
|
|
19
|
+
// The worklist content rendered on /home/radiology (procedures-homepage-dashboard-slot).
|
|
20
|
+
export const proceduresWorklistDashboard = getSyncLifecycle(ImagingWorklist, options);
|
|
21
|
+
|
|
22
|
+
// Enter-report / verify / view side panel launched from the worklist tabs.
|
|
23
|
+
export const procedureReportWorkspace = getAsyncLifecycle(() => import('./report-workspace/report.workspace'), options);
|
|
24
|
+
|
|
25
|
+
export function startupApp() {
|
|
26
|
+
defineConfigSchema(moduleName, configSchema);
|
|
27
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { useTranslation } from 'react-i18next';
|
|
3
|
+
import { useConfig } from '@openmrs/esm-framework';
|
|
4
|
+
import { PageHeader as CommonPageHeader } from '@digihmis/esm-commons-lib';
|
|
5
|
+
import { ConfigObject } from '../config-schema';
|
|
6
|
+
|
|
7
|
+
// The shared header lives in @digihmis/esm-commons-lib; this passes the module's title + icon.
|
|
8
|
+
export const PageHeader: React.FC = () => {
|
|
9
|
+
const { t } = useTranslation();
|
|
10
|
+
const { proceduresIcon } = useConfig<ConfigObject>();
|
|
11
|
+
return <CommonPageHeader title={t('procedures', 'Procedures')} iconSrc={proceduresIcon.src} iconAlt={proceduresIcon.alt} />;
|
|
12
|
+
};
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import React, { useEffect, useMemo, useState } from 'react';
|
|
2
|
+
import { useTranslation } from 'react-i18next';
|
|
3
|
+
import {
|
|
4
|
+
DataTable,
|
|
5
|
+
DataTableSkeleton,
|
|
6
|
+
Layer,
|
|
7
|
+
Pagination,
|
|
8
|
+
Table,
|
|
9
|
+
TableBody,
|
|
10
|
+
TableCell,
|
|
11
|
+
TableContainer,
|
|
12
|
+
TableExpandedRow,
|
|
13
|
+
TableExpandHeader,
|
|
14
|
+
TableExpandRow,
|
|
15
|
+
TableHead,
|
|
16
|
+
TableHeader,
|
|
17
|
+
TableRow,
|
|
18
|
+
Tag,
|
|
19
|
+
Tile,
|
|
20
|
+
} from '@carbon/react';
|
|
21
|
+
import { formatDatetime, parseDate } from '@openmrs/esm-framework';
|
|
22
|
+
import { type ImagingStatus, type WorklistEntry } from '../../procedures.resource';
|
|
23
|
+
import styles from './procedures-orders-table.scss';
|
|
24
|
+
|
|
25
|
+
interface ImagingOrdersTableProps {
|
|
26
|
+
entries: Array<WorklistEntry>;
|
|
27
|
+
isLoading?: boolean;
|
|
28
|
+
responsiveSize?: 'lg' | 'sm';
|
|
29
|
+
dateField?: 'dateActivated' | 'scheduledDate';
|
|
30
|
+
dateHeader?: string;
|
|
31
|
+
emptyLabel?: string;
|
|
32
|
+
/** Row action cell for a single study, shown per study in the expanded panel. */
|
|
33
|
+
renderActions?: (entry: WorklistEntry) => React.ReactNode;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface PatientGroup {
|
|
37
|
+
id: string;
|
|
38
|
+
patient: string;
|
|
39
|
+
orderer: string;
|
|
40
|
+
date?: string;
|
|
41
|
+
studies: Array<WorklistEntry>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const PAGE_SIZES = [10, 20, 30, 50];
|
|
45
|
+
|
|
46
|
+
/** A study's workflow status → the tag that reads it at a glance. */
|
|
47
|
+
function statusTag(status: ImagingStatus | undefined, t: (k: string, f: string) => string) {
|
|
48
|
+
switch (status) {
|
|
49
|
+
case 'scheduled':
|
|
50
|
+
return <Tag type="teal">{t('scheduled', 'Scheduled')}</Tag>;
|
|
51
|
+
case 'in_progress':
|
|
52
|
+
return <Tag type="blue">{t('inProgress', 'In progress')}</Tag>;
|
|
53
|
+
case 'reported':
|
|
54
|
+
return <Tag type="purple">{t('reported', 'Reported')}</Tag>;
|
|
55
|
+
case 'verified':
|
|
56
|
+
return <Tag type="green">{t('verified', 'Verified')}</Tag>;
|
|
57
|
+
case 'declined':
|
|
58
|
+
return <Tag type="red">{t('cancelled', 'Cancelled')}</Tag>;
|
|
59
|
+
default:
|
|
60
|
+
return <Tag type="gray">{t('ordered', 'Ordered')}</Tag>;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The reusable radiology worklist table: studies grouped by patient into paginated, expandable rows. */
|
|
65
|
+
const ImagingOrdersTable: React.FC<ImagingOrdersTableProps> = ({
|
|
66
|
+
entries,
|
|
67
|
+
isLoading,
|
|
68
|
+
responsiveSize = 'sm',
|
|
69
|
+
dateField = 'dateActivated',
|
|
70
|
+
dateHeader,
|
|
71
|
+
emptyLabel,
|
|
72
|
+
renderActions,
|
|
73
|
+
}) => {
|
|
74
|
+
const { t } = useTranslation();
|
|
75
|
+
const [page, setPage] = useState(1);
|
|
76
|
+
const [pageSize, setPageSize] = useState(10);
|
|
77
|
+
|
|
78
|
+
const groups = useMemo<Array<PatientGroup>>(() => {
|
|
79
|
+
const map = new Map<string, PatientGroup>();
|
|
80
|
+
for (const entry of entries) {
|
|
81
|
+
const key = entry.visit?.uuid ?? entry.patient?.uuid ?? entry.order.uuid;
|
|
82
|
+
let group = map.get(key);
|
|
83
|
+
if (!group) {
|
|
84
|
+
group = {
|
|
85
|
+
id: key,
|
|
86
|
+
patient: entry.patient?.display ?? '—',
|
|
87
|
+
orderer: entry.orderer?.display ?? '—',
|
|
88
|
+
date: entry[dateField],
|
|
89
|
+
studies: [],
|
|
90
|
+
};
|
|
91
|
+
map.set(key, group);
|
|
92
|
+
}
|
|
93
|
+
group.studies.push(entry);
|
|
94
|
+
const d = entry[dateField];
|
|
95
|
+
if (d && (!group.date || d < group.date)) {
|
|
96
|
+
group.date = d;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return Array.from(map.values());
|
|
100
|
+
}, [entries, dateField]);
|
|
101
|
+
|
|
102
|
+
const pageCount = Math.max(1, Math.ceil(groups.length / pageSize));
|
|
103
|
+
useEffect(() => {
|
|
104
|
+
setPage((current) => Math.min(current, pageCount));
|
|
105
|
+
}, [pageCount]);
|
|
106
|
+
|
|
107
|
+
const paginated = useMemo(() => groups.slice((page - 1) * pageSize, page * pageSize), [groups, page, pageSize]);
|
|
108
|
+
const byId = useMemo(() => new Map(groups.map((g) => [g.id, g])), [groups]);
|
|
109
|
+
|
|
110
|
+
const headers = [
|
|
111
|
+
{ key: 'patient', header: t('patient', 'Patient') },
|
|
112
|
+
{ key: 'studies', header: t('studies', 'Studies') },
|
|
113
|
+
{ key: 'orderer', header: t('orderedBy', 'Ordered by') },
|
|
114
|
+
{ key: 'date', header: dateHeader ?? t('date', 'Date') },
|
|
115
|
+
];
|
|
116
|
+
|
|
117
|
+
const rows = paginated.map((g) => ({
|
|
118
|
+
id: g.id,
|
|
119
|
+
patient: g.patient,
|
|
120
|
+
studies: g.studies.length,
|
|
121
|
+
orderer: g.orderer,
|
|
122
|
+
date: g.date ? formatDatetime(parseDate(g.date), { mode: 'standard' }) : '—',
|
|
123
|
+
}));
|
|
124
|
+
|
|
125
|
+
if (isLoading) {
|
|
126
|
+
return <DataTableSkeleton role="progressbar" compact={responsiveSize === 'sm'} zebra={false} />;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (entries.length === 0) {
|
|
130
|
+
return (
|
|
131
|
+
<Layer>
|
|
132
|
+
<Tile className={styles.emptyTile}>{emptyLabel ?? t('noStudiesHere', 'No studies in this list.')}</Tile>
|
|
133
|
+
</Layer>
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return (
|
|
138
|
+
<Layer className={styles.tableContainer}>
|
|
139
|
+
<DataTable rows={rows} headers={headers} size={responsiveSize}>
|
|
140
|
+
{({ rows, headers, getHeaderProps, getRowProps, getExpandedRowProps, getTableProps }) => (
|
|
141
|
+
<TableContainer>
|
|
142
|
+
<Table {...getTableProps()} aria-label={t('proceduresWorklist', 'Procedures worklist')}>
|
|
143
|
+
<TableHead>
|
|
144
|
+
<TableRow>
|
|
145
|
+
<TableExpandHeader aria-label={t('expandRow', 'Expand row')} />
|
|
146
|
+
{headers.map((header) => {
|
|
147
|
+
const { key, ...headerProps } = getHeaderProps({ header });
|
|
148
|
+
return (
|
|
149
|
+
<TableHeader key={header.key} {...headerProps}>
|
|
150
|
+
{header.header}
|
|
151
|
+
</TableHeader>
|
|
152
|
+
);
|
|
153
|
+
})}
|
|
154
|
+
</TableRow>
|
|
155
|
+
</TableHead>
|
|
156
|
+
<TableBody>
|
|
157
|
+
{rows.map((row) => {
|
|
158
|
+
const { key, ...rowProps } = getRowProps({ row });
|
|
159
|
+
const group = byId.get(row.id);
|
|
160
|
+
return (
|
|
161
|
+
<React.Fragment key={row.id}>
|
|
162
|
+
<TableExpandRow {...rowProps}>
|
|
163
|
+
{row.cells.map((cell) =>
|
|
164
|
+
cell.info.header === 'studies' ? (
|
|
165
|
+
<TableCell key={cell.id}>
|
|
166
|
+
<Tag type="cool-gray">{t('studyCount', '{{count}} study(ies)', { count: cell.value })}</Tag>
|
|
167
|
+
</TableCell>
|
|
168
|
+
) : (
|
|
169
|
+
<TableCell key={cell.id}>{cell.value}</TableCell>
|
|
170
|
+
),
|
|
171
|
+
)}
|
|
172
|
+
</TableExpandRow>
|
|
173
|
+
<TableExpandedRow
|
|
174
|
+
className={styles.expandedRow}
|
|
175
|
+
colSpan={headers.length + 1}
|
|
176
|
+
{...getExpandedRowProps({ row })}>
|
|
177
|
+
{group && (
|
|
178
|
+
<ul className={styles.studyList}>
|
|
179
|
+
{group.studies.map((entry) => (
|
|
180
|
+
<li className={styles.studyRow} key={entry.order.uuid}>
|
|
181
|
+
<div className={styles.studyInfo}>
|
|
182
|
+
<span className={styles.studyName}>{entry.concept?.display ?? entry.order.display}</span>
|
|
183
|
+
{entry.urgency === 'STAT' && <Tag type="magenta">{t('stat', 'STAT')}</Tag>}
|
|
184
|
+
{statusTag(entry.status, t)}
|
|
185
|
+
{entry.procedure?.modality && (
|
|
186
|
+
<span className={styles.meta}>{entry.procedure.modality.display}</span>
|
|
187
|
+
)}
|
|
188
|
+
{entry.status === 'reported' && entry.procedure?.reportedBy && (
|
|
189
|
+
<span className={styles.meta}>
|
|
190
|
+
{t('reportedByLabel', 'Reported by {{who}}', { who: entry.procedure.reportedBy })}
|
|
191
|
+
</span>
|
|
192
|
+
)}
|
|
193
|
+
{entry.status === 'verified' && entry.procedure?.approvedBy && (
|
|
194
|
+
<span className={styles.meta}>
|
|
195
|
+
{t('verifiedByLabel', 'Verified by {{who}}', { who: entry.procedure.approvedBy })}
|
|
196
|
+
</span>
|
|
197
|
+
)}
|
|
198
|
+
</div>
|
|
199
|
+
{renderActions && <div className={styles.studyActions}>{renderActions(entry)}</div>}
|
|
200
|
+
</li>
|
|
201
|
+
))}
|
|
202
|
+
</ul>
|
|
203
|
+
)}
|
|
204
|
+
</TableExpandedRow>
|
|
205
|
+
</React.Fragment>
|
|
206
|
+
);
|
|
207
|
+
})}
|
|
208
|
+
</TableBody>
|
|
209
|
+
</Table>
|
|
210
|
+
</TableContainer>
|
|
211
|
+
)}
|
|
212
|
+
</DataTable>
|
|
213
|
+
|
|
214
|
+
<Pagination
|
|
215
|
+
page={page}
|
|
216
|
+
pageSize={pageSize}
|
|
217
|
+
pageSizes={PAGE_SIZES}
|
|
218
|
+
totalItems={groups.length}
|
|
219
|
+
size={responsiveSize}
|
|
220
|
+
onChange={({ page: p, pageSize: ps }) => {
|
|
221
|
+
setPage(ps === pageSize ? p : 1);
|
|
222
|
+
setPageSize(ps);
|
|
223
|
+
}}
|
|
224
|
+
/>
|
|
225
|
+
</Layer>
|
|
226
|
+
);
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
export default ImagingOrdersTable;
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
@use '@carbon/layout';
|
|
2
|
+
@use '@carbon/type';
|
|
3
|
+
@use '@carbon/colors';
|
|
4
|
+
|
|
5
|
+
.tableContainer {
|
|
6
|
+
background-color: colors.$white;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
.emptyTile {
|
|
10
|
+
display: flex;
|
|
11
|
+
align-items: center;
|
|
12
|
+
justify-content: center;
|
|
13
|
+
padding: layout.$spacing-07;
|
|
14
|
+
color: colors.$gray-70;
|
|
15
|
+
background: colors.$gray-10;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
.expandedRow {
|
|
19
|
+
td {
|
|
20
|
+
padding: 0;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
.studyList {
|
|
25
|
+
list-style: none;
|
|
26
|
+
margin: 0;
|
|
27
|
+
padding: layout.$spacing-05;
|
|
28
|
+
background: colors.$white;
|
|
29
|
+
box-shadow:
|
|
30
|
+
inset 0 1px 0 colors.$gray-20,
|
|
31
|
+
inset 0 -1px 0 colors.$gray-20;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
.studyRow {
|
|
35
|
+
display: flex;
|
|
36
|
+
align-items: center;
|
|
37
|
+
justify-content: space-between;
|
|
38
|
+
gap: layout.$spacing-05;
|
|
39
|
+
padding: layout.$spacing-03 0;
|
|
40
|
+
border-bottom: 1px solid colors.$gray-20;
|
|
41
|
+
|
|
42
|
+
&:last-child {
|
|
43
|
+
border-bottom: 0;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
.studyInfo {
|
|
48
|
+
display: flex;
|
|
49
|
+
align-items: center;
|
|
50
|
+
gap: layout.$spacing-03;
|
|
51
|
+
flex-wrap: wrap;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
.studyName {
|
|
55
|
+
@include type.type-style('body-compact-01');
|
|
56
|
+
font-weight: 600;
|
|
57
|
+
color: colors.$gray-100;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
.meta {
|
|
61
|
+
@include type.type-style('label-01');
|
|
62
|
+
color: colors.$gray-60;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
.studyActions {
|
|
66
|
+
display: flex;
|
|
67
|
+
align-items: center;
|
|
68
|
+
gap: layout.$spacing-02;
|
|
69
|
+
flex-shrink: 0;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
@media (max-width: 42rem) {
|
|
73
|
+
.studyRow {
|
|
74
|
+
flex-direction: column;
|
|
75
|
+
align-items: flex-start;
|
|
76
|
+
gap: layout.$spacing-02;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import React, { useMemo } from 'react';
|
|
2
|
+
import { useTranslation } from 'react-i18next';
|
|
3
|
+
import { Button, Tab, TabList, TabPanel, TabPanels, Tabs } from '@carbon/react';
|
|
4
|
+
import { Calendar, CheckmarkOutline, DocumentAdd, InProgress, Printer, View, ImageMedical } from '@carbon/react/icons';
|
|
5
|
+
import { launchWorkspace, showSnackbar, useLayoutType } from '@openmrs/esm-framework';
|
|
6
|
+
import {
|
|
7
|
+
bucketByStatus,
|
|
8
|
+
cancelStudy,
|
|
9
|
+
openImagingReport,
|
|
10
|
+
scheduleStudy,
|
|
11
|
+
startStudy,
|
|
12
|
+
useImagingWorklist,
|
|
13
|
+
type WorklistEntry,
|
|
14
|
+
} from '../procedures.resource';
|
|
15
|
+
import ImagingOrdersTable from './procedures-orders-table/procedures-orders-table.component';
|
|
16
|
+
import styles from './procedures-tab.scss';
|
|
17
|
+
|
|
18
|
+
/** The radiology queue: procedure orders bucketed by workflow stage, one tab each. */
|
|
19
|
+
const ImagingWorklistQueue: React.FC = () => {
|
|
20
|
+
const { t } = useTranslation();
|
|
21
|
+
const { entries, isLoading, mutate } = useImagingWorklist();
|
|
22
|
+
const responsiveSize = useLayoutType() === 'tablet' ? 'lg' : 'sm';
|
|
23
|
+
const buckets = useMemo(() => bucketByStatus(entries), [entries]);
|
|
24
|
+
|
|
25
|
+
const notify = (promise: Promise<unknown>, title: string) =>
|
|
26
|
+
promise.then(
|
|
27
|
+
() => {
|
|
28
|
+
showSnackbar({ kind: 'success', isLowContrast: true, title });
|
|
29
|
+
mutate();
|
|
30
|
+
},
|
|
31
|
+
(err: { responseBody?: { error?: { message?: string } }; message?: string }) =>
|
|
32
|
+
showSnackbar({
|
|
33
|
+
kind: 'error',
|
|
34
|
+
title: t('actionFailed', 'Could not complete the action'),
|
|
35
|
+
subtitle: err?.responseBody?.error?.message ?? err?.message,
|
|
36
|
+
}),
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
const schedule = (entry: WorklistEntry) => notify(scheduleStudy(entry), t('studyScheduled', 'Study scheduled'));
|
|
40
|
+
const start = (entry: WorklistEntry) => notify(startStudy(entry), t('studyStarted', 'Study started'));
|
|
41
|
+
const cancel = (entry: WorklistEntry) => notify(cancelStudy(entry), t('studyCancelled', 'Study cancelled'));
|
|
42
|
+
const report = (entry: WorklistEntry) =>
|
|
43
|
+
launchWorkspace('procedure-report-workspace', {
|
|
44
|
+
workspaceTitle: entry.concept?.display ?? entry.order.display,
|
|
45
|
+
order: entry,
|
|
46
|
+
mode: 'report',
|
|
47
|
+
onSaved: mutate,
|
|
48
|
+
});
|
|
49
|
+
const verify = (entry: WorklistEntry) =>
|
|
50
|
+
launchWorkspace('procedure-report-workspace', {
|
|
51
|
+
workspaceTitle: entry.concept?.display ?? entry.order.display,
|
|
52
|
+
order: entry,
|
|
53
|
+
mode: 'verify',
|
|
54
|
+
onSaved: mutate,
|
|
55
|
+
});
|
|
56
|
+
const view = (entry: WorklistEntry) =>
|
|
57
|
+
launchWorkspace('procedure-report-workspace', {
|
|
58
|
+
workspaceTitle: entry.concept?.display ?? entry.order.display,
|
|
59
|
+
order: entry,
|
|
60
|
+
mode: 'view',
|
|
61
|
+
});
|
|
62
|
+
const print = (entry: WorklistEntry) => entry.procedure?.uuid && openImagingReport(entry.procedure.uuid);
|
|
63
|
+
|
|
64
|
+
return (
|
|
65
|
+
<div className={styles.tabsContainer}>
|
|
66
|
+
<Tabs>
|
|
67
|
+
<TabList contained aria-label={t('proceduresWorklist', 'Procedures worklist')}>
|
|
68
|
+
<Tab renderIcon={ImageMedical}>
|
|
69
|
+
{t('ordered', 'Ordered')} ({buckets.ordered.length})
|
|
70
|
+
</Tab>
|
|
71
|
+
<Tab renderIcon={Calendar}>
|
|
72
|
+
{t('scheduled', 'Scheduled')} ({buckets.scheduled.length})
|
|
73
|
+
</Tab>
|
|
74
|
+
<Tab renderIcon={InProgress}>
|
|
75
|
+
{t('inProgress', 'In progress')} ({buckets.in_progress.length})
|
|
76
|
+
</Tab>
|
|
77
|
+
<Tab renderIcon={DocumentAdd}>
|
|
78
|
+
{t('reported', 'Reported')} ({buckets.reported.length})
|
|
79
|
+
</Tab>
|
|
80
|
+
<Tab renderIcon={CheckmarkOutline}>
|
|
81
|
+
{t('verified', 'Verified')} ({buckets.verified.length})
|
|
82
|
+
</Tab>
|
|
83
|
+
</TabList>
|
|
84
|
+
<TabPanels>
|
|
85
|
+
<TabPanel>
|
|
86
|
+
<ImagingOrdersTable
|
|
87
|
+
entries={buckets.ordered}
|
|
88
|
+
isLoading={isLoading}
|
|
89
|
+
responsiveSize={responsiveSize}
|
|
90
|
+
emptyLabel={t('noNewOrders', 'No new procedure orders.')}
|
|
91
|
+
renderActions={(entry) => (
|
|
92
|
+
<div className={styles.rowActions}>
|
|
93
|
+
<Button kind="ghost" size={responsiveSize} renderIcon={Calendar} onClick={() => schedule(entry)}>
|
|
94
|
+
{t('schedule', 'Schedule')}
|
|
95
|
+
</Button>
|
|
96
|
+
<Button kind="ghost" size={responsiveSize} renderIcon={InProgress} onClick={() => start(entry)}>
|
|
97
|
+
{t('startNow', 'Start now')}
|
|
98
|
+
</Button>
|
|
99
|
+
<Button kind="danger--ghost" size={responsiveSize} onClick={() => cancel(entry)}>
|
|
100
|
+
{t('cancel', 'Cancel')}
|
|
101
|
+
</Button>
|
|
102
|
+
</div>
|
|
103
|
+
)}
|
|
104
|
+
/>
|
|
105
|
+
</TabPanel>
|
|
106
|
+
<TabPanel>
|
|
107
|
+
<ImagingOrdersTable
|
|
108
|
+
entries={buckets.scheduled}
|
|
109
|
+
isLoading={isLoading}
|
|
110
|
+
responsiveSize={responsiveSize}
|
|
111
|
+
dateField="scheduledDate"
|
|
112
|
+
dateHeader={t('scheduledFor', 'Scheduled for')}
|
|
113
|
+
emptyLabel={t('noneScheduled', 'Nothing scheduled.')}
|
|
114
|
+
renderActions={(entry) => (
|
|
115
|
+
<div className={styles.rowActions}>
|
|
116
|
+
<Button kind="ghost" size={responsiveSize} renderIcon={InProgress} onClick={() => start(entry)}>
|
|
117
|
+
{t('startStudy', 'Start study')}
|
|
118
|
+
</Button>
|
|
119
|
+
<Button kind="danger--ghost" size={responsiveSize} onClick={() => cancel(entry)}>
|
|
120
|
+
{t('cancel', 'Cancel')}
|
|
121
|
+
</Button>
|
|
122
|
+
</div>
|
|
123
|
+
)}
|
|
124
|
+
/>
|
|
125
|
+
</TabPanel>
|
|
126
|
+
<TabPanel>
|
|
127
|
+
<ImagingOrdersTable
|
|
128
|
+
entries={buckets.in_progress}
|
|
129
|
+
isLoading={isLoading}
|
|
130
|
+
responsiveSize={responsiveSize}
|
|
131
|
+
emptyLabel={t('noneInProgress', 'No studies in progress.')}
|
|
132
|
+
renderActions={(entry) => (
|
|
133
|
+
<div className={styles.rowActions}>
|
|
134
|
+
<Button kind="ghost" size={responsiveSize} renderIcon={DocumentAdd} onClick={() => report(entry)}>
|
|
135
|
+
{t('enterReport', 'Enter report')}
|
|
136
|
+
</Button>
|
|
137
|
+
</div>
|
|
138
|
+
)}
|
|
139
|
+
/>
|
|
140
|
+
</TabPanel>
|
|
141
|
+
<TabPanel>
|
|
142
|
+
<ImagingOrdersTable
|
|
143
|
+
entries={buckets.reported}
|
|
144
|
+
isLoading={isLoading}
|
|
145
|
+
responsiveSize={responsiveSize}
|
|
146
|
+
emptyLabel={t('noneReported', 'Nothing awaiting verification.')}
|
|
147
|
+
renderActions={(entry) => (
|
|
148
|
+
<div className={styles.rowActions}>
|
|
149
|
+
<Button kind="ghost" size={responsiveSize} renderIcon={CheckmarkOutline} onClick={() => verify(entry)}>
|
|
150
|
+
{t('reviewVerify', 'Review & verify')}
|
|
151
|
+
</Button>
|
|
152
|
+
<Button kind="ghost" size={responsiveSize} renderIcon={Printer} onClick={() => print(entry)}>
|
|
153
|
+
{t('print', 'Print')}
|
|
154
|
+
</Button>
|
|
155
|
+
</div>
|
|
156
|
+
)}
|
|
157
|
+
/>
|
|
158
|
+
</TabPanel>
|
|
159
|
+
<TabPanel>
|
|
160
|
+
<ImagingOrdersTable
|
|
161
|
+
entries={buckets.verified}
|
|
162
|
+
isLoading={isLoading}
|
|
163
|
+
responsiveSize={responsiveSize}
|
|
164
|
+
emptyLabel={t('noneVerified', 'No verified reports yet.')}
|
|
165
|
+
renderActions={(entry) => (
|
|
166
|
+
<div className={styles.rowActions}>
|
|
167
|
+
<Button kind="ghost" size={responsiveSize} renderIcon={View} onClick={() => view(entry)}>
|
|
168
|
+
{t('viewReport', 'View report')}
|
|
169
|
+
</Button>
|
|
170
|
+
<Button kind="ghost" size={responsiveSize} renderIcon={Printer} onClick={() => print(entry)}>
|
|
171
|
+
{t('print', 'Print')}
|
|
172
|
+
</Button>
|
|
173
|
+
</div>
|
|
174
|
+
)}
|
|
175
|
+
/>
|
|
176
|
+
</TabPanel>
|
|
177
|
+
</TabPanels>
|
|
178
|
+
</Tabs>
|
|
179
|
+
</div>
|
|
180
|
+
);
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
export default ImagingWorklistQueue;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
@use '@carbon/layout';
|
|
2
|
+
@use '@carbon/colors';
|
|
3
|
+
|
|
4
|
+
.tabsContainer {
|
|
5
|
+
padding: 0;
|
|
6
|
+
margin-top: layout.$spacing-03;
|
|
7
|
+
|
|
8
|
+
:global(.cds--tab-content) {
|
|
9
|
+
border: 1px solid colors.$gray-20;
|
|
10
|
+
border-top: 0;
|
|
11
|
+
padding: 0;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
.rowActions {
|
|
16
|
+
display: flex;
|
|
17
|
+
align-items: center;
|
|
18
|
+
gap: layout.$spacing-02;
|
|
19
|
+
}
|