diff --git a/src/__tests__/patients/view/ViewPatient.test.tsx b/src/__tests__/patients/view/ViewPatient.test.tsx index 1bd7d808a2..7ce8bf4bf1 100644 --- a/src/__tests__/patients/view/ViewPatient.test.tsx +++ b/src/__tests__/patients/view/ViewPatient.test.tsx @@ -130,7 +130,7 @@ describe('ViewPatient', () => { const tabs = tabsHeader.find(Tab) expect(tabsHeader).toHaveLength(1) - expect(tabs).toHaveLength(8) + expect(tabs).toHaveLength(9) expect(tabs.at(0).prop('label')).toEqual('patient.generalInformation') expect(tabs.at(1).prop('label')).toEqual('patient.relatedPersons.label') expect(tabs.at(2).prop('label')).toEqual('scheduling.appointments.label') @@ -139,6 +139,7 @@ describe('ViewPatient', () => { expect(tabs.at(5).prop('label')).toEqual('patient.notes.label') expect(tabs.at(6).prop('label')).toEqual('patient.labs.label') expect(tabs.at(7).prop('label')).toEqual('patient.carePlan.label') + expect(tabs.at(8).prop('label')).toEqual('patient.visits.label') }) it('should mark the general information tab as active and render the general information component when route is /patients/:id', async () => { diff --git a/src/__tests__/patients/visits/AddVisitModal.test.tsx b/src/__tests__/patients/visits/AddVisitModal.test.tsx new file mode 100644 index 0000000000..01c4ae3163 --- /dev/null +++ b/src/__tests__/patients/visits/AddVisitModal.test.tsx @@ -0,0 +1,119 @@ +import { Modal } from '@hospitalrun/components' +import { mount } from 'enzyme' +import { createMemoryHistory } from 'history' +import React from 'react' +import { act } from 'react-dom/test-utils' +import { Provider } from 'react-redux' +import { Router } from 'react-router-dom' +import createMockStore from 'redux-mock-store' +import thunk from 'redux-thunk' + +import * as patientSlice from '../../../patients/patient-slice' +import AddVisitModal from '../../../patients/visits/AddVisitModal' +import VisitForm from '../../../patients/visits/VisitForm' +import PatientRepository from '../../../shared/db/PatientRepository' +import Patient from '../../../shared/model/Patient' +import { VisitStatus } from '../../../shared/model/Visit' +import { RootState } from '../../../shared/store' + +const mockStore = createMockStore([thunk]) + +describe('Add Visit Modal', () => { + const patient = { + id: 'patientId', + visits: [ + { + id: '123', + startDateTime: new Date().toISOString(), + endDateTime: new Date().toISOString(), + type: 'standard type', + status: VisitStatus.Arrived, + reason: 'routine', + location: 'main', + }, + ], + } as Patient + + const visitError = { + title: 'visit error', + } + + const onCloseSpy = jest.fn() + const setup = () => { + jest.spyOn(PatientRepository, 'find').mockResolvedValue(patient) + jest.spyOn(PatientRepository, 'saveOrUpdate') + const store = mockStore({ patient: { patient, visitError } } as any) + const history = createMemoryHistory() + const wrapper = mount( + + + + + , + ) + + wrapper.update() + return { wrapper } + } + + it('should render a modal', () => { + const { wrapper } = setup() + + const modal = wrapper.find(Modal) + + expect(modal).toHaveLength(1) + + const successButton = modal.prop('successButton') + const cancelButton = modal.prop('closeButton') + expect(modal.prop('title')).toEqual('patient.visits.new') + expect(successButton?.children).toEqual('patient.visits.new') + expect(successButton?.icon).toEqual('add') + expect(cancelButton?.children).toEqual('actions.cancel') + }) + + it('should render the visit form', () => { + const { wrapper } = setup() + + const visitForm = wrapper.find(VisitForm) + expect(visitForm).toHaveLength(1) + expect(visitForm.prop('visitError')).toEqual(visitError) + }) + + it('should dispatch add visit when the save button is clicked', async () => { + const { wrapper } = setup() + jest.spyOn(patientSlice, 'addVisit') + + act(() => { + const visitForm = wrapper.find(VisitForm) + const onChange = visitForm.prop('onChange') as any + onChange(patient.visits[0]) + }) + wrapper.update() + + await act(async () => { + const modal = wrapper.find(Modal) + const successButton = modal.prop('successButton') + const onClick = successButton?.onClick as any + await onClick() + }) + + expect(patientSlice.addVisit).toHaveBeenCalledTimes(1) + expect(patientSlice.addVisit).toHaveBeenCalledWith(patient.id, patient.visits[0]) + }) + + it('should call the on close function when the cancel button is clicked', () => { + const { wrapper } = setup() + + const modal = wrapper.find(Modal) + + expect(modal).toHaveLength(1) + + act(() => { + const cancelButton = modal.prop('closeButton') + const onClick = cancelButton?.onClick as any + onClick() + }) + + expect(onCloseSpy).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/__tests__/patients/visits/ViewVisit.test.tsx b/src/__tests__/patients/visits/ViewVisit.test.tsx new file mode 100644 index 0000000000..f4fad6f169 --- /dev/null +++ b/src/__tests__/patients/visits/ViewVisit.test.tsx @@ -0,0 +1,52 @@ +import { mount } from 'enzyme' +import { createMemoryHistory } from 'history' +import React from 'react' +import { Provider } from 'react-redux' +import { Route, Router } from 'react-router-dom' +import createMockStore from 'redux-mock-store' +import thunk from 'redux-thunk' + +import ViewVisit from '../../../patients/visits/ViewVisit' +import VisitForm from '../../../patients/visits/VisitForm' +import Patient from '../../../shared/model/Patient' +import { RootState } from '../../../shared/store' + +const mockStore = createMockStore([thunk]) + +describe('View Visit', () => { + const patient = { + id: 'patientId', + visits: [{ id: '123', reason: 'reason for visit' }], + } as Patient + + const setup = () => { + const store = mockStore({ patient: { patient }, user: { user: { id: '123' } } } as any) + const history = createMemoryHistory() + history.push(`/patients/${patient.id}/visits/${patient.visits[0].id}`) + const wrapper = mount( + + + + + + + , + ) + + return { wrapper } + } + + it('should render the visit reason', () => { + const { wrapper } = setup() + + expect(wrapper.find('h2').text()).toEqual(patient.visits[0].reason) + }) + + it('should render a visit form with the correct data', () => { + const { wrapper } = setup() + + const visitForm = wrapper.find(VisitForm) + expect(visitForm).toHaveLength(1) + expect(visitForm.prop('visit')).toEqual(patient.visits[0]) + }) +}) diff --git a/src/__tests__/patients/visits/VisitForm.test.tsx b/src/__tests__/patients/visits/VisitForm.test.tsx new file mode 100644 index 0000000000..31acf77708 --- /dev/null +++ b/src/__tests__/patients/visits/VisitForm.test.tsx @@ -0,0 +1,241 @@ +import { Alert } from '@hospitalrun/components' +import { addDays } from 'date-fns' +import { mount } from 'enzyme' +import React from 'react' +import { act } from 'react-dom/test-utils' + +import VisitForm from '../../../patients/visits/VisitForm' +import Patient from '../../../shared/model/Patient' +import Visit, { VisitStatus } from '../../../shared/model/Visit' + +describe('Visit Form', () => { + let onVisitChangeSpy: any + + const visit: Visit = { + startDateTime: new Date().toISOString(), + endDateTime: new Date().toISOString(), + type: 'emergency', + status: VisitStatus.Arrived, + reason: 'routine visit', + location: 'main', + } + const setup = (disabled = false, initializeVisit = true, error?: any) => { + onVisitChangeSpy = jest.fn() + const mockPatient = { id: '123' } as Patient + const wrapper = mount( + , + ) + return { wrapper } + } + + it('should render a start date picker', () => { + const { wrapper } = setup() + + const startDateTimePicker = wrapper.findWhere((w) => w.prop('name') === 'startDateTime') + + expect(startDateTimePicker).toHaveLength(1) + expect(startDateTimePicker.prop('patient.visit.startDateTime')) + expect(startDateTimePicker.prop('isRequired')).toBeTruthy() + expect(startDateTimePicker.prop('value')).toEqual(new Date(visit.startDateTime)) + }) + + it('should call the on change handler when start date changes', () => { + const expectedNewStartDateTime = addDays(1, new Date().getDate()) + const { wrapper } = setup(false, false) + + const startDateTimePicker = wrapper.findWhere((w) => w.prop('name') === 'startDateTime') + act(() => { + const onChange = startDateTimePicker.prop('onChange') as any + onChange(expectedNewStartDateTime) + }) + + expect(onVisitChangeSpy).toHaveBeenCalledWith({ + startDateTime: expectedNewStartDateTime.toISOString(), + }) + }) + + it('should render an end date picker', () => { + const { wrapper } = setup() + + const endDateTimePicker = wrapper.findWhere((w) => w.prop('name') === 'endDateTime') + + expect(endDateTimePicker).toHaveLength(1) + expect(endDateTimePicker.prop('patient.visit.endDateTime')) + expect(endDateTimePicker.prop('isRequired')).toBeTruthy() + expect(endDateTimePicker.prop('value')).toEqual(new Date(visit.endDateTime)) + }) + + it('should call the on change handler when end date changes', () => { + const expectedNewEndDateTime = addDays(1, new Date().getDate()) + const { wrapper } = setup(false, false) + + const endDateTimePicker = wrapper.findWhere((w) => w.prop('name') === 'endDateTime') + act(() => { + const onChange = endDateTimePicker.prop('onChange') as any + onChange(expectedNewEndDateTime) + }) + + expect(onVisitChangeSpy).toHaveBeenCalledWith({ + endDateTime: expectedNewEndDateTime.toISOString(), + }) + }) + + it('should render a type input', () => { + const { wrapper } = setup() + + const typeInput = wrapper.findWhere((w) => w.prop('name') === 'type') + expect(typeInput).toHaveLength(1) + expect(typeInput.prop('patient.visit.type')) + expect(typeInput.prop('value')).toEqual(visit.type) + }) + + it('should call the on change handler when type changes', () => { + const expectedNewType = 'some new type' + const { wrapper } = setup(false, false) + + const typeInput = wrapper.findWhere((w) => w.prop('name') === 'type') + act(() => { + const onChange = typeInput.prop('onChange') as any + onChange({ currentTarget: { value: expectedNewType } }) + }) + + expect(onVisitChangeSpy).toHaveBeenCalledWith({ type: expectedNewType }) + }) + + it('should render a status selector', () => { + const { wrapper } = setup() + + const statusSelector = wrapper.findWhere((w) => w.prop('name') === 'status') + + expect(statusSelector).toHaveLength(1) + expect(statusSelector.prop('patient.visit.status')) + expect(statusSelector.prop('isRequired')).toBeTruthy() + expect(statusSelector.prop('defaultSelected')[0].value).toEqual(visit.status) + expect(statusSelector.prop('options')).toEqual( + Object.values(VisitStatus).map((v) => ({ label: v, value: v })), + ) + }) + + it('should call the on change handler when status changes', () => { + const expectedNewStatus = VisitStatus.Finished + const { wrapper } = setup(false, false) + act(() => { + const statusSelector = wrapper.findWhere((w) => w.prop('name') === 'status') + const onChange = statusSelector.prop('onChange') as any + onChange([expectedNewStatus]) + }) + + expect(onVisitChangeSpy).toHaveBeenCalledWith({ status: expectedNewStatus }) + }) + + it('should render a reason input', () => { + const { wrapper } = setup() + + const reasonInput = wrapper.findWhere((w) => w.prop('name') === 'reason') + + expect(reasonInput).toHaveLength(1) + expect(reasonInput.prop('patient.visit.reason')) + expect(reasonInput.prop('isRequired')).toBeTruthy() + expect(reasonInput.prop('value')).toEqual(visit.reason) + }) + + it('should call the on change handler when reason changes', () => { + const expectedNewReason = 'some new reason' + const { wrapper } = setup(false, false) + act(() => { + const reasonInput = wrapper.findWhere((w) => w.prop('name') === 'reason') + const onChange = reasonInput.prop('onChange') as any + onChange({ currentTarget: { value: expectedNewReason } }) + }) + + expect(onVisitChangeSpy).toHaveBeenCalledWith({ reason: expectedNewReason }) + }) + + it('should render a location input', () => { + const { wrapper } = setup() + + const locationInput = wrapper.findWhere((w) => w.prop('name') === 'location') + + expect(locationInput).toHaveLength(1) + expect(locationInput.prop('patient.visit.location')) + expect(locationInput.prop('isRequired')).toBeTruthy() + expect(locationInput.prop('value')).toEqual(visit.location) + }) + + it('should call the on change handler when location changes', () => { + const expectedNewLocation = 'some new location' + const { wrapper } = setup(false, false) + act(() => { + const locationInput = wrapper.findWhere((w) => w.prop('name') === 'location') + const onChange = locationInput.prop('onChange') as any + onChange({ currentTarget: { value: expectedNewLocation } }) + }) + + expect(onVisitChangeSpy).toHaveBeenCalledWith({ location: expectedNewLocation }) + }) + + it('should render all of the fields as disabled if the form is disabled', () => { + const { wrapper } = setup(true) + const startDateTimePicker = wrapper.findWhere((w) => w.prop('name') === 'startDateTime') + const endDateTimePicker = wrapper.findWhere((w) => w.prop('name') === 'endDateTime') + const typeInput = wrapper.findWhere((w) => w.prop('name') === 'type') + const statusSelector = wrapper.findWhere((w) => w.prop('name') === 'status') + const reasonInput = wrapper.findWhere((w) => w.prop('name') === 'reason') + const locationInput = wrapper.findWhere((w) => w.prop('name') === 'location') + + expect(startDateTimePicker.prop('isEditable')).toBeFalsy() + expect(endDateTimePicker.prop('isEditable')).toBeFalsy() + expect(typeInput.prop('isEditable')).toBeFalsy() + expect(statusSelector.prop('isEditable')).toBeFalsy() + expect(reasonInput.prop('isEditable')).toBeFalsy() + expect(locationInput.prop('isEditable')).toBeFalsy() + }) + + it('should render the form fields in an error state', () => { + const expectedError = { + message: 'error message', + startDateTime: 'start date error', + endDateTime: 'end date error', + type: 'type error', + status: 'status error', + reason: 'reason error', + location: 'location error', + } + + const { wrapper } = setup(false, false, expectedError) + + const alert = wrapper.find(Alert) + const startDateTimePicker = wrapper.findWhere((w) => w.prop('name') === 'startDateTime') + const endDateTimePicker = wrapper.findWhere((w) => w.prop('name') === 'endDateTime') + const typeInput = wrapper.findWhere((w) => w.prop('name') === 'type') + const statusSelector = wrapper.findWhere((w) => w.prop('name') === 'status') + const reasonInput = wrapper.findWhere((w) => w.prop('name') === 'reason') + const locationInput = wrapper.findWhere((w) => w.prop('name') === 'location') + + expect(alert).toHaveLength(1) + expect(alert.prop('message')).toEqual(expectedError.message) + + expect(startDateTimePicker.prop('isInvalid')).toBeTruthy() + expect(startDateTimePicker.prop('feedback')).toEqual(expectedError.startDateTime) + + expect(endDateTimePicker.prop('isInvalid')).toBeTruthy() + expect(endDateTimePicker.prop('feedback')).toEqual(expectedError.endDateTime) + + expect(typeInput.prop('isInvalid')).toBeTruthy() + expect(typeInput.prop('feedback')).toEqual(expectedError.type) + + expect(statusSelector.prop('isInvalid')).toBeTruthy() + + expect(reasonInput.prop('isInvalid')).toBeTruthy() + expect(reasonInput.prop('feedback')).toEqual(expectedError.reason) + + expect(locationInput.prop('isInvalid')).toBeTruthy() + expect(locationInput.prop('feedback')).toEqual(expectedError.location) + }) +}) diff --git a/src/__tests__/patients/visits/VisitTab.test.tsx b/src/__tests__/patients/visits/VisitTab.test.tsx new file mode 100644 index 0000000000..f075b10ef3 --- /dev/null +++ b/src/__tests__/patients/visits/VisitTab.test.tsx @@ -0,0 +1,100 @@ +import { Button } from '@hospitalrun/components' +import { mount } from 'enzyme' +import { createMemoryHistory } from 'history' +import React from 'react' +import { act } from 'react-dom/test-utils' +import { Provider } from 'react-redux' +import { Router } from 'react-router-dom' +import createMockStore from 'redux-mock-store' +import thunk from 'redux-thunk' + +import AddVisitModal from '../../../patients/visits/AddVisitModal' +import ViewVisit from '../../../patients/visits/ViewVisit' +import VisitTab from '../../../patients/visits/VisitTab' +import VisitTable from '../../../patients/visits/VisitTable' +import Permissions from '../../../shared/model/Permissions' +import { RootState } from '../../../shared/store' + +const mockStore = createMockStore([thunk]) + +describe('Visit Tab', () => { + const patient = { + id: 'patientId', + } + + const setup = (route: string, permissions: Permissions[]) => { + const store = mockStore({ patient: { patient }, user: { permissions } } as any) + const history = createMemoryHistory() + history.push(route) + const wrapper = mount( + + + + + , + ) + + wrapper.update() + return { wrapper, history } + } + + it('should render an add visit button if user has correct permissions', () => { + const { wrapper } = setup('/patients/123/visits', [Permissions.AddVisit]) + + const addNewButton = wrapper.find(Button).at(0) + expect(addNewButton).toHaveLength(1) + expect(addNewButton.text().trim()).toEqual('patient.visits.new') + }) + + it('should open the add visit modal on click', () => { + const { wrapper } = setup('/patients/123/visits', [Permissions.AddVisit]) + + act(() => { + const addNewButton = wrapper.find(Button).at(0) + const onClick = addNewButton.prop('onClick') as any + onClick() + }) + wrapper.update() + + const modal = wrapper.find(AddVisitModal) + expect(modal.prop('show')).toBeTruthy() + }) + + it('should close the modal when the close button is clicked', () => { + const { wrapper } = setup('/patients/123/visits', [Permissions.AddVisit]) + + act(() => { + const addNewButton = wrapper.find(Button).at(0) + const onClick = addNewButton.prop('onClick') as any + onClick() + }) + wrapper.update() + + act(() => { + const modal = wrapper.find(AddVisitModal) + const onClose = modal.prop('onCloseButtonClick') as any + onClose() + }) + wrapper.update() + + expect(wrapper.find(AddVisitModal).prop('show')).toBeFalsy() + }) + + it('should not render visit button if user does not have permissions', () => { + const { wrapper } = setup('/patients/123/visits', []) + + expect(wrapper.find(Button)).toHaveLength(0) + }) + + it('should render the visits table when on /patient/:id/visits', () => { + const { wrapper } = setup('/patients/123/visits', [Permissions.ReadVisits]) + + expect(wrapper.find(VisitTable)).toHaveLength(1) + }) + + it('should render the visit view when on /patient/:id/visits/:visitId', () => { + const { wrapper } = setup('/patients/123/visits/456', [Permissions.ReadVisits]) + + expect(wrapper.find(ViewVisit)).toHaveLength(1) + }) +}) diff --git a/src/__tests__/patients/visits/VisitTable.test.tsx b/src/__tests__/patients/visits/VisitTable.test.tsx new file mode 100644 index 0000000000..9ba76016b4 --- /dev/null +++ b/src/__tests__/patients/visits/VisitTable.test.tsx @@ -0,0 +1,91 @@ +import { Table } from '@hospitalrun/components' +import { mount, ReactWrapper } from 'enzyme' +import { createMemoryHistory } from 'history' +import React from 'react' +import { act } from 'react-dom/test-utils' +import { Provider } from 'react-redux' +import { Router } from 'react-router-dom' +import createMockStore from 'redux-mock-store' +import thunk from 'redux-thunk' + +import VisitTable from '../../../patients/visits/VisitTable' +import Patient from '../../../shared/model/Patient' +import Visit, { VisitStatus } from '../../../shared/model/Visit' +import { RootState } from '../../../shared/store' + +const mockStore = createMockStore([thunk]) + +describe('Visit Table', () => { + const visit: Visit = { + id: 'id', + startDateTime: new Date(2020, 6, 3).toISOString(), + endDateTime: new Date(2020, 6, 5).toISOString(), + type: 'standard type', + status: VisitStatus.Arrived, + reason: 'some reason', + location: 'main building', + } + const patient = { + id: 'patientId', + diagnoses: [{ id: '123', name: 'some name', diagnosisDate: new Date().toISOString() }], + visits: [visit], + } as Patient + + const setup = () => { + const store = mockStore({ patient: { patient } } as any) + const history = createMemoryHistory() + history.push(`/patients/${patient.id}/visits/${patient.visits[0].id}`) + const wrapper = mount( + + + + + , + ) + + return { wrapper: wrapper as ReactWrapper, history } + } + + it('should render a table', () => { + const { wrapper } = setup() + + const table = wrapper.find(Table) + const columns = table.prop('columns') + const actions = table.prop('actions') as any + expect(columns[0]).toEqual( + expect.objectContaining({ label: 'patient.visits.startDateTime', key: 'startDateTime' }), + ) + expect(columns[1]).toEqual( + expect.objectContaining({ label: 'patient.visits.endDateTime', key: 'endDateTime' }), + ) + expect(columns[2]).toEqual( + expect.objectContaining({ label: 'patient.visits.type', key: 'type' }), + ) + expect(columns[3]).toEqual( + expect.objectContaining({ label: 'patient.visits.status', key: 'status' }), + ) + expect(columns[4]).toEqual( + expect.objectContaining({ label: 'patient.visits.reason', key: 'reason' }), + ) + expect(columns[5]).toEqual( + expect.objectContaining({ label: 'patient.visits.location', key: 'location' }), + ) + + expect(actions[0]).toEqual(expect.objectContaining({ label: 'actions.view' })) + expect(table.prop('actionsHeaderText')).toEqual('actions.label') + expect(table.prop('data')).toEqual(patient.visits) + }) + + it('should navigate to the visit view when the view details button is clicked', () => { + const { wrapper, history } = setup() + + const tr = wrapper.find('tr').at(1) + + act(() => { + const onClick = tr.find('button').prop('onClick') as any + onClick({ stopPropagation: jest.fn() }) + }) + + expect(history.location.pathname).toEqual(`/patients/${patient.id}/visits/${visit.id}`) + }) +}) diff --git a/src/__tests__/shared/components/Sidebar.test.tsx b/src/__tests__/shared/components/Sidebar.test.tsx index fd32f5c6d8..5a141f838e 100644 --- a/src/__tests__/shared/components/Sidebar.test.tsx +++ b/src/__tests__/shared/components/Sidebar.test.tsx @@ -32,6 +32,8 @@ describe('Sidebar', () => { Permissions.ViewIncidents, Permissions.ViewIncident, Permissions.ReportIncident, + Permissions.ReadVisits, + Permissions.AddVisit, Permissions.RequestImaging, Permissions.ViewImagings, ] diff --git a/src/__tests__/shared/components/navbar/Navbar.test.tsx b/src/__tests__/shared/components/navbar/Navbar.test.tsx index f2afeb206d..99b72f06a3 100644 --- a/src/__tests__/shared/components/navbar/Navbar.test.tsx +++ b/src/__tests__/shared/components/navbar/Navbar.test.tsx @@ -56,6 +56,8 @@ describe('Navbar', () => { Permissions.ViewIncidents, Permissions.ViewIncident, Permissions.ReportIncident, + Permissions.AddVisit, + Permissions.ReadVisits, Permissions.RequestImaging, Permissions.ViewImagings, ] diff --git a/src/patients/patient-slice.ts b/src/patients/patient-slice.ts index b0a3b21292..83f8695913 100644 --- a/src/patients/patient-slice.ts +++ b/src/patients/patient-slice.ts @@ -10,6 +10,7 @@ import Diagnosis from '../shared/model/Diagnosis' import Note from '../shared/model/Note' import Patient from '../shared/model/Patient' import RelatedPerson from '../shared/model/RelatedPerson' +import Visit from '../shared/model/Visit' import { AppThunk } from '../shared/store' import { uuid } from '../shared/util/uuid' import { cleanupPatient } from './util/set-patient-helper' @@ -26,6 +27,7 @@ interface PatientState { noteError?: AddNoteError relatedPersonError?: AddRelatedPersonError carePlanError?: AddCarePlanError + visitError?: AddVisitError } interface Error { @@ -74,6 +76,14 @@ interface AddCarePlanError { condition?: string } +interface AddVisitError { + message?: string + status?: string + intent?: string + startDateTime?: string + endDateTime?: string +} + const initialState: PatientState = { status: 'loading', isUpdatedSuccessfully: false, @@ -86,6 +96,7 @@ const initialState: PatientState = { noteError: undefined, relatedPersonError: undefined, carePlanError: undefined, + visitError: undefined, } function start(state: PatientState) { @@ -139,6 +150,10 @@ const patientSlice = createSlice({ state.status = 'error' state.carePlanError = payload }, + addVisitError(state, { payload }: PayloadAction) { + state.status = 'error' + state.visitError = payload + }, }, }) @@ -156,6 +171,7 @@ export const { addRelatedPersonError, addNoteError, addCarePlanError, + addVisitError, } = patientSlice.actions export const fetchPatient = (id: string): AppThunk => async (dispatch) => { @@ -483,4 +499,57 @@ export const addCarePlan = ( } } +function validateVisit(visit: Visit): AddVisitError { + const error: AddVisitError = {} + + if (!visit.startDateTime) { + error.startDateTime = 'patient.visits.error.startDateRequired' + } + + if (!visit.endDateTime) { + error.endDateTime = 'patient.visits.error.endDateRequired' + } + + if (!visit.type) { + error.status = 'patient.visits.error.typeRequired' + } + + if (visit.startDateTime && visit.endDateTime) { + if (isBefore(new Date(visit.endDateTime), new Date(visit.startDateTime))) { + error.endDateTime = 'patient.visits.error.endDateMustBeAfterStartDate' + } + } + + if (!visit.status) { + error.status = 'patient.visits.error.statusRequired' + } + if (!visit.reason) { + error.status = 'patient.visits.error.reasonRequired' + } + if (!visit.location) { + error.status = 'patient.visits.error.locationRequired' + } + + return error +} + +export const addVisit = ( + patientId: string, + visit: Visit, + onSuccess?: (patient: Patient) => void, +): AppThunk => async (dispatch) => { + const visitError = validateVisit(visit) + if (isEmpty(visitError)) { + const patient = await PatientRepository.find(patientId) + const visits = patient.visits || ([] as Visit[]) + visits.push({ + id: uuid(), + createdAt: new Date(Date.now().valueOf()).toISOString(), + ...visit, + }) + patient.visits = visits + + await dispatch(updatePatient(patient, onSuccess)) + } +} export default patientSlice.reducer diff --git a/src/patients/view/ViewPatient.tsx b/src/patients/view/ViewPatient.tsx index 233dad0959..fc9adf6761 100644 --- a/src/patients/view/ViewPatient.tsx +++ b/src/patients/view/ViewPatient.tsx @@ -27,6 +27,7 @@ import Note from '../notes/NoteTab' import { fetchPatient } from '../patient-slice' import RelatedPerson from '../related-persons/RelatedPersonTab' import { getPatientFullName } from '../util/patient-name-util' +import VisitTab from '../visits/VisitTab' const getPatientCode = (p: Patient): string => { if (p) { @@ -133,6 +134,11 @@ const ViewPatient = () => { label={t('patient.carePlan.label')} onClick={() => history.push(`/patients/${patient.id}/care-plans`)} /> + history.push(`/patients/${patient.id}/visits`)} + /> @@ -159,6 +165,9 @@ const ViewPatient = () => { + + + ) diff --git a/src/patients/visits/AddVisitModal.tsx b/src/patients/visits/AddVisitModal.tsx new file mode 100644 index 0000000000..8c6fbfd4dd --- /dev/null +++ b/src/patients/visits/AddVisitModal.tsx @@ -0,0 +1,68 @@ +import { Modal } from '@hospitalrun/components' +import { addMonths } from 'date-fns' +import React, { useState, useEffect } from 'react' +import { useDispatch, useSelector } from 'react-redux' + +import useTranslator from '../../shared/hooks/useTranslator' +import Visit from '../../shared/model/Visit' +import { RootState } from '../../shared/store' +import { addVisit } from '../patient-slice' +import VisitForm from './VisitForm' + +interface Props { + show: boolean + onCloseButtonClick: () => void +} + +const initialVisitState = { + startDateTime: new Date().toISOString(), + endDateTime: addMonths(new Date(), 1).toISOString(), +} + +const AddVisitModal = (props: Props) => { + const { show, onCloseButtonClick } = props + const dispatch = useDispatch() + const { t } = useTranslator() + const { visitError, patient } = useSelector((state: RootState) => state.patient) + const [visit, setVisit] = useState(initialVisitState) + + useEffect(() => { + setVisit(initialVisitState) + }, [show]) + + const onVisitChange = (newVisit: Partial) => { + setVisit(newVisit as Visit) + } + + const onSaveButtonClick = () => { + dispatch(addVisit(patient.id, visit as Visit)) + } + + const onClose = () => { + onCloseButtonClick() + } + + const body = + return ( + + ) +} + +export default AddVisitModal diff --git a/src/patients/visits/ViewVisit.tsx b/src/patients/visits/ViewVisit.tsx new file mode 100644 index 0000000000..cd66efba3b --- /dev/null +++ b/src/patients/visits/ViewVisit.tsx @@ -0,0 +1,34 @@ +import findLast from 'lodash/findLast' +import React, { useEffect, useState } from 'react' +import { useSelector } from 'react-redux' +import { useParams } from 'react-router-dom' + +import Visit from '../../shared/model/Visit' +import { RootState } from '../../shared/store' +import VisitForm from './VisitForm' + +const ViewVisit = () => { + const { patient } = useSelector((root: RootState) => root.patient) + const { visitId } = useParams() + + const [visit, setVisit] = useState() + + useEffect(() => { + if (patient && visitId) { + const currentVisit = findLast(patient.visits, (c: Visit) => c.id === visitId) + setVisit(currentVisit) + } + }, [setVisit, visitId, patient]) + + if (visit) { + return ( + <> +

{visit?.reason}

+ + + ) + } + return <> +} + +export default ViewVisit diff --git a/src/patients/visits/VisitForm.tsx b/src/patients/visits/VisitForm.tsx new file mode 100644 index 0000000000..d6b992d848 --- /dev/null +++ b/src/patients/visits/VisitForm.tsx @@ -0,0 +1,144 @@ +import { Alert, Column, Row } from '@hospitalrun/components' +import React, { useState } from 'react' + +import DateTimePickerWithLabelFormGroup from '../../shared/components/input/DateTimePickerWithLabelFormGroup' +import SelectWithLabelFormGroup, { + Option, +} from '../../shared/components/input/SelectWithLableFormGroup' +import TextFieldWithLabelFormGroup from '../../shared/components/input/TextFieldWithLabelFormGroup' +import TextInputWithLabelFormGroup from '../../shared/components/input/TextInputWithLabelFormGroup' +import useTranslator from '../../shared/hooks/useTranslator' +import Visit, { VisitStatus } from '../../shared/model/Visit' + +interface Error { + message?: string + startDateTime?: string + endDateTime?: string + type?: string + status?: string + reason?: string + location?: string +} +interface Props { + visit: Partial + visitError?: Error + onChange?: (newVisit: Partial) => void + disabled?: boolean +} + +const VisitForm = (props: Props) => { + const { t } = useTranslator() + const { visit, visitError, disabled, onChange } = props + + const [status, setStatus] = useState(visit.status) + + const onFieldChange = (name: string, value: string | VisitStatus) => { + if (onChange) { + const newVisit = { + ...visit, + [name]: value, + } + onChange(newVisit) + } + } + + const statusOptions: Option[] = + Object.values(VisitStatus).map((v) => ({ label: v, value: v })) || [] + + return ( +
+ {visitError?.message && } + + + onFieldChange('startDateTime', date.toISOString())} + /> + + + onFieldChange('endDateTime', date.toISOString())} + /> + + + + + onFieldChange('type', event.currentTarget.value)} + /> + + + + + value === status)} + onChange={(values) => { + onFieldChange('status', values[0]) + setStatus(values[0] as VisitStatus) + }} + isEditable={!disabled} + isInvalid={!!visitError?.status} + /> + + + + + onFieldChange('reason', event.currentTarget.value)} + /> + + + + + onFieldChange('location', event.currentTarget.value)} + /> + + + + ) +} + +VisitForm.defaultProps = { + disabled: false, +} + +export default VisitForm diff --git a/src/patients/visits/VisitTab.tsx b/src/patients/visits/VisitTab.tsx new file mode 100644 index 0000000000..78a80c7ad1 --- /dev/null +++ b/src/patients/visits/VisitTab.tsx @@ -0,0 +1,51 @@ +import { Button } from '@hospitalrun/components' +import React, { useState } from 'react' +import { useSelector } from 'react-redux' +import { Route, Switch } from 'react-router-dom' + +import useTranslator from '../../shared/hooks/useTranslator' +import Permissions from '../../shared/model/Permissions' +import { RootState } from '../../shared/store' +import AddVisitModal from './AddVisitModal' +import ViewVisit from './ViewVisit' +import VisitTable from './VisitTable' + +const VisitTab = () => { + const { t } = useTranslator() + const { permissions } = useSelector((state: RootState) => state.user) + const [showAddVisitModal, setShowAddVisitModal] = useState(false) + return ( + <> +
+
+ {permissions.includes(Permissions.AddVisit) && ( + + )} +
+
+
+ + + + + + + + + setShowAddVisitModal(false)} + /> + + ) +} + +export default VisitTab diff --git a/src/patients/visits/VisitTable.tsx b/src/patients/visits/VisitTable.tsx new file mode 100644 index 0000000000..6cecfee952 --- /dev/null +++ b/src/patients/visits/VisitTable.tsx @@ -0,0 +1,46 @@ +import { Table } from '@hospitalrun/components' +import format from 'date-fns/format' +import React from 'react' +import { useSelector } from 'react-redux' +import { useHistory } from 'react-router-dom' + +import useTranslator from '../../shared/hooks/useTranslator' +import { RootState } from '../../shared/store' + +const VisitTable = () => { + const history = useHistory() + const { t } = useTranslator() + const { patient } = useSelector((state: RootState) => state.patient) + + return ( + row.id} + data={patient.visits || []} + columns={[ + { + label: t('patient.visits.startDateTime'), + key: 'startDateTime', + formatter: (row) => format(new Date(row.startDateTime), 'yyyy-MM-dd hh:mm a'), + }, + { + label: t('patient.visits.endDateTime'), + key: 'endDateTime', + formatter: (row) => format(new Date(row.endDateTime), 'yyyy-MM-dd hh:mm a'), + }, + { label: t('patient.visits.type'), key: 'type' }, + { label: t('patient.visits.status'), key: 'status' }, + { label: t('patient.visits.reason'), key: 'reason' }, + { label: t('patient.visits.location'), key: 'location' }, + ]} + actionsHeaderText={t('actions.label')} + actions={[ + { + label: t('actions.view'), + action: (row) => history.push(`/patients/${patient.id}/visits/${row.id}`), + }, + ]} + /> + ) +} + +export default VisitTable diff --git a/src/shared/components/navbar/pageMap.tsx b/src/shared/components/navbar/pageMap.tsx index d6b77d12ef..bab446729b 100644 --- a/src/shared/components/navbar/pageMap.tsx +++ b/src/shared/components/navbar/pageMap.tsx @@ -59,6 +59,18 @@ const pageMap: { path: '/incidents', icon: 'incident', }, + newVisit: { + permission: Permissions.AddVisit, + label: 'visits.visit.new', + path: '/visits', + icon: 'add', + }, + viewVisits: { + permission: Permissions.ReadVisits, + label: 'visits.visit.label', + path: '/visits', + icon: 'visit', + }, newImaging: { permission: Permissions.RequestImaging, label: 'imagings.requests.new', diff --git a/src/shared/locales/enUs/translations/patient/index.ts b/src/shared/locales/enUs/translations/patient/index.ts index ed2908d559..ed7066cdce 100644 --- a/src/shared/locales/enUs/translations/patient/index.ts +++ b/src/shared/locales/enUs/translations/patient/index.ts @@ -130,6 +130,27 @@ export default { endDate: 'End date is required', }, }, + visit: 'Visit', + visits: { + new: 'Add Visit', + label: 'Visits', + startDateTime: 'Start Date', + endDateTime: 'End Date', + type: 'Type', + status: 'Status', + reason: 'Reason', + location: 'Location', + error: { + unableToAdd: 'Unable to add a new visit.', + startDateRequired: 'Start date is required.', + endDateRequired: 'End date is required', + endDateMustBeAfterStartDate: 'End date must be after start date', + typeRequired: 'Type is required.', + statusRequired: 'Status is required.', + reasonRequired: 'Reason is required.', + locationRequired: 'Location is required.', + }, + }, types: { charity: 'Charity', private: 'Private', diff --git a/src/shared/model/Patient.ts b/src/shared/model/Patient.ts index 6703da59e9..51ee1d8356 100644 --- a/src/shared/model/Patient.ts +++ b/src/shared/model/Patient.ts @@ -6,6 +6,7 @@ import Diagnosis from './Diagnosis' import Name from './Name' import Note from './Note' import RelatedPerson from './RelatedPerson' +import Visit from './Visit' export default interface Patient extends AbstractDBModel, Name, ContactInformation { sex: string @@ -22,4 +23,5 @@ export default interface Patient extends AbstractDBModel, Name, ContactInformati index: string carePlans: CarePlan[] bloodType: string + visits: Visit[] } diff --git a/src/shared/model/Permissions.ts b/src/shared/model/Permissions.ts index 917ca2d6fd..e11b06cf8f 100644 --- a/src/shared/model/Permissions.ts +++ b/src/shared/model/Permissions.ts @@ -17,6 +17,8 @@ enum Permissions { ResolveIncident = 'resolve:incident', AddCarePlan = 'write:care_plan', ReadCarePlan = 'read:care_plan', + AddVisit = 'write:visit', + ReadVisits = 'read:visit', RequestImaging = 'write:imaging', ViewImagings = 'read:imagings', } diff --git a/src/shared/model/Visit.ts b/src/shared/model/Visit.ts new file mode 100644 index 0000000000..5953916f53 --- /dev/null +++ b/src/shared/model/Visit.ts @@ -0,0 +1,23 @@ +import AbstractDBModel from './AbstractDBModel' + +export enum VisitStatus { + Planned = 'planned', + Arrived = 'arrived', + Triaged = 'triaged', + InProgress = 'in progress', + OnLeave = 'on leave', + Finished = 'finished', + Cancelled = 'cancelled', +} + +export default interface Visit extends AbstractDBModel { + id: string + createdAt: string + updatedAt: string + startDateTime: string + endDateTime: string + type: string + status: VisitStatus + reason: string + location: string +} diff --git a/src/user/user-slice.ts b/src/user/user-slice.ts index 388a84ee18..84af456645 100644 --- a/src/user/user-slice.ts +++ b/src/user/user-slice.ts @@ -38,6 +38,8 @@ const initialState: UserState = { Permissions.ResolveIncident, Permissions.AddCarePlan, Permissions.ReadCarePlan, + Permissions.AddVisit, + Permissions.ReadVisits, Permissions.ViewImagings, Permissions.RequestImaging, ],