diff --git a/packages/esm-patient-registration-app/src/patient-registration/field/address/address-field.component.tsx b/packages/esm-patient-registration-app/src/patient-registration/field/address/address-field.component.tsx index 4cbeed387..d0371c9a4 100644 --- a/packages/esm-patient-registration-app/src/patient-registration/field/address/address-field.component.tsx +++ b/packages/esm-patient-registration-app/src/patient-registration/field/address/address-field.component.tsx @@ -110,7 +110,7 @@ export const AddressComponent: React.FC = () => { style={{ margin: '0', minWidth: '100%' }} kind="error" lowContrast={true} - title={t('errorFetchingOrderedFields', 'Error occurred fetching ordered fields for address hierarchy')} + title={t('errorFetchingOrderedFields', 'Error occured fetching ordered fields for address hierarchy')} /> ); diff --git a/packages/esm-service-queues-app/src/index.ts b/packages/esm-service-queues-app/src/index.ts index 2232d92d6..eb73327f3 100644 --- a/packages/esm-service-queues-app/src/index.ts +++ b/packages/esm-service-queues-app/src/index.ts @@ -43,6 +43,11 @@ export const editQueueEntryStatusModal = getAsyncLifecycle( }, ); +export const patientInfoBannerSlot = getAsyncLifecycle(() => import('./patient-info/patient-info.component'), { + featureName: 'patient info slot', + moduleName, +}); + export const removeQueueEntry = getAsyncLifecycle( () => import('./remove-queue-entry-dialog/remove-queue-entry.component'), { @@ -164,14 +169,6 @@ export const activeVisitsRowActions = getAsyncLifecycle( }, ); -export const patientBannerQueueEntryStatus = getAsyncLifecycle( - () => import('./patient-info/patient-banner-queue-entry-status.extension'), - { - featureName: 'patient-info-queue-entry-status', - moduleName, - }, -); - export function startupApp() { registerBreadcrumbs([]); diff --git a/packages/esm-service-queues-app/src/patient-info/appointment-details.component.tsx b/packages/esm-service-queues-app/src/patient-info/appointment-details.component.tsx new file mode 100644 index 000000000..ca064b56f --- /dev/null +++ b/packages/esm-service-queues-app/src/patient-info/appointment-details.component.tsx @@ -0,0 +1,98 @@ +import React from 'react'; +import dayjs from 'dayjs'; +import { useTranslation } from 'react-i18next'; +import { InlineLoading } from '@carbon/react'; +import { formatDatetime, parseDate, type Visit } from '@openmrs/esm-framework'; +import { useAppointments } from './appointments.resource'; +import styles from './appointment-details.scss'; +import { usePastVisits } from '../past-visit/past-visit.resource'; +import { type Appointment } from '../types'; + +interface AppointmentDetailsProps { + patientUuid: string; +} + +interface PastAppointmentDetailsProps { + pastVisit: Visit; + isLoading: boolean; +} + +interface UpcomingAppointmentDetailsProps { + upcomingAppointment: Appointment; + isLoading: boolean; +} + +const PastAppointmentDetails: React.FC = ({ pastVisit, isLoading }) => { + const { t } = useTranslation(); + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (pastVisit) { + return ( +
+ +

{t('lastEncounter', 'Last encounter')}

+

+ {formatDatetime(parseDate(pastVisit?.startDatetime))} ·{' '} + {pastVisit?.visitType ? pastVisit?.visitType?.display : '--'} ·{' '} + {pastVisit?.location ? pastVisit.location?.display : '--'} +

+
+
+ ); + } + return ( +

{t('noLastEncounter', 'There is no last encounter to display for this patient')}

+ ); +}; + +const UpcomingAppointmentDetails: React.FC = ({ upcomingAppointment, isLoading }) => { + const { t } = useTranslation(); + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (upcomingAppointment) { + return ( +
+ +

{t('returnDate', 'Return Date')}

+

+ {formatDatetime(parseDate(upcomingAppointment?.startDateTime))} ·{' '} + {upcomingAppointment?.service ? upcomingAppointment?.service?.name : '--'} ·{' '} + {upcomingAppointment?.location ? upcomingAppointment?.location?.name : '--'}{' '} +

+
+
+ ); + } + + return

{t('noReturnDate', 'There is no return date to display for this patient')}

; +}; + +const AppointmentDetails: React.FC = ({ patientUuid }) => { + const { t } = useTranslation(); + const startDate = dayjs(new Date().toISOString()).subtract(6, 'month').toISOString(); + const { upcomingAppointment, isLoading } = useAppointments(patientUuid, startDate); + const { visits, isLoading: loading } = usePastVisits(patientUuid); + + return ( + <> + + + + ); +}; + +export default AppointmentDetails; diff --git a/packages/esm-service-queues-app/src/patient-info/appointment-details.scss b/packages/esm-service-queues-app/src/patient-info/appointment-details.scss new file mode 100644 index 000000000..82846f986 --- /dev/null +++ b/packages/esm-service-queues-app/src/patient-info/appointment-details.scss @@ -0,0 +1,34 @@ +@use '@carbon/colors'; +@use '@carbon/layout'; +@use '@carbon/type'; +@use '@openmrs/esm-styleguide/src/vars' as *; + +.widgetCard { + border: 1px solid colors.$blue-20; + border-left: none; + border-right: none; + background-color: colors.$blue-10; + padding: layout.$spacing-05; +} + +.title { + @include type.type-style('heading-compact-01'); + color: $text-02; + margin-bottom: layout.$spacing-02; +} + +.subtitle { + color: $text-02; + @include type.type-style('body-compact-01'); +} + +.content { + @include type.type-style('heading-compact-01'); + background-color: colors.$blue-10; + padding: layout.$spacing-05; + border: 1px solid colors.$blue-20; +} + +.tile { + text-align: center; +} diff --git a/packages/esm-service-queues-app/src/patient-info/appointment-details.test.tsx b/packages/esm-service-queues-app/src/patient-info/appointment-details.test.tsx new file mode 100644 index 000000000..fa700b1e9 --- /dev/null +++ b/packages/esm-service-queues-app/src/patient-info/appointment-details.test.tsx @@ -0,0 +1,41 @@ +import React from 'react'; +import { screen } from '@testing-library/react'; +import { mockPatient, renderWithSwr } from 'tools'; +import { useAppointments } from './appointments.resource'; +import { usePastVisits } from './../past-visit/past-visit.resource'; +import AppointmentDetails from './appointment-details.component'; + +const testProps = { + patientUuid: mockPatient.id, +}; + +const mockUseAppointments = jest.mocked(useAppointments); +const mockUsePastVisits = jest.mocked(usePastVisits); + +jest.mock('./../past-visit/past-visit.resource', () => ({ + usePastVisits: jest.fn(), +})); + +jest.mock('./appointments.resource', () => ({ + useAppointments: jest.fn(), +})); + +describe('RecentandUpcomingAppointments', () => { + it('renders no data if past and upcoming visit is empty', async () => { + mockUseAppointments.mockReturnValueOnce({ + upcomingAppointment: null, + error: null, + isLoading: false, + isValidating: false, + }); + mockUsePastVisits.mockReturnValueOnce({ visits: null, error: null, isLoading: false, isValidating: false }); + renderAppointments(); + + expect(screen.getByText(/there is no last encounter to display for this patient/i)).toBeInTheDocument(); + expect(screen.getByText(/there is no return date to display for this patient/i)).toBeInTheDocument(); + }); +}); + +const renderAppointments = () => { + renderWithSwr(); +}; diff --git a/packages/esm-service-queues-app/src/patient-info/appointments.resource.ts b/packages/esm-service-queues-app/src/patient-info/appointments.resource.ts new file mode 100644 index 000000000..338290b60 --- /dev/null +++ b/packages/esm-service-queues-app/src/patient-info/appointments.resource.ts @@ -0,0 +1,43 @@ +import dayjs from 'dayjs'; +import useSWR from 'swr'; +import { openmrsFetch, restBaseUrl } from '@openmrs/esm-framework'; +import { type AppointmentsFetchResponse } from '../types'; + +export const appointmentsSearchUrl = `${restBaseUrl}/appointments/search`; + +export function useAppointments(patientUuid: string, startDate: string) { + const abortController = new AbortController(); + + const fetcher = () => + openmrsFetch(appointmentsSearchUrl, { + method: 'POST', + signal: abortController.signal, + headers: { + 'Content-Type': 'application/json', + }, + body: { + patientUuid: patientUuid, + startDate: startDate, + }, + }); + + const { data, error, isLoading, isValidating } = useSWR( + appointmentsSearchUrl, + fetcher, + ); + + const appointments = data?.data?.length + ? data.data.sort((a, b) => (b.startDateTime > a.startDateTime ? 1 : -1)) + : null; + + const upcomingAppointment = appointments?.find((appointment) => + dayjs((appointment.startDateTime / 1000) * 1000).isAfter(dayjs()), + ); + + return { + upcomingAppointment: upcomingAppointment ? upcomingAppointment : null, + error, + isLoading, + isValidating, + }; +} diff --git a/packages/esm-service-queues-app/src/patient-info/hooks/usePatientAttributes.tsx b/packages/esm-service-queues-app/src/patient-info/hooks/usePatientAttributes.tsx new file mode 100644 index 000000000..2e4ce0185 --- /dev/null +++ b/packages/esm-service-queues-app/src/patient-info/hooks/usePatientAttributes.tsx @@ -0,0 +1,42 @@ +import { openmrsFetch, restBaseUrl, useConfig, type Patient } from '@openmrs/esm-framework'; +import useSWRImmutable from 'swr/immutable'; +import { type ConfigObject } from '../../config-schema'; +import { type Attribute } from '../../types'; + +const customRepresentation = + 'custom:(uuid,display,identifiers:(identifier,uuid,preferred,location:(uuid,name),identifierType:(uuid,name,format,formatDescription,validator)),person:(uuid,display,gender,birthdate,dead,age,deathDate,birthdateEstimated,causeOfDeath,preferredName:(uuid,preferred,givenName,middleName,familyName),attributes,preferredAddress:(uuid,preferred,address1,address2,cityVillage,longitude,stateProvince,latitude,country,postalCode,countyDistrict,address3,address4,address5,address6,address7)))'; + +/** + * React hook that takes a patientUuid and returns patient attributes {@link Attribute} + * @param patientUuid Unique patient identifier + * @returns An object containing patient attributes, an `isLoading` boolean and an `error` object + */ +export const usePatientAttributes = (patientUuid: string) => { + const { data, error, isLoading } = useSWRImmutable<{ data: Patient }>( + `${restBaseUrl}/patient/${patientUuid}?v=${customRepresentation}`, + openmrsFetch, + ); + + return { + isLoading, + attributes: data?.data.person.attributes ?? [], + error: error, + }; +}; +/** + * React hook that takes patientUuid {@link string} and return contact details + * derived from patient-attributes using configured attributeTypes + * @param patientUuid Unique patient identifier {@type string} + * @returns Object containing `contactAttribute` {@link Attribute} loading status + */ +export const usePatientContactAttributes = (patientUuid: string) => { + const { contactAttributeType } = useConfig(); + const { attributes, isLoading } = usePatientAttributes(patientUuid); + const contactAttributes = attributes.filter( + ({ attributeType }) => contactAttributeType?.some((uuid) => attributeType.uuid === uuid), + ); + return { + contactAttributes: contactAttributes ?? [], + isLoading, + }; +}; diff --git a/packages/esm-service-queues-app/src/patient-info/patient-banner-queue-entry-status.extension.tsx b/packages/esm-service-queues-app/src/patient-info/patient-banner-queue-entry-status.extension.tsx deleted file mode 100644 index d40ce740b..000000000 --- a/packages/esm-service-queues-app/src/patient-info/patient-banner-queue-entry-status.extension.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import { Tag , Button } from '@carbon/react'; -import React from 'react'; -import { useQueueEntries } from '../hooks/useQueueEntries'; -import styles from './patient-banner-queue-entry-status.scss'; -import { useTranslation } from 'react-i18next'; -import { isDesktop, showModal, useLayoutType } from '@openmrs/esm-framework'; - -// See: patient-banner-patient-info.component.tsx -interface PatientBannerQueueEntryStatusProps { - patientUuid: string; - renderedFrom: string; -} - -/** - * This extension appears in the patient banner to indicate the patient's - * queue entry status, with a quick link to transition them to q new queue / status - */ -const PatientBannerQueueEntryStatus: React.FC = ({ patientUuid, renderedFrom }) => { - const { queueEntries } = useQueueEntries({ patient: patientUuid, isEnded: false }); - const layout = useLayoutType(); - const queueEntry = queueEntries?.[0]; - const { t } = useTranslation(); - const isPatientChart = renderedFrom === 'patient-chart'; - if (!isPatientChart || !queueEntry) { - return <>; - } - - const mappedPriority = queueEntry.priority.display === 'Urgent' ? 'Priority' : queueEntry.priority.display; - - return ( -
- · - {queueEntry.queue.name} - - {mappedPriority} - - -
- ); -}; - -// The color of the priority tag should not be hard coded, see: -// https://openmrs.atlassian.net/browse/O3-4469 -const getTagType = (priority: string) => { - switch (priority) { - case 'emergency': - return 'red'; - case 'not urgent': - return 'green'; - default: - return 'gray'; - } -}; - -export default PatientBannerQueueEntryStatus; diff --git a/packages/esm-service-queues-app/src/patient-info/patient-banner-queue-entry-status.scss b/packages/esm-service-queues-app/src/patient-info/patient-banner-queue-entry-status.scss deleted file mode 100644 index 9eba9de28..000000000 --- a/packages/esm-service-queues-app/src/patient-info/patient-banner-queue-entry-status.scss +++ /dev/null @@ -1,29 +0,0 @@ -@use '@carbon/colors'; -@use '@carbon/layout'; -@use '@carbon/type'; -@use '@openmrs/esm-styleguide/src/vars' as *; - -.queueEntryStatusContainer { - display: flex; - align-items: baseline; - gap: layout.$spacing-02; -} - -.navDivider { - background-color: var(--brand-03); - width: 0.05rem; - height: 1.5rem; -} - -.tag { - margin: layout.$spacing-02 0; - min-width: layout.$spacing-10; -} - -.priorityTag { - @extend .tag; - @include type.type-style('label-01'); - color: #943d00; - background-color: #ffc9a3; - min-width: layout.$spacing-10; -} diff --git a/packages/esm-service-queues-app/src/patient-info/patient-info.component.tsx b/packages/esm-service-queues-app/src/patient-info/patient-info.component.tsx new file mode 100644 index 000000000..798cb6c52 --- /dev/null +++ b/packages/esm-service-queues-app/src/patient-info/patient-info.component.tsx @@ -0,0 +1,84 @@ +import React, { useState } from 'react'; +import classNames from 'classnames'; +import { useTranslation } from 'react-i18next'; +import { ClickableTile } from '@carbon/react'; +import { Edit } from '@carbon/react/icons'; +import { + age, + ConfigurableLink, + formatDate, + getPatientName, + parseDate, + PatientBannerContactDetails, + PatientBannerToggleContactDetailsButton, + PatientPhoto, +} from '@openmrs/esm-framework'; +import AppointmentDetails from './appointment-details.component'; +import styles from './patient-info.scss'; + +interface PatientInfoProps { + patient: fhir.Patient; + handlePatientInfoClick: () => void; +} + +const PatientInfo: React.FC = ({ patient, handlePatientInfoClick }) => { + const { t } = useTranslation(); + const patientName = getPatientName(patient); + const [showContactDetails, setShowContactDetails] = useState(false); + + const toggleShowMore = (e: React.MouseEvent) => { + e.stopPropagation(); + setShowContactDetails((prevState) => !prevState); + }; + + return ( + // TODO: Fix this warning: validateDOMNesting(...): cannot appear as a descendant of . + // This is because the ConfigurableLink is inside a ClickableTile. + +
+ +
+
+ {patientName} + + + +
+
+
+ + {(patient.gender ?? t('unknown', 'Unknown')).replace(/^\w/, (c) => c.toUpperCase())} ·{' '} + + {age(patient.birthDate)} · + {formatDate(parseDate(patient.birthDate), { mode: 'wide', time: false })} +
+
+
+ + {patient.identifier.length ? patient.identifier.map((identifier) => identifier.value).join(', ') : '--'} + + +
+
+
+ {showContactDetails && ( + <> + + + + )} +
+ ); +}; + +export default PatientInfo; diff --git a/packages/esm-service-queues-app/src/patient-info/patient-info.scss b/packages/esm-service-queues-app/src/patient-info/patient-info.scss new file mode 100644 index 000000000..dd16d9d9a --- /dev/null +++ b/packages/esm-service-queues-app/src/patient-info/patient-info.scss @@ -0,0 +1,60 @@ +@use '@carbon/layout'; +@use '@carbon/type'; +@use '@openmrs/esm-styleguide/src/vars' as *; + +.container { + border-bottom: 0.0125rem solid $color-gray-30; + background-color: $ui-02; + padding: 0; +} + +.patientInfoContainer { + background-color: $ui-02; + padding: 0 0 0 layout.$spacing-05; + display: flex; + align-items: center; + min-height: 7rem; +} + +.activePatientInfoContainer { + background-color: $color-blue-10; + padding: 0 0 0 layout.$spacing-05; + display: flex; + align-items: center; + min-height: 7rem; +} + +.patientName { + @include type.type-style('heading-03'); +} + +.patientInfoContent { + margin: layout.$spacing-05 0 0 layout.$spacing-05; + width: 100%; +} + +.demographics { + @include type.type-style('body-compact-02'); + color: $text-02; + margin-top: layout.$spacing-02; +} + +.identifier { + @include type.type-style('body-compact-02'); + color: $ui-04; +} + +.patientInfoRow { + display: flex; + justify-content: space-between; + align-items: center; + + & > button { + min-height: layout.$spacing-07; + } +} + +.patientEditBtn { + color: $ui-05; + margin: layout.$spacing-03; +} diff --git a/packages/esm-service-queues-app/src/patient-info/patient-info.test.tsx b/packages/esm-service-queues-app/src/patient-info/patient-info.test.tsx new file mode 100644 index 000000000..63dcebe72 --- /dev/null +++ b/packages/esm-service-queues-app/src/patient-info/patient-info.test.tsx @@ -0,0 +1,32 @@ +import React from 'react'; +import { screen, render } from '@testing-library/react'; +import { age } from '@openmrs/esm-framework'; +import { mockPatient, mockPatientWithLongName, mockPatientWithoutFormattedName } from 'tools'; +import PatientInfo from './patient-info.component'; + +const mockAge = jest.mocked(age); + +describe('Patient info', () => { + it.each([ + [mockPatient, 'Wilson, John'], + [mockPatientWithLongName, 'family name, Some very long given name'], + [mockPatientWithoutFormattedName, 'given middle family name'], + ])(`should render patient info correctly`, async (patient, displayName) => { + mockAge.mockReturnValue('35'); + + renderPatientInfo(patient); + + expect(screen.getByText(new RegExp(displayName))).toBeInTheDocument(); + expect(screen.getByText(/35/)).toBeInTheDocument(); + expect(screen.getByText(/Male/i)).toBeInTheDocument(); + expect(screen.getByText(/04 — Apr — 1972/i)).toBeInTheDocument(); + expect(screen.getByText(/100732HE, 100GEJ/i)).toBeInTheDocument(); + + const showDetailsButton = screen.getByText('Patient Banner Toggle Contact Details Button'); + expect(showDetailsButton).toBeInTheDocument(); + }); +}); + +const renderPatientInfo = (patient) => { + render( {}} patient={patient} />); +}; diff --git a/packages/esm-service-queues-app/src/routes.json b/packages/esm-service-queues-app/src/routes.json index 24d9b67d6..f1dead9f7 100644 --- a/packages/esm-service-queues-app/src/routes.json +++ b/packages/esm-service-queues-app/src/routes.json @@ -40,6 +40,10 @@ "name": "service-queues-dashboard", "slot": "service-queues-dashboard-slot" }, + { + "name": "patient-info-banner-slot", + "component": "patientInfoBannerSlot" + }, { "name": "remove-queue-entry", "component": "removeQueueEntry" @@ -63,11 +67,6 @@ "name": "visit-form-queue-fields", "component": "visitFormQueueFields", "slot":"visit-form-bottom-slot" - }, - { - "name": "queue-patient-info-queue-entry-status", - "component": "patientBannerQueueEntryStatus", - "slot": "patient-banner-tags-slot" } ], "modals": [ diff --git a/packages/esm-service-queues-app/src/types/index.ts b/packages/esm-service-queues-app/src/types/index.ts index bb5e0f39d..7b36f6167 100644 --- a/packages/esm-service-queues-app/src/types/index.ts +++ b/packages/esm-service-queues-app/src/types/index.ts @@ -476,7 +476,6 @@ export interface QueueEntrySearchCriteria { service?: Array | string; status?: Array | string; isEnded: boolean; - patient?: string; } // TODO: The follow types match the types from backend. diff --git a/packages/esm-service-queues-app/translations/en.json b/packages/esm-service-queues-app/translations/en.json index 760138f01..f74de8495 100644 --- a/packages/esm-service-queues-app/translations/en.json +++ b/packages/esm-service-queues-app/translations/en.json @@ -20,7 +20,6 @@ "bp": "Bp", "call": "Call", "cancel": "Cancel", - "change": "Change", "checkedInPatients": "Checked in patients", "checkFilters": "Check the filters above", "clearAllQueueEntries": "Clear all queue entries?", @@ -43,6 +42,7 @@ "discard": "Discard", "dose": "Dose", "edit": "Edit", + "editPatientDetails": "Edit patient details", "editQueueEntry": "Edit queue entry", "editQueueEntryInstruction": "Edit fields of existing queue entry", "encounterType": "Encounter type", @@ -70,6 +70,8 @@ "indication": "Indication", "invalidQueue": "Invalid Queue", "invalidtableConfig": "Invalid table configuration", + "lastEncounter": "Last encounter", + "loading": "Loading...", "location": "Location", "maleLabelText": "Male", "medications": "Medications", @@ -82,6 +84,7 @@ "nextPage": "Next page", "noColumnsDefined": "No table columns defined. Check Configuration", "noEncountersFound": "No encounters found", + "noLastEncounter": "There is no last encounter to display for this patient", "noMedicationsFound": "No medications found", "noNotesFound": "No notes found", "noPatientsToDisplay": "No patients to display", @@ -90,6 +93,7 @@ "noPrioritiesForService": "The selected service does not have any allowed priorities. This is an error in configuration. Please contact your system administrator.", "noPrioritiesForServiceTitle": "No priorities available", "noPriorityFound": "No priority found", + "noReturnDate": "There is no return date to display for this patient", "noServicesConfigured": "No services configured", "noStatusConfigured": "No status configured", "notableConfig": "No table configuration", @@ -216,6 +220,7 @@ "undoQueueEntryTransitionSuccess": "Undo transition success", "undoTransition": "Undo transition", "unexpectedServerResponse": "Unexpected Server Response", + "unknown": "Unknown", "updateEntry": "Update entry", "useTodaysDate": "Use today's date", "visitTabs": "Visit tabs", diff --git a/yarn.lock b/yarn.lock index f5842fa56..09e34ed94 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3276,8 +3276,8 @@ __metadata: linkType: hard "@openmrs/esm-patient-common-lib@npm:next": - version: 9.2.1-pre.6870 - resolution: "@openmrs/esm-patient-common-lib@npm:9.2.1-pre.6870" + version: 9.2.2-pre.6922 + resolution: "@openmrs/esm-patient-common-lib@npm:9.2.2-pre.6922" dependencies: "@carbon/react": "npm:^1.12.0" lodash-es: "npm:^4.17.21" @@ -3286,7 +3286,7 @@ __metadata: "@openmrs/esm-framework": 6.x react: 18.x single-spa: 6.x - checksum: 10/0891a1c2b020d16c5dc57c69dd58bbcaac1ef081686a038a16a3b3fc18abe71b03fb26360b39dd421c862d7ec3f63ec70e4ccf0b08fc726a29188f55c6f6ec26 + checksum: 10/41001b0beba68c277b2013bd6457dc3c742436f188fec23ae3ea395bca3802c6aae725eac783a00110c434100615c1f41a7348b01a7789ded180d75bbb459d18 languageName: node linkType: hard