@kenyaemr/esm-appointments-app 7.0.2-pre.65
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 +42 -0
- package/dist/130.js +2 -0
- package/dist/130.js.LICENSE.txt +3 -0
- package/dist/130.js.map +1 -0
- package/dist/152.js +1 -0
- package/dist/152.js.map +1 -0
- package/dist/224.js +1 -0
- package/dist/224.js.map +1 -0
- package/dist/255.js +2 -0
- package/dist/255.js.LICENSE.txt +9 -0
- package/dist/255.js.map +1 -0
- package/dist/271.js +1 -0
- package/dist/303.js +1 -0
- package/dist/303.js.map +1 -0
- package/dist/309.js +1 -0
- package/dist/309.js.map +1 -0
- package/dist/319.js +1 -0
- package/dist/4.js +1 -0
- package/dist/4.js.map +1 -0
- package/dist/445.js +2 -0
- package/dist/445.js.LICENSE.txt +54 -0
- package/dist/445.js.map +1 -0
- package/dist/460.js +1 -0
- package/dist/501.js +1 -0
- package/dist/501.js.map +1 -0
- package/dist/574.js +1 -0
- package/dist/591.js +2 -0
- package/dist/591.js.LICENSE.txt +32 -0
- package/dist/591.js.map +1 -0
- package/dist/644.js +1 -0
- package/dist/729.js +1 -0
- package/dist/729.js.map +1 -0
- package/dist/757.js +1 -0
- package/dist/784.js +2 -0
- package/dist/784.js.LICENSE.txt +9 -0
- package/dist/784.js.map +1 -0
- package/dist/788.js +1 -0
- package/dist/807.js +1 -0
- package/dist/833.js +1 -0
- package/dist/857.js +2 -0
- package/dist/857.js.LICENSE.txt +5 -0
- package/dist/857.js.map +1 -0
- package/dist/904.js +1 -0
- package/dist/904.js.map +1 -0
- package/dist/kenyaemr-esm-appointments-app.js +1 -0
- package/dist/kenyaemr-esm-appointments-app.js.buildmanifest.json +699 -0
- package/dist/kenyaemr-esm-appointments-app.js.map +1 -0
- package/dist/main.js +2 -0
- package/dist/main.js.LICENSE.txt +64 -0
- package/dist/main.js.map +1 -0
- package/dist/routes.json +1 -0
- package/jest.config.js +3 -0
- package/package.json +57 -0
- package/src/admin/appointment-services/appointment-services-hook.ts +31 -0
- package/src/admin/appointment-services/appointment-services-validation.ts +17 -0
- package/src/admin/appointment-services/appointment-services.component.tsx +182 -0
- package/src/admin/appointment-services/appointment-services.scss +25 -0
- package/src/appointments/appointment-tabs.component.tsx +48 -0
- package/src/appointments/appointment-tabs.scss +53 -0
- package/src/appointments/appointment-tabs.test.tsx +55 -0
- package/src/appointments/common-components/appointments-actions.component.tsx +86 -0
- package/src/appointments/common-components/appointments-actions.scss +4 -0
- package/src/appointments/common-components/appointments-actions.test.tsx +201 -0
- package/src/appointments/common-components/appointments-table.component.tsx +277 -0
- package/src/appointments/common-components/appointments-table.scss +133 -0
- package/src/appointments/common-components/appointments-table.test.tsx +134 -0
- package/src/appointments/common-components/checkin-button.component.tsx +43 -0
- package/src/appointments/common-components/end-appointment-modal.component.tsx +104 -0
- package/src/appointments/common-components/end-appointment-modal.test.tsx +80 -0
- package/src/appointments/common-components/location-select-option.component.tsx +48 -0
- package/src/appointments/details/appointment-details.component.tsx +91 -0
- package/src/appointments/details/appointment-details.scss +81 -0
- package/src/appointments/details/appointment-details.test.tsx +103 -0
- package/src/appointments/scheduled/appointments-list.component.tsx +33 -0
- package/src/appointments/scheduled/early-appointments.component.tsx +32 -0
- package/src/appointments/scheduled/scheduled-appointments.component.tsx +215 -0
- package/src/appointments/scheduled/scheduled-appointments.scss +4 -0
- package/src/appointments/unscheduled/unscheduled-appointments.component.tsx +146 -0
- package/src/appointments/unscheduled/unscheduled-appointments.test.tsx +131 -0
- package/src/appointments/utils.tsx +80 -0
- package/src/appointments.component.tsx +44 -0
- package/src/appointments.test.tsx +15 -0
- package/src/calendar/appointments-calendar-view-view.scss +24 -0
- package/src/calendar/appointments-calendar-view.component.tsx +36 -0
- package/src/calendar/appointments-calendar-view.test.tsx +22 -0
- package/src/calendar/header/calendar-header.component.tsx +34 -0
- package/src/calendar/header/calendar-header.scss +32 -0
- package/src/calendar/monthly/days-of-week.component.tsx +16 -0
- package/src/calendar/monthly/days-of-week.scss +33 -0
- package/src/calendar/monthly/monthly-calendar-view.component.tsx +34 -0
- package/src/calendar/monthly/monthly-header.module.scss +14 -0
- package/src/calendar/monthly/monthly-header.module.tsx +40 -0
- package/src/calendar/monthly/monthly-view-workload.scss +188 -0
- package/src/calendar/monthly/monthly-workload-view-expanded.component.tsx +42 -0
- package/src/calendar/monthly/monthly-workload-view.component.tsx +109 -0
- package/src/config-schema.ts +151 -0
- package/src/constants.ts +55 -0
- package/src/createDashboardLink.component.tsx +39 -0
- package/src/dashboard.meta.ts +21 -0
- package/src/declarations.d.ts +4 -0
- package/src/empty-state/empty-data-illustration.component.tsx +39 -0
- package/src/empty-state/empty-state.component.tsx +32 -0
- package/src/empty-state/empty-state.scss +69 -0
- package/src/form/appointments-form.component.tsx +891 -0
- package/src/form/appointments-form.resource.ts +165 -0
- package/src/form/appointments-form.scss +113 -0
- package/src/form/appointments-form.test.tsx +212 -0
- package/src/header/appointments-header.component.tsx +79 -0
- package/src/header/appointments-header.scss +95 -0
- package/src/header/appointments-illustration.component.tsx +22 -0
- package/src/helpers/excel.ts +61 -0
- package/src/helpers/functions.ts +82 -0
- package/src/helpers/index.ts +2 -0
- package/src/helpers/time.tsx +15 -0
- package/src/home/home-appointments.component.tsx +22 -0
- package/src/home/home-appointments.scss +10 -0
- package/src/hooks/patientAppointmentContext.ts +15 -0
- package/src/hooks/selectedDateContext.ts +10 -0
- package/src/hooks/useAppointmentList.ts +48 -0
- package/src/hooks/useAppointmentService.ts +11 -0
- package/src/hooks/useAppointmentsCalendar.ts +68 -0
- package/src/hooks/useClinicalMetrics.ts +79 -0
- package/src/hooks/useDefaultLocation.ts +14 -0
- package/src/hooks/useOverlay.tsx +45 -0
- package/src/hooks/usePatientAppointmentHistory.ts +49 -0
- package/src/hooks/useProviders.ts +18 -0
- package/src/hooks/useTodaysVisits.ts +19 -0
- package/src/hooks/useUnscheduledAppointments.ts +45 -0
- package/src/index.ts +111 -0
- package/src/metrics/appointments-metrics.component.tsx +71 -0
- package/src/metrics/appointments-metrics.scss +15 -0
- package/src/metrics/appointments-metrics.test.tsx +49 -0
- package/src/metrics/metrics-card.component.tsx +76 -0
- package/src/metrics/metrics-card.scss +77 -0
- package/src/metrics/metrics-header.component.tsx +62 -0
- package/src/metrics/metrics-header.scss +33 -0
- package/src/past-visit/encounter-list.component.tsx +54 -0
- package/src/past-visit/past-visit.component.tsx +106 -0
- package/src/past-visit/past-visit.resource.ts +25 -0
- package/src/past-visit/past-visit.scss +106 -0
- package/src/patient-appointments/patient-appointments-action-menu.component.tsx +65 -0
- package/src/patient-appointments/patient-appointments-action-menu.scss +7 -0
- package/src/patient-appointments/patient-appointments-base.component.tsx +165 -0
- package/src/patient-appointments/patient-appointments-base.scss +85 -0
- package/src/patient-appointments/patient-appointments-base.test.tsx +91 -0
- package/src/patient-appointments/patient-appointments-cancel-modal.component.tsx +66 -0
- package/src/patient-appointments/patient-appointments-detailed-summary.component.tsx +15 -0
- package/src/patient-appointments/patient-appointments-header.scss +27 -0
- package/src/patient-appointments/patient-appointments-header.tsx +42 -0
- package/src/patient-appointments/patient-appointments-overview.component.tsx +35 -0
- package/src/patient-appointments/patient-appointments-overview.scss +7 -0
- package/src/patient-appointments/patient-appointments-table.scss +0 -0
- package/src/patient-appointments/patient-appointments-table.tsx +128 -0
- package/src/patient-appointments/patient-appointments.resource.ts +72 -0
- package/src/patient-appointments/patient-upcoming-appointments-card.component.tsx +122 -0
- package/src/patient-appointments/patient-upcoming-appointments-card.scss +46 -0
- package/src/patient-search/patient-search.component.tsx +34 -0
- package/src/patient-search/patient-search.scss +23 -0
- package/src/root.component.tsx +26 -0
- package/src/root.scss +50 -0
- package/src/routes.json +153 -0
- package/src/scheduled-appointments-config-schema.ts +169 -0
- package/src/types/index.ts +189 -0
- package/src/workload/monthly-view-workload/monthly-view.component.tsx +69 -0
- package/src/workload/monthly-view-workload/monthly-workload.scss +223 -0
- package/src/workload/monthly-view-workload/monthlyWorkCard.tsx +45 -0
- package/src/workload/workload-card.component.tsx +31 -0
- package/src/workload/workload.component.tsx +47 -0
- package/src/workload/workload.resource.ts +78 -0
- package/src/workload/workload.scss +92 -0
- package/translations/am.json +148 -0
- package/translations/ar.json +148 -0
- package/translations/en.json +159 -0
- package/translations/es.json +148 -0
- package/translations/fr.json +148 -0
- package/translations/he.json +148 -0
- package/translations/km.json +148 -0
- package/translations/zh.json +148 -0
- package/translations/zh_CN.json +148 -0
- package/tsconfig.json +5 -0
- package/webpack.config.js +1 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { filterByServiceType } from '../utils';
|
|
3
|
+
import { useAppointmentList } from '../../hooks/useAppointmentList';
|
|
4
|
+
import AppointmentsTable from '../common-components/appointments-table.component';
|
|
5
|
+
|
|
6
|
+
interface AppointmentsListProps {
|
|
7
|
+
appointmentServiceType?: string;
|
|
8
|
+
status?: string;
|
|
9
|
+
title: string;
|
|
10
|
+
date: string;
|
|
11
|
+
filterCancelled?: boolean;
|
|
12
|
+
}
|
|
13
|
+
const AppointmentsList: React.FC<AppointmentsListProps> = ({
|
|
14
|
+
appointmentServiceType,
|
|
15
|
+
status,
|
|
16
|
+
title,
|
|
17
|
+
date,
|
|
18
|
+
filterCancelled = false,
|
|
19
|
+
}) => {
|
|
20
|
+
const { appointmentList, isLoading } = useAppointmentList(status, date);
|
|
21
|
+
|
|
22
|
+
const appointments = filterByServiceType(appointmentList, appointmentServiceType).map((appointment) => ({
|
|
23
|
+
id: appointment.uuid,
|
|
24
|
+
...appointment,
|
|
25
|
+
}));
|
|
26
|
+
|
|
27
|
+
const activeAppointments = filterCancelled
|
|
28
|
+
? appointments.filter((appointment) => appointment.status !== 'Cancelled')
|
|
29
|
+
: appointments;
|
|
30
|
+
return <AppointmentsTable appointments={activeAppointments} isLoading={isLoading} tableHeading={title} />;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export default AppointmentsList;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { useEarlyAppointmentList } from '../../hooks/useAppointmentList';
|
|
3
|
+
import { filterByServiceType } from '../utils';
|
|
4
|
+
import AppointmentsTable from '../common-components/appointments-table.component';
|
|
5
|
+
import { useTranslation } from 'react-i18next';
|
|
6
|
+
|
|
7
|
+
interface EarlyAppointmentsProps {
|
|
8
|
+
appointmentServiceType?: string;
|
|
9
|
+
date: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Component to display early appointments
|
|
14
|
+
* Note that although we define this extension in routes.jsx, we currently don't wire it into the scheduled-appointments-panels-slot by default because it requests a custom endpoint (see useEarlyAppointments) not provided by the standard Bahmni Appointments module
|
|
15
|
+
*/
|
|
16
|
+
const EarlyAppointments: React.FC<EarlyAppointmentsProps> = ({ appointmentServiceType, date }) => {
|
|
17
|
+
const { t } = useTranslation();
|
|
18
|
+
const { earlyAppointmentList, isLoading } = useEarlyAppointmentList(date);
|
|
19
|
+
|
|
20
|
+
const appointments = filterByServiceType(earlyAppointmentList, appointmentServiceType).map((appointment, index) => {
|
|
21
|
+
return {
|
|
22
|
+
id: `${index}`,
|
|
23
|
+
...appointment,
|
|
24
|
+
};
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
return (
|
|
28
|
+
<AppointmentsTable appointments={appointments} isLoading={isLoading} tableHeading={t('cameEarly', 'Came Early')} />
|
|
29
|
+
);
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export default EarlyAppointments;
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import React, { useCallback, useContext, useEffect, useReducer, useRef, useState } from 'react';
|
|
2
|
+
import { useTranslation } from 'react-i18next';
|
|
3
|
+
import { ContentSwitcher, Switch } from '@carbon/react';
|
|
4
|
+
import dayjs from 'dayjs';
|
|
5
|
+
import isSameOrBefore from 'dayjs/plugin/isSameOrBefore';
|
|
6
|
+
import {
|
|
7
|
+
ExtensionSlot,
|
|
8
|
+
Extension,
|
|
9
|
+
useConnectedExtensions,
|
|
10
|
+
type ConnectedExtension,
|
|
11
|
+
type ConfigObject,
|
|
12
|
+
useLayoutType,
|
|
13
|
+
isDesktop,
|
|
14
|
+
} from '@openmrs/esm-framework';
|
|
15
|
+
import styles from './scheduled-appointments.scss';
|
|
16
|
+
import SelectedDateContext from '../../hooks/selectedDateContext';
|
|
17
|
+
|
|
18
|
+
dayjs.extend(isSameOrBefore);
|
|
19
|
+
|
|
20
|
+
interface ScheduledAppointmentsProps {
|
|
21
|
+
appointmentServiceType?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
type DateType = 'pastDate' | 'today' | 'futureDate';
|
|
25
|
+
|
|
26
|
+
const scheduledAppointmentsPanelsSlot = 'scheduled-appointments-panels-slot';
|
|
27
|
+
|
|
28
|
+
const ScheduledAppointments: React.FC<ScheduledAppointmentsProps> = ({ appointmentServiceType }) => {
|
|
29
|
+
const { t } = useTranslation();
|
|
30
|
+
const { selectedDate } = useContext(SelectedDateContext);
|
|
31
|
+
const layout = useLayoutType();
|
|
32
|
+
const responsiveSize = isDesktop(layout) ? 'sm' : 'md';
|
|
33
|
+
|
|
34
|
+
// added to prevent auto-removal of translations for dynamic keys
|
|
35
|
+
// t('checkedIn', 'Checked in');
|
|
36
|
+
// t('expected', 'Expected');
|
|
37
|
+
|
|
38
|
+
const [currentTab, setCurrentTab] = useState<string>(null);
|
|
39
|
+
const [dateType, setDateType] = useState<DateType>('today');
|
|
40
|
+
const scheduledAppointmentPanels = useConnectedExtensions(scheduledAppointmentsPanelsSlot);
|
|
41
|
+
const { allowedExtensions, showExtension, hideExtension } = useAllowedExtensions();
|
|
42
|
+
const shouldShowPanel = useCallback(
|
|
43
|
+
(panel: Omit<ConnectedExtension, 'config'>) => allowedExtensions[panel.name] ?? false,
|
|
44
|
+
[allowedExtensions],
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
const dayjsDate = dayjs(selectedDate);
|
|
49
|
+
const now = dayjs();
|
|
50
|
+
if (dayjsDate.isBefore(now, 'date')) {
|
|
51
|
+
setDateType('pastDate');
|
|
52
|
+
} else if (dayjsDate.isAfter(now, 'date')) {
|
|
53
|
+
setDateType('futureDate');
|
|
54
|
+
} else {
|
|
55
|
+
setDateType('today');
|
|
56
|
+
}
|
|
57
|
+
}, [selectedDate]);
|
|
58
|
+
|
|
59
|
+
useEffect(() => {
|
|
60
|
+
// This is intended to cover two things:
|
|
61
|
+
// 1. If no current tab is set, set it to the first allowed tab
|
|
62
|
+
// 2. If a current tab is set, but the tab is no longer allowed in this context, set it to the
|
|
63
|
+
// first allowed tab
|
|
64
|
+
if (allowedExtensions && (currentTab === null || !allowedExtensions[currentTab])) {
|
|
65
|
+
for (const extension of Object.getOwnPropertyNames(allowedExtensions)) {
|
|
66
|
+
if (allowedExtensions[extension]) {
|
|
67
|
+
setCurrentTab(extension);
|
|
68
|
+
break;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}, [allowedExtensions, currentTab]);
|
|
73
|
+
|
|
74
|
+
const panelsToShow = scheduledAppointmentPanels.filter(shouldShowPanel);
|
|
75
|
+
|
|
76
|
+
return (
|
|
77
|
+
<>
|
|
78
|
+
<ContentSwitcher
|
|
79
|
+
className={styles.switcher}
|
|
80
|
+
size={responsiveSize}
|
|
81
|
+
onChange={({ name }) => setCurrentTab(name)}
|
|
82
|
+
selectedIndex={panelsToShow.findIndex((panel) => panel.name == currentTab) ?? 0}
|
|
83
|
+
selectionMode="manual">
|
|
84
|
+
{panelsToShow.map((panel) => {
|
|
85
|
+
return <Switch key={`panel-${panel.name}`} name={panel.name} text={t(panel.config.title)} />;
|
|
86
|
+
})}
|
|
87
|
+
</ContentSwitcher>
|
|
88
|
+
|
|
89
|
+
<ExtensionSlot name={scheduledAppointmentsPanelsSlot}>
|
|
90
|
+
{(extension) => {
|
|
91
|
+
return (
|
|
92
|
+
<ExtensionWrapper
|
|
93
|
+
extension={extension}
|
|
94
|
+
currentTab={currentTab}
|
|
95
|
+
appointmentServiceType={appointmentServiceType}
|
|
96
|
+
date={selectedDate}
|
|
97
|
+
dateType={dateType}
|
|
98
|
+
showExtensionTab={showExtension}
|
|
99
|
+
hideExtensionTab={hideExtension}
|
|
100
|
+
/>
|
|
101
|
+
);
|
|
102
|
+
}}
|
|
103
|
+
</ExtensionSlot>
|
|
104
|
+
</>
|
|
105
|
+
);
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
function useAllowedExtensions() {
|
|
109
|
+
const [allowedExtensions, dispatch] = useReducer(
|
|
110
|
+
(state: Record<string, boolean>, action: { type: 'show_extension' | 'hide_extension'; extension: string }) => {
|
|
111
|
+
let addedState = {} as Record<string, boolean>;
|
|
112
|
+
switch (action.type) {
|
|
113
|
+
case 'show_extension':
|
|
114
|
+
addedState[action.extension] = true;
|
|
115
|
+
break;
|
|
116
|
+
case 'hide_extension':
|
|
117
|
+
addedState[action.extension] = false;
|
|
118
|
+
break;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return { ...state, ...addedState };
|
|
122
|
+
},
|
|
123
|
+
{},
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
allowedExtensions: allowedExtensions as Readonly<Record<string, boolean>>,
|
|
128
|
+
showExtension: (extension: string) => dispatch({ type: 'show_extension', extension }),
|
|
129
|
+
hideExtension: (extension: string) => dispatch({ type: 'hide_extension', extension }),
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function ExtensionWrapper({
|
|
134
|
+
extension,
|
|
135
|
+
currentTab,
|
|
136
|
+
appointmentServiceType,
|
|
137
|
+
date,
|
|
138
|
+
dateType,
|
|
139
|
+
showExtensionTab,
|
|
140
|
+
hideExtensionTab,
|
|
141
|
+
}: {
|
|
142
|
+
extension: ConnectedExtension;
|
|
143
|
+
currentTab: string;
|
|
144
|
+
appointmentServiceType: string;
|
|
145
|
+
date: string;
|
|
146
|
+
dateType: DateType;
|
|
147
|
+
showExtensionTab: (extension: string) => void;
|
|
148
|
+
hideExtensionTab: (extension: string) => void;
|
|
149
|
+
}) {
|
|
150
|
+
const currentConfig = useRef(null);
|
|
151
|
+
const currentDateType = useRef(dateType);
|
|
152
|
+
|
|
153
|
+
// This use effect hook controls whether the tab for this extension should render
|
|
154
|
+
useEffect(() => {
|
|
155
|
+
if (
|
|
156
|
+
currentConfig.current === null ||
|
|
157
|
+
(currentConfig.current !== null && !shallowEqual(currentConfig.current, extension.config)) ||
|
|
158
|
+
currentDateType.current !== dateType
|
|
159
|
+
) {
|
|
160
|
+
currentConfig.current = extension.config;
|
|
161
|
+
currentDateType.current = dateType;
|
|
162
|
+
shouldDisplayExtensionTab(extension?.config, dateType)
|
|
163
|
+
? showExtensionTab(extension.name)
|
|
164
|
+
: hideExtensionTab(extension.name);
|
|
165
|
+
}
|
|
166
|
+
}, [extension, dateType, showExtensionTab, hideExtensionTab]);
|
|
167
|
+
|
|
168
|
+
return (
|
|
169
|
+
<div
|
|
170
|
+
key={extension.name}
|
|
171
|
+
className={styles.container}
|
|
172
|
+
style={{ display: currentTab === extension.name ? 'block' : 'none' }}>
|
|
173
|
+
<Extension
|
|
174
|
+
state={{
|
|
175
|
+
date,
|
|
176
|
+
appointmentServiceType,
|
|
177
|
+
status: extension.config?.status,
|
|
178
|
+
title: extension.config?.title,
|
|
179
|
+
}}
|
|
180
|
+
/>
|
|
181
|
+
</div>
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function shouldDisplayExtensionTab(config: ConfigObject | undefined, dateType: DateType): boolean {
|
|
186
|
+
if (!config) {
|
|
187
|
+
return false;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
switch (dateType) {
|
|
191
|
+
case 'futureDate':
|
|
192
|
+
return config.showForFutureDate ?? false;
|
|
193
|
+
case 'pastDate':
|
|
194
|
+
return config.showForPastDate ?? false;
|
|
195
|
+
case 'today':
|
|
196
|
+
return config.showForToday ?? false;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function shallowEqual(objA: object, objB: object) {
|
|
201
|
+
if (Object.is(objA, objB)) {
|
|
202
|
+
return true;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (typeof objA !== 'object' || objA === null || typeof objB !== 'object' || objB === null) {
|
|
206
|
+
return false;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const objAKeys = Object.getOwnPropertyNames(objA);
|
|
210
|
+
const objBKeys = Object.getOwnPropertyNames(objB);
|
|
211
|
+
|
|
212
|
+
return objAKeys.length === objBKeys.length && objAKeys.every((key) => objA[key] === objB[key]);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export default ScheduledAppointments;
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import React, { useState } from 'react';
|
|
2
|
+
import { useTranslation } from 'react-i18next';
|
|
3
|
+
import {
|
|
4
|
+
DataTable,
|
|
5
|
+
TableContainer,
|
|
6
|
+
Table,
|
|
7
|
+
TableHead,
|
|
8
|
+
TableHeader,
|
|
9
|
+
TableRow,
|
|
10
|
+
TableBody,
|
|
11
|
+
TableCell,
|
|
12
|
+
TableToolbar,
|
|
13
|
+
TableToolbarContent,
|
|
14
|
+
TableToolbarSearch,
|
|
15
|
+
Pagination,
|
|
16
|
+
DataTableSkeleton,
|
|
17
|
+
Button,
|
|
18
|
+
} from '@carbon/react';
|
|
19
|
+
import { Download } from '@carbon/react/icons';
|
|
20
|
+
import { ConfigurableLink, useConfig, usePagination } from '@openmrs/esm-framework';
|
|
21
|
+
import { useUnscheduledAppointments } from '../../hooks/useUnscheduledAppointments';
|
|
22
|
+
import { downloadUnscheduledAppointments } from '../../helpers/excel';
|
|
23
|
+
import { EmptyState } from '../../empty-state/empty-state.component';
|
|
24
|
+
import { getPageSizes, useSearchResults } from '../utils';
|
|
25
|
+
import { type ConfigObject } from '../../config-schema';
|
|
26
|
+
|
|
27
|
+
const UnscheduledAppointments: React.FC = () => {
|
|
28
|
+
const { t } = useTranslation();
|
|
29
|
+
const [pageSize, setPageSize] = useState(25);
|
|
30
|
+
|
|
31
|
+
const [searchString, setSearchString] = useState('');
|
|
32
|
+
const { data: unscheduledAppointments, isLoading, error } = useUnscheduledAppointments();
|
|
33
|
+
const searchResults = useSearchResults(unscheduledAppointments, searchString);
|
|
34
|
+
const { customPatientChartUrl } = useConfig<ConfigObject>();
|
|
35
|
+
|
|
36
|
+
const headerData = [
|
|
37
|
+
{
|
|
38
|
+
header: 'Patient Name',
|
|
39
|
+
key: 'name',
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
header: 'Identifier',
|
|
43
|
+
key: 'identifier',
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
header: 'Gender',
|
|
47
|
+
key: 'gender',
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
header: 'Phone Number',
|
|
51
|
+
key: 'phoneNumber',
|
|
52
|
+
},
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
const { results, currentPage, goTo } = usePagination(searchResults, pageSize);
|
|
56
|
+
const rowData = results?.map((visit) => ({
|
|
57
|
+
id: `${visit.uuid}`,
|
|
58
|
+
name: (
|
|
59
|
+
<ConfigurableLink
|
|
60
|
+
style={{ textDecoration: 'none' }}
|
|
61
|
+
to={customPatientChartUrl}
|
|
62
|
+
templateParams={{ patientUuid: visit.uuid }}>
|
|
63
|
+
{visit.name}
|
|
64
|
+
</ConfigurableLink>
|
|
65
|
+
),
|
|
66
|
+
gender: visit.gender === 'F' ? 'Female' : 'Male',
|
|
67
|
+
phoneNumber: visit.phoneNumber === '' ? '--' : visit.phoneNumber,
|
|
68
|
+
identifier: visit?.identifier,
|
|
69
|
+
}));
|
|
70
|
+
|
|
71
|
+
if (isLoading) {
|
|
72
|
+
return <DataTableSkeleton />;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (!unscheduledAppointments?.length) {
|
|
76
|
+
return (
|
|
77
|
+
<EmptyState
|
|
78
|
+
displayText={t('unscheduledAppointments_lower', 'unscheduled appointments')}
|
|
79
|
+
headerTitle={t('unscheduledAppointments', 'Unscheduled appointments')}
|
|
80
|
+
/>
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return (
|
|
85
|
+
<div>
|
|
86
|
+
<DataTable rows={rowData} headers={headerData} isSortable>
|
|
87
|
+
{({ rows, headers, getHeaderProps, getTableProps }) => (
|
|
88
|
+
<TableContainer
|
|
89
|
+
title={`${t('unscheduledAppointments', 'Unscheduled appointments')} ${unscheduledAppointments.length}`}
|
|
90
|
+
description={`${t(`Total ${unscheduledAppointments.length ?? 0}`)}`}>
|
|
91
|
+
<TableToolbar>
|
|
92
|
+
<TableToolbarContent>
|
|
93
|
+
<TableToolbarSearch
|
|
94
|
+
style={{ backgroundColor: '#f4f4f4' }}
|
|
95
|
+
tabIndex={0}
|
|
96
|
+
onChange={(event) => setSearchString(event.target.value)}
|
|
97
|
+
/>
|
|
98
|
+
<Button
|
|
99
|
+
size="lg"
|
|
100
|
+
kind="tertiary"
|
|
101
|
+
renderIcon={Download}
|
|
102
|
+
onClick={() => downloadUnscheduledAppointments(unscheduledAppointments)}>
|
|
103
|
+
{t('download', 'Download')}
|
|
104
|
+
</Button>
|
|
105
|
+
</TableToolbarContent>
|
|
106
|
+
</TableToolbar>
|
|
107
|
+
<Table {...getTableProps()}>
|
|
108
|
+
<TableHead>
|
|
109
|
+
<TableRow>
|
|
110
|
+
{headers.map((header) => (
|
|
111
|
+
<TableHeader {...getHeaderProps({ header })}>{header.header}</TableHeader>
|
|
112
|
+
))}
|
|
113
|
+
</TableRow>
|
|
114
|
+
</TableHead>
|
|
115
|
+
<TableBody>
|
|
116
|
+
{rows.map((row) => (
|
|
117
|
+
<TableRow key={row.id}>
|
|
118
|
+
{row.cells.map((cell) => (
|
|
119
|
+
<TableCell key={cell.id}>{cell.value}</TableCell>
|
|
120
|
+
))}
|
|
121
|
+
</TableRow>
|
|
122
|
+
))}
|
|
123
|
+
</TableBody>
|
|
124
|
+
</Table>
|
|
125
|
+
</TableContainer>
|
|
126
|
+
)}
|
|
127
|
+
</DataTable>
|
|
128
|
+
|
|
129
|
+
<Pagination
|
|
130
|
+
backwardText="Previous page"
|
|
131
|
+
forwardText="Next page"
|
|
132
|
+
page={currentPage}
|
|
133
|
+
pageNumberText="Page Number"
|
|
134
|
+
pageSize={pageSize}
|
|
135
|
+
onChange={({ page, pageSize }) => {
|
|
136
|
+
goTo(page);
|
|
137
|
+
setPageSize(pageSize);
|
|
138
|
+
}}
|
|
139
|
+
pageSizes={getPageSizes(unscheduledAppointments, pageSize) ?? []}
|
|
140
|
+
totalItems={unscheduledAppointments.length ?? 0}
|
|
141
|
+
/>
|
|
142
|
+
</div>
|
|
143
|
+
);
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
export default UnscheduledAppointments;
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import userEvent from '@testing-library/user-event';
|
|
3
|
+
import { render, screen } from '@testing-library/react';
|
|
4
|
+
import { getByTextWithMarkup } from '../../../../../tools/test-utils';
|
|
5
|
+
import { useUnscheduledAppointments } from '../../hooks/useUnscheduledAppointments';
|
|
6
|
+
import { downloadUnscheduledAppointments } from '../../helpers/excel';
|
|
7
|
+
import UnscheduledAppointments from './unscheduled-appointments.component';
|
|
8
|
+
|
|
9
|
+
const mockDownloadAppointmentsAsExcel = downloadUnscheduledAppointments as jest.Mock;
|
|
10
|
+
const mockUseUnscheduledAppointments = useUnscheduledAppointments as jest.Mock;
|
|
11
|
+
|
|
12
|
+
jest.mock('../../helpers/excel');
|
|
13
|
+
jest.mock('../../hooks/useOverlay');
|
|
14
|
+
jest.mock('../../hooks/useUnscheduledAppointments');
|
|
15
|
+
|
|
16
|
+
jest.mock('@openmrs/esm-framework', () => {
|
|
17
|
+
const originalModule = jest.requireActual('@openmrs/esm-framework');
|
|
18
|
+
return {
|
|
19
|
+
...originalModule,
|
|
20
|
+
useConfig: jest.fn(() => ({
|
|
21
|
+
customPatientChartUrl: 'someUrl',
|
|
22
|
+
})),
|
|
23
|
+
};
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe('UnscheduledAppointments component', () => {
|
|
27
|
+
const mockUnscheduledAppointments = [
|
|
28
|
+
{
|
|
29
|
+
uuid: '1234',
|
|
30
|
+
name: 'Test Patient',
|
|
31
|
+
identifier: '1234-56-78',
|
|
32
|
+
gender: 'M',
|
|
33
|
+
phoneNumber: '123-456-7890',
|
|
34
|
+
age: 20,
|
|
35
|
+
dob: 1262304000,
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
uuid: '5678',
|
|
39
|
+
name: 'Another Patient',
|
|
40
|
+
identifier: '2345-67-89',
|
|
41
|
+
gender: 'F',
|
|
42
|
+
phoneNumber: '',
|
|
43
|
+
age: 30,
|
|
44
|
+
dob: 1262304000,
|
|
45
|
+
},
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
it('renders the component correctly', async () => {
|
|
49
|
+
mockUseUnscheduledAppointments.mockReturnValue({
|
|
50
|
+
isLoading: false,
|
|
51
|
+
data: mockUnscheduledAppointments,
|
|
52
|
+
error: null,
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
render(<UnscheduledAppointments />);
|
|
56
|
+
|
|
57
|
+
const header = screen.getByText('Unscheduled appointments 2');
|
|
58
|
+
expect(header).toBeInTheDocument();
|
|
59
|
+
|
|
60
|
+
const patientName = await screen.findByText('Test Patient');
|
|
61
|
+
expect(patientName).toBeInTheDocument();
|
|
62
|
+
expect(patientName).toHaveAttribute('href', 'someUrl');
|
|
63
|
+
|
|
64
|
+
const identifier = screen.getByText('1234-56-78');
|
|
65
|
+
expect(identifier).toBeInTheDocument();
|
|
66
|
+
|
|
67
|
+
const gender = screen.getByText('Male');
|
|
68
|
+
expect(gender).toBeInTheDocument();
|
|
69
|
+
|
|
70
|
+
const phoneNumber = screen.getByText('123-456-7890');
|
|
71
|
+
expect(phoneNumber).toBeInTheDocument();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('allows the user to search for appointments', async () => {
|
|
75
|
+
const user = userEvent.setup();
|
|
76
|
+
|
|
77
|
+
mockUseUnscheduledAppointments.mockReturnValue({
|
|
78
|
+
isLoading: false,
|
|
79
|
+
data: mockUnscheduledAppointments,
|
|
80
|
+
error: null,
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
render(<UnscheduledAppointments />);
|
|
84
|
+
|
|
85
|
+
const searchInput = await screen.findByRole('searchbox');
|
|
86
|
+
await user.type(searchInput, 'Another');
|
|
87
|
+
|
|
88
|
+
const patientName = screen.getByText('Another Patient');
|
|
89
|
+
expect(patientName).toBeInTheDocument();
|
|
90
|
+
|
|
91
|
+
const identifier = screen.getByText('2345-67-89');
|
|
92
|
+
expect(identifier).toBeInTheDocument();
|
|
93
|
+
|
|
94
|
+
const gender = screen.getByText('Female');
|
|
95
|
+
expect(gender).toBeInTheDocument();
|
|
96
|
+
|
|
97
|
+
const phoneNumber = screen.getByText('--');
|
|
98
|
+
expect(phoneNumber).toBeInTheDocument();
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('allows the user to download a list of unscheduled appointments', async () => {
|
|
102
|
+
const user = userEvent.setup();
|
|
103
|
+
|
|
104
|
+
mockUseUnscheduledAppointments.mockReturnValue({
|
|
105
|
+
isLoading: false,
|
|
106
|
+
data: mockUnscheduledAppointments,
|
|
107
|
+
error: null,
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
render(<UnscheduledAppointments />);
|
|
111
|
+
|
|
112
|
+
const downloadButton = await screen.findByText('Download');
|
|
113
|
+
expect(downloadButton).toBeInTheDocument();
|
|
114
|
+
|
|
115
|
+
await user.click(downloadButton);
|
|
116
|
+
|
|
117
|
+
expect(mockDownloadAppointmentsAsExcel).toHaveBeenCalledWith(mockUnscheduledAppointments);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('renders a message if there are no unscheduled appointments', async () => {
|
|
121
|
+
mockUseUnscheduledAppointments.mockReturnValue({
|
|
122
|
+
isLoading: false,
|
|
123
|
+
data: [],
|
|
124
|
+
error: null,
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
render(<UnscheduledAppointments />);
|
|
128
|
+
|
|
129
|
+
expect(getByTextWithMarkup('There are no unscheduled appointments to display')).toBeInTheDocument();
|
|
130
|
+
});
|
|
131
|
+
});
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { useMemo } from 'react';
|
|
2
|
+
import { type Appointment } from '../types';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Returns an array of page sizes for the given data and page size.
|
|
6
|
+
* If the page size is not specified, the default value is 10.
|
|
7
|
+
*
|
|
8
|
+
* @template T The type of the data array.
|
|
9
|
+
* @param {Array<T>} data The data array.
|
|
10
|
+
* @param {number} [pageSize=10] The page size.
|
|
11
|
+
* @returns {Array<number>} An array of page sizes.
|
|
12
|
+
*/
|
|
13
|
+
export function getPageSizes<T>(data: Array<T>, pageSize: number = 10): Array<number> {
|
|
14
|
+
if (!data) {
|
|
15
|
+
return [];
|
|
16
|
+
}
|
|
17
|
+
const numberOfPages = Math.ceil(data.length / pageSize);
|
|
18
|
+
return Array.from({ length: numberOfPages }, (_, i) => (i + 1) * pageSize);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
Returns an array of filtered data that contains search string
|
|
23
|
+
@template T
|
|
24
|
+
@param {T[]} data - The array of data to filter
|
|
25
|
+
@param {string} searchString - The string to search for in the data
|
|
26
|
+
@returns {T[]} The filtered array of data
|
|
27
|
+
*/
|
|
28
|
+
export function useSearchResults<T>(data: T[], searchString: string): T[] {
|
|
29
|
+
const searchResults = useMemo(() => {
|
|
30
|
+
if (searchString && searchString.trim() !== '') {
|
|
31
|
+
const search = searchString.toLowerCase();
|
|
32
|
+
return data.filter((appointment) =>
|
|
33
|
+
Object.entries(appointment).some(([header, value]) => {
|
|
34
|
+
if (header === 'patientUuid') {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
return `${value}`.toLowerCase().includes(search);
|
|
38
|
+
}),
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return data;
|
|
43
|
+
}, [searchString, data]);
|
|
44
|
+
|
|
45
|
+
return searchResults;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Accepts an array of Appointment and a searchString
|
|
50
|
+
* Returns those Appointments that match the search string based on the following:
|
|
51
|
+
* case-insensitive partial match on patient name
|
|
52
|
+
* case-insensitive partial match on the primary patient identifier
|
|
53
|
+
* case-insensitive exact match on any of the patient identifiers
|
|
54
|
+
* @param {Appointment[]} data - The array of data to filter
|
|
55
|
+
* @param {string} searchString - The string to search for in the data
|
|
56
|
+
* @returns {Appointment[]} The filtered array of data
|
|
57
|
+
*/
|
|
58
|
+
export function useAppointmentSearchResults(data: Appointment[], searchString: string): Appointment[] {
|
|
59
|
+
return useMemo(() => {
|
|
60
|
+
if (searchString && searchString.trim() !== '') {
|
|
61
|
+
const lowerCaseSearch = searchString.toLowerCase();
|
|
62
|
+
return data.filter((appointment) => {
|
|
63
|
+
if (appointment.patient.name?.toLowerCase()?.includes(lowerCaseSearch)) {
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
if (appointment.patient.identifier?.toLowerCase()?.includes(lowerCaseSearch)) {
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
return false;
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
return data;
|
|
73
|
+
}, [searchString, data]);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function filterByServiceType(appointmentList: any[], appointmentServiceType: string) {
|
|
77
|
+
return appointmentServiceType
|
|
78
|
+
? appointmentList.filter(({ service }) => service?.uuid === appointmentServiceType)
|
|
79
|
+
: appointmentList;
|
|
80
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import React, { useEffect, useState } from 'react';
|
|
2
|
+
import { useTranslation } from 'react-i18next';
|
|
3
|
+
import dayjs from 'dayjs';
|
|
4
|
+
import AppointmentTabs from './appointments/appointment-tabs.component';
|
|
5
|
+
import AppointmentsHeader from './header/appointments-header.component';
|
|
6
|
+
import AppointmentMetrics from './metrics/appointments-metrics.component';
|
|
7
|
+
import { WorkspaceOverlay } from '@openmrs/esm-framework';
|
|
8
|
+
import { useParams } from 'react-router-dom';
|
|
9
|
+
import SelectedDateContext from './hooks/selectedDateContext';
|
|
10
|
+
import { omrsDateFormat } from './constants';
|
|
11
|
+
|
|
12
|
+
const Appointments: React.FC = () => {
|
|
13
|
+
const { t } = useTranslation();
|
|
14
|
+
const [appointmentServiceType, setAppointmentServiceType] = useState<string>('');
|
|
15
|
+
const [selectedDate, setSelectedDate] = useState<string>(dayjs().startOf('day').format(omrsDateFormat));
|
|
16
|
+
|
|
17
|
+
let params = useParams();
|
|
18
|
+
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
if (params.date) {
|
|
21
|
+
setSelectedDate(dayjs(params.date).startOf('day').format(omrsDateFormat));
|
|
22
|
+
}
|
|
23
|
+
}, [params.date]);
|
|
24
|
+
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
if (params.serviceType) {
|
|
27
|
+
setAppointmentServiceType(params.serviceType);
|
|
28
|
+
}
|
|
29
|
+
}, [params.serviceType]);
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
<SelectedDateContext.Provider value={{ selectedDate, setSelectedDate }}>
|
|
33
|
+
<AppointmentsHeader
|
|
34
|
+
title={t('home', 'Home')}
|
|
35
|
+
appointmentServiceType={appointmentServiceType}
|
|
36
|
+
onChange={setAppointmentServiceType}
|
|
37
|
+
/>
|
|
38
|
+
<AppointmentMetrics appointmentServiceType={appointmentServiceType} />
|
|
39
|
+
<AppointmentTabs appointmentServiceType={appointmentServiceType} />
|
|
40
|
+
</SelectedDateContext.Provider>
|
|
41
|
+
);
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export default Appointments;
|