@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.
Files changed (181) hide show
  1. package/.turbo/turbo-build.log +42 -0
  2. package/dist/130.js +2 -0
  3. package/dist/130.js.LICENSE.txt +3 -0
  4. package/dist/130.js.map +1 -0
  5. package/dist/152.js +1 -0
  6. package/dist/152.js.map +1 -0
  7. package/dist/224.js +1 -0
  8. package/dist/224.js.map +1 -0
  9. package/dist/255.js +2 -0
  10. package/dist/255.js.LICENSE.txt +9 -0
  11. package/dist/255.js.map +1 -0
  12. package/dist/271.js +1 -0
  13. package/dist/303.js +1 -0
  14. package/dist/303.js.map +1 -0
  15. package/dist/309.js +1 -0
  16. package/dist/309.js.map +1 -0
  17. package/dist/319.js +1 -0
  18. package/dist/4.js +1 -0
  19. package/dist/4.js.map +1 -0
  20. package/dist/445.js +2 -0
  21. package/dist/445.js.LICENSE.txt +54 -0
  22. package/dist/445.js.map +1 -0
  23. package/dist/460.js +1 -0
  24. package/dist/501.js +1 -0
  25. package/dist/501.js.map +1 -0
  26. package/dist/574.js +1 -0
  27. package/dist/591.js +2 -0
  28. package/dist/591.js.LICENSE.txt +32 -0
  29. package/dist/591.js.map +1 -0
  30. package/dist/644.js +1 -0
  31. package/dist/729.js +1 -0
  32. package/dist/729.js.map +1 -0
  33. package/dist/757.js +1 -0
  34. package/dist/784.js +2 -0
  35. package/dist/784.js.LICENSE.txt +9 -0
  36. package/dist/784.js.map +1 -0
  37. package/dist/788.js +1 -0
  38. package/dist/807.js +1 -0
  39. package/dist/833.js +1 -0
  40. package/dist/857.js +2 -0
  41. package/dist/857.js.LICENSE.txt +5 -0
  42. package/dist/857.js.map +1 -0
  43. package/dist/904.js +1 -0
  44. package/dist/904.js.map +1 -0
  45. package/dist/kenyaemr-esm-appointments-app.js +1 -0
  46. package/dist/kenyaemr-esm-appointments-app.js.buildmanifest.json +699 -0
  47. package/dist/kenyaemr-esm-appointments-app.js.map +1 -0
  48. package/dist/main.js +2 -0
  49. package/dist/main.js.LICENSE.txt +64 -0
  50. package/dist/main.js.map +1 -0
  51. package/dist/routes.json +1 -0
  52. package/jest.config.js +3 -0
  53. package/package.json +57 -0
  54. package/src/admin/appointment-services/appointment-services-hook.ts +31 -0
  55. package/src/admin/appointment-services/appointment-services-validation.ts +17 -0
  56. package/src/admin/appointment-services/appointment-services.component.tsx +182 -0
  57. package/src/admin/appointment-services/appointment-services.scss +25 -0
  58. package/src/appointments/appointment-tabs.component.tsx +48 -0
  59. package/src/appointments/appointment-tabs.scss +53 -0
  60. package/src/appointments/appointment-tabs.test.tsx +55 -0
  61. package/src/appointments/common-components/appointments-actions.component.tsx +86 -0
  62. package/src/appointments/common-components/appointments-actions.scss +4 -0
  63. package/src/appointments/common-components/appointments-actions.test.tsx +201 -0
  64. package/src/appointments/common-components/appointments-table.component.tsx +277 -0
  65. package/src/appointments/common-components/appointments-table.scss +133 -0
  66. package/src/appointments/common-components/appointments-table.test.tsx +134 -0
  67. package/src/appointments/common-components/checkin-button.component.tsx +43 -0
  68. package/src/appointments/common-components/end-appointment-modal.component.tsx +104 -0
  69. package/src/appointments/common-components/end-appointment-modal.test.tsx +80 -0
  70. package/src/appointments/common-components/location-select-option.component.tsx +48 -0
  71. package/src/appointments/details/appointment-details.component.tsx +91 -0
  72. package/src/appointments/details/appointment-details.scss +81 -0
  73. package/src/appointments/details/appointment-details.test.tsx +103 -0
  74. package/src/appointments/scheduled/appointments-list.component.tsx +33 -0
  75. package/src/appointments/scheduled/early-appointments.component.tsx +32 -0
  76. package/src/appointments/scheduled/scheduled-appointments.component.tsx +215 -0
  77. package/src/appointments/scheduled/scheduled-appointments.scss +4 -0
  78. package/src/appointments/unscheduled/unscheduled-appointments.component.tsx +146 -0
  79. package/src/appointments/unscheduled/unscheduled-appointments.test.tsx +131 -0
  80. package/src/appointments/utils.tsx +80 -0
  81. package/src/appointments.component.tsx +44 -0
  82. package/src/appointments.test.tsx +15 -0
  83. package/src/calendar/appointments-calendar-view-view.scss +24 -0
  84. package/src/calendar/appointments-calendar-view.component.tsx +36 -0
  85. package/src/calendar/appointments-calendar-view.test.tsx +22 -0
  86. package/src/calendar/header/calendar-header.component.tsx +34 -0
  87. package/src/calendar/header/calendar-header.scss +32 -0
  88. package/src/calendar/monthly/days-of-week.component.tsx +16 -0
  89. package/src/calendar/monthly/days-of-week.scss +33 -0
  90. package/src/calendar/monthly/monthly-calendar-view.component.tsx +34 -0
  91. package/src/calendar/monthly/monthly-header.module.scss +14 -0
  92. package/src/calendar/monthly/monthly-header.module.tsx +40 -0
  93. package/src/calendar/monthly/monthly-view-workload.scss +188 -0
  94. package/src/calendar/monthly/monthly-workload-view-expanded.component.tsx +42 -0
  95. package/src/calendar/monthly/monthly-workload-view.component.tsx +109 -0
  96. package/src/config-schema.ts +151 -0
  97. package/src/constants.ts +55 -0
  98. package/src/createDashboardLink.component.tsx +39 -0
  99. package/src/dashboard.meta.ts +21 -0
  100. package/src/declarations.d.ts +4 -0
  101. package/src/empty-state/empty-data-illustration.component.tsx +39 -0
  102. package/src/empty-state/empty-state.component.tsx +32 -0
  103. package/src/empty-state/empty-state.scss +69 -0
  104. package/src/form/appointments-form.component.tsx +891 -0
  105. package/src/form/appointments-form.resource.ts +165 -0
  106. package/src/form/appointments-form.scss +113 -0
  107. package/src/form/appointments-form.test.tsx +212 -0
  108. package/src/header/appointments-header.component.tsx +79 -0
  109. package/src/header/appointments-header.scss +95 -0
  110. package/src/header/appointments-illustration.component.tsx +22 -0
  111. package/src/helpers/excel.ts +61 -0
  112. package/src/helpers/functions.ts +82 -0
  113. package/src/helpers/index.ts +2 -0
  114. package/src/helpers/time.tsx +15 -0
  115. package/src/home/home-appointments.component.tsx +22 -0
  116. package/src/home/home-appointments.scss +10 -0
  117. package/src/hooks/patientAppointmentContext.ts +15 -0
  118. package/src/hooks/selectedDateContext.ts +10 -0
  119. package/src/hooks/useAppointmentList.ts +48 -0
  120. package/src/hooks/useAppointmentService.ts +11 -0
  121. package/src/hooks/useAppointmentsCalendar.ts +68 -0
  122. package/src/hooks/useClinicalMetrics.ts +79 -0
  123. package/src/hooks/useDefaultLocation.ts +14 -0
  124. package/src/hooks/useOverlay.tsx +45 -0
  125. package/src/hooks/usePatientAppointmentHistory.ts +49 -0
  126. package/src/hooks/useProviders.ts +18 -0
  127. package/src/hooks/useTodaysVisits.ts +19 -0
  128. package/src/hooks/useUnscheduledAppointments.ts +45 -0
  129. package/src/index.ts +111 -0
  130. package/src/metrics/appointments-metrics.component.tsx +71 -0
  131. package/src/metrics/appointments-metrics.scss +15 -0
  132. package/src/metrics/appointments-metrics.test.tsx +49 -0
  133. package/src/metrics/metrics-card.component.tsx +76 -0
  134. package/src/metrics/metrics-card.scss +77 -0
  135. package/src/metrics/metrics-header.component.tsx +62 -0
  136. package/src/metrics/metrics-header.scss +33 -0
  137. package/src/past-visit/encounter-list.component.tsx +54 -0
  138. package/src/past-visit/past-visit.component.tsx +106 -0
  139. package/src/past-visit/past-visit.resource.ts +25 -0
  140. package/src/past-visit/past-visit.scss +106 -0
  141. package/src/patient-appointments/patient-appointments-action-menu.component.tsx +65 -0
  142. package/src/patient-appointments/patient-appointments-action-menu.scss +7 -0
  143. package/src/patient-appointments/patient-appointments-base.component.tsx +165 -0
  144. package/src/patient-appointments/patient-appointments-base.scss +85 -0
  145. package/src/patient-appointments/patient-appointments-base.test.tsx +91 -0
  146. package/src/patient-appointments/patient-appointments-cancel-modal.component.tsx +66 -0
  147. package/src/patient-appointments/patient-appointments-detailed-summary.component.tsx +15 -0
  148. package/src/patient-appointments/patient-appointments-header.scss +27 -0
  149. package/src/patient-appointments/patient-appointments-header.tsx +42 -0
  150. package/src/patient-appointments/patient-appointments-overview.component.tsx +35 -0
  151. package/src/patient-appointments/patient-appointments-overview.scss +7 -0
  152. package/src/patient-appointments/patient-appointments-table.scss +0 -0
  153. package/src/patient-appointments/patient-appointments-table.tsx +128 -0
  154. package/src/patient-appointments/patient-appointments.resource.ts +72 -0
  155. package/src/patient-appointments/patient-upcoming-appointments-card.component.tsx +122 -0
  156. package/src/patient-appointments/patient-upcoming-appointments-card.scss +46 -0
  157. package/src/patient-search/patient-search.component.tsx +34 -0
  158. package/src/patient-search/patient-search.scss +23 -0
  159. package/src/root.component.tsx +26 -0
  160. package/src/root.scss +50 -0
  161. package/src/routes.json +153 -0
  162. package/src/scheduled-appointments-config-schema.ts +169 -0
  163. package/src/types/index.ts +189 -0
  164. package/src/workload/monthly-view-workload/monthly-view.component.tsx +69 -0
  165. package/src/workload/monthly-view-workload/monthly-workload.scss +223 -0
  166. package/src/workload/monthly-view-workload/monthlyWorkCard.tsx +45 -0
  167. package/src/workload/workload-card.component.tsx +31 -0
  168. package/src/workload/workload.component.tsx +47 -0
  169. package/src/workload/workload.resource.ts +78 -0
  170. package/src/workload/workload.scss +92 -0
  171. package/translations/am.json +148 -0
  172. package/translations/ar.json +148 -0
  173. package/translations/en.json +159 -0
  174. package/translations/es.json +148 -0
  175. package/translations/fr.json +148 -0
  176. package/translations/he.json +148 -0
  177. package/translations/km.json +148 -0
  178. package/translations/zh.json +148 -0
  179. package/translations/zh_CN.json +148 -0
  180. package/tsconfig.json +5 -0
  181. 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,4 @@
1
+ .switcher {
2
+ margin: 1rem 0;
3
+ max-width: 70%;
4
+ }
@@ -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;