diff --git a/packages/esm-appointments-app/src/appointments/common-components/appointments-table.component.tsx b/packages/esm-appointments-app/src/appointments/common-components/appointments-table.component.tsx index 1af409af8b..ff806d6163 100644 --- a/packages/esm-appointments-app/src/appointments/common-components/appointments-table.component.tsx +++ b/packages/esm-appointments-app/src/appointments/common-components/appointments-table.component.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useMemo, useState } from 'react'; +import React, { useState, memo } from 'react'; import { useTranslation } from 'react-i18next'; import dayjs from 'dayjs'; import isToday from 'dayjs/plugin/isToday'; @@ -7,6 +7,7 @@ import { Button, DataTable, DataTableSkeleton, + Dropdown, Layer, OverflowMenu, OverflowMenuItem, @@ -62,109 +63,65 @@ interface AppointmentsTableProps { isLoading: boolean; tableHeading: string; hasActiveFilters?: boolean; + statusDropdownItems?: Array<{ id: string; name: string; display: string }>; + selectedStatusItem?: { id: string; name: string; display: string } | null; + onStatusChange?: ({ selectedItem }: { selectedItem: any }) => void; + responsiveSize?: string; } - -const AppointmentsTable: React.FC = ({ +const AppointmentsTable = memo(function AppointmentsTable({ appointments, isLoading, tableHeading, hasActiveFilters, -}) => { + statusDropdownItems = [], + selectedStatusItem, + onStatusChange, + responsiveSize: providedResponsiveSize, +}: AppointmentsTableProps) { const { t } = useTranslation(); - const [pageSize, setPageSize] = useState(25); + const [pageSize, setPageSize] = useState(10); const [searchString, setSearchString] = useState(''); const config = useConfig(); const { appointmentsTableColumns } = config; + const layout = useLayoutType(); + const responsiveSize = providedResponsiveSize || (isDesktop(layout) ? 'sm' : 'lg'); + const searchResults = useAppointmentSearchResults(appointments, searchString); const { results, goTo, currentPage } = usePagination(searchResults, pageSize); - const { customPatientChartUrl, patientIdentifierType } = useConfig(); const { visits } = useTodaysVisits(); - const [selectedAppointmentUuids, setSelectedAppointmentUuids] = useState(new Set()); - const layout = useLayoutType(); - const responsiveSize = isDesktop(layout) ? 'sm' : 'lg'; - - useEffect(() => { - setSelectedAppointmentUuids(new Set()); - }, [appointments]); + const { customPatientChartUrl, patientIdentifierType } = useConfig(); - const headerData = appointmentsTableColumns.map((columnKey) => ({ - header: t(columnKey, columnKey), - key: columnKey, + const headerData = appointmentsTableColumns.map((col) => ({ + key: col, + header: t(col, col), })); - const rowData = useMemo( - () => - results?.map((appointment) => ({ - id: appointment.uuid, - patientName: ( - - {appointment.patient.name} - - ), - nextAppointmentDate: '--', - identifier: patientIdentifierType - ? (appointment.patient[patientIdentifierType.replaceAll(' ', '')] ?? appointment.patient.identifier) - : appointment.patient.identifier, - dateTime: formatDatetime(new Date(appointment.startDateTime)), - serviceType: appointment.service.name, - location: appointment.location?.name, - provider: appointment.providers?.[0]?.name ?? '--', - status: , - appointment, - })), - [results, customPatientChartUrl, patientIdentifierType], - ); - - const appointmentUuidsWithChangeableStatus = useMemo(() => { - return appointments - .filter((appointment) => { - const visitDate = dayjs(appointment.startDateTime); - const isFutureAppointment = visitDate.isAfter(dayjs()); - const isTodayAppointment = visitDate.isToday(); - const hasActiveVisitToday = visits?.some( - (visit) => visit?.patient?.uuid === appointment.patient?.uuid && visit?.startDatetime, - ); - return isFutureAppointment || (isTodayAppointment && !hasActiveVisitToday); - }) - .map((appointment) => appointment.uuid); - }, [appointments, visits]); - - if (isLoading) { - return ; - } - - if (hasActiveFilters && !appointments?.length) { - return ( -
- - -

- {t('noMatchingAppointments', 'No matching appointments found')} -

-

{t('checkFilters', 'Check the filters above')}

-
-
-
- ); - } + const rowData = results.map((appointment) => { + const patientUuid = appointment.patient.uuid; + const visitDate = dayjs(appointment.startDateTime); + const isFuture = visitDate.isAfter(dayjs()); + const isToday = visitDate.isToday(); + const hasActiveVisit = visits?.some((v) => v.patient.uuid === patientUuid && v.startDatetime); - if (!appointments?.length) { - const translatedHeading = t(tableHeading); - return ( - launchCreateAppointmentForm(t)} - /> - ); - } + return { + id: appointment.uuid, + patientName: ( + + {appointment.patient.name} + + ), + identifier: patientIdentifierType + ? (appointment.patient[patientIdentifierType.replaceAll(' ', '')] ?? appointment.patient.identifier) + : appointment.patient.identifier, + dateTime: formatDatetime(parseDate(appointment.startDateTime)), + serviceType: appointment.service.name, + location: appointment.location?.name ?? '--', + provider: appointment.providers?.[0]?.name ?? '--', + status: , + _appointment: appointment, + _canEdit: isFuture || (isToday && !hasActiveVisit), + }; + }); return ( @@ -173,107 +130,81 @@ const AppointmentsTable: React.FC = ({

{`${t(tableHeading)} ${t('appointments', 'Appointments')}`}

- - {({ - rows, - headers, - getExpandHeaderProps, - getHeaderProps, - getRowProps, - getSelectionProps, - getTableProps, - getTableContainerProps, - getToolbarProps, - }) => ( - <> - - - 0} - totalSelected={selectedAppointmentUuids.size} - // TODO: add translation for Carbon's table batch actions - // https://openmrs.atlassian.net/browse/O3-5409 - onCancel={() => setSelectedAppointmentUuids(new Set())}> - { - const selectedAppointments = appointments.filter((app) => selectedAppointmentUuids.has(app.uuid)); - const closeModal = showModal('batch-change-appointments-statuses-modal', { - appointments: selectedAppointments, - closeModal: () => closeModal(), - }); - }}> - {t('changeStatus', 'Change status')} - - - - setSearchString((event as React.ChangeEvent).target.value)} - persistent - size={responsiveSize} - /> - - - +
+ setSearchString(e.target.value)} + className={styles.searchbar} + /> + + {statusDropdownItems.length > 0 && onStatusChange && ( +
+ item?.display ?? ''} + label={selectedStatusItem?.display || statusDropdownItems[0]?.display || t('all', 'All')} + selectedItem={selectedStatusItem || statusDropdownItems[0]} + onChange={onStatusChange} + type="inline" + size={responsiveSize} + titleText={t('filterByStatus', 'Filter by status')} + /> +
+ )} + + +
+ + {isLoading ? ( + + ) : ( + + {({ rows, headers, getTableProps, getHeaderProps, getRowProps }) => ( + - - 0 - } - onSelect={() => { - if (selectedAppointmentUuids.size < appointmentUuidsWithChangeableStatus.length) { - setSelectedAppointmentUuids(new Set(appointmentUuidsWithChangeableStatus)); - } else { - setSelectedAppointmentUuids(new Set()); - } - }} - /> + {headers.map((header) => ( - {header.header} + + {header.header} + ))} - + {t('actions', 'Actions')} - {rows.map((row) => { - const matchingAppointment = appointments.find((appointment) => appointment.uuid === row.id); - - if (!matchingAppointment) { - return null; - } - - const canChangeStatus = appointmentUuidsWithChangeableStatus.includes(matchingAppointment.uuid); - - return ( + {rows.length === 0 ? ( + + + + + + ) : ( + rows.map((row) => ( = ({ }} /> {row.cells.map((cell) => ( - {cell.value?.content ?? cell.value} + {cell.value} ))} - {canChangeStatus ? ( - + {row.cells._canEdit && ( + launchWorkspace2('appointments-form-workspace', { - patientUuid: matchingAppointment.patient.uuid, - appointment: matchingAppointment, + appointment: row.cells._appointment, + patientUuid: row.cells._appointment.patient.uuid, + context: 'editing', }) } /> - ) : null} + )} - {row.isExpanded ? ( - - + {row.isExpanded && ( + + - ) : ( - )} - ); - })} + )) + )}
- {rows.length === 0 ? ( -
- - -
-

{t('noAppointmentsToDisplay', 'No appointments to display')}

-

{t('checkFilters', 'Check the filters above')}

-
-
-
-
- ) : null} - - )} -
- { - goTo(page); - setPageSize(pageSize); - }} - totalItems={appointments.length ?? 0} - /> + )} +
+ )} + + {!isLoading && appointments.length > 0 && ( + { + goTo(page); + setPageSize(pageSize); + }} + /> + )}
); -}; +}); export default AppointmentsTable; diff --git a/packages/esm-appointments-app/src/appointments/common-components/appointments-table.scss b/packages/esm-appointments-app/src/appointments/common-components/appointments-table.scss index 3a73eacaf9..54fddc2310 100644 --- a/packages/esm-appointments-app/src/appointments/common-components/appointments-table.scss +++ b/packages/esm-appointments-app/src/appointments/common-components/appointments-table.scss @@ -33,7 +33,7 @@ justify-content: space-between; align-items: center; padding: layout.$spacing-04 0 layout.$spacing-04 layout.$spacing-05; - background-color: colors.$white-0; + background-color: colors.$gray-20; } .tabletHeading { @@ -73,7 +73,8 @@ .searchbar { input { - background-color: colors.$gray-10; + background-color: colors.$white-0; + border-bottom:none; } } @@ -84,8 +85,23 @@ .toolbar { display: flex; - justify-content: space-between; + justify-content: flex-start; + + justify-content: flex-start; + align-items: center; + gap: layout.$spacing-03; + flex-wrap: nowrap; +} + +.filterContainer { + white-space: nowrap; + flex-shrink: 0; + + :global(.cds--list-box__field) { + width: auto; + min-width: 150px; + } } .menuItem { diff --git a/packages/esm-appointments-app/src/appointments/scheduled/appointments-list.component.tsx b/packages/esm-appointments-app/src/appointments/scheduled/appointments-list.component.tsx index c83e00067d..c621087c84 100644 --- a/packages/esm-appointments-app/src/appointments/scheduled/appointments-list.component.tsx +++ b/packages/esm-appointments-app/src/appointments/scheduled/appointments-list.component.tsx @@ -10,6 +10,12 @@ interface AppointmentsListProps { appointmentServiceTypes?: Array; date: string; excludeCancelledAppointments?: boolean; + status?: string | null; + title: string; + statusDropdownItems?: Array<{ id: string; name: string; display: string }>; + selectedStatusItem?: { id: string; name: string; display: string } | null; + onStatusChange?: ({ selectedItem }) => void; + responsiveSize?: string; } /** @@ -20,6 +26,12 @@ const AppointmentsList: React.FC = ({ appointmentServiceTypes, date, excludeCancelledAppointments = false, + status, + title, + statusDropdownItems, + selectedStatusItem, + onStatusChange, + responsiveSize, }) => { const { t } = useTranslation(); const { status, title = t('todays', "Today's") } = useConfig(); @@ -44,6 +56,10 @@ const AppointmentsList: React.FC = ({ hasActiveFilters={appointmentServiceTypes?.length > 0} isLoading={isLoading} tableHeading={title} + statusDropdownItems={statusDropdownItems} + selectedStatusItem={selectedStatusItem} + onStatusChange={onStatusChange} + responsiveSize={responsiveSize} /> ); }; diff --git a/packages/esm-appointments-app/src/appointments/scheduled/scheduled-appointments.component.tsx b/packages/esm-appointments-app/src/appointments/scheduled/scheduled-appointments.component.tsx index 28ea0055e8..4a3331af21 100644 --- a/packages/esm-appointments-app/src/appointments/scheduled/scheduled-appointments.component.tsx +++ b/packages/esm-appointments-app/src/appointments/scheduled/scheduled-appointments.component.tsx @@ -1,14 +1,12 @@ import React, { useCallback, useEffect, useReducer, useRef, useState } from 'react'; import dayjs from 'dayjs'; import { useTranslation } from 'react-i18next'; -import { ContentSwitcher, Switch } from '@carbon/react'; import isSameOrBefore from 'dayjs/plugin/isSameOrBefore'; import { ExtensionSlot, Extension, useConnectedExtensions, type ConnectedExtension, - type ConfigObject, useLayoutType, isDesktop, useAssignedExtensions, @@ -19,28 +17,21 @@ import { type AppointmentPanelConfig } from '../../scheduled-appointments-config dayjs.extend(isSameOrBefore); -interface ScheduledAppointmentsProps { - appointmentServiceTypes?: Array; -} - type DateType = 'pastDate' | 'today' | 'futureDate'; - const scheduledAppointmentsPanelsSlot = 'scheduled-appointments-panels-slot'; -const ScheduledAppointments: React.FC = ({ appointmentServiceTypes }) => { +const ScheduledAppointments: React.FC<{ appointmentServiceTypes?: Array }> = ({ appointmentServiceTypes }) => { const { t } = useTranslation(); const { selectedDate } = useAppointmentsStore(); const layout = useLayoutType(); const responsiveSize = isDesktop(layout) ? 'sm' : 'md'; - // added to prevent auto-removal of translations for dynamic keys - // t('checkedIn', 'Checked in'); - // t('expected', 'Expected'); - - const [currentTab, setCurrentTab] = useState(null); + const [currentTab, setCurrentTab] = useState(null); const [dateType, setDateType] = useState('today'); - const scheduledAppointmentPanels = useAssignedExtensions(scheduledAppointmentsPanelsSlot); + + const scheduledAppointmentPanels = useConnectedExtensions(scheduledAppointmentsPanelsSlot); const { allowedExtensions, showExtension, hideExtension } = useAllowedExtensions(); + const shouldShowPanel = useCallback( (panel: Omit) => allowedExtensions[panel.name] ?? false, [allowedExtensions], @@ -49,80 +40,71 @@ const ScheduledAppointments: React.FC = ({ appointme useEffect(() => { const dayjsDate = dayjs(selectedDate); const now = dayjs(); - if (dayjsDate.isBefore(now, 'date')) { - setDateType('pastDate'); - } else if (dayjsDate.isAfter(now, 'date')) { - setDateType('futureDate'); - } else { - setDateType('today'); - } + if (dayjsDate.isBefore(now, 'date')) setDateType('pastDate'); + else if (dayjsDate.isAfter(now, 'date')) setDateType('futureDate'); + else setDateType('today'); }, [selectedDate]); useEffect(() => { - // This is intended to cover two things: - // 1. If no current tab is set, set it to the first allowed tab - // 2. If a current tab is set, but the tab is no longer allowed in this context, set it to the - // first allowed tab - if (allowedExtensions && (currentTab === null || !allowedExtensions[currentTab])) { - for (const extension of Object.getOwnPropertyNames(allowedExtensions)) { - if (allowedExtensions[extension]) { - setCurrentTab(extension); - break; - } - } + if (allowedExtensions && (currentTab === null || (currentTab !== 'all' && !allowedExtensions[currentTab]))) { + setCurrentTab('all'); } }, [allowedExtensions, currentTab]); const panelsToShow = scheduledAppointmentPanels.filter(shouldShowPanel); - return ( - <> - setCurrentTab(name as string)} - selectedIndex={panelsToShow.findIndex((panel) => panel.name == currentTab) ?? 0} - selectionMode="manual"> - {panelsToShow.map((panel) => ( - - {t(panel.config.title)} - - ))} - + const statusDropdownItems = [ + { + id: 'all', + name: 'all', + display: t('all', 'All'), + }, + ...panelsToShow.map((panel) => ({ + id: panel.name, + name: panel.name, + display: t(panel.config.title), + })), + ]; + + const selectedStatusItem = + statusDropdownItems.find((item) => item.name === currentTab) || (statusDropdownItems[0] ?? null); + + const handleStatusChange = useCallback(({ selectedItem }: { selectedItem: any }) => { + if (selectedItem?.name) { + setCurrentTab(selectedItem.name); + } + }, []); + const firstPanelToShow = panelsToShow[0]; + + return ( +
- {(extension) => { - return ( - - ); - }} + {(extension) => ( + + )} - +
); }; function useAllowedExtensions() { const [allowedExtensions, dispatch] = useReducer( (state: Record, action: { type: 'show_extension' | 'hide_extension'; extension: string }) => { - let addedState = {} as Record; - switch (action.type) { - case 'show_extension': - addedState[action.extension] = true; - break; - case 'hide_extension': - addedState[action.extension] = false; - break; - } - - return { ...state, ...addedState }; + return { ...state, [action.extension]: action.type === 'show_extension' }; }, {}, ); @@ -142,61 +124,68 @@ function ExtensionWrapper({ dateType, showExtensionTab, hideExtensionTab, + statusDropdownItems, + selectedStatusItem, + onStatusChange, + responsiveSize, + firstPanelToShow, }: { extension: ConnectedExtension; - currentTab: string; + currentTab: string | null; appointmentServiceTypes: Array; date: string; dateType: DateType; - showExtensionTab: (extension: string) => void; - hideExtensionTab: (extension: string) => void; + showExtensionTab: (ext: string) => void; + hideExtensionTab: (ext: string) => void; + statusDropdownItems: Array<{ id: string; name: string; display: string }>; + selectedStatusItem: { id: string; name: string; display: string } | null; + onStatusChange: ({ selectedItem }: { selectedItem: any }) => void; + responsiveSize: string; + firstPanelToShow: ConnectedExtension | undefined; }) { - const currentConfig = useRef(null); + const { t } = useTranslation(); + const currentConfig = useRef(extension.config); const currentDateType = useRef(dateType); - // This use effect hook controls whether the tab for this extension should render useEffect(() => { - if ( - currentConfig.current === null || - (currentConfig.current !== null && !shallowEqual(currentConfig.current, extension.config)) || - currentDateType.current !== dateType - ) { + const configChanged = !shallowEqual(currentConfig.current, extension.config); + const dateTypeChanged = currentDateType.current !== dateType; + + if (configChanged || dateTypeChanged || !currentConfig.current) { currentConfig.current = extension.config; currentDateType.current = dateType; - if (shouldDisplayExtensionTab(extension?.config as AppointmentPanelConfig, dateType)) { - showExtensionTab(extension.name); - } else { - hideExtensionTab(extension.name); - } } + + const shouldShow = shouldDisplayExtensionTab(extension.config, dateType); + shouldShow ? showExtensionTab(extension.name) : hideExtensionTab(extension.name); }, [extension, dateType, showExtensionTab, hideExtensionTab]); - if (extension.config == null) { - return null; - } + const isAllSelected = currentTab === 'all'; + const shouldShowExtension = isAllSelected ? firstPanelToShow?.name === extension.name : currentTab === extension.name; return (
+ style={{ display: shouldShowExtension ? 'block' : 'none' }} + className={styles.extensionWrapper}>
); } -function shouldDisplayExtensionTab(config: AppointmentPanelConfig | undefined, dateType: DateType): boolean { - if (!config) { - return false; - } - +function shouldDisplayExtensionTab(config: any, dateType: DateType): boolean { + if (!config) return false; switch (dateType) { case 'futureDate': return config.showForFutureDate ?? false; @@ -204,22 +193,18 @@ function shouldDisplayExtensionTab(config: AppointmentPanelConfig | undefined, d return config.showForPastDate ?? false; case 'today': return config.showForToday ?? false; + default: + return false; } } -function shallowEqual(objA: object, objB: object) { - if (Object.is(objA, objB)) { - return true; - } - - if (typeof objA !== 'object' || objA === null || typeof objB !== 'object' || objB === null) { - return false; - } - - const objAKeys = Object.getOwnPropertyNames(objA); - const objBKeys = Object.getOwnPropertyNames(objB); - - return objAKeys.length === objBKeys.length && objAKeys.every((key) => objA[key] === objB[key]); +function shallowEqual(a: any, b: any): boolean { + if (a === b) return true; + if (!a || !b || typeof a !== 'object' || typeof b !== 'object') return false; + const keysA = Object.keys(a); + const keysB = Object.keys(b); + if (keysA.length !== keysB.length) return false; + return keysA.every((key) => Object.prototype.hasOwnProperty.call(b, key) && a[key] === b[key]); } export default ScheduledAppointments; diff --git a/packages/esm-appointments-app/src/hooks/useAppointmentList.ts b/packages/esm-appointments-app/src/hooks/useAppointmentList.ts index 3400bc0a8a..d6ec894f54 100644 --- a/packages/esm-appointments-app/src/hooks/useAppointmentList.ts +++ b/packages/esm-appointments-app/src/hooks/useAppointmentList.ts @@ -4,26 +4,31 @@ import { openmrsFetch, restBaseUrl } from '@openmrs/esm-framework'; import { type AppointmentsFetchResponse } from '../types'; import { useAppointmentsStore } from '../store'; -export const useAppointmentList = (appointmentStatus: string, date?: string) => { +export const useAppointmentList = (appointmentStatus: string | null, date?: string) => { const { selectedDate } = useAppointmentsStore(); const startDate = date ? date : selectedDate; const endDate = dayjs(startDate).endOf('day').format('YYYY-MM-DDTHH:mm:ss.SSSZZ'); // TODO: fix? is this correct? const searchUrl = `${restBaseUrl}/appointments/search`; const abortController = new AbortController(); - const fetcher = ([url, startDate, endDate, status]) => - openmrsFetch(url, { + const fetcher = ([url, startDate, endDate, status]) => { + const body: { startDate: string; endDate: string; status?: string } = { + startDate: startDate, + endDate: endDate, + }; + + if (status) { + body.status = status; + } + return openmrsFetch(url, { method: 'POST', signal: abortController.signal, headers: { 'Content-Type': 'application/json', }, - body: { - startDate: startDate, - endDate: endDate, - status: status, - }, + body, }); + }; const { data, error, isLoading, mutate } = useSWR( [searchUrl, startDate, endDate, appointmentStatus],