diff --git a/packages/esm-ward-app/src/ward-workspace/patient-discharge/patient-discharge.scss b/packages/esm-ward-app/src/ward-workspace/patient-discharge/patient-discharge.scss index 6fdb87b7fc..373aa4cc48 100644 --- a/packages/esm-ward-app/src/ward-workspace/patient-discharge/patient-discharge.scss +++ b/packages/esm-ward-app/src/ward-workspace/patient-discharge/patient-discharge.scss @@ -1,3 +1,4 @@ +@use '@carbon/colors'; @use '@carbon/type'; @use '@carbon/layout'; @use '@openmrs/esm-styleguide/src/vars' as *; @@ -23,19 +24,19 @@ flex: 1; } -.productiveHeading02 { +.columnLabel { @include type.type-style('heading-compact-02'); + color: colors.$gray-70; + padding: layout.$spacing-02; } .buttonSet { - margin: 0 (-(layout.$spacing-05)) (-(layout.$spacing-05)) (-(layout.$spacing-05)); + display: flex; + align-items: center; + margin: ((layout.$spacing-05)) (-(layout.$spacing-05)) (-(layout.$spacing-05)) (-(layout.$spacing-05)); button { max-width: unset !important; - width: auto !important; - - > svg { - fill: currentColor !important; - } + width: 50% !important; } } diff --git a/packages/esm-ward-app/src/ward-workspace/patient-discharge/patient-discharge.test.tsx b/packages/esm-ward-app/src/ward-workspace/patient-discharge/patient-discharge.test.tsx new file mode 100644 index 0000000000..e4ec090c18 --- /dev/null +++ b/packages/esm-ward-app/src/ward-workspace/patient-discharge/patient-discharge.test.tsx @@ -0,0 +1,294 @@ +import React from 'react'; +import userEvent from '@testing-library/user-event'; +import { render, screen, waitFor } from '@testing-library/react'; +import { closeWorkspaceGroup2, showSnackbar, useAppContext } from '@openmrs/esm-framework'; +import { useCreateEncounter, removePatientFromBed } from '../../ward.resource'; +import PatientDischargeWorkspace from './patient-discharge.workspace'; +import { mockInpatientRequestAlice, mockPatientAlice, mockVisitAlice } from '__mocks__'; +import type { WardPatient, WardPatientWorkspaceDefinition } from '../../types'; + +const mockShowSnackbar = jest.mocked(showSnackbar); +const mockUseCreateEncounter = jest.mocked(useCreateEncounter); +const mockRemovePatientFromBed = jest.mocked(removePatientFromBed); +const mockUseAppContext = jest.mocked(useAppContext); +const mockCloseWorkspaceGroup2 = jest.mocked(closeWorkspaceGroup2); + +jest.mock('../../ward.resource', () => ({ + useCreateEncounter: jest.fn(), + removePatientFromBed: jest.fn(), +})); + +jest.mock('../patient-banner/patient-banner.component', () => () =>
patient-banner
); + +const mockWardPatient: WardPatient = { + patient: mockPatientAlice, + visit: mockVisitAlice, + bed: { + id: 1, + uuid: 'bed-1', + bedNumber: 'Bed 1', + bedType: { + uuid: 'bed-type', + name: 'General', + displayName: 'General', + description: '', + resourceVersion: '', + }, + row: 1, + column: 1, + status: 'OCCUPIED', + }, + inpatientAdmission: null as any, + inpatientRequest: mockInpatientRequestAlice as any, +}; + +const testProps: WardPatientWorkspaceDefinition = { + groupProps: { wardPatient: mockWardPatient }, + closeWorkspace: jest.fn(), + launchChildWorkspace: jest.fn(), + workspaceProps: null, + windowProps: null, + workspaceName: 'discharge', + windowName: 'discharge', + isRootWorkspace: false, +}; + +describe('PatientDischargeWorkspace', () => { + const mockCreateEncounter = jest.fn(); + const mockWardPatientMutate = jest.fn(); + + beforeEach(() => { + mockUseCreateEncounter.mockReturnValue({ + createEncounter: mockCreateEncounter, + emrConfiguration: { + consultFreeTextCommentsConcept: { uuid: 'consult-concept' }, + exitFromInpatientEncounterType: { uuid: 'exit-encounter' } as any, + } as any, + isLoadingEmrConfiguration: false, + errorFetchingEmrConfiguration: null, + }); + + mockUseAppContext.mockReturnValue({ + wardPatientGroupDetails: { mutate: mockWardPatientMutate }, + } as any); + }); + + it('renders discharge workspace with note field and action buttons', () => { + render(); + + expect(screen.getByPlaceholderText(/write any notes here/i)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Confirm discharge/i })).toBeInTheDocument(); + }); + + it('calls closeWorkspace when cancel button is clicked', async () => { + const user = userEvent.setup(); + + render(); + + await user.click(screen.getByRole('button', { name: /cancel/i })); + + expect(testProps.closeWorkspace).toHaveBeenCalled(); + }); + + it('submits discharge with note, removes patient from bed, and shows success snackbar', async () => { + const user = userEvent.setup(); + + mockCreateEncounter.mockResolvedValueOnce({ ok: true } as any); + mockRemovePatientFromBed.mockResolvedValueOnce({ ok: true } as any); + + render(); + + const noteInput = screen.getByPlaceholderText(/write any notes here/i); + await user.clear(noteInput); + await user.type(noteInput, 'Patient recovered'); + + await user.click(screen.getByRole('button', { name: /Confirm discharge/i })); + + await waitFor(() => { + expect(mockCreateEncounter).toHaveBeenCalledWith( + mockWardPatient.patient, + { uuid: 'exit-encounter' }, + mockWardPatient.visit.uuid, + [ + { + concept: 'consult-concept', + value: 'Patient recovered', + }, + ], + ); + }); + + // Then verify all other calls happened + expect(mockRemovePatientFromBed).toHaveBeenCalledWith(mockWardPatient.bed.id, mockWardPatient.patient.uuid); + expect(mockWardPatientMutate).toHaveBeenCalled(); + expect(mockShowSnackbar).toHaveBeenCalledWith({ + title: 'Patient was discharged', + kind: 'success', + }); + expect(testProps.closeWorkspace).toHaveBeenCalledWith({ discardUnsavedChanges: true }); + expect(mockCloseWorkspaceGroup2).toHaveBeenCalled(); + }); + + it('submits discharge without note, removes patient from bed, and shows success snackbar', async () => { + const user = userEvent.setup(); + + mockCreateEncounter.mockResolvedValueOnce({ ok: true } as any); + mockRemovePatientFromBed.mockResolvedValueOnce({ ok: true } as any); + + render(); + + await user.click(screen.getByRole('button', { name: /Confirm discharge/i })); + + await waitFor(() => { + expect(mockCreateEncounter).toHaveBeenCalledWith( + mockWardPatient.patient, + { uuid: 'exit-encounter' }, + mockWardPatient.visit.uuid, + [], + ); + }); + + // Then verify all other calls happened + expect(mockRemovePatientFromBed).toHaveBeenCalledWith(mockWardPatient.bed.id, mockWardPatient.patient.uuid); + expect(mockWardPatientMutate).toHaveBeenCalled(); + expect(mockShowSnackbar).toHaveBeenCalledWith({ + title: 'Patient was discharged', + kind: 'success', + }); + expect(testProps.closeWorkspace).toHaveBeenCalledWith({ discardUnsavedChanges: true }); + expect(mockCloseWorkspaceGroup2).toHaveBeenCalled(); + }); + + it('disables discharge button during submission', async () => { + const user = userEvent.setup(); + + mockCreateEncounter.mockImplementation( + () => new Promise((resolve) => setTimeout(() => resolve({ ok: true }), 100)), + ); + mockRemovePatientFromBed.mockResolvedValueOnce({ ok: true } as any); + + render(); + + const noteInput = screen.getByPlaceholderText(/write any notes here/i); + await user.type(noteInput, 'Patient recovered'); + + const dischargeButton = screen.getByRole('button', { name: /Confirm discharge/i }); + expect(dischargeButton).toBeEnabled(); + + await user.click(dischargeButton); + + // Button should show loading state + await waitFor(() => { + expect(screen.getByText(/discharging/i)).toBeInTheDocument(); + }); + + await waitFor(() => { + expect(testProps.closeWorkspace).toHaveBeenCalled(); + }); + }); + + it('disables cancel button during submission', async () => { + const user = userEvent.setup(); + + mockCreateEncounter.mockImplementation( + () => new Promise((resolve) => setTimeout(() => resolve({ ok: true }), 100)), + ); + mockRemovePatientFromBed.mockResolvedValueOnce({ ok: true } as any); + + render(); + + const noteInput = screen.getByPlaceholderText(/write any notes here/i); + await user.type(noteInput, 'Patient recovered'); + + await user.click(screen.getByRole('button', { name: /Confirm discharge/i })); + + // Wait for loading state to appear, then check if cancel button is disabled + await waitFor(() => { + expect(screen.getByText(/discharging/i)).toBeInTheDocument(); + }); + + const cancelButton = screen.getByRole('button', { name: /cancel/i }); + expect(cancelButton).toBeDisabled(); + + await waitFor(() => { + expect(testProps.closeWorkspace).toHaveBeenCalled(); + }); + }); + + it('shows error snackbar when discharge fails', async () => { + const user = userEvent.setup(); + mockCreateEncounter.mockRejectedValueOnce({ message: 'Discharge failed' }); + + render(); + + const noteInput = screen.getByPlaceholderText(/write any notes here/i); + await user.type(noteInput, 'Patient recovered'); + await user.click(screen.getByRole('button', { name: /Confirm discharge/i })); + + await waitFor(() => { + expect(mockShowSnackbar).toHaveBeenCalledWith({ + title: 'Error discharging patient', + subtitle: 'Discharge failed', + kind: 'error', + }); + }); + }); + + it('shows fallback error message when error has no message', async () => { + const user = userEvent.setup(); + + mockCreateEncounter.mockRejectedValueOnce({}); + + render(); + + const noteInput = screen.getByPlaceholderText(/write any notes here/i); + await user.type(noteInput, 'Patient recovered'); + await user.click(screen.getByRole('button', { name: /Confirm discharge/i })); + + await waitFor(() => { + expect(mockShowSnackbar).toHaveBeenCalledWith({ + title: 'Error discharging patient', + subtitle: 'Unable to discharge patient. Please try again.', + kind: 'error', + }); + }); + }); + + it('does not call mutate when discharge fails', async () => { + const user = userEvent.setup(); + mockCreateEncounter.mockRejectedValueOnce({ message: 'Network issue' }); + + render(); + + const noteInput = screen.getByPlaceholderText(/write any notes here/i); + await user.type(noteInput, 'Patient recovered'); + await user.click(screen.getByRole('button', { name: /Confirm discharge/i })); + + await waitFor(() => { + expect(mockShowSnackbar).toHaveBeenCalledWith(expect.objectContaining({ kind: 'error' })); + }); + + expect(mockWardPatientMutate).not.toHaveBeenCalled(); + }); + + it('re-enables buttons after failed discharge', async () => { + const user = userEvent.setup(); + mockCreateEncounter.mockRejectedValueOnce({ message: 'Network issue' }); + + render(); + + const noteInput = screen.getByPlaceholderText(/write any notes here/i); + await user.type(noteInput, 'Patient recovered'); + + const dischargeButton = screen.getByRole('button', { name: /Confirm discharge/i }); + await user.click(dischargeButton); + + await waitFor(() => { + expect(mockShowSnackbar).toHaveBeenCalledWith(expect.objectContaining({ kind: 'error' })); + }); + + expect(dischargeButton).toBeEnabled(); + expect(screen.getByRole('button', { name: /cancel/i })).toBeEnabled(); + }); +}); diff --git a/packages/esm-ward-app/src/ward-workspace/patient-discharge/patient-discharge.workspace.tsx b/packages/esm-ward-app/src/ward-workspace/patient-discharge/patient-discharge.workspace.tsx index b66c7ca7fa..6ff1c59482 100644 --- a/packages/esm-ward-app/src/ward-workspace/patient-discharge/patient-discharge.workspace.tsx +++ b/packages/esm-ward-app/src/ward-workspace/patient-discharge/patient-discharge.workspace.tsx @@ -1,13 +1,24 @@ import React, { useCallback, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { Button, ButtonSet, InlineNotification } from '@carbon/react'; -import { Exit } from '@carbon/react/icons'; -import { closeWorkspaceGroup2, ExtensionSlot, showSnackbar, useAppContext, Workspace2 } from '@openmrs/esm-framework'; -import { type WardPatientWorkspaceDefinition, type WardPatientWorkspaceProps, type WardViewContext } from '../../types'; +import { Button, ButtonSet, Form, InlineNotification, InlineLoading, TextArea } from '@carbon/react'; +import { + closeWorkspaceGroup2, + ExtensionSlot, + ResponsiveWrapper, + showSnackbar, + useAppContext, + Workspace2, +} from '@openmrs/esm-framework'; +import { Controller, useForm } from 'react-hook-form'; +import { type ObsPayload, type WardPatientWorkspaceDefinition, type WardViewContext } from '../../types'; import { removePatientFromBed, useCreateEncounter } from '../../ward.resource'; import WardPatientWorkspaceBanner from '../patient-banner/patient-banner.component'; import styles from './patient-discharge.scss'; +type FormValues = { + dischargeData: string; +}; + export default function PatientDischargeWorkspace({ groupProps: { wardPatient }, closeWorkspace, @@ -18,41 +29,61 @@ export default function PatientDischargeWorkspace({ useCreateEncounter(); const { wardPatientGroupDetails } = useAppContext('ward-view-context') ?? {}; - const submitDischarge = useCallback(() => { - setIsSubmitting(true); + const { control, handleSubmit } = useForm({ defaultValues: { dischargeData: '' } }); - createEncounter(wardPatient?.patient, emrConfiguration.exitFromInpatientEncounterType, wardPatient?.visit?.uuid) - .then((response) => { - if (response?.ok) { - if (wardPatient?.bed?.id) { - return removePatientFromBed(wardPatient.bed.id, wardPatient?.patient?.uuid); - } - return response; - } - }) - .then((response) => { - if (response?.ok) { - showSnackbar({ - title: t('patientWasDischarged', 'Patient was discharged'), - kind: 'success', + const onSubmit = useCallback( + async (data: FormValues) => { + setIsSubmitting(true); + + try { + const note = data.dischargeData; + const obs: Array = []; + + if (note) { + obs.push({ + concept: emrConfiguration?.consultFreeTextCommentsConcept?.uuid, + value: note, }); + } + + await createEncounter( + wardPatient?.patient, + emrConfiguration.exitFromInpatientEncounterType, + wardPatient?.visit?.uuid, + obs, + ); - closeWorkspace({ discardUnsavedChanges: true }); - closeWorkspaceGroup2(); + if (wardPatient?.bed?.id) { + await removePatientFromBed(wardPatient.bed.id, wardPatient?.patient?.uuid); } - }) - .catch((err: Error) => { + + showSnackbar({ + title: t('patientWasDischarged', 'Patient was discharged'), + kind: 'success', + }); + + closeWorkspace({ discardUnsavedChanges: true }); + closeWorkspaceGroup2(); + wardPatientGroupDetails?.mutate?.(); + } catch (err) { + const message = + err?.responseBody?.error?.message || + err?.message || + t('unableToDischargePatient', 'Unable to discharge patient. Please try again.'); + showSnackbar({ title: t('errorDischargingPatient', 'Error discharging patient'), - subtitle: err.message, + subtitle: message, kind: 'error', }); - }) - .finally(() => { + } finally { setIsSubmitting(false); - wardPatientGroupDetails?.mutate?.(); - }); - }, [createEncounter, wardPatient, emrConfiguration, t, closeWorkspace, wardPatientGroupDetails]); + } + }, + [createEncounter, wardPatient, emrConfiguration, t, closeWorkspace, wardPatientGroupDetails], + ); + + const onError = (errors) => console.error(errors); if (!wardPatientGroupDetails) { return null; @@ -64,10 +95,10 @@ export default function PatientDischargeWorkspace({
-
+
{errorFetchingEmrConfiguration && ( -
+
)}
+ + +
+

{t('note', 'Note')}

+ ( + +