@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.
@@ -0,0 +1,129 @@
1
+ import React from 'react';
2
+ import { useTranslation } from 'react-i18next';
3
+ import { Button, InlineLoading, Tile } from '@carbon/react';
4
+ import { Alarm, ConnectionTwoWay, Login, Logout } from '@carbon/react/icons';
5
+ import { formatDate, useConfig, useLayoutType } from '@openmrs/esm-framework';
6
+ import { CardHeader } from '@openmrs/esm-patient-common-lib';
7
+ import { useProviderClockIn } from '@digihmis/esm-commons-lib';
8
+ import { type ConfigObject } from '../config-schema';
9
+ import { bucketByStatus, useImagingWorklist } from '../procedures.resource';
10
+ import { PageHeader } from '../page-header/page-header.component';
11
+ import ImagingWorklistQueue from '../procedures-tab/procedures-tab.component';
12
+ import styles from './procedures-worklist.scss';
13
+
14
+ const ImagingWorklist: React.FC = () => {
15
+ const { t } = useTranslation();
16
+ const { entries, isLoading } = useImagingWorklist();
17
+ const { proceduresQueueUuid } = useConfig<ConfigObject>();
18
+ const { isClockedIn, activeSession, isProcessing, handleClockIn, handleClockOut, handleSwitchServicePoint } =
19
+ useProviderClockIn({ queueUuid: proceduresQueueUuid, moduleName: 'Procedures' });
20
+
21
+ const buckets = React.useMemo(() => bucketByStatus(entries), [entries]);
22
+
23
+ const isToday = (value?: string) => {
24
+ if (!value) {
25
+ return false;
26
+ }
27
+ const d = new Date(value);
28
+ const n = new Date();
29
+ return d.getFullYear() === n.getFullYear() && d.getMonth() === n.getMonth() && d.getDate() === n.getDate();
30
+ };
31
+ const verifiedToday = React.useMemo(
32
+ () => buckets.verified.filter((e) => isToday(e.procedure?.approvedAt ?? e.dateActivated)).length,
33
+ [buckets.verified],
34
+ );
35
+
36
+ return (
37
+ <div>
38
+ <PageHeader />
39
+ <div className={styles.dashboardContainer}>
40
+ <Tile className={styles.tileContainer}>
41
+ <div className={styles.displayDetails}>
42
+ <div className={styles.countLabel}>{t('awaiting', 'Awaiting')}</div>
43
+ <div className={styles.displayData}>
44
+ {isLoading ? <InlineLoading description="" /> : buckets.ordered.length + buckets.scheduled.length}
45
+ </div>
46
+ </div>
47
+ <div className={styles.displayDetails}>
48
+ <div className={styles.countLabel}>{t('inProgress', 'In progress')}</div>
49
+ <div className={styles.displayData}>
50
+ {isLoading ? <InlineLoading description="" /> : buckets.in_progress.length}
51
+ </div>
52
+ </div>
53
+ <div className={styles.displayDetails}>
54
+ <div className={styles.countLabel}>{t('toVerify', 'To verify')}</div>
55
+ <div className={styles.displayData}>
56
+ {isLoading ? <InlineLoading description="" /> : buckets.reported.length}
57
+ </div>
58
+ </div>
59
+ <div className={styles.displayDetails}>
60
+ <div className={styles.countLabel}>{t('verifiedToday', 'Verified Today')}</div>
61
+ <div className={styles.displayData}>{isLoading ? <InlineLoading description="" /> : verifiedToday}</div>
62
+ </div>
63
+ </Tile>
64
+
65
+ {isClockedIn && activeSession && (
66
+ <Tile className={styles.clockInBanner}>
67
+ <div className={styles.clockInInfo}>
68
+ <Alarm size={20} className={styles.alarmIcon} />
69
+ <div className={styles.clockInText}>
70
+ <strong>{t('clockedInTo', 'Clocked in to')}: </strong>
71
+ {activeSession?.queueRoom?.display}
72
+ <span className={styles.clockInTime}>
73
+ {' '}
74
+ ({t('since', 'since')} {formatDate(new Date(activeSession.clockInTime), { mode: 'wide' })})
75
+ </span>
76
+ </div>
77
+ </div>
78
+ </Tile>
79
+ )}
80
+ <CardHeader title={activeSession?.queueRoom?.display || t('proceduresQueue', 'Procedures Queue')}>
81
+ <div className={styles.headerActions}>
82
+ <Button
83
+ kind={isClockedIn ? 'danger' : 'primary'}
84
+ size="sm"
85
+ renderIcon={(props) => (isClockedIn ? <Logout size={16} {...props} /> : <Login size={16} {...props} />)}
86
+ onClick={isClockedIn ? handleClockOut : handleClockIn}
87
+ disabled={isProcessing}>
88
+ {isProcessing ? (
89
+ <InlineLoading
90
+ description={isClockedIn ? t('clockingOut', 'Clocking out') : t('clockingIn', 'Clocking in')}
91
+ />
92
+ ) : isClockedIn ? (
93
+ t('clockOut', 'Clock Out')
94
+ ) : (
95
+ t('clockIn', 'Clock In')
96
+ )}
97
+ </Button>
98
+ {isClockedIn && (
99
+ <Button
100
+ kind="secondary"
101
+ size="sm"
102
+ renderIcon={(props) => <ConnectionTwoWay size={16} {...props} />}
103
+ onClick={handleSwitchServicePoint}
104
+ disabled={isProcessing}>
105
+ {t('changeStation', 'Change Station')}
106
+ </Button>
107
+ )}
108
+ </div>
109
+ </CardHeader>
110
+ {isClockedIn && activeSession ? (
111
+ <ImagingWorklistQueue />
112
+ ) : (
113
+ <div className={styles.emptyStateContainer}>
114
+ <Tile className={styles.tile}>
115
+ <div className={styles.tileContent}>
116
+ <p className={styles.content}>{t('pleaseClockIn', 'Please clock in to view the queue')}</p>
117
+ <p className={styles.emptyStateHelperText}>
118
+ {t('clockInPrompt', 'Click the "Clock In" button above to start managing patients')}
119
+ </p>
120
+ </div>
121
+ </Tile>
122
+ </div>
123
+ )}
124
+ </div>
125
+ </div>
126
+ );
127
+ };
128
+
129
+ export default ImagingWorklist;
@@ -0,0 +1,166 @@
1
+ @use '@carbon/layout';
2
+ @use '@carbon/type';
3
+ @use '@carbon/colors';
4
+ @use '@openmrs/esm-styleguide/src/vars' as *;
5
+
6
+ .tileContainer {
7
+ border: 1px solid colors.$cool-gray-30;
8
+ margin-bottom: layout.$spacing-04;
9
+ display: grid;
10
+ grid-template-columns: 1fr 1fr 1fr 1fr;
11
+ gap: 0;
12
+ height: 80px;
13
+
14
+ :global(.omrs-breakpoint-lt-desktop) & {
15
+ padding: layout.$spacing-04;
16
+ }
17
+
18
+ :global(.omrs-breakpoint-lt-tablet) & {
19
+ padding: layout.$spacing-03;
20
+ grid-template-columns: 1fr;
21
+ grid-template-rows: auto auto auto;
22
+ gap: layout.$spacing-04;
23
+ }
24
+ }
25
+
26
+ .tileHeader {
27
+ color: colors.$gray-70;
28
+ margin-bottom: layout.$spacing-03;
29
+ text-transform: none;
30
+ letter-spacing: 0;
31
+
32
+ :global(.omrs-breakpoint-lt-tablet) & {
33
+ margin-bottom: layout.$spacing-02;
34
+ }
35
+ }
36
+
37
+ .displayDetails {
38
+ display: flex;
39
+ flex-direction: column;
40
+ align-items: flex-start;
41
+
42
+ :global(.omrs-breakpoint-lt-tablet) & {
43
+ padding: layout.$spacing-03 0;
44
+ border-right: none;
45
+ border-bottom: 1px solid colors.$gray-20;
46
+ padding-bottom: layout.$spacing-04;
47
+ margin-bottom: layout.$spacing-03;
48
+ }
49
+ }
50
+
51
+ .countLabel {
52
+ color: colors.$gray-70;
53
+ margin-bottom: layout.$spacing-02;
54
+ @include type.type-style('caption-01');
55
+ font-weight: bold;
56
+
57
+ :global(.omrs-breakpoint-lt-tablet) & {
58
+ @include type.type-style('caption-01');
59
+ font-weight: bold;
60
+ margin-bottom: layout.$spacing-01;
61
+ }
62
+ }
63
+
64
+ .displayData {
65
+ color: colors.$gray-100;
66
+ font-weight: bold;
67
+ @include type.type-style('caption-01');
68
+ font-size: 25px;
69
+
70
+ :global(.omrs-breakpoint-lt-tablet) & {
71
+ font-size: 2.625rem;
72
+ line-height: 2.75rem;
73
+ }
74
+ }
75
+
76
+ .headerActions {
77
+ display: flex;
78
+ align-items: center;
79
+ justify-content: center;
80
+ padding-right: 1rem;
81
+
82
+ :global(.omrs-breakpoint-lt-tablet) & {
83
+ gap: 0.25rem;
84
+ }
85
+ }
86
+
87
+ .dashboardContainer {
88
+ padding: layout.$spacing-05;
89
+ padding-bottom: 0;
90
+ background-color: var(--cds-field);
91
+
92
+ :global(.omrs-breakpoint-lt-desktop) & {
93
+ padding: layout.$spacing-02;
94
+ padding-bottom: 0;
95
+ }
96
+
97
+ :global(.omrs-breakpoint-lt-tablet) & {
98
+ padding: layout.$spacing-03;
99
+ padding-bottom: 0;
100
+ }
101
+ }
102
+
103
+ .clockInInfo {
104
+ display: flex;
105
+ align-items: center;
106
+ justify-content: flex-end;
107
+ width: 100%;
108
+ gap: layout.$spacing-03;
109
+
110
+ :global(.omrs-breakpoint-lt-tablet) & {
111
+ flex-direction: row;
112
+ align-items: center;
113
+ gap: layout.$spacing-02;
114
+ }
115
+ }
116
+
117
+ .clockInText {
118
+ display: flex;
119
+ align-items: center;
120
+ flex-wrap: wrap;
121
+ gap: 0.25rem;
122
+
123
+ :global(.omrs-breakpoint-lt-tablet) & {
124
+ font-size: 0.875rem;
125
+ }
126
+ }
127
+
128
+ .clockInTime {
129
+ color: colors.$gray-70;
130
+ white-space: nowrap;
131
+ }
132
+
133
+ .alarmIcon {
134
+ flex-shrink: 0;
135
+
136
+ :global(.omrs-breakpoint-lt-tablet) & {
137
+ width: 18px;
138
+ height: 18px;
139
+ }
140
+ }
141
+
142
+ .emptyStateContainer {
143
+ border: 1px solid colors.$gray-20;
144
+ padding: 3rem 0;
145
+ }
146
+
147
+ .tile {
148
+ margin: auto;
149
+ width: fit-content;
150
+ background: colors.$gray-20;
151
+ }
152
+
153
+ .tileContent {
154
+ display: flex;
155
+ flex-direction: column;
156
+ align-items: center;
157
+ }
158
+
159
+ .content {
160
+ @include type.type-style('heading-compact-02');
161
+ color: colors.$gray-70;
162
+ margin-bottom: layout.$spacing-03;
163
+ }
164
+ .loader {
165
+ min-height: fit-content;
166
+ }
@@ -0,0 +1,163 @@
1
+ import { openmrsFetch, restBaseUrl, type FetchResponse } from '@openmrs/esm-framework';
2
+ import useSWR from 'swr';
3
+
4
+ /** The stages a radiology order moves through. */
5
+ export type ImagingStatus = 'ordered' | 'scheduled' | 'in_progress' | 'reported' | 'verified' | 'declined';
6
+
7
+ /** The report state carried on the linked Procedure record. */
8
+ export interface ProcedureInfo {
9
+ uuid: string;
10
+ status?: string;
11
+ modality?: { uuid: string; display: string };
12
+ bodySite?: { uuid: string; display: string };
13
+ hasPreliminaryReport?: boolean;
14
+ hasFinalReport?: boolean;
15
+ impressions?: string;
16
+ reportedBy?: string;
17
+ reportedAt?: string;
18
+ approvedBy?: string;
19
+ approvedAt?: string;
20
+ }
21
+
22
+ /** One radiology order on the worklist. */
23
+ export interface WorklistEntry {
24
+ order: {
25
+ uuid: string;
26
+ display: string;
27
+ accessionNumber?: string;
28
+ dateStopped?: string;
29
+ orderer?: { uuid?: string; display: string };
30
+ };
31
+ patient?: { uuid: string; display: string };
32
+ visit?: { uuid: string; display: string };
33
+ concept?: { uuid: string; display: string };
34
+ orderer?: { uuid: string; display: string };
35
+ urgency?: string;
36
+ dateActivated?: string;
37
+ scheduledDate?: string;
38
+ encounterDatetime?: string;
39
+ status?: ImagingStatus;
40
+ fulfillerStatus?: string;
41
+ fulfillerComment?: string;
42
+ procedure?: ProcedureInfo | null;
43
+ }
44
+
45
+ const WORKLIST_URL = `${restBaseUrl}/digihmisorders/worklist?department=procedure&status=all`;
46
+
47
+ /** Every radiology order, tagged with its workflow bucket. */
48
+ export function useImagingWorklist() {
49
+ const { data, error, isLoading, mutate } = useSWR<FetchResponse<{ results: Array<WorklistEntry> }>>(
50
+ WORKLIST_URL,
51
+ openmrsFetch,
52
+ );
53
+ return { entries: data?.data?.results ?? [], error, isLoading, mutate };
54
+ }
55
+
56
+ /** Split worklist entries into their workflow buckets, preserving order. */
57
+ export function bucketByStatus(entries: Array<WorklistEntry>): Record<ImagingStatus, Array<WorklistEntry>> {
58
+ const buckets: Record<ImagingStatus, Array<WorklistEntry>> = {
59
+ ordered: [],
60
+ scheduled: [],
61
+ in_progress: [],
62
+ reported: [],
63
+ verified: [],
64
+ declined: [],
65
+ };
66
+ for (const entry of entries) {
67
+ const status = (entry.status ?? 'ordered') as ImagingStatus;
68
+ (buckets[status] ?? buckets.ordered).push(entry);
69
+ }
70
+ return buckets;
71
+ }
72
+
73
+ /** Create a Procedure record for an order (used to open the workflow at a given status). */
74
+ function createProcedure(orderUuid: string, body: Record<string, unknown>) {
75
+ return openmrsFetch(`${restBaseUrl}/procedure`, {
76
+ method: 'POST',
77
+ headers: { 'Content-Type': 'application/json' },
78
+ body: { orderUuid, ...body },
79
+ });
80
+ }
81
+
82
+ /** Update an existing Procedure record. */
83
+ function updateProcedure(procedureUuid: string, body: Record<string, unknown>) {
84
+ return openmrsFetch(`${restBaseUrl}/procedure/${procedureUuid}`, {
85
+ method: 'POST',
86
+ headers: { 'Content-Type': 'application/json' },
87
+ body,
88
+ });
89
+ }
90
+
91
+ /** Move an entry to a Procedure status, creating the record on first transition. */
92
+ function transition(entry: WorklistEntry, body: Record<string, unknown>) {
93
+ return entry.procedure?.uuid
94
+ ? updateProcedure(entry.procedure.uuid, body)
95
+ : createProcedure(entry.order.uuid, body);
96
+ }
97
+
98
+ /** Ordered → Scheduled: book the study. */
99
+ export function scheduleStudy(entry: WorklistEntry) {
100
+ return transition(entry, { status: 'PREPARATION' });
101
+ }
102
+
103
+ /** Scheduled → In progress: the study is being acquired. */
104
+ export function startStudy(entry: WorklistEntry) {
105
+ return transition(entry, { status: 'IN_PROGRESS', startDatetime: new Date().toISOString() });
106
+ }
107
+
108
+ /** In progress → Reported: save the preliminary report + impressions. */
109
+ export function submitReport(entry: WorklistEntry, report: string, impressions?: string) {
110
+ return transition(entry, { preliminaryReport: report, impressions, status: 'PRELIMINARY' });
111
+ }
112
+
113
+ /** Reported → Verified: approve/release the report (locking it). */
114
+ export function verifyReport(entry: WorklistEntry, finalReport: string, impressions?: string) {
115
+ if (!entry.procedure?.uuid) {
116
+ return Promise.reject(new Error('No report to verify'));
117
+ }
118
+ return updateProcedure(entry.procedure.uuid, {
119
+ procedureReport: finalReport,
120
+ impressions,
121
+ status: 'APPROVED',
122
+ reportLockedAt: new Date().toISOString(),
123
+ });
124
+ }
125
+
126
+ /** Cancel: decline the order (before a report exists) or mark the study not done. */
127
+ export function cancelStudy(entry: WorklistEntry, reason?: string) {
128
+ if (entry.procedure?.uuid) {
129
+ return updateProcedure(entry.procedure.uuid, { status: 'NOT_DONE' });
130
+ }
131
+ const params = new URLSearchParams({ status: 'DECLINED' });
132
+ if (reason) {
133
+ params.set('comment', reason);
134
+ }
135
+ return openmrsFetch(`${restBaseUrl}/digihmisorders/worklist/${entry.order.uuid}?${params.toString()}`, {
136
+ method: 'POST',
137
+ });
138
+ }
139
+
140
+ export interface ProcedureDetail extends ProcedureInfo {
141
+ preliminaryReport?: string;
142
+ procedureReport?: string;
143
+ reportType?: 'PRELIMINARY' | 'FINAL' | null;
144
+ preliminaryReportEnteredBy?: { display: string };
145
+ }
146
+
147
+ const PROCEDURE_REP =
148
+ 'custom:(uuid,status,preliminaryReport,procedureReport,impressions,reportType,' +
149
+ 'modality:(uuid,display),bodySite:(uuid,display),preliminaryReportEnteredBy:(display),' +
150
+ 'preliminaryReportEnteredAt,preliminaryReportApprovedBy:(display),preliminaryReportApprovedAt)';
151
+
152
+ /** The full Procedure record for an order (report text + impressions), for the report/view workspaces. */
153
+ export function useProcedure(orderUuid?: string) {
154
+ const url = orderUuid ? `${restBaseUrl}/procedure?orderUuid=${orderUuid}&v=${PROCEDURE_REP}` : null;
155
+ const { data, error, isLoading } = useSWR<FetchResponse<{ results: Array<ProcedureDetail> }>>(url, openmrsFetch);
156
+ return { procedure: data?.data?.results?.[0], error, isLoading };
157
+ }
158
+
159
+ /** Open the printable imaging report PDF for a procedure in a new tab. */
160
+ export function openImagingReport(procedureUuid: string) {
161
+ const base = (window as unknown as { openmrsBase?: string }).openmrsBase ?? '/openmrs';
162
+ window.open(`${base}${restBaseUrl}/digihmisorders/imagingReport?procedureUuid=${procedureUuid}`, '_blank', 'noopener');
163
+ }
@@ -0,0 +1,48 @@
1
+ @use '@carbon/layout';
2
+ @use '@carbon/type';
3
+ @use '@carbon/colors';
4
+
5
+ .form {
6
+ display: flex;
7
+ flex-direction: column;
8
+ justify-content: space-between;
9
+ height: 100%;
10
+ }
11
+
12
+ .formContent {
13
+ flex: 1 1 auto;
14
+ overflow-y: auto;
15
+ padding: layout.$spacing-05;
16
+ }
17
+
18
+ .subtitle {
19
+ @include type.type-style('heading-compact-01');
20
+ color: colors.$gray-70;
21
+ margin-bottom: layout.$spacing-05;
22
+ }
23
+
24
+ .audit {
25
+ @include type.type-style('label-01');
26
+ color: colors.$gray-60;
27
+ }
28
+
29
+ .buttonSet {
30
+ display: flex;
31
+ width: 100%;
32
+ margin-top: auto;
33
+
34
+ :global(.cds--btn) {
35
+ flex: 1 1 50%;
36
+ max-inline-size: none;
37
+ height: layout.$spacing-10;
38
+ }
39
+ }
40
+
41
+ .tablet {
42
+ padding: layout.$spacing-05;
43
+ background-color: colors.$gray-10;
44
+ }
45
+
46
+ .desktop {
47
+ padding: 0;
48
+ }
@@ -0,0 +1,168 @@
1
+ import React, { useEffect, useState } from 'react';
2
+ import { useTranslation } from 'react-i18next';
3
+ import { Controller, useForm } from 'react-hook-form';
4
+ import { zodResolver } from '@hookform/resolvers/zod';
5
+ import { z } from 'zod';
6
+ import { Button, ButtonSet, Form, InlineLoading, Stack, TextArea } from '@carbon/react';
7
+ import { Printer } from '@carbon/react/icons';
8
+ import { type DefaultWorkspaceProps, showSnackbar, useLayoutType } from '@openmrs/esm-framework';
9
+ import classNames from 'classnames';
10
+ import { openImagingReport, submitReport, useProcedure, verifyReport, type WorklistEntry } from '../procedures.resource';
11
+ import styles from './report.workspace.scss';
12
+
13
+ type Mode = 'report' | 'verify' | 'view';
14
+
15
+ interface ReportWorkspaceProps extends DefaultWorkspaceProps {
16
+ order: WorklistEntry;
17
+ mode: Mode;
18
+ onSaved?: () => void;
19
+ }
20
+
21
+ const reportSchema = z.object({
22
+ report: z.string().trim().min(1, 'Enter the report'),
23
+ impressions: z.string().optional(),
24
+ });
25
+
26
+ type ReportForm = z.infer<typeof reportSchema>;
27
+
28
+ const ProcedureReportWorkspace: React.FC<ReportWorkspaceProps> = ({
29
+ order,
30
+ mode,
31
+ onSaved,
32
+ closeWorkspace,
33
+ closeWorkspaceWithSavedChanges,
34
+ promptBeforeClosing,
35
+ }) => {
36
+ const { t } = useTranslation();
37
+ const isTablet = useLayoutType() === 'tablet';
38
+ const { procedure, isLoading } = useProcedure(order.order.uuid);
39
+ const readOnly = mode === 'view';
40
+ const [saving, setSaving] = useState(false);
41
+
42
+ const {
43
+ control,
44
+ handleSubmit,
45
+ reset,
46
+ formState: { errors, isDirty, isValid },
47
+ } = useForm<ReportForm>({
48
+ mode: 'all',
49
+ resolver: zodResolver(reportSchema),
50
+ defaultValues: { report: '', impressions: '' },
51
+ });
52
+
53
+ // Prefill from the saved record once it loads (final report takes precedence over preliminary).
54
+ useEffect(() => {
55
+ if (procedure) {
56
+ reset({
57
+ report: procedure.procedureReport || procedure.preliminaryReport || '',
58
+ impressions: procedure.impressions || '',
59
+ });
60
+ }
61
+ }, [procedure, reset]);
62
+
63
+ useEffect(() => {
64
+ promptBeforeClosing(() => !readOnly && isDirty);
65
+ }, [readOnly, isDirty, promptBeforeClosing]);
66
+
67
+ const onSubmit = (values: ReportForm) => {
68
+ setSaving(true);
69
+ const impressions = values.impressions?.trim() || undefined;
70
+ const action =
71
+ mode === 'verify'
72
+ ? verifyReport(order, values.report.trim(), impressions)
73
+ : submitReport(order, values.report.trim(), impressions);
74
+ action.then(
75
+ () => {
76
+ showSnackbar({
77
+ kind: 'success',
78
+ isLowContrast: true,
79
+ title: mode === 'verify' ? t('reportVerified', 'Report verified') : t('reportSaved', 'Report saved'),
80
+ });
81
+ setSaving(false);
82
+ onSaved?.();
83
+ closeWorkspaceWithSavedChanges();
84
+ },
85
+ (err) => {
86
+ setSaving(false);
87
+ showSnackbar({
88
+ kind: 'error',
89
+ title: t('reportError', 'Could not save report'),
90
+ subtitle: err?.responseBody?.error?.message ?? err?.message,
91
+ });
92
+ },
93
+ );
94
+ };
95
+
96
+ const primaryLabel = mode === 'verify' ? t('verifyRelease', 'Verify & release') : t('saveReport', 'Save report');
97
+
98
+ return (
99
+ <Form className={styles.form} onSubmit={handleSubmit(onSubmit)}>
100
+ <div className={styles.formContent}>
101
+ <p className={styles.subtitle}>
102
+ {order.concept?.display ?? order.order.display}
103
+ {order.patient?.display ? ` — ${order.patient.display}` : ''}
104
+ </p>
105
+ {isLoading ? (
106
+ <InlineLoading description={t('loading', 'Loading…')} />
107
+ ) : (
108
+ <Stack gap={5}>
109
+ <Controller
110
+ name="report"
111
+ control={control}
112
+ render={({ field }) => (
113
+ <TextArea
114
+ id="procedure-report"
115
+ labelText={t('report', 'Report / findings')}
116
+ rows={10}
117
+ readOnly={readOnly}
118
+ invalid={Boolean(errors.report)}
119
+ invalidText={errors.report?.message}
120
+ {...field}
121
+ />
122
+ )}
123
+ />
124
+ <Controller
125
+ name="impressions"
126
+ control={control}
127
+ render={({ field }) => (
128
+ <TextArea
129
+ id="procedure-impressions"
130
+ labelText={t('impression', 'Impression')}
131
+ rows={4}
132
+ readOnly={readOnly}
133
+ {...field}
134
+ />
135
+ )}
136
+ />
137
+ {procedure?.reportType === 'PRELIMINARY' && procedure?.preliminaryReportEnteredBy && (
138
+ <p className={styles.audit}>
139
+ {t('reportedByLabel', 'Reported by {{who}}', { who: procedure.preliminaryReportEnteredBy.display })}
140
+ </p>
141
+ )}
142
+ </Stack>
143
+ )}
144
+ </div>
145
+ <ButtonSet className={classNames(styles.buttonSet, { [styles.tablet]: isTablet, [styles.desktop]: !isTablet })}>
146
+ <Button className={styles.button} kind="secondary" onClick={() => closeWorkspace()}>
147
+ {readOnly ? t('close', 'Close') : t('cancel', 'Cancel')}
148
+ </Button>
149
+ {readOnly ? (
150
+ <Button
151
+ className={styles.button}
152
+ kind="primary"
153
+ renderIcon={Printer}
154
+ disabled={!procedure?.uuid}
155
+ onClick={() => procedure?.uuid && openImagingReport(procedure.uuid)}>
156
+ {t('print', 'Print')}
157
+ </Button>
158
+ ) : (
159
+ <Button className={styles.button} kind="primary" type="submit" disabled={saving || isLoading || !isValid}>
160
+ {saving ? <InlineLoading description={t('saving', 'Saving…')} /> : primaryLabel}
161
+ </Button>
162
+ )}
163
+ </ButtonSet>
164
+ </Form>
165
+ );
166
+ };
167
+
168
+ export default ProcedureReportWorkspace;