diff --git a/__mocks__/appointments.mock.ts b/__mocks__/appointments.mock.ts index 646735dc0..e852e520f 100644 --- a/__mocks__/appointments.mock.ts +++ b/__mocks__/appointments.mock.ts @@ -264,6 +264,7 @@ export const mockAppointmentsData = { ], }; +// TODO: still used by the queue-linelist-base-table test? export const mockMappedAppointmentsData = { data: [ { @@ -374,3 +375,73 @@ export const mockProviders = { }, ], }; + +export const mockUseAppointmentServiceData = [ + { + appointmentServiceId: 1, + name: 'Outpatient', + description: null, + speciality: {}, + startDateTime: new Date().toISOString(), + endTime: '', + maxAppointmentsLimit: null, + durationMins: 15, + location: {}, + uuid: 'e2ec9cf0-ec38-4d2b-af6c-59c82fa30b90', + color: '#006400', + initialAppointmentStatus: 'Scheduled', + creatorName: null, + weeklyAvailability: [ + { + dayOfWeek: 'MONDAY', + startTime: '07:00:00', + endTime: '20:00:00', + maxAppointmentsLimit: null, + uuid: '7c7c53c8-c104-40cc-9926-50fc6fe4c4c1', + }, + { + dayOfWeek: 'TUESDAY', + startTime: '07:00:00', + endTime: '20:00:00', + maxAppointmentsLimit: null, + uuid: '7683b94e-6c48-4132-b402-54837a8c0fb2', + }, + { + dayOfWeek: 'SUNDAY', + startTime: '07:00:00', + endTime: '20:00:00', + maxAppointmentsLimit: null, + uuid: '00be8427-0037-4984-8875-6a5a2bc57e8e', + }, + { + dayOfWeek: 'FRIDAY', + startTime: '07:00:00', + endTime: '20:00:00', + maxAppointmentsLimit: null, + uuid: 'af6b8d5b-be05-4e24-8601-30573f848bec', + }, + { + dayOfWeek: 'THURSDAY', + startTime: '07:00:00', + endTime: '20:00:00', + maxAppointmentsLimit: null, + uuid: 'eb35e91b-6909-41fe-9d09-750b83fb3b9c', + }, + { + dayOfWeek: 'SATURDAY', + startTime: '07:00:00', + endTime: '20:00:00', + maxAppointmentsLimit: null, + uuid: '7f6347fd-c514-4fd2-ab79-d7fd760bf82f', + }, + { + dayOfWeek: 'WEDNESDAY', + startTime: '07:00:00', + endTime: '20:00:00', + maxAppointmentsLimit: null, + uuid: 'dad83f54-a0a2-4ba9-819b-01e906c89b69', + }, + ], + serviceTypes: [{ duration: 15, name: 'Chemotherapy', uuid: '53d58ff1-0c45-4e2e-9bd2-9cc826cb46e1' }], + }, +]; diff --git a/package.json b/package.json index 3107d5204..b2a01d788 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@openmrs/esm-patient-management", - "version": "5.2.1", + "version": "6.0.0", "private": true, "description": "Patient management microfrontend for the OpenMRS 3.x frontend", "workspaces": [ @@ -11,21 +11,25 @@ "ci:publish": "yarn workspaces foreach --all --topological --exclude @openmrs/esm-patient-management npm publish --access public --tag latest", "ci:prepublish": "yarn workspaces foreach --all --topological --exclude @openmrs/esm-patient-management npm publish --access public --tag next", "release": "yarn workspaces foreach --all --topological version", - "verify": "turbo lint && turbo typescript && turbo test --color --concurrency=5", + "verify": "turbo lint typescript test --color --concurrency=2", "prettier": "prettier --config prettier.config.js --write \"packages/**/*.{ts,tsx,css,scss}\" \"e2e/**/*.ts\"", "test-e2e": "playwright test", "postinstall": "husky install", "extract-translations": "turbo extract-translations -- --config ../../tools/i18next-parser.config.js" }, "dependencies": { + "@hookform/resolvers": "^3.3.1", "classnames": "^2.3.2", + "react-hook-form": "^7.46.2", "swr": "^2.0.1", - "xlsx": "^0.18.5" + "xlsx": "^0.18.5", + "zod": "^3.22.2" }, "devDependencies": { "@babel/core": "^7.11.6", "@carbon/react": "~1.37.0", "@openmrs/esm-framework": "next", + "@openmrs/esm-patient-common-lib": "next", "@playwright/test": "1.40.1", "@swc/core": "^1.2.165", "@swc/jest": "^0.2.29", @@ -60,6 +64,7 @@ "jest": "^29.7.0", "jest-cli": "^29.7.0", "jest-environment-jsdom": "^29.7.0", + "lint-staged": "^15.2.1", "openmrs": "next", "prettier": "^3.1.1", "react": "^18.1.0", diff --git a/packages/esm-active-visits-app/package.json b/packages/esm-active-visits-app/package.json index f19e5205c..abc73a3fc 100644 --- a/packages/esm-active-visits-app/package.json +++ b/packages/esm-active-visits-app/package.json @@ -1,6 +1,6 @@ { "name": "@openmrs/esm-active-visits-app", - "version": "5.2.1", + "version": "6.0.0", "description": "Active visits widget microfrontend for the OpenMRS SPA", "browser": "dist/openmrs-esm-active-visits-app.js", "main": "src/index.ts", @@ -13,7 +13,7 @@ "debug": "npm run serve", "build": "webpack --mode production", "analyze": "webpack --mode=production --env.analyze=true", - "lint": "cross-env TIMING=1 eslint src --ext ts,tsx", + "lint": "cross-env eslint src --ext ts,tsx", "test": "cross-env TZ=UTC jest --config jest.config.js --verbose false --passWithNoTests --color", "test:watch": "cross-env TZ=UTC jest --watch --config jest.config.js --color", "coverage": "yarn test --coverage", diff --git a/packages/esm-active-visits-app/src/active-visits-widget/active-visits.component.tsx b/packages/esm-active-visits-app/src/active-visits-widget/active-visits.component.tsx index 1117893c5..beba61f66 100644 --- a/packages/esm-active-visits-app/src/active-visits-widget/active-visits.component.tsx +++ b/packages/esm-active-visits-app/src/active-visits-widget/active-visits.component.tsx @@ -1,4 +1,4 @@ -import React, { useMemo, useState, useCallback, type MouseEvent, type AnchorHTMLAttributes } from 'react'; +import React, { useMemo, useState, useCallback } from 'react'; import { DataTable, DataTableSkeleton, @@ -7,15 +7,15 @@ import { Pagination, Search, Table, - TableContainer, - TableHead, - TableRow, - TableHeader, TableBody, TableCell, - TableExpandRow, - TableExpandHeader, + TableContainer, TableExpandedRow, + TableExpandHeader, + TableExpandRow, + TableHead, + TableHeader, + TableRow, Tile, } from '@carbon/react'; import { useTranslation } from 'react-i18next'; @@ -29,10 +29,10 @@ import { ConfigurableLink, } from '@openmrs/esm-framework'; import { EmptyDataIllustration } from './empty-data-illustration.component'; -import { useActiveVisits, getOriginFromPathName } from './active-visits.resource'; +import { useActiveVisits } from './active-visits.resource'; import styles from './active-visits.scss'; -function ComputeExpensiveValue(t, config) { +function generateTableHeaders(t, config) { let headersIndex = 0; const headers = [ @@ -101,7 +101,7 @@ const ActiveVisitsTable = () => { const [pageSize, setPageSize] = useState(config?.activeVisits?.pageSize ?? 10); const { activeVisits, isLoading, isValidating, error } = useActiveVisits(); const [searchString, setSearchString] = useState(''); - const headerData = useMemo(() => ComputeExpensiveValue(t, config), [config, t]); + const headerData = useMemo(() => generateTableHeaders(t, config), [config, t]); const searchResults = useMemo(() => { if (activeVisits !== undefined && activeVisits.length > 0) { @@ -222,23 +222,27 @@ const ActiveVisitsTable = () => { {rows.map((row, index) => { - const visit = activeVisits.find((visit) => visit.id === row.id); + const currentVisit = activeVisits.find((visit) => visit.id === row.id); - if (!visit) { + if (!currentVisit) { return null; } - const patientLink = `$\{openmrsSpaBase}/patient/${visit.patientUuid}/chart/Patient%20Summary`; + const patientChartUrl = '${openmrsSpaBase}/patient/${patientUuid}/chart/Patient%20Summary'; return ( + data-testid={`activeVisitRow${currentVisit.patientUuid || 'unknown'}`}> {row.cells.map((cell) => ( - {cell.info.header === 'name' && visit.patientUuid ? ( - {cell.value} + {cell.info.header === 'name' && currentVisit.patientUuid ? ( + + {cell.value} + ) : ( cell.value )} @@ -252,8 +256,8 @@ const ActiveVisitsTable = () => { className={styles.visitSummaryContainer} name="visit-summary-slot" state={{ - visitUuid: visit.visitUuid, - patientUuid: visit.patientUuid, + patientUuid: currentVisit.patientUuid, + visitUuid: currentVisit.visitUuid, }} /> diff --git a/packages/esm-active-visits-app/src/active-visits-widget/active-visits.scss b/packages/esm-active-visits-app/src/active-visits-widget/active-visits.scss index d69cdd819..634559e78 100644 --- a/packages/esm-active-visits-app/src/active-visits-widget/active-visits.scss +++ b/packages/esm-active-visits-app/src/active-visits-widget/active-visits.scss @@ -23,6 +23,15 @@ background-color: $ui-02; } +.activeVisitsTable { + tbody tr[data-parent-row] { + // Don't show a bottom border on the last row so we don't end up with a double border from the activeVisitContainer + &:nth-last-of-type(2) > td { + border-bottom: none; + } + } +} + .backgroundDataFetchingIndicator { align-items: center; display: flex; @@ -62,16 +71,22 @@ margin: 1rem auto; } -.expandedActiveVisitRow > td > div { - max-height: max-content !important; -} +.expandedActiveVisitRow { + td { + padding: 0 2rem; -.expandedActiveVisitRow td { - padding: 0 2rem; -} + > div { + max-height: max-content !important; + } + } -.expandedActiveVisitRow th[colspan] td[colspan] > div:first-child { - padding: 0 1rem; + th[colspan] td[colspan] > div:first-child { + padding: 0 1rem; + } + + &:last-of-type th[colspan]:last-child { + border-bottom: none; + } } .action { diff --git a/packages/esm-active-visits-app/src/visits-summary/visit-detail-overview.scss b/packages/esm-active-visits-app/src/visits-summary/visit-detail-overview.scss index 7f3597ac5..4ddb40a87 100644 --- a/packages/esm-active-visits-app/src/visits-summary/visit-detail-overview.scss +++ b/packages/esm-active-visits-app/src/visits-summary/visit-detail-overview.scss @@ -17,6 +17,32 @@ color: $ui-05; } +.medicationRecord { + display: flex; + flex-direction: column; + justify-content: space-between; + + .bodyLong01 { + margin: 0.25rem 0; + } +} + +.medicationContainer { + background-color: $ui-01; + padding: 1rem; + width: 100% !important; +} + +.dosage { + @include type.type-style('heading-compact-01'); +} + +.metadata { + @include type.type-style('label-01'); + color: $text-02; + margin: spacing.$spacing-03 0 spacing.$spacing-05; +} + .visitsDetailWidgetContainer { background-color: $ui-background; width: 100%; @@ -50,6 +76,10 @@ tbody tr[data-parent-row]:nth-child(even) td { background-color: $ui-01; } + + td { + border-bottom: none !important; + } } .visitEmptyState { @@ -92,7 +122,7 @@ margin: 0 1rem; :global(.cds--tabs) { - max-height: fit-content; + max-height: 7rem; } } @@ -179,11 +209,6 @@ border-bottom: 1px solid $ui-03; } -.testSummaryExtension p { - @include type.type-style('body-01'); - color: #525252; -} - .actions { margin: 0 1rem; } @@ -199,6 +224,58 @@ } } +.notesContainer { + margin-bottom: 2rem; +} + +.noteText { + background-color: $ui-01; + padding: 1rem; + width: 100% !important; + white-space: pre-wrap; +} + +.desktopHeading, +.tabletHeading { + text-align: left; + text-transform: capitalize; + margin-bottom: spacing.$spacing-05; + + h4 { + @include type.type-style('heading-compact-02'); + color: $text-02; + + &:after { + content: ''; + display: block; + width: 2rem; + padding-top: 3px; + border-bottom: 0.375rem solid; + @include brand-03(border-bottom-color); + } + } +} + +.tile { + text-align: center; +} + +.emptyStateContent { + @include type.type-style('heading-compact-01'); + color: $text-02; + margin-top: spacing.$spacing-05; + margin-bottom: spacing.$spacing-03; +} + +.emptyStateContainer { + background-color: $ui-02; + border: 1px solid $ui-03; + width: 100%; + margin: 0 auto; + max-width: 95vw; + padding-bottom: 0; +} + // Overriding styles for RTL support html[dir='rtl'] { .visitsDetailHeaderContainer { diff --git a/packages/esm-active-visits-app/src/visits-summary/visit.resource.ts b/packages/esm-active-visits-app/src/visits-summary/visit.resource.ts index e9f2dc6a5..cee2b508d 100644 --- a/packages/esm-active-visits-app/src/visits-summary/visit.resource.ts +++ b/packages/esm-active-visits-app/src/visits-summary/visit.resource.ts @@ -1,4 +1,4 @@ -import { openmrsFetch, type Visit } from '@openmrs/esm-framework'; +import { openmrsFetch, type OpenmrsResource, type Visit } from '@openmrs/esm-framework'; import useSWR from 'swr'; export interface Encounter { @@ -72,7 +72,10 @@ export interface Observation { export interface Order { uuid: string; dateActivated: string; + dateStopped?: Date | null; dose: number; + dosingInstructions: string | null; + dosingType?: 'org.openmrs.FreeTextDosingInstructions' | 'org.openmrs.SimpleDosingInstructions'; doseUnits: { uuid: string; display: string; @@ -81,6 +84,7 @@ export interface Order { uuid: string; name: string; strength: string; + display: string; }; duration: number; durationUnits: { @@ -92,6 +96,9 @@ export interface Order { display: string; }; numRefills: number; + orderNumber: string; + orderReason: string | null; + orderReasonNonCoded: string | null; orderer: { uuid: string; person: { @@ -107,6 +114,8 @@ export interface Order { uuid: string; display: string; }; + quantity: number; + quantityUnits: OpenmrsResource; } export interface Note { diff --git a/packages/esm-active-visits-app/src/visits-summary/visits-components/medications-summary.component.tsx b/packages/esm-active-visits-app/src/visits-summary/visits-components/medications-summary.component.tsx index 83f4a8456..56c253396 100644 --- a/packages/esm-active-visits-app/src/visits-summary/visits-components/medications-summary.component.tsx +++ b/packages/esm-active-visits-app/src/visits-summary/visits-components/medications-summary.component.tsx @@ -2,7 +2,7 @@ import React from 'react'; import classNames from 'classnames'; import capitalize from 'lodash-es/capitalize'; import { useTranslation } from 'react-i18next'; -import { formatTime, parseDate } from '@openmrs/esm-framework'; +import { formatDate, formatTime, parseDate } from '@openmrs/esm-framework'; import { type OrderItem, getDosage } from '../visit.resource'; import styles from '../visit-detail-overview.scss'; @@ -16,36 +16,83 @@ const MedicationSummary: React.FC = ({ medications }) => return ( {medications.length > 0 ? ( - medications.map( - (medication: OrderItem, ind) => - medication.order?.dose && - medication.order?.orderType?.display === 'Drug Order' && ( - -

- {capitalize(medication.order.drug?.name)} —{' '} - {medication.order.doseUnits?.display} — {medication.order.route?.display} -
- {t('dose', 'Dose').toUpperCase()}{' '} - {getDosage(medication.order.drug?.strength, medication.order.dose).toLowerCase()}{' '} - — {medication.order.frequency?.display} —{' '} - {!medication.order.duration - ? t('orderIndefiniteDuration', 'Indefinite duration') - : t('orderDurationAndUnit', 'for {{duration}} {{durationUnit}}', { - duration: medication.order.duration, - durationUnit: medication.order.durationUnits?.display, - })} -
- {t('refills', 'Refills').toUpperCase()}{' '} - {medication.order.numRefills} -

-

- {formatTime(parseDate(medication.order.dateActivated))} ·{' '} - {medication.provider && medication.provider.name} ·{' '} - {medication.provider && medication.provider.role} -

-
- ), - ) +
+ {medications.map( + (medication, i) => + medication.order?.dose && + medication.order?.orderType?.display === 'Drug Order' && ( + +
+
+

+ {capitalize(medication?.order?.drug?.name)}{' '} + {medication?.order?.drug?.strength && ( + <>— {medication?.order?.drug?.strength?.toLowerCase()} + )}{' '} + {medication?.order?.doseUnits?.display && ( + <>— {medication?.order?.doseUnits?.display?.toLowerCase()} + )}{' '} +

+

+ {t('dose', 'Dose').toUpperCase()} {' '} + + {medication?.order?.dose} {medication?.order?.doseUnits?.display?.toLowerCase()} + {' '} + {medication.order?.route?.display && ( + — {medication?.order?.route?.display?.toLowerCase()} — + )} + {medication?.order?.frequency?.display?.toLowerCase()} —{' '} + {!medication?.order?.duration + ? t('orderIndefiniteDuration', 'Indefinite duration') + : t('orderDurationAndUnit', 'for {{duration}} {{durationUnit}}', { + duration: medication?.order?.duration, + durationUnit: medication?.order?.durationUnits?.display?.toLowerCase(), + })} + {medication?.order?.numRefills !== 0 && ( + + — {t('refills', 'Refills').toUpperCase()}{' '} + {medication?.order?.numRefills} + {''} + + )} + {medication?.order?.dosingInstructions && ( + — {medication?.order?.dosingInstructions?.toLocaleLowerCase()} + )} +

+

+ {medication?.order?.orderReasonNonCoded ? ( + + {t('indication', 'Indication').toUpperCase()}{' '} + {medication?.order?.orderReasonNonCoded} + + ) : null} + {medication?.order?.quantity ? ( + + — {t('quantity', 'Quantity').toUpperCase()}{' '} + {medication?.order?.quantity} + + ) : null} + {medication?.order?.dateStopped ? ( + + + {medication?.order?.quantity ? ` — ` : ''} {t('endDate', 'End date').toUpperCase()} + {' '} + {formatDate(new Date(medication?.order?.dateStopped))} + + ) : null} +

+
+
+ +

+ {formatTime(parseDate(medication?.order?.dateActivated))} + {medication?.provider?.name && <> · {medication?.provider?.name}} + {medication?.provider?.role && <>, {medication?.provider?.role}} +

+
+ ), + )} +
) : (

{t('noMedicationsFound', 'No medications found')} diff --git a/packages/esm-active-visits-app/src/visits-summary/visits-components/notes-summary.component.tsx b/packages/esm-active-visits-app/src/visits-summary/visits-components/notes-summary.component.tsx index 53d0e4d8f..a0b0b3796 100644 --- a/packages/esm-active-visits-app/src/visits-summary/visits-components/notes-summary.component.tsx +++ b/packages/esm-active-visits-app/src/visits-summary/visits-components/notes-summary.component.tsx @@ -1,7 +1,10 @@ import React from 'react'; import classNames from 'classnames'; import { useTranslation } from 'react-i18next'; +import { Layer, Tile } from '@carbon/react'; +import { isDesktop, useLayoutType } from '@openmrs/esm-framework'; import type { Note } from '../visit.resource'; +import { EmptyDataIllustration } from '../../active-visits-widget/empty-data-illustration.component'; import styles from '../visit-detail-overview.scss'; interface NotesSummaryProps { @@ -10,24 +13,38 @@ interface NotesSummaryProps { const NotesSummary: React.FC = ({ notes }) => { const { t } = useTranslation(); + const layout = useLayoutType(); return ( - - {notes.length > 0 ? ( - notes.map((note, index) => ( - -

+ <> + {notes.length ? ( + notes.map((note: Note, i) => ( +

+

{note.note}

-

- {note.time} · {note.provider.name} · {note.provider.role} +

+ {note.time} {note.provider.name ? · {note.provider.name} : null} + {note.provider.role ? · {note.provider.role} : null}

- +
)) ) : ( -

{t('noNotesFound', 'No notes found')}

+
+ + +
+

{t('notes', 'Notes')}

+
+ +

+ {t('noNotesToShowForPatient', 'There are no notes to display for this patient')} +

+
+
+
)} -
+ ); }; diff --git a/packages/esm-active-visits-app/src/visits-summary/visits-components/tests-summary.component.tsx b/packages/esm-active-visits-app/src/visits-summary/visits-components/tests-summary.component.tsx index be6d48485..43434cf3c 100644 --- a/packages/esm-active-visits-app/src/visits-summary/visits-components/tests-summary.component.tsx +++ b/packages/esm-active-visits-app/src/visits-summary/visits-components/tests-summary.component.tsx @@ -1,5 +1,4 @@ import React, { useMemo } from 'react'; -import classNames from 'classnames'; import { ExtensionSlot } from '@openmrs/esm-framework'; import { type Encounter } from '../visit.resource'; import styles from '../visit-detail-overview.scss'; @@ -8,13 +7,13 @@ const TestsSummary = ({ patientUuid, encounters }: { patientUuid: string; encoun const filter = useMemo(() => { const encounterIds = encounters.map((e) => `Encounter/${e.uuid}`); return ([entry]) => { - return encounterIds.includes(entry.encounter.reference); + return encounterIds.includes(entry.encounter?.reference); }; }, [encounters]); return ( -
- +
+
); }; diff --git a/packages/esm-active-visits-app/translations/en.json b/packages/esm-active-visits-app/translations/en.json index c548cd74e..c9bb3a0e6 100644 --- a/packages/esm-active-visits-app/translations/en.json +++ b/packages/esm-active-visits-app/translations/en.json @@ -6,23 +6,25 @@ "diagnoses": "Diagnoses", "dose": "Dose", "encounterType": "Encounter Type", + "endDate": "End date", "filterTable": "Filter table", "gender": "Gender", "idNumber": "ID Number", + "indication": "Indication", "medications": "Medications", "name": "Name", "noActiveVisitsForLocation": "There are no active visits to display for this location.", "noDiagnosesFound": "No diagnoses found", "noEncountersFound": "No encounters found", "noMedicationsFound": "No medications found", - "noNotesFound": "No notes found", + "noNotesToShowForPatient": "There are no notes to display for this patient", "noObservationsFound": "No observations found", "notes": "Notes", - "noVisitsFound": "No visits found", "noVisitsToDisplay": "No visits to display", "orderDurationAndUnit": "for {{duration}} {{durationUnit}}", "orderIndefiniteDuration": "Indefinite duration", "provider": "Provider", + "quantity": "Quantity", "refills": "Refills", "tests": "Tests", "thereIsNoInformationToDisplayHere": "There is no information to display here", diff --git a/packages/esm-appointments-app/package.json b/packages/esm-appointments-app/package.json index 9a56a8b2d..fff741606 100644 --- a/packages/esm-appointments-app/package.json +++ b/packages/esm-appointments-app/package.json @@ -1,6 +1,6 @@ { "name": "@openmrs/esm-appointments-app", - "version": "5.2.1", + "version": "6.0.0", "description": "Appointments front-end module for the OpenMRS SPA", "browser": "dist/openmrs-esm-appointments-app.js", "main": "src/index.ts", diff --git a/packages/esm-appointments-app/src/admin/appointment-services/appointment-services-hook.ts b/packages/esm-appointments-app/src/admin/appointment-services/appointment-services-hook.ts index 863de12ed..d12153c9e 100644 --- a/packages/esm-appointments-app/src/admin/appointment-services/appointment-services-hook.ts +++ b/packages/esm-appointments-app/src/admin/appointment-services/appointment-services-hook.ts @@ -1,12 +1,11 @@ import { openmrsFetch } from '@openmrs/esm-framework'; -import { amPm } from '../../helpers'; -import { AppointmentService } from '../../types'; +import { type AppointmentService } from '../../types'; const appointmentServiceInitialValue: AppointmentService = { appointmentServiceId: 0, creatorName: '', description: '', - durationMins: '', + durationMins: 0, endTime: '', initialAppointmentStatus: '', location: { uuid: '', display: '' }, diff --git a/packages/esm-appointments-app/src/appointments-calendar/patient-list/calenar-patient-list.scss b/packages/esm-appointments-app/src/appointments-calendar/patient-list/calenar-patient-list.scss deleted file mode 100644 index b9905134a..000000000 --- a/packages/esm-appointments-app/src/appointments-calendar/patient-list/calenar-patient-list.scss +++ /dev/null @@ -1,14 +0,0 @@ -@use '@carbon/styles/scss/type'; -@use '@carbon/colors'; - -.container { - margin: 1rem; -} - -.header { - margin-bottom: 1rem; - & > h2 { - @include type.type-style('heading-02'); - color: colors.$gray-100; - } -} diff --git a/packages/esm-appointments-app/src/appointments.component.tsx b/packages/esm-appointments-app/src/appointments.component.tsx index b32573ff2..4b6487d38 100644 --- a/packages/esm-appointments-app/src/appointments.component.tsx +++ b/packages/esm-appointments-app/src/appointments.component.tsx @@ -1,11 +1,11 @@ import React, { useState } from 'react'; import { useTranslation } from 'react-i18next'; import AppointmentTabs from './appointments/appointment-tabs.component'; -import AppointmentsHeader from './appointments-header/appointments-header.component'; -import AppointmentMetrics from './appointments-metrics/appointments-metrics.component'; +import AppointmentsHeader from './header/appointments-header.component'; +import AppointmentMetrics from './metrics/appointments-metrics.component'; import Overlay from './overlay.component'; -const ClinicalAppointments: React.FC = () => { +const Appointments: React.FC = () => { const { t } = useTranslation(); const [appointmentServiceType, setAppointmentServiceType] = useState(''); @@ -19,4 +19,4 @@ const ClinicalAppointments: React.FC = () => { ); }; -export default ClinicalAppointments; +export default Appointments; diff --git a/packages/esm-appointments-app/src/appointments/appointments-table.resource.ts b/packages/esm-appointments-app/src/appointments/appointments-table.resource.ts deleted file mode 100644 index cd940f582..000000000 --- a/packages/esm-appointments-app/src/appointments/appointments-table.resource.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { useMemo } from 'react'; -import useSWR from 'swr'; -import { openmrsFetch } from '@openmrs/esm-framework'; -import { AppointmentService, Appointment } from '../types'; -import { getAppointment, useAppointmentDate } from '../helpers'; -import isEmpty from 'lodash-es/isEmpty'; - -export function useAppointments(status?: string, forDate?: string) { - const { currentAppointmentDate } = useAppointmentDate(); - const startDate = forDate ? forDate : currentAppointmentDate; - const apiUrl = `/ws/rest/v1/appointment/appointmentStatus?forDate=${startDate}&status=${status}`; - const allAppointmentsUrl = `/ws/rest/v1/appointment/all?forDate=${startDate}`; - - const { data, error, isLoading, isValidating, mutate } = useSWR<{ data: Array }, Error>( - isEmpty(status) ? allAppointmentsUrl : apiUrl, - openmrsFetch, - ); - - const appointments = useMemo(() => data?.data?.map((appointment) => getAppointment(appointment)) ?? [], [data?.data]); - - return { - appointments, - isLoading, - isError: error, - isValidating, - mutate, - }; -} - -export function useServices() { - const apiUrl = `/ws/rest/v1/appointmentService/all/default`; - const { data, error, isLoading, isValidating } = useSWR<{ data: Array }, Error>( - apiUrl, - openmrsFetch, - ); - - return { - services: data ? data.data : [], - isLoading, - isError: error, - isValidating, - }; -} diff --git a/packages/esm-appointments-app/src/appointments/appointments.resources.ts b/packages/esm-appointments-app/src/appointments/appointments.resources.ts deleted file mode 100644 index 5afcf8883..000000000 --- a/packages/esm-appointments-app/src/appointments/appointments.resources.ts +++ /dev/null @@ -1,74 +0,0 @@ -import useSWR from 'swr'; -import { openmrsFetch } from '@openmrs/esm-framework'; -import { AppointmentService, AppointmentsFetchResponse, Provider } from '../types'; - -export const appointmentsSearchUrl = `/ws/rest/v1/appointments/search`; - -export function useAppointments(patientUuid: string, startDate: string) { - const abortController = new AbortController(); - /* - SWR isn't meant to make POST requests for data fetching. This is a consequence of the API only exposing this resource via POST. - This works but likely isn't recommended. - */ - 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; - - return { - data: data ? appointments : null, - isError: error, - isLoading, - isValidating, - }; -} - -export function useAppointmentService() { - const { data, error, isLoading } = useSWR<{ data: Array }, Error>( - `/ws/rest/v1/appointmentService/all/full`, - openmrsFetch, - ); - - return { - data: data ? data.data : null, - isError: error, - isLoading, - }; -} - -export function getAppointmentsByUuid(appointmentUuid: string) { - const abortController = new AbortController(); - - return openmrsFetch(`/ws/rest/v1/appointments/${appointmentUuid}`, { - signal: abortController.signal, - }); -} - -export function getAppointmentService(uuid) { - const abortController = new AbortController(); - - return openmrsFetch(`/ws/rest/v1/appointmentService?uuid=` + uuid, { - signal: abortController.signal, - }); -} - -export function getTimeSlots() { - //https://openmrs-spa.org/openmrs/ws/rest/v1/appointment/all?forDate=2020-03-02T21:00:00.000Z -} diff --git a/packages/esm-appointments-app/src/appointments/common-components/appointments-actions.component.tsx b/packages/esm-appointments-app/src/appointments/common-components/appointments-actions.component.tsx index f3eb9b643..354df5f0e 100644 --- a/packages/esm-appointments-app/src/appointments/common-components/appointments-actions.component.tsx +++ b/packages/esm-appointments-app/src/appointments/common-components/appointments-actions.component.tsx @@ -5,28 +5,28 @@ import utc from 'dayjs/plugin/utc'; import { Button, OverflowMenu, OverflowMenuItem } from '@carbon/react'; import { TaskComplete } from '@carbon/react/icons'; import { useTranslation } from 'react-i18next'; -import { launchOverlay } from '../../hooks/useOverlay'; -import { type MappedAppointment } from '../../types'; +import { closeOverlay, launchOverlay } from '../../hooks/useOverlay'; +import { type Appointment } from '../../types'; import { showModal } from '@openmrs/esm-framework'; import { useVisits } from '../../hooks/useVisits'; -import AppointmentForm from '../forms/create-edit-form/appointments-form.component'; +import AppointmentForm from '../../form/appointments-form.component'; import CheckInButton from './checkin-button.component'; -import DefaulterTracingForm from '../forms/defaulter-tracing-form/default-tracing-form.component'; dayjs.extend(utc); dayjs.extend(isToday); interface AppointmentActionsProps { visits: Array; - appointment: MappedAppointment; + appointment: Appointment; scheduleType: string; + mutate: () => void; } -const AppointmentActions: React.FC = ({ visits, appointment, scheduleType }) => { +const AppointmentActions: React.FC = ({ visits, appointment, mutate }) => { const { t } = useTranslation(); const { mutateVisit } = useVisits(); - const patientUuid = appointment.patientUuid; - const visitDate = dayjs(appointment.dateTime); + const patientUuid = appointment.patient.uuid; + const visitDate = dayjs(appointment.startDateTime); const isFutureAppointment = visitDate.isAfter(dayjs()); const isTodayAppointment = visitDate.isToday(); const hasActiveVisit = visits?.some((visit) => visit?.patient?.uuid === patientUuid && visit?.startDatetime); @@ -44,17 +44,12 @@ const AppointmentActions: React.FC = ({ visits, appoint }); }; - const handleOpenDefaulterForm = () => { - launchOverlay('CCC Defaulter tracing form', ); - }; - /** * Renders the appropriate visit status button based on the current appointment state. * @returns {JSX.Element} The rendered button. */ const renderVisitStatus = () => { const checkedOutText = t('checkedOut', 'Checked out'); - const followUpButtonText = t('launchFormUpForm', 'Follow up'); switch (true) { case hasCheckedOut: @@ -70,27 +65,10 @@ const AppointmentActions: React.FC = ({ visits, appoint ); case isTodayAppointment: { - const isAfterNoon = new Date().getHours() > 12; - - if (scheduleType === 'Pending' && isAfterNoon) { - return ( - - ); - } - return ; } default: - if (!isFutureAppointment) { - return ( - - ); - } return null; } }; @@ -105,7 +83,12 @@ const AppointmentActions: React.FC = ({ visits, appoint onClick={() => launchOverlay( t('editAppointments', 'Edit Appointment'), - , + , ) } /> diff --git a/packages/esm-appointments-app/src/appointments/common-components/appointments-actions.test.tsx b/packages/esm-appointments-app/src/appointments/common-components/appointments-actions.test.tsx index 555fa8b85..bf40d2121 100644 --- a/packages/esm-appointments-app/src/appointments/common-components/appointments-actions.test.tsx +++ b/packages/esm-appointments-app/src/appointments/common-components/appointments-actions.test.tsx @@ -1,16 +1,59 @@ import { render, screen } from '@testing-library/react'; import React from 'react'; import AppointmentActions from './appointments-actions.component'; -import { type MappedAppointment } from '../../types'; +import { type Appointment } from '../../types'; + +const appointment: Appointment = { + uuid: '7cd38a6d-377e-491b-8284-b04cf8b8c6d8', + appointmentNumber: '0000', + patient: { + identifier: '100GEJ', + identifiers: [], + name: 'John Wilson', + uuid: '8673ee4f-e2ab-4077-ba55-4980f408773e', + gender: 'M', + age: '35', + birthDate: '1986-04-03T00:00:00.000+0000', + phoneNumber: '0700000000', + }, + service: { + appointmentServiceId: 1, + name: 'Outpatient', + description: null, + startTime: '', + endTime: '', + maxAppointmentsLimit: null, + durationMins: null, + location: { + uuid: '8d6c993e-c2cc-11de-8d13-0010c6dffd0f', + }, + uuid: 'e2ec9cf0-ec38-4d2b-af6c-59c82fa30b90', + initialAppointmentStatus: 'Scheduled', + creatorName: null, + }, + provider: { + uuid: 'f9badd80-ab76-11e2-9e96-0800200c9a66', + person: { uuid: '24252571-dd5a-11e6-9d9c-0242ac150002', display: 'Dr James Cook' }, + }, + location: { name: 'HIV Clinic', uuid: '2131aff8-2e2a-480a-b7ab-4ac53250262b' }, + startDateTime: new Date().toISOString(), + appointmentKind: 'WalkIn', + status: 'Scheduled', + comments: 'Some comments', + additionalInfo: null, + providers: [{ uuid: '24252571-dd5a-11e6-9d9c-0242ac150002', display: 'Dr James Cook' }], + recurring: false, + voided: false, + teleconsultationLink: null, + extensions: [], +}; describe('AppointmentActions', () => { const defaultProps = { visits: [], - appointment: { - patientUuid: '123', - dateTime: new Date().toISOString(), - } as MappedAppointment, + appointment: appointment, scheduleType: 'Pending', + mutate: () => {}, }; beforeAll(() => { @@ -30,7 +73,7 @@ describe('AppointmentActions', () => { it('renders the correct button when the patient has checked out', () => { const visits = [ { - patient: { uuid: '123' }, + patient: { uuid: '8673ee4f-e2ab-4077-ba55-4980f408773e' }, startDatetime: new Date().toISOString(), stopDatetime: new Date().toISOString(), }, @@ -44,7 +87,7 @@ describe('AppointmentActions', () => { it('renders the correct button when the patient has an active visit and today is the appointment date', () => { const visits = [ { - patient: { uuid: '123' }, + patient: { uuid: '8673ee4f-e2ab-4077-ba55-4980f408773e' }, startDatetime: new Date().toISOString(), stopDatetime: null, }, @@ -68,11 +111,4 @@ describe('AppointmentActions', () => { const button = screen.getByRole('button', { name: /actions/i }); expect(button).toBeInTheDocument(); }); - - it('renders the correct button when the appointment is in the past or has not been scheduled', () => { - const props = { ...defaultProps, appointment: { ...defaultProps.appointment, dateTime: '2022-01-01' } }; - render(); - const button = screen.getByRole('button', { name: /follow up/i }); - expect(button).toBeInTheDocument(); - }); }); 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 854234f9a..4dcb932f7 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 @@ -25,7 +25,7 @@ import { Download } from '@carbon/react/icons'; import { EmptyState } from '../../empty-state/empty-state.component'; import { downloadAppointmentsAsExcel } from '../../helpers/excel'; import { launchOverlay } from '../../hooks/useOverlay'; -import { type MappedAppointment } from '../../types'; +import { type Appointment } from '../../types'; import { getPageSizes, useSearchResults } from '../utils'; import { type ConfigObject } from '../../config-schema'; import AppointmentDetails from '../details/appointment-details.component'; @@ -34,10 +34,10 @@ import PatientSearch from '../../patient-search/patient-search.component'; import styles from './appointments-table.scss'; interface AppointmentsTableProps { - appointments: Array; + appointments: Array; isLoading: boolean; tableHeading: string; - mutate?: () => void; + mutate: () => void; visits?: Array; scheduleType?: string; } @@ -46,6 +46,7 @@ const AppointmentsTable: React.FC = ({ appointments, isLoading, tableHeading, + mutate, visits, scheduleType, }) => { @@ -82,16 +83,18 @@ const AppointmentsTable: React.FC = ({ - {appointment.name} + templateParams={{ patientUuid: appointment.patient.uuid }}> + {appointment.patient.name} ), nextAppointmentDate: '--', - identifier: appointment.identifier, - dateTime: formatDatetime(new Date(appointment.dateTime)), - serviceType: appointment.serviceType, + identifier: appointment.patient.identifier, + dateTime: formatDatetime(new Date(appointment.startDateTime)), + serviceType: appointment.service.name, provider: appointment.provider, - actions: , + actions: ( + + ), })); if (isLoading) { @@ -130,7 +133,9 @@ const AppointmentsTable: React.FC = ({ onClick={() => downloadAppointmentsAsExcel( appointments, - `${tableHeading} Appointments ${formatDate(new Date(appointments[0]?.dateTime), { year: true })}`, + `${tableHeading} Appointments ${formatDate(new Date(appointments[0]?.startDateTime), { + year: true, + })}`, ) }> {t('download', 'Download')} diff --git a/packages/esm-appointments-app/src/appointments/common-components/appointments-table.test.tsx b/packages/esm-appointments-app/src/appointments/common-components/appointments-table.test.tsx index f14bd5e12..116edbfdc 100644 --- a/packages/esm-appointments-app/src/appointments/common-components/appointments-table.test.tsx +++ b/packages/esm-appointments-app/src/appointments/common-components/appointments-table.test.tsx @@ -1,7 +1,7 @@ import React from 'react'; import userEvent from '@testing-library/user-event'; import { render, screen } from '@testing-library/react'; -import { type MappedAppointment } from '../../types'; +import { type Appointment } from '../../types'; import { usePagination } from '@openmrs/esm-framework'; import { downloadAppointmentsAsExcel } from '../../helpers/excel'; import { launchOverlay } from '../../hooks/useOverlay'; @@ -9,26 +9,50 @@ import AppointmentsTable from './appointments-table.component'; import PatientSearch from '../../patient-search/patient-search.component'; // Define mock appointments data for testing purposes -const appointments: Array = [ +const appointments: Appointment = [ { - patientUuid: '1234', - name: 'John Smith', - identifier: '12345', - dateTime: '2023-04-13T12:00:00.000Z', - serviceType: 'Service', - provider: 'Dr. Jane Doe', - id: '1234', - age: '50', - gender: 'F', - providers: [], - appointmentKind: 'Scheduled', - appointmentNumber: '1234', - location: 'location', - phoneNumber: '1234567890', - status: 'status', - comments: 'some comments', - dob: '2020-04-13T12:00:00.000Z', - serviceUuid: '1234', + uuid: '7cd38a6d-377e-491b-8284-b04cf8b8c6d8', + appointmentNumber: '00001', + patient: { + identifier: '100GEJ', + identifiers: [], + name: 'John Wilson', + uuid: '8673ee4f-e2ab-4077-ba55-4980f408773e', + gender: 'M', + age: '35', + birthDate: '1986-04-03T00:00:00.000+0000', + phoneNumber: '0700000000', + }, + service: { + appointmentServiceId: 1, + name: 'Outpatient', + description: null, + startTime: '', + endTime: '', + maxAppointmentsLimit: null, + durationMins: null, + location: { + uuid: '8d6c993e-c2cc-11de-8d13-0010c6dffd0f', + }, + uuid: 'e2ec9cf0-ec38-4d2b-af6c-59c82fa30b90', + initialAppointmentStatus: 'Scheduled', + creatorName: null, + }, + provider: { + uuid: 'f9badd80-ab76-11e2-9e96-0800200c9a66', + person: { uuid: '24252571-dd5a-11e6-9d9c-0242ac150002', display: 'Dr James Cook' }, + }, + location: { name: 'HIV Clinic', uuid: '2131aff8-2e2a-480a-b7ab-4ac53250262b' }, + startDateTime: new Date().toISOString(), + appointmentKind: 'WalkIn', + status: 'Scheduled', + comments: 'Some comments', + additionalInfo: null, + providers: [{ uuid: '24252571-dd5a-11e6-9d9c-0242ac150002', display: 'Dr James Cook' }], + recurring: false, + voided: false, + teleconsultationLink: null, + extensions: [], }, ]; @@ -96,13 +120,9 @@ describe('AppointmentsBaseTable', () => { expect(screen.getByText('Patient name')).toBeInTheDocument(); expect(screen.getByText('Identifier')).toBeInTheDocument(); - expect(screen.getByText('Service Type')).toBeInTheDocument(); - expect(screen.getByText('Actions')).toBeInTheDocument(); - const patient = screen.getByText('John Smith'); + const patient = screen.getByText('John Wilson'); expect(patient).toBeInTheDocument(); expect(patient).toHaveAttribute('href', 'someUrl'); - expect(screen.getByText('12345')).toBeInTheDocument(); - expect(screen.getByText('Service')).toBeInTheDocument(); }); it('should update search string when search input is changed', async () => { diff --git a/packages/esm-appointments-app/src/appointments/common-components/checkin-button.component.tsx b/packages/esm-appointments-app/src/appointments/common-components/checkin-button.component.tsx index 36d378679..eccc4e4af 100644 --- a/packages/esm-appointments-app/src/appointments/common-components/checkin-button.component.tsx +++ b/packages/esm-appointments-app/src/appointments/common-components/checkin-button.component.tsx @@ -3,7 +3,7 @@ import { Button } from '@carbon/react'; import { useTranslation } from 'react-i18next'; import { launchOverlay } from '../../hooks/useOverlay'; import VisitForm from '../../patient-queue/visit-form/visit-form.component'; -import { type MappedAppointment } from '../../types'; +import { type Appointment } from '../../types'; import dayjs from 'dayjs'; import isToday from 'dayjs/plugin/isToday'; import utc from 'dayjs/plugin/utc'; @@ -12,14 +12,14 @@ dayjs.extend(isToday); interface CheckInButtonProps { patientUuid: string; - appointment: MappedAppointment; + appointment: Appointment; } const CheckInButton: React.FC = ({ appointment, patientUuid }) => { const { t } = useTranslation(); return ( <> - {(dayjs(appointment.dateTime).isAfter(dayjs()) || dayjs(appointment.dateTime).isToday()) && ( + {(dayjs(appointment.startDateTime).isAfter(dayjs()) || dayjs(appointment.startDateTime).isToday()) && (
diff --git a/packages/esm-appointments-app/src/appointments-calendar/weekly/weekly-workload-view.test.tsx b/packages/esm-appointments-app/src/calendar/weekly/weekly-workload-view.test.tsx similarity index 94% rename from packages/esm-appointments-app/src/appointments-calendar/weekly/weekly-workload-view.test.tsx rename to packages/esm-appointments-app/src/calendar/weekly/weekly-workload-view.test.tsx index 7a9e0ea6e..5f1915047 100644 --- a/packages/esm-appointments-app/src/appointments-calendar/weekly/weekly-workload-view.test.tsx +++ b/packages/esm-appointments-app/src/calendar/weekly/weekly-workload-view.test.tsx @@ -4,7 +4,7 @@ import WeeklyWorkloadView from './weekly-workload-view.component'; import { type CalendarType, type DailyAppointmentsCountByService } from '../../types'; import userEvent from '@testing-library/user-event'; import { render, screen } from '@testing-library/react'; -import { spaBasePath } from '../../constants'; +import { spaHomePage } from '../../constants'; import { navigate } from '@openmrs/esm-framework'; jest.mock('@openmrs/esm-framework', () => ({ @@ -48,7 +48,7 @@ describe('WeeklyWorkloadView Component', () => { await user.click(screen.getByText('HIV')); expect(navigate).toHaveBeenCalledWith({ - to: `${spaBasePath}/appointments/list/Thu, 17 Aug 2023 00:00:00 GMT/HIV`, + to: `${spaHomePage}/appointments/list/Thu, 17 Aug 2023 00:00:00 GMT/HIV`, }); }); diff --git a/packages/esm-appointments-app/src/config-schema.ts b/packages/esm-appointments-app/src/config-schema.ts index 3874349ac..5be477f19 100644 --- a/packages/esm-appointments-app/src/config-schema.ts +++ b/packages/esm-appointments-app/src/config-schema.ts @@ -1,5 +1,5 @@ import { Type, validators } from '@openmrs/esm-framework'; -import { spaBasePath } from './constants'; +import { spaHomePage } from './constants'; export const configSchema = { concepts: { @@ -49,7 +49,7 @@ export const configSchema = { appointmentsBaseUrl: { _type: Type.String, _description: 'Configurable alternative URL for the Appointments UI. Eg, the Bahmni Appointments UI URL', - _default: `${spaBasePath}`, + _default: `${spaHomePage}`, }, hiddenFormFields: { _type: Type.Array, @@ -68,7 +68,7 @@ export const configSchema = { }, customPatientChartUrl: { _type: Type.String, - _description: `Template URL that will be used when clicking on the patient name in the queues table. + _description: `Template URL that will be used when clicking on the patient name in the queues table. Available argument: patientUuid, openmrsSpaBase, openBase (openmrsSpaBase and openBase are available to any )`, _default: '${openmrsSpaBase}/patient/${patientUuid}/chart', diff --git a/packages/esm-appointments-app/src/constants.ts b/packages/esm-appointments-app/src/constants.ts index 4bccae4c0..6a81b56f8 100644 --- a/packages/esm-appointments-app/src/constants.ts +++ b/packages/esm-appointments-app/src/constants.ts @@ -1,5 +1,53 @@ export const spaRoot = window['getOpenmrsSpaBase']; export const basePath = '/appointments'; -export const spaBasePath = `${window.spaBase}/home`; +export const spaHomePage = ` ${window.getOpenmrsSpaBase()}home`; export const omrsDateFormat = 'YYYY-MM-DDTHH:mm:ss.SSSZZ'; export const appointmentLocationTagName = 'Appointment Location'; + +export const datePickerPlaceHolder = 'dd/mm/yyyy'; +export const dateFormat = 'DD/MM/YYYY'; +export const datePickerFormat = 'd/m/Y'; +export const weekDays = [ + { + id: 'MONDAY', + label: 'Monday', + labelCode: 'monday', + order: 0, + }, + { + id: 'TUESDAY', + label: 'Tuesday', + labelCode: 'tuesday', + order: 1, + }, + { + id: 'WEDNESDAY', + label: 'Wednesday', + labelCode: 'wednesday', + order: 2, + }, + { + id: 'THURSDAY', + label: 'Thursday', + labelCode: 'thursday', + order: 3, + }, + { + id: 'FRIDAY', + label: 'Friday', + labelCode: 'friday', + order: 4, + }, + { + id: 'SATURDAY', + label: 'Saturday', + labelCode: 'saturday', + order: 5, + }, + { + id: 'SUNDAY', + label: 'Sunday', + labelCode: 'sunday', + order: 6, + }, +]; diff --git a/packages/esm-appointments-app/src/form/appointments-form.component.tsx b/packages/esm-appointments-app/src/form/appointments-form.component.tsx new file mode 100644 index 000000000..29dd9c9e8 --- /dev/null +++ b/packages/esm-appointments-app/src/form/appointments-form.component.tsx @@ -0,0 +1,712 @@ +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import dayjs from 'dayjs'; +import { + Button, + ButtonSet, + DatePickerInput, + DatePicker, + Form, + InlineLoading, + Layer, + MultiSelect, + NumberInput, + Select, + SelectItem, + Stack, + RadioButtonGroup, + RadioButton, + TextArea, + TimePickerSelect, + TimePicker, + Toggle, +} from '@carbon/react'; +import { Controller, useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { z } from 'zod'; +import { useLocations, useSession, showSnackbar, useLayoutType, useConfig } from '@openmrs/esm-framework'; +import { convertTime12to24 } from '@openmrs/esm-patient-common-lib'; +import { saveAppointment, saveRecurringAppointments, useAppointmentService } from './appointments-form.resource'; +import Workload from '../workload/workload.component'; +import type { Appointment, AppointmentPayload, RecurringPattern } from '../types'; +import { type ConfigObject } from '../config-schema'; +import { dateFormat, datePickerFormat, datePickerPlaceHolder, weekDays } from '../constants'; +import styles from './appointments-form.scss'; + +const appointmentsFormSchema = z.object({ + duration: z.number(), + location: z.string().refine((value) => value !== ''), + appointmentStatus: z.string().optional(), + appointmentNote: z.string(), + appointmentType: z.string().refine((value) => value !== ''), + selectedService: z.string().refine((value) => value !== ''), + recurringPatternType: z.enum(['DAY', 'WEEK']), + recurringPatternPeriod: z.number(), + recurringPatternDaysOfWeek: z.array(z.string()), + selectedDaysOfWeekText: z.string().optional(), + startTime: z.string(), + timeFormat: z.enum(['AM', 'PM']), + appointmentDateTime: z.object({ + startDate: z.date(), + startDateText: z.string(), + recurringPatternEndDate: z.date().nullable(), + recurringPatternEndDateText: z.string().nullable(), + }), +}); + +type AppointmentFormData = z.infer; + +interface AppointmentsFormProps { + appointment?: Appointment; + recurringPattern?: RecurringPattern; + patientUuid?: string; + context: string; + closeWorkspace: () => void; + mutate: () => void; +} + +const AppointmentsForm: React.FC = ({ + appointment, + recurringPattern, + patientUuid, + context, + closeWorkspace, + mutate, +}) => { + const editedAppointmentTimeFormat = new Date(appointment?.startDateTime).getHours() >= 12 ? 'PM' : 'AM'; + const defaultTimeFormat = appointment?.startDateTime + ? editedAppointmentTimeFormat + : new Date().getHours() >= 12 + ? 'PM' + : 'AM'; + const { t } = useTranslation(); + const isTablet = useLayoutType() === 'tablet'; + const locations = useLocations(); + const session = useSession(); + const { data: services, isLoading } = useAppointmentService(); + const { appointmentStatuses, appointmentTypes, allowAllDayAppointments } = useConfig(); + + const [isRecurringAppointment, setIsRecurringAppointment] = useState(false); + const [isAllDayAppointment, setIsAllDayAppointment] = useState(false); + + const defaultRecurringPatternType = recurringPattern?.type || 'DAY'; + const defaultRecurringPatternPeriod = recurringPattern?.period || 1; + const defaultRecurringPatternDaysOfWeek = recurringPattern?.daysOfWeek || []; + + const [isSubmitting, setIsSubmitting] = useState(false); + + const defaultStartDate = appointment?.startDateTime ? new Date(appointment?.startDateTime) : new Date(); + const defaultEndDate = recurringPattern?.endDate ? new Date(recurringPattern?.endDate) : null; + const defaultEndDateText = recurringPattern?.endDate + ? dayjs(new Date(recurringPattern.endDate)).format(dateFormat) + : ''; + const defaultStartDateText = appointment?.startDateTime + ? dayjs(new Date(appointment.startDateTime)).format(dateFormat) + : dayjs(new Date()).format(dateFormat); + + const defaultAppointmentStartDate = appointment?.startDateTime + ? dayjs(new Date(appointment?.startDateTime)).format('hh:mm') + : dayjs(new Date()).format('hh:mm'); + + const defaultDuration = + appointment?.startDateTime && appointment?.endDateTime + ? dayjs(appointment.endDateTime).diff(dayjs(appointment.startDateTime), 'minutes') + : null; + + const { control, getValues, setValue, watch, handleSubmit } = useForm({ + mode: 'all', + resolver: zodResolver(appointmentsFormSchema), + defaultValues: { + location: appointment?.location?.uuid ?? session?.sessionLocation?.uuid ?? '', + appointmentNote: appointment?.comments || '', + appointmentStatus: appointment?.status || '', + appointmentType: appointment?.appointmentKind || '', + selectedService: appointment?.service?.name || '', + recurringPatternType: defaultRecurringPatternType, + recurringPatternPeriod: defaultRecurringPatternPeriod, + recurringPatternDaysOfWeek: defaultRecurringPatternDaysOfWeek, + startTime: defaultAppointmentStartDate, + duration: defaultDuration, + timeFormat: defaultTimeFormat, + appointmentDateTime: { + startDate: defaultStartDate, + startDateText: defaultStartDateText, + recurringPatternEndDate: defaultEndDate, + recurringPatternEndDateText: defaultEndDateText, + }, + }, + }); + + const handleMultiselectChange = (e) => { + setValue( + 'selectedDaysOfWeekText', + (() => { + if (e?.selectedItems?.length < 1) { + return t('daysOfWeek', 'Days of the week'); + } else { + return e.selectedItems + .map((weekDay) => { + return weekDay.label; + }) + .join(', '); + } + })(), + ); + setValue( + 'recurringPatternDaysOfWeek', + e.selectedItems.map((s) => { + return s.id; + }), + ); + }; + + const defaultSelectedDaysOfWeekText: string = (() => { + if (getValues('recurringPatternDaysOfWeek')?.length < 1) { + return t('daysOfWeek', 'Days of the week'); + } else { + return weekDays + .filter((weekDay) => getValues('recurringPatternDaysOfWeek').includes(weekDay.id)) + .map((weekDay) => { + return weekDay.label; + }) + .join(', '); + } + })(); + + // Same for creating and editing + const handleSaveAppointment = (data: AppointmentFormData) => { + setIsSubmitting(true); + + // Construct appointment payload + const appointmentPayload = constructAppointmentPayload(data); + + // Construct recurring pattern payload + const recurringAppointmentPayload = { + appointmentRequest: appointmentPayload, + recurringPattern: constructRecurringPattern(data), + }; + + const abortController = new AbortController(); + (isRecurringAppointment + ? saveRecurringAppointments(recurringAppointmentPayload, abortController) + : saveAppointment(appointmentPayload, abortController) + ).then( + ({ status }) => { + if (status === 200) { + setIsSubmitting(false); + closeWorkspace(); + mutate(); + + showSnackbar({ + isLowContrast: true, + kind: 'success', + subtitle: t('appointmentNowVisible', 'It is now visible on the Appointments page'), + title: + context === 'editing' + ? t('appointmentEdited', 'Appointment edited') + : t('appointmentScheduled', 'Appointment scheduled'), + }); + } + if (status === 204) { + setIsSubmitting(false); + showSnackbar({ + title: + context === 'editing' + ? t('appointmentEditError', 'Error editing appointment') + : t('appointmentFormError', 'Error scheduling appointment'), + kind: 'error', + isLowContrast: false, + subtitle: t('noContent', 'No Content'), + }); + } + }, + (error) => { + setIsSubmitting(false); + showSnackbar({ + title: + context === 'editing' + ? t('appointmentEditError', 'Error editing appointment') + : t('appointmentFormError', 'Error scheduling appointment'), + kind: 'error', + isLowContrast: false, + subtitle: error?.message, + }); + }, + ); + }; + + const constructAppointmentPayload = (data: AppointmentFormData): AppointmentPayload => { + const { + selectedService, + startTime, + timeFormat, + appointmentDateTime: { startDate }, + duration, + appointmentType: selectedAppointmentType, + location: userLocation, + appointmentNote, + appointmentStatus, + } = data; + + const serviceUuid = services?.find((service) => service.name === selectedService)?.uuid; + const [hours, minutes] = convertTime12to24(startTime, timeFormat); + const startDatetime = startDate.setHours(hours, minutes); + const endDatetime = dayjs(startDatetime).add(duration, 'minutes').toDate(); + + return { + appointmentKind: selectedAppointmentType, + status: appointmentStatus, + serviceUuid: serviceUuid, + startDateTime: dayjs(startDatetime).format(), + endDateTime: dayjs(endDatetime).format(), + providerUuid: session?.currentProvider?.uuid, + providers: [{ uuid: session.currentProvider.uuid }], + locationUuid: userLocation, + patientUuid: patientUuid, + comments: appointmentNote, + uuid: context === 'editing' ? appointment.uuid : undefined, + }; + }; + + const constructRecurringPattern = (data: AppointmentFormData): RecurringPattern => { + const { + appointmentDateTime: { recurringPatternEndDate }, + recurringPatternType, + recurringPatternPeriod, + recurringPatternDaysOfWeek, + } = data; + + const [hours, minutes] = [23, 59]; + const endDate = recurringPatternEndDate?.setHours(hours, minutes); + + return { + type: recurringPatternType, + period: recurringPatternPeriod, + endDate: endDate ? dayjs(endDate).format() : null, + daysOfWeek: recurringPatternDaysOfWeek, + }; + }; + + if (isLoading) + return ( + + ); + + return ( +
+ +
+ {t('location', 'Location')} +
+ + ( + + )} + /> + +
+
+
+ {t('service', 'Service')} + + ( + + )} + /> + +
+ +
+ {t('appointmentType_title', 'Appointment Type')} + + ( + + )} + /> + +
+ +
+ {t('recurringAppointment', 'Recurring Appointment')} + setIsRecurringAppointment(!isRecurringAppointment)} + /> +
+ +
+ {t('dateTime', 'Date & Time')} +
+ {isRecurringAppointment && ( +
+ {allowAllDayAppointments && ( + setIsAllDayAppointment(!isAllDayAppointment)} + toggled={isAllDayAppointment} + /> + )} + + ( + + { + onChange({ + startDate: new Date(startDate), + recurringPatternEndDate: new Date(endDate), + recurringPatternEndDateText: dayjs(new Date(endDate)).format(dateFormat), + startDateText: dayjs(new Date(startDate)).format(dateFormat), + }); + }}> + + + + + )} + /> + + + {!isAllDayAppointment && ( + + )} + + + ( + { + onChange(Number(e.target.value)); + }} + /> + )} + /> + + + + ( + onChange(type)} + valueSelected={value}> + + + + )} + /> + + + {watch('recurringPatternType') === 'WEEK' && ( +
+ ( + (item ? t(item.labelCode, item.label) : '')} + selectionFeedback="top-after-reopen" + sortItems={(items) => { + return items.sort((a, b) => a.order > b.order); + }} + initialSelectedItems={weekDays.filter((i) => { + return getValues('recurringPatternDaysOfWeek').includes(i.id); + })} + onChange={(e) => { + onChange(e); + handleMultiselectChange(e); + }} + /> + )} + /> +
+ )} +
+ )} + + {!isRecurringAppointment && ( +
+ {allowAllDayAppointments && ( + setIsAllDayAppointment(!isAllDayAppointment)} + toggled={isAllDayAppointment} + /> + )} + + ( + onChange({ ...value, startDate: date })}> + + + )} + /> + + + {!isAllDayAppointment && ( + + )} +
+ )} +
+
+ + {getValues('selectedService') && ( +
+ + + +
+ )} + + {context !== 'creating' ? ( +
+ {t('appointmentStatus', 'Appointment Status')} + + ( + + )} + /> + +
+ ) : null} + +
+ {t('note', 'Note')} + + ( + +