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({
-