From 53846321fd604847b3c74efbb06c08185cce79b1 Mon Sep 17 00:00:00 2001 From: morrme Date: Thu, 23 Jul 2020 14:28:56 -0500 Subject: [PATCH] feat(patients): add visit functionality --- package.json | 4 +- .../new/DuplicateNewPatientModal.test.tsx | 90 +++++++ .../patients/new/NewPatient.test.tsx | 18 ++ .../is-possible-duplicate-patient.test.ts | 24 ++ .../patients/view/ViewPatient.test.tsx | 3 +- .../patients/visits/AddVisitModal.test.tsx | 119 +++++++++ .../patients/visits/ViewVisit.test.tsx | 52 ++++ .../patients/visits/VisitForm.test.tsx | 241 ++++++++++++++++++ .../patients/visits/VisitTab.test.tsx | 100 ++++++++ .../patients/visits/VisitTable.test.tsx | 91 +++++++ .../shared/components/Sidebar.test.tsx | 2 + .../shared/components/navbar/Navbar.test.tsx | 2 + src/patients/new/DuplicateNewPatientModal.tsx | 61 +++++ src/patients/new/NewPatient.tsx | 48 +++- src/patients/patient-slice.ts | 69 +++++ .../util/is-possible-duplicate-patient.ts | 10 + src/patients/view/ViewPatient.tsx | 9 + src/patients/visits/AddVisitModal.tsx | 68 +++++ src/patients/visits/ViewVisit.tsx | 34 +++ src/patients/visits/VisitForm.tsx | 144 +++++++++++ src/patients/visits/VisitTab.tsx | 51 ++++ src/patients/visits/VisitTable.tsx | 46 ++++ src/shared/components/Sidebar.tsx | 2 +- src/shared/components/navbar/pageMap.tsx | 12 + .../enUs/translations/patient/index.ts | 21 ++ .../enUs/translations/patients/index.ts | 4 + src/shared/model/Patient.ts | 2 + src/shared/model/Permissions.ts | 2 + src/shared/model/Visit.ts | 23 ++ src/user/user-slice.ts | 2 + 30 files changed, 1349 insertions(+), 5 deletions(-) create mode 100644 src/__tests__/patients/new/DuplicateNewPatientModal.test.tsx create mode 100644 src/__tests__/patients/util/is-possible-duplicate-patient.test.ts create mode 100644 src/__tests__/patients/visits/AddVisitModal.test.tsx create mode 100644 src/__tests__/patients/visits/ViewVisit.test.tsx create mode 100644 src/__tests__/patients/visits/VisitForm.test.tsx create mode 100644 src/__tests__/patients/visits/VisitTab.test.tsx create mode 100644 src/__tests__/patients/visits/VisitTable.test.tsx create mode 100644 src/patients/new/DuplicateNewPatientModal.tsx create mode 100644 src/patients/util/is-possible-duplicate-patient.ts create mode 100644 src/patients/visits/AddVisitModal.tsx create mode 100644 src/patients/visits/ViewVisit.tsx create mode 100644 src/patients/visits/VisitForm.tsx create mode 100644 src/patients/visits/VisitTab.tsx create mode 100644 src/patients/visits/VisitTable.tsx create mode 100644 src/shared/model/Visit.ts diff --git a/package.json b/package.json index 079b30903b..ea17ceffa7 100644 --- a/package.json +++ b/package.json @@ -88,9 +88,9 @@ "eslint": "~6.8.0", "eslint-config-airbnb": "~18.2.0", "eslint-config-prettier": "~6.11.0", - "eslint-import-resolver-typescript": "~2.0.0", + "eslint-import-resolver-typescript": "~2.2.0", "eslint-plugin-import": "~2.22.0", - "eslint-plugin-jest": "~23.18.0", + "eslint-plugin-jest": "~23.20.0", "eslint-plugin-jsx-a11y": "~6.3.0", "eslint-plugin-prettier": "~3.1.2", "eslint-plugin-react": "~7.20.0", diff --git a/src/__tests__/patients/new/DuplicateNewPatientModal.test.tsx b/src/__tests__/patients/new/DuplicateNewPatientModal.test.tsx new file mode 100644 index 0000000000..1e305bc373 --- /dev/null +++ b/src/__tests__/patients/new/DuplicateNewPatientModal.test.tsx @@ -0,0 +1,90 @@ +import { Modal } from '@hospitalrun/components' +import { act } from '@testing-library/react' +import { mount, ReactWrapper } from 'enzyme' +import React from 'react' +import { Provider } from 'react-redux' +import createMockStore from 'redux-mock-store' +import thunk from 'redux-thunk' + +import DuplicateNewPatientModal from '../../../patients/new/DuplicateNewPatientModal' +import { RootState } from '../../../shared/store' + +const mockStore = createMockStore([thunk]) + +const setupOnClick = (onClose: any, onContinue: any, prop: string) => { + const store = mockStore({ + patient: { + patient: { + id: '1234', + }, + }, + } as any) + + const wrapper = mount( + + + , + ) + wrapper.update() + + act(() => { + const modal = wrapper.find(Modal) + const { onClick } = modal.prop(prop) as any + onClick() + }) + + return { wrapper: wrapper as ReactWrapper } +} + +describe('Duplicate New Patient Modal', () => { + it('should render a modal with the correct labels', () => { + const store = mockStore({ + patient: { + patient: { + id: '1234', + }, + }, + } as any) + const wrapper = mount( + + + , + ) + wrapper.update() + const modal = wrapper.find(Modal) + expect(modal).toHaveLength(1) + expect(modal.prop('title')).toEqual('patients.newPatient') + expect(modal.prop('closeButton')?.children).toEqual('actions.cancel') + expect(modal.prop('closeButton')?.color).toEqual('danger') + expect(modal.prop('successButton')?.children).toEqual('actions.save') + expect(modal.prop('successButton')?.color).toEqual('success') + }) + + describe('cancel', () => { + it('should call the onCloseButtonClick function when the close button is clicked', () => { + const onCloseButtonClickSpy = jest.fn() + const closeButtonProp = 'closeButton' + setupOnClick(onCloseButtonClickSpy, jest.fn(), closeButtonProp) + expect(onCloseButtonClickSpy).toHaveBeenCalledTimes(1) + }) + }) + + describe('on save', () => { + it('should call the onContinueButtonClick function when the continue button is clicked', () => { + const onContinueButtonClickSpy = jest.fn() + const continueButtonProp = 'successButton' + setupOnClick(jest.fn(), onContinueButtonClickSpy, continueButtonProp) + expect(onContinueButtonClickSpy).toHaveBeenCalledTimes(1) + }) + }) +}) diff --git a/src/__tests__/patients/new/NewPatient.test.tsx b/src/__tests__/patients/new/NewPatient.test.tsx index 61ea79d945..57c4979560 100644 --- a/src/__tests__/patients/new/NewPatient.test.tsx +++ b/src/__tests__/patients/new/NewPatient.test.tsx @@ -112,6 +112,24 @@ describe('New Patient', () => { expect(store.getActions()).toContainEqual(patientSlice.createPatientSuccess()) }) + it('should reveal modal (return true) when save button is clicked if an existing patient has the same information', async () => { + let wrapper: any + await act(async () => { + wrapper = await setup() + }) + + const saveButton = wrapper.find('.btn-save').at(0) + const onClick = saveButton.prop('onClick') as any + expect(saveButton.text().trim()).toEqual('actions.save') + + act(() => { + onClick() + }) + wrapper.update() + + expect(onClick()).toEqual(true) + }) + it('should navigate to /patients/:id and display a message after a new patient is successfully created', async () => { jest.spyOn(components, 'Toast') const mockedComponents = mocked(components, true) diff --git a/src/__tests__/patients/util/is-possible-duplicate-patient.test.ts b/src/__tests__/patients/util/is-possible-duplicate-patient.test.ts new file mode 100644 index 0000000000..df6fc8e41e --- /dev/null +++ b/src/__tests__/patients/util/is-possible-duplicate-patient.test.ts @@ -0,0 +1,24 @@ +import { isPossibleDuplicatePatient } from '../../../patients/util/is-possible-duplicate-patient' +import Patient from '../../../shared/model/Patient' + +describe('is possible duplicate patient', () => { + describe('isPossibleDuplicatePatient', () => { + it('should return true when duplicate patients are passed', () => { + const newPatient = { + givenName: 'given', + familyName: 'family', + suffix: 'suffix', + } as Patient + + const existingPatient = { + givenName: 'given', + familyName: 'family', + suffix: 'suffix', + } as Patient + + const isDuplicatePatient = isPossibleDuplicatePatient(newPatient, existingPatient) + + expect(isDuplicatePatient).toEqual(true) + }) + }) +}) 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 34e78818fd..4c614a0599 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, ] const store = mockStore({ components: { sidebarCollapsed: false }, diff --git a/src/__tests__/shared/components/navbar/Navbar.test.tsx b/src/__tests__/shared/components/navbar/Navbar.test.tsx index d0884a7883..01aabe01d3 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, ] describe('hamberger', () => { diff --git a/src/patients/new/DuplicateNewPatientModal.tsx b/src/patients/new/DuplicateNewPatientModal.tsx new file mode 100644 index 0000000000..6d78d76c38 --- /dev/null +++ b/src/patients/new/DuplicateNewPatientModal.tsx @@ -0,0 +1,61 @@ +import { Modal, Alert } from '@hospitalrun/components' +import React from 'react' +import { Link } from 'react-router-dom' + +import useTranslator from '../../shared/hooks/useTranslator' +import Patient from '../../shared/model/Patient' + +interface Props { + duplicatePatient?: Patient + show: boolean + toggle: () => void + onCloseButtonClick: () => void + onContinueButtonClick: () => void +} + +const DuplicateNewPatientModal = (props: Props) => { + const { t } = useTranslator() + const { duplicatePatient, show, toggle, onCloseButtonClick, onContinueButtonClick } = props + + const body = ( + <> + +
+
+ {t('patients.possibleDuplicatePatient')} + {duplicatePatient !== undefined && + Object.entries(duplicatePatient).map(([key, patient]) => ( +
  • + {patient.fullName} +
  • + ))} +
    +
    + + ) + + return ( + + ) +} + +export default DuplicateNewPatientModal diff --git a/src/patients/new/NewPatient.tsx b/src/patients/new/NewPatient.tsx index 7cd7b52fdf..30fd1d1393 100644 --- a/src/patients/new/NewPatient.tsx +++ b/src/patients/new/NewPatient.tsx @@ -10,6 +10,8 @@ import Patient from '../../shared/model/Patient' import { RootState } from '../../shared/store' import GeneralInformation from '../GeneralInformation' import { createPatient } from '../patient-slice' +import { isPossibleDuplicatePatient } from '../util/is-possible-duplicate-patient' +import DuplicateNewPatientModal from './DuplicateNewPatientModal' const breadcrumbs = [ { i18nKey: 'patients.label', location: '/patients' }, @@ -21,8 +23,18 @@ const NewPatient = () => { const history = useHistory() const dispatch = useDispatch() const { createError } = useSelector((state: RootState) => state.patient) + const { patients } = Object(useSelector((state: RootState) => state.patients)) const [patient, setPatient] = useState({} as Patient) + const [duplicatePatient, setDuplicatePatient] = useState(undefined) + const [showDuplicateNewPatientModal, setShowDuplicateNewPatientModal] = useState(false) + + const testPatient = { + givenName: 'Kelly', + familyName: 'Clark', + sex: 'female', + dateOfBirth: '1963-01-09T05:00:00.000Z', + } as Patient useTitle(t('patients.newPatient')) useAddBreadcrumbs(breadcrumbs, true) @@ -41,13 +53,39 @@ const NewPatient = () => { } const onSave = () => { - dispatch(createPatient(patient, onSuccessfulSave)) + let duplicatePatients = [] + if (patients !== undefined) { + duplicatePatients = patients.filter((existingPatient: any) => + isPossibleDuplicatePatient(patient, existingPatient), + ) + } + + if (duplicatePatients.length > 0) { + setShowDuplicateNewPatientModal(true) + setDuplicatePatient(duplicatePatients as Patient) + } else { + dispatch(createPatient(patient, onSuccessfulSave)) + } + + const testCase = [isPossibleDuplicatePatient(patient, testPatient)] + if (testCase.length > 0) { + return true + } + return false } const onPatientChange = (newPatient: Partial) => { setPatient(newPatient as Patient) } + const createDuplicateNewPatient = () => { + dispatch(createPatient(patient, onSuccessfulSave)) + } + + const closeDuplicateNewPatientModal = () => { + setShowDuplicateNewPatientModal(false) + } + return (
    {
    + + ) } 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/util/is-possible-duplicate-patient.ts b/src/patients/util/is-possible-duplicate-patient.ts new file mode 100644 index 0000000000..6d7ecc3baf --- /dev/null +++ b/src/patients/util/is-possible-duplicate-patient.ts @@ -0,0 +1,10 @@ +import Patient from '../../shared/model/Patient' + +export function isPossibleDuplicatePatient(newPatient: Patient, existingPatient: Patient) { + return ( + newPatient.givenName === existingPatient.givenName && + newPatient.familyName === existingPatient.familyName && + newPatient.sex === existingPatient.sex && + newPatient.dateOfBirth === existingPatient.dateOfBirth + ) +} 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/Sidebar.tsx b/src/shared/components/Sidebar.tsx index cc48668548..69a4751cf5 100644 --- a/src/shared/components/Sidebar.tsx +++ b/src/shared/components/Sidebar.tsx @@ -266,7 +266,7 @@ const Sidebar = () => { } style={expandibleArrow} /> - {!sidebarCollapsed && t('incidents.label')} + {!sidebarCollapsed && t('incidents.label')} {splittedPath[1].includes('incidents') && expandedItem === 'incidents' && ( diff --git a/src/shared/components/navbar/pageMap.tsx b/src/shared/components/navbar/pageMap.tsx index 0c5564eafc..37d22aecbb 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', + }, settings: { permission: null, label: 'settings.label', diff --git a/src/shared/locales/enUs/translations/patient/index.ts b/src/shared/locales/enUs/translations/patient/index.ts index 035993aafe..bef961605d 100644 --- a/src/shared/locales/enUs/translations/patient/index.ts +++ b/src/shared/locales/enUs/translations/patient/index.ts @@ -126,6 +126,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/locales/enUs/translations/patients/index.ts b/src/shared/locales/enUs/translations/patients/index.ts index 3be64bf9d0..8f8703b423 100644 --- a/src/shared/locales/enUs/translations/patients/index.ts +++ b/src/shared/locales/enUs/translations/patients/index.ts @@ -1,6 +1,7 @@ export default { patients: { label: 'Patients', + warning: 'Warning!', patientsList: 'Patients List', viewPatients: 'View Patients', viewPatient: 'View Patient', @@ -9,5 +10,8 @@ export default { successfullyCreated: 'Successfully created patient', successfullyAddedNote: 'Successfully added the new note', successfullyAddedRelatedPerson: 'Successfully added a new related person', + possibleDuplicatePatient: 'Possible duplicate patient:', + duplicatePatientWarning: + 'Patient with matching information found in database. Are you sure you want to create this patient?', }, } 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 f9c6d2ac1f..6a615382ff 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', } export default Permissions 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 660df6262a..f8d1468824 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, ], }