diff --git a/src/HospitalRun.tsx b/src/HospitalRun.tsx index 4bd60bdd01..8335d29852 100644 --- a/src/HospitalRun.tsx +++ b/src/HospitalRun.tsx @@ -6,6 +6,7 @@ import { Route, Switch } from 'react-router-dom' import Dashboard from './dashboard/Dashboard' import Imagings from './imagings/Imagings' import Incidents from './incidents/Incidents' +import Inventory from './inventory/Inventory' import Labs from './labs/Labs' import Medications from './medications/Medications' import Breadcrumbs from './page-header/breadcrumbs/Breadcrumbs' @@ -55,6 +56,7 @@ const HospitalRun = () => { + diff --git a/src/__tests__/inventory/AddInventoryItem.test.tsx b/src/__tests__/inventory/AddInventoryItem.test.tsx new file mode 100644 index 0000000000..b7d6f60275 --- /dev/null +++ b/src/__tests__/inventory/AddInventoryItem.test.tsx @@ -0,0 +1,9 @@ +describe('AddInventoryItem', () => { + it('add item and cancel buttons render', () => {}) + + it('cancel returns you to inventory page', () => {}) + + it('save takes you to view item', () => {}) + + it('text fields are editable', () => {}) +}) diff --git a/src/__tests__/inventory/EditItem.test.tsx b/src/__tests__/inventory/EditItem.test.tsx new file mode 100644 index 0000000000..c63c30f6be --- /dev/null +++ b/src/__tests__/inventory/EditItem.test.tsx @@ -0,0 +1,11 @@ +describe('EditItem', () => { + it('text fields are editable', () => {}) + + it('save and cancel buttons render', () => {}) + + it('if data is loading the spinner should render', () => {}) + + it('check that useUpdateItem is called when save is clicked', () => {}) + + it('cancel should return to View Item', () => {}) +}) diff --git a/src/__tests__/inventory/Inventory.test.tsx b/src/__tests__/inventory/Inventory.test.tsx new file mode 100644 index 0000000000..b9c0ec03e6 --- /dev/null +++ b/src/__tests__/inventory/Inventory.test.tsx @@ -0,0 +1,108 @@ +// Code taken from Incidents.test.tsx + +import { mount, ReactWrapper } from 'enzyme' +import React from 'react' +import { act } from 'react-dom/test-utils' +import { Provider } from 'react-redux' +import { MemoryRouter } from 'react-router-dom' +import createMockStore from 'redux-mock-store' +import thunk from 'redux-thunk' + +import Incidents from '../../incidents/Incidents' +import ReportIncident from '../../incidents/report/ReportIncident' +import ViewIncident from '../../incidents/view/ViewIncident' +import VisualizeIncidents from '../../incidents/visualize/VisualizeIncidents' +import * as titleUtil from '../../page-header/title/TitleContext' +import IncidentRepository from '../../shared/db/IncidentRepository' +import Incident from '../../shared/model/Incident' +import Permissions from '../../shared/model/Permissions' +import { RootState } from '../../shared/store' + +const mockStore = createMockStore([thunk]) + +describe('Incidents', () => { + const setup = async (permissions: Permissions[], path: string) => { + const expectedIncident = { + id: '1234', + code: '1234', + date: new Date().toISOString(), + reportedOn: new Date().toISOString(), + } as Incident + jest.spyOn(titleUtil, 'useUpdateTitle').mockImplementation(() => jest.fn()) + jest.spyOn(IncidentRepository, 'search').mockResolvedValue([]) + jest.spyOn(IncidentRepository, 'find').mockResolvedValue(expectedIncident) + const store = mockStore({ + user: { permissions }, + breadcrumbs: { breadcrumbs: [] }, + components: { sidebarCollapsed: false }, + } as any) + + let wrapper: any + await act(async () => { + wrapper = await mount( + + + + + + + , + ) + }) + wrapper.find(Incidents).props().updateTitle = jest.fn() + wrapper.update() + + return { wrapper: wrapper as ReactWrapper } + } + + describe('title', () => { + it('should have called the useUpdateTitle hook', async () => { + await setup([Permissions.ViewIncidents], '/incidents') + expect(titleUtil.useUpdateTitle).toHaveBeenCalledTimes(1) + }) + }) + + describe('routing', () => { + describe('/incidents/new', () => { + it('should render the new incident screen when /incidents/new is accessed', async () => { + const { wrapper } = await setup([Permissions.ReportIncident], '/incidents/new') + + expect(wrapper.find(ReportIncident)).toHaveLength(1) + }) + + it('should not navigate to /incidents/new if the user does not have ReportIncident permissions', async () => { + const { wrapper } = await setup([], '/incidents/new') + + expect(wrapper.find(ReportIncident)).toHaveLength(0) + }) + }) + + describe('/incidents/visualize', () => { + it('should render the incident visualize screen when /incidents/visualize is accessed', async () => { + const { wrapper } = await setup([Permissions.ViewIncidentWidgets], '/incidents/visualize') + + expect(wrapper.find(VisualizeIncidents)).toHaveLength(1) + }) + + it('should not navigate to /incidents/visualize if the user does not have ViewIncidentWidgets permissions', async () => { + const { wrapper } = await setup([], '/incidents/visualize') + + expect(wrapper.find(VisualizeIncidents)).toHaveLength(0) + }) + }) + + describe('/incidents/:id', () => { + it('should render the view incident screen when /incidents/:id is accessed', async () => { + const { wrapper } = await setup([Permissions.ViewIncident], '/incidents/1234') + + expect(wrapper.find(ViewIncident)).toHaveLength(1) + }) + + it('should not navigate to /incidents/:id if the user does not have ViewIncident permissions', async () => { + const { wrapper } = await setup([], '/incidents/1234') + + expect(wrapper.find(ViewIncident)).toHaveLength(0) + }) + }) + }) +}) diff --git a/src/__tests__/inventory/hooks/useAddInventoryItem.test.tsx b/src/__tests__/inventory/hooks/useAddInventoryItem.test.tsx new file mode 100644 index 0000000000..49b2c0056d --- /dev/null +++ b/src/__tests__/inventory/hooks/useAddInventoryItem.test.tsx @@ -0,0 +1,57 @@ +/* eslint-disable no-console */ + +import shortid from 'shortid' + +import * as itemValidator from '../../../inventory/add/validate-inventory-item' +import { InventoryItemError } from '../../../inventory/add/validate-inventory-item' +import useAddInventoryItem from '../../../inventory/hooks/useAddInventoryItem' +import InventoryRepository from '../../../shared/db/InventoryRepository' +import InventoryItem from '../../../shared/model/InventoryItem' +import executeMutation from '../../test-utils/use-mutation.util' + +// This is code taken and slightly edited from Incidents. Not sure if you want +// to make use of it or start from scracth + +describe('useAddInventoryItem', () => { + // beforeEach(() => { + // jest.restoreAllMocks() + // console.error = jest.fn() + // }) + // it('should add an item with the correct data', async () => { + // const expectedId = '123456' + // const givenItemInformation = { + // name: 'some name', + // rank: 'some rank', + // type: 'clothing', + // crossReference: 'some cross reference', + // reorderPoint: 'some reorder point', + // distributionUnit: 'ampoule', + // pricePerUnit: 0, + // note: 'some note', + // } as InventoryItem + // const expectedItem = { + // ...givenItemInformation, + // id: `I-${expectedId}`, + // } as InventoryItem + // jest.spyOn(shortid, 'generate').mockReturnValue(expectedId) + // jest.spyOn(InventoryRepository, 'save').mockResolvedValue(expectedItem) + // const actualData = await executeMutation(() => useAddInventoryItem(), givenItemInformation) + // expect(InventoryRepository.save).toHaveBeenCalledTimes(1) + // expect(InventoryRepository.save).toBeCalledWith(expectedItem) + // expect(actualData).toEqual(expectedItem) + // }) + // it('should throw an error if validation fails', async () => { + // // review InventoryItemError + // const expectedInventoryError = { + // description: 'some description error', + // } as InventoryItemError + // jest.spyOn(itemValidator, 'default').mockReturnValue(expectedInventoryError) + // jest.spyOn(InventoryRepository, 'save').mockResolvedValue({} as InventoryItem) + // try { + // await executeMutation(() => useAddInventoryItem(), {}) + // } catch (e) { + // expect(e).toEqual(expectedInventoryError) + // expect(InventoryRepository.save).not.toHaveBeenCalled() + // } + // }) +}) diff --git a/src/__tests__/inventory/hooks/useDeleteItem.test.tsx b/src/__tests__/inventory/hooks/useDeleteItem.test.tsx new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/__tests__/inventory/hooks/useInventory.test.tsx b/src/__tests__/inventory/hooks/useInventory.test.tsx new file mode 100644 index 0000000000..5022a2929e --- /dev/null +++ b/src/__tests__/inventory/hooks/useInventory.test.tsx @@ -0,0 +1,34 @@ +import { act, renderHook } from '@testing-library/react-hooks' + +import useInventory from '../../../inventory/hooks/useInventory' +import InventorySearchRequest from '../../../inventory/model/InventorySearchRequest' +import InventoryRepository from '../../../shared/db/InventoryRepository' +import InventoryItem from '../../../shared/model/InventoryItem' +import waitUntilQueryIsSuccessful from '../../test-utils/wait-for-query.util' + +describe('useInventory', () => { + it('should search inventory', async () => { + const expectedSearchRequest: InventorySearchRequest = { + type: 'all', + text: '', + } + const expectedItems = [ + { + id: 'some id', + }, + ] as InventoryItem[] + jest.spyOn(InventoryRepository, 'search').mockResolvedValue(expectedItems) + + let actualData: any + await act(async () => { + const renderHookResult = renderHook(() => useInventory(expectedSearchRequest)) + const { result } = renderHookResult + await waitUntilQueryIsSuccessful(renderHookResult) + actualData = result.current.data + }) + + expect(InventoryRepository.search).toHaveBeenCalledTimes(1) + expect(InventoryRepository.search).toBeCalledWith(expectedSearchRequest) + expect(actualData).toEqual(expectedItems) + }) +}) diff --git a/src/__tests__/inventory/hooks/useItem.test.tsx b/src/__tests__/inventory/hooks/useItem.test.tsx new file mode 100644 index 0000000000..193ade28d6 --- /dev/null +++ b/src/__tests__/inventory/hooks/useItem.test.tsx @@ -0,0 +1,28 @@ +import { renderHook, act } from '@testing-library/react-hooks' + +import useItem from '../../../inventory/hooks/useItem' +import InventoryRepository from '../../../shared/db/InventoryRepository' +import InventoryItem from '../../../shared/model/InventoryItem' +import waitUntilQueryIsSuccessful from '../../test-utils/wait-for-query.util' + +describe('useItem', () => { + it('should get an item by id', async () => { + const expectedItemId = 'some id' + const expectedItem = { + id: expectedItemId, + } as InventoryItem + jest.spyOn(InventoryRepository, 'find').mockResolvedValue(expectedItem) + + let actualData: any + await act(async () => { + const renderHookResult = renderHook(() => useItem(expectedItemId)) + const { result } = renderHookResult + await waitUntilQueryIsSuccessful(renderHookResult) + actualData = result.current.data + }) + + expect(InventoryRepository.find).toHaveBeenCalledTimes(1) + expect(InventoryRepository.find).toBeCalledWith(expectedItemId) + expect(actualData).toEqual(expectedItem) + }) +}) diff --git a/src/__tests__/inventory/hooks/useUpdateItem.test.tsx b/src/__tests__/inventory/hooks/useUpdateItem.test.tsx new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/__tests__/inventory/view/InventorySearch.test.tsx b/src/__tests__/inventory/view/InventorySearch.test.tsx new file mode 100644 index 0000000000..3933655d5c --- /dev/null +++ b/src/__tests__/inventory/view/InventorySearch.test.tsx @@ -0,0 +1,5 @@ +describe('InventorySearch', () => { + it('typing in search bar calls onChange', () => {}) + + it('filtering calls onChange', () => {}) +}) diff --git a/src/__tests__/inventory/view/ViewInventory.test.tsx b/src/__tests__/inventory/view/ViewInventory.test.tsx new file mode 100644 index 0000000000..658d7c2922 --- /dev/null +++ b/src/__tests__/inventory/view/ViewInventory.test.tsx @@ -0,0 +1,5 @@ +describe('ViewInventory', () => { + it('test if props of table component match a given list', () => {}) + + it('add inventory button renders', () => {}) +}) diff --git a/src/__tests__/inventory/view/ViewInventoryTable.test.tsx b/src/__tests__/inventory/view/ViewInventoryTable.test.tsx new file mode 100644 index 0000000000..d8b229a9cd --- /dev/null +++ b/src/__tests__/inventory/view/ViewInventoryTable.test.tsx @@ -0,0 +1,11 @@ +describe('ViewInventoryTable', () => { + it('spinner renders when loading', () => {}) + + it('renders table', () => {}) + + it('clicking view takes you to item details', () => {}) + + it('if there are no items, alert should pop up', () => {}) + + it('data columns should match column headers', () => {}) +}) diff --git a/src/__tests__/inventory/view/ViewItem.test.tsx b/src/__tests__/inventory/view/ViewItem.test.tsx new file mode 100644 index 0000000000..8ef4495803 --- /dev/null +++ b/src/__tests__/inventory/view/ViewItem.test.tsx @@ -0,0 +1,29 @@ +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 { Route, Router } from 'react-router-dom' +import createMockStore from 'redux-mock-store' +import thunk from 'redux-thunk' + +import ViewIncident from '../../../incidents/view/ViewIncident' +import ViewIncidentDetails from '../../../incidents/view/ViewIncidentDetails' +import * as breadcrumbUtil from '../../../page-header/breadcrumbs/useAddBreadcrumbs' +import * as ButtonBarProvider from '../../../page-header/button-toolbar/ButtonBarProvider' +import * as titleUtil from '../../../page-header/title/TitleContext' +import IncidentRepository from '../../../shared/db/IncidentRepository' +import Incident from '../../../shared/model/Incident' +import Permissions from '../../../shared/model/Permissions' +import { RootState } from '../../../shared/store' + +const { TitleProvider } = titleUtil +const mockStore = createMockStore([thunk]) + +describe('ViewItem', () => { + it('clicking edit takes you to EditItem', () => {}) + + it('text fields should not be editable', () => {}) + + it('check if delete button exists and leads you to a confirmation', () => {}) +}) diff --git a/src/__tests__/inventory/view/ViewItemDetails.test.tsx b/src/__tests__/inventory/view/ViewItemDetails.test.tsx new file mode 100644 index 0000000000..b29150cd4e --- /dev/null +++ b/src/__tests__/inventory/view/ViewItemDetails.test.tsx @@ -0,0 +1,17 @@ +import { Button } from '@hospitalrun/components' +import { mount, ReactWrapper } from 'enzyme' +import { createMemoryHistory } from 'history' +import React from 'react' +import { act } from 'react-dom/test-utils' +import { Router } from 'react-router' + +import ViewIncidentDetails from '../../../incidents/view/ViewIncidentDetails' +import * as breadcrumbUtil from '../../../page-header/breadcrumbs/useAddBreadcrumbs' +import * as ButtonBarProvider from '../../../page-header/button-toolbar/ButtonBarProvider' +import IncidentRepository from '../../../shared/db/IncidentRepository' +import Incident from '../../../shared/model/Incident' +import Permissions from '../../../shared/model/Permissions' + +describe('ViewItemDetails', () => { + it('renders correct data in each field', () => {}) +}) diff --git a/src/__tests__/shared/components/Sidebar.test.tsx b/src/__tests__/shared/components/Sidebar.test.tsx index 7602971847..df3fda9eaa 100644 --- a/src/__tests__/shared/components/Sidebar.test.tsx +++ b/src/__tests__/shared/components/Sidebar.test.tsx @@ -42,6 +42,9 @@ describe('Sidebar', () => { Permissions.AddVisit, Permissions.RequestImaging, Permissions.ViewImagings, + Permissions.AddItem, + Permissions.ViewItem, + Permissions.ViewInventory, ] const store = mockStore({ components: { sidebarCollapsed: false }, @@ -461,27 +464,6 @@ describe('Sidebar', () => { expect(incidentsIndex).not.toBe(-1) }) - it('should be the last one in the sidebar', () => { - const wrapper = setup('/incidents') - - const listItems = wrapper.find(ListItem) - const reportsLabel = listItems.length - 2 - - expect(listItems.at(reportsLabel).text().trim()).toBe('incidents.reports.label') - expect( - listItems - .at(reportsLabel - 1) - .text() - .trim(), - ).toBe('incidents.reports.new') - expect( - listItems - .at(reportsLabel - 2) - .text() - .trim(), - ).toBe('incidents.label') - }) - it('should render the new incident report link', () => { const wrapper = setup('/incidents') @@ -849,4 +831,141 @@ describe('Sidebar', () => { expect(history.location.pathname).toEqual('/medications') }) }) + + describe('inventory links', () => { + it('should be the last one in the sidebar', () => { + const wrapper = setup('/inventory') + + const listItems = wrapper.find(ListItem) + const inventoryLabel = listItems.length - 1 + + expect(listItems.at(inventoryLabel).text().trim()).toBe('inventory.items.label') + expect( + listItems + .at(inventoryLabel - 1) + .text() + .trim(), + ).toBe('inventory.items.new') + expect( + listItems + .at(inventoryLabel - 2) + .text() + .trim(), + ).toBe('inventory.label') + }) + + it('should render the main inventory link', () => { + const wrapper = setup('/inventory') + + const listItems = wrapper.find(ListItem) + const inventoryIndex = getIndex(listItems, 'inventory.label') + + expect(inventoryIndex).not.toBe(-1) + }) + + it('should render the add inventory item link', () => { + const wrapper = setup('/inventory') + + const listItems = wrapper.find(ListItem) + const inventoryIndex = getIndex(listItems, 'inventory.items.new') + + expect(inventoryIndex).not.toBe(-1) + }) + + it('should not render the add inventory item link when user does not have add item privileges', () => { + const wrapper = setupNoPermissions('/inventory') + + const listItems = wrapper.find(ListItem) + const labsIndex = getIndex(listItems, 'inventory.items.new') + + expect(labsIndex).toBe(-1) + }) + + it('should render the inventory list link', () => { + const wrapper = setup('/inventory') + + const listItems = wrapper.find(ListItem) + const inventoryIndex = getIndex(listItems, 'inventory.items.label') + + expect(inventoryIndex).not.toBe(-1) + }) + + it('should not render the inventory list link when user does not have view inventory privileges', () => { + const wrapper = setupNoPermissions('/inventory') + + const listItems = wrapper.find(ListItem) + const inventoryIndex = getIndex(listItems, 'inventory.items.label') + + expect(inventoryIndex).toBe(-1) + }) + + it('main inventory link should be active when the current path is /inventory', () => { + const wrapper = setup('/inventory') + + const listItems = wrapper.find(ListItem) + const inventoryIndex = getIndex(listItems, 'inventory.label') + + expect(listItems.at(inventoryIndex).prop('active')).toBeTruthy() + }) + + it('should navigate to /inventory when the main lab link is clicked', () => { + const wrapper = setup('/') + + const listItems = wrapper.find(ListItem) + const inventoryIndex = getIndex(listItems, 'inventory.label') + + act(() => { + const onClick = listItems.at(inventoryIndex).prop('onClick') as any + onClick() + }) + + expect(history.location.pathname).toEqual('/inventory') + }) + + it('add inventory item link should be active when the current path is /inventory/new', () => { + const wrapper = setup('/inventory/new') + + const listItems = wrapper.find(ListItem) + const inventoryIndex = getIndex(listItems, 'inventory.items.new') + + expect(listItems.at(inventoryIndex).prop('active')).toBeTruthy() + }) + + it('should navigate to /inventory/new when the add inventory item link is clicked', () => { + const wrapper = setup('/inventory') + + const listItems = wrapper.find(ListItem) + const inventoryIndex = getIndex(listItems, 'inventory.items.new') + + act(() => { + const onClick = listItems.at(inventoryIndex).prop('onClick') as any + onClick() + }) + + expect(history.location.pathname).toEqual('/inventory/new') + }) + + it('inventory list link should be active when the current path is /inventory', () => { + const wrapper = setup('/inventory') + + const listItems = wrapper.find(ListItem) + const inventoryIndex = getIndex(listItems, 'inventory.items.label') + + expect(listItems.at(inventoryIndex).prop('active')).toBeTruthy() + }) + + it('should navigate to /inventory when the inventory list link is clicked', () => { + const wrapper = setup('/inventory/new') + + const listItems = wrapper.find(ListItem) + const inventoryIndex = getIndex(listItems, 'inventory.items.label') + + act(() => { + const onClick = listItems.at(inventoryIndex).prop('onClick') as any + onClick() + }) + + expect(history.location.pathname).toEqual('/inventory') + }) + }) }) diff --git a/src/__tests__/shared/components/navbar/Navbar.test.tsx b/src/__tests__/shared/components/navbar/Navbar.test.tsx index f91d76b4fe..fa036168d5 100644 --- a/src/__tests__/shared/components/navbar/Navbar.test.tsx +++ b/src/__tests__/shared/components/navbar/Navbar.test.tsx @@ -65,6 +65,9 @@ describe('Navbar', () => { Permissions.ReadVisits, Permissions.RequestImaging, Permissions.ViewImagings, + Permissions.AddItem, + Permissions.ViewItem, + Permissions.ViewInventory, ] describe('hamberger', () => { @@ -87,7 +90,7 @@ describe('Navbar', () => { }) it('should not show an item if user does not have a permission', () => { - // exclude labs, incidents, and imagings permissions + // exclude labs, incidents, inventory, and imagings permissions const wrapper = setup(cloneDeep(allPermissions).slice(0, 6)) const hospitalRunNavbar = wrapper.find(HospitalRunNavbar) const hamberger = hospitalRunNavbar.find('.nav-hamberger') @@ -98,6 +101,8 @@ describe('Navbar', () => { 'labs.requests.label', 'incidents.reports.new', 'incidents.reports.label', + 'inventory.items.new', + 'inventory.items.label', 'medications.requests.new', 'medications.requests.label', 'imagings.requests.new', @@ -165,6 +170,7 @@ describe('Navbar', () => { expect(option.props.children).not.toEqual('labs.requests.new') expect(option.props.children).not.toEqual('incidents.requests.new') expect(option.props.children).not.toEqual('imagings.requests.new') + expect(option.props.children).not.toEqual('inventory.items.new') }) }) }) diff --git a/src/inventory/Inventory.tsx b/src/inventory/Inventory.tsx new file mode 100644 index 0000000000..fadee87b45 --- /dev/null +++ b/src/inventory/Inventory.tsx @@ -0,0 +1,56 @@ +import React from 'react' +import { useSelector } from 'react-redux' +import { Switch } from 'react-router-dom' + +import useAddBreadcrumbs from '../page-header/breadcrumbs/useAddBreadcrumbs' +import PrivateRoute from '../shared/components/PrivateRoute' +import Permissions from '../shared/model/Permissions' +import { RootState } from '../shared/store' +import AddInventoryItem from './add/AddInventoryItem' +import EditItem from './edit/EditItem' +import ViewInventory from './view/ViewInventory' +import ViewItem from './view/ViewItem' + +const Inventory = () => { + const { permissions } = useSelector((state: RootState) => state.user) + const breadcrumbs = [ + { + i18nKey: 'inventory.label', + location: `/inventory`, + }, + ] + useAddBreadcrumbs(breadcrumbs, true) + + return ( + + + + + + + ) +} + +export default Inventory diff --git a/src/inventory/add/AddInventoryItem.tsx b/src/inventory/add/AddInventoryItem.tsx new file mode 100644 index 0000000000..dbeccf59ef --- /dev/null +++ b/src/inventory/add/AddInventoryItem.tsx @@ -0,0 +1,213 @@ +import { Button } from '@hospitalrun/components' +import React, { useState, useEffect } from 'react' +import { useHistory } from 'react-router-dom' + +import useAddBreadcrumbs from '../../page-header/breadcrumbs/useAddBreadcrumbs' +import { useUpdateTitle } from '../../page-header/title/TitleContext' +import SelectWithLabelFormGroup, { + Option, +} from '../../shared/components/input/SelectWithLabelFormGroup' +import TextFieldWithLabelFormGroup from '../../shared/components/input/TextFieldWithLabelFormGroup' +import TextInputWithLabelFormGroup from '../../shared/components/input/TextInputWithLabelFormGroup' +import useTranslator from '../../shared/hooks/useTranslator' +import InventoryItem from '../../shared/model/InventoryItem' +import useAddInventoryItem from '../hooks/useAddInventoryItem' +import { InventoryItemError } from './validate-inventory-item' + +const AddInventoryItem = () => { + const [mutate] = useAddInventoryItem() + const { t } = useTranslator() + const history = useHistory() + const updateTitle = useUpdateTitle() + useEffect(() => { + updateTitle(t('inventory.items.new')) + }) + const [error, setError] = useState(undefined) + + const [addInventoryItem, setAddInventoryItem] = useState(({ + name: '', + rank: '', + type: '', + crossReference: '', + reorderPoint: ('' as unknown) as number, + distributionUnit: '', + pricePerUnit: ('' as unknown) as number, + note: '', + } as unknown) as InventoryItem) + + const typeOptions: Option[] = [ + { label: t('inventory.type.clothing'), value: 'clothing' }, + { label: t('inventory.type.equipment'), value: 'equipment' }, + { label: t('inventory.type.medication'), value: 'medication' }, + ] + + const distributionUnitOptions: Option[] = [ + { label: t('inventory.distributionUnit.ampoule'), value: 'ampoule' }, + { label: t('inventory.distributionUnit.bag'), value: 'bag' }, + { label: t('inventory.distributionUnit.bottle'), value: 'bottle' }, + { label: t('inventory.distributionUnit.box'), value: 'box' }, + { label: t('inventory.distributionUnit.bundle'), value: 'bundle' }, + { label: t('inventory.distributionUnit.capsule'), value: 'capsule' }, + { label: t('inventory.distributionUnit.case'), value: 'case' }, + { label: t('inventory.distributionUnit.container'), value: 'container' }, + { label: t('inventory.distributionUnit.cream'), value: 'cream' }, + { label: t('inventory.distributionUnit.each'), value: 'each' }, + { label: t('inventory.distributionUnit.gel'), value: 'gel' }, + { label: t('inventory.distributionUnit.nebule'), value: 'nebule' }, + { label: t('inventory.distributionUnit.ointment'), value: 'ointment' }, + { label: t('inventory.distributionUnit.pack'), value: 'pack' }, + { label: t('inventory.distributionUnit.pair'), value: 'pair' }, + { label: t('inventory.distributionUnit.pallet'), value: 'pallet' }, + { label: t('inventory.distributionUnit.patch'), value: 'patch' }, + { label: t('inventory.distributionUnit.pcs'), value: 'pcs' }, + { label: t('inventory.distributionUnit.pill'), value: 'pill' }, + { label: t('inventory.distributionUnit.plastic'), value: 'plastic' }, + { label: t('inventory.distributionUnit.polyamp'), value: 'polyamp' }, + { label: t('inventory.distributionUnit.rollset'), value: 'rollset' }, + { label: t('inventory.distributionUnit.spray'), value: 'spray' }, + { label: t('inventory.distributionUnit.suppository'), value: 'suppository' }, + { label: t('inventory.distributionUnit.suspension'), value: 'suspension' }, + { label: t('inventory.distributionUnit.syrup'), value: 'syrup' }, + { label: t('inventory.distributionUnit.tablet'), value: 'tablet' }, + { label: t('inventory.distributionUnit.tray'), value: 'tray' }, + { label: t('inventory.distributionUnit.tube'), value: 'tube' }, + { label: t('inventory.distributionUnit.vial'), value: 'vial' }, + ] + + const breadcrumbs = [ + { + i18nKey: 'inventory.items.new', + location: `/inventory/new`, + }, + ] + useAddBreadcrumbs(breadcrumbs) + + const onFieldChange = (key: string, value: string | boolean) => { + setAddInventoryItem((previousAddInventoryItem) => ({ + ...previousAddInventoryItem, + [key]: value, + })) + } + + const onTextInputChange = (text: string, key: string) => { + setAddInventoryItem((previousAddInventoryItem) => ({ + ...previousAddInventoryItem, + [key]: text, + })) + } + + const onSave = async () => { + try { + const newInventoryItem = await mutate(addInventoryItem as InventoryItem) + history.push(`/inventory/${newInventoryItem?.id}`) + } catch (e) { + setError(e) + } + } + + const onCancel = () => { + history.push('/inventory') + } + + return ( + <> +
+ onTextInputChange(event.currentTarget.value, 'name')} + /> + onTextInputChange(event.currentTarget.value, 'rank')} + /> +
+ value === addInventoryItem.type)} + onChange={(values) => onFieldChange && onFieldChange('type', values[0])} + isEditable + /> +
+ onTextInputChange(event.currentTarget.value, 'crossReference')} + /> + onTextInputChange(event.currentTarget.value, 'reorderPoint')} + isInvalid={!!error?.reorderPointError} + feedback={t(error?.reorderPointError as string)} + /> +
+ value === addInventoryItem.distributionUnit, + )} + onChange={(values) => onFieldChange && onFieldChange('distributionUnit', values[0])} + isEditable + /> +
+ onTextInputChange(event.currentTarget.value, 'pricePerUnit')} + isInvalid={!!error?.pricePerUnitError} + feedback={t(error?.pricePerUnitError as string)} + /> +
+ onTextInputChange(event.currentTarget.value, 'note')} + /> +
+
+
+ + +
+
+ + + ) +} + +export default AddInventoryItem diff --git a/src/inventory/add/validate-inventory-item.ts b/src/inventory/add/validate-inventory-item.ts new file mode 100644 index 0000000000..5a25248a6c --- /dev/null +++ b/src/inventory/add/validate-inventory-item.ts @@ -0,0 +1,72 @@ +import InventoryItem from '../../shared/model/InventoryItem' + +export class InventoryItemError extends Error { + itemNameError?: string + + rankError?: string + + crossReferenceError?: string + + reorderPointError?: string + + pricePerUnitError?: string + + constructor( + message: string, + itemNameError: string, + rankError: string, + crossReferenceError: string, + reorderPointError: string, + pricePerUnitError: string, + ) { + super(message) + this.itemNameError = itemNameError + this.rankError = rankError + this.crossReferenceError = crossReferenceError + this.reorderPointError = reorderPointError + this.pricePerUnitError = pricePerUnitError + Object.setPrototypeOf(this, InventoryItemError.prototype) + } +} + +export default function validateItem(item: InventoryItem): InventoryItemError { + const newError: any = {} + + if (!item.name) { + newError.itemNameError = 'inventory.items.error.nameRequired' + } + + if (!item.rank) { + newError.rankError = 'inventory.items.error.rankRequired' + } + + if (!item.crossReference) { + newError.crossReferenceError = 'inventory.items.error.crossReferenceRequired' + } + + if (!item.reorderPoint) { + newError.reorderPointError = 'inventory.items.error.reorderPointRequired' + } + + if (Number.isNaN(Number(item.reorderPoint))) { + newError.reorderPointError = 'inventory.items.error.reorderPointNaN' + } + + if (Number(item.reorderPoint) < 0) { + newError.reorderPointError = 'inventory.items.error.negative' + } + + if (!item.pricePerUnit) { + newError.pricePerUnitError = 'inventory.items.error.pricePerUnitRequired' + } + + if (Number.isNaN(Number(item.pricePerUnit))) { + newError.pricePerUnitError = 'inventory.items.error.pricePerUnitNaN' + } + + if (Number(item.pricePerUnit) < 0) { + newError.pricePerUnitError = 'inventory.items.error.negative' + } + + return newError as InventoryItemError +} diff --git a/src/inventory/edit/EditItem.tsx b/src/inventory/edit/EditItem.tsx new file mode 100644 index 0000000000..874ce319cb --- /dev/null +++ b/src/inventory/edit/EditItem.tsx @@ -0,0 +1,92 @@ +import { Spinner, Button, Toast } from '@hospitalrun/components' +import _ from 'lodash' +import React, { useEffect, useState } from 'react' +import { useHistory, useParams } from 'react-router-dom' + +import useAddBreadcrumbs from '../../page-header/breadcrumbs/useAddBreadcrumbs' +import { useUpdateTitle } from '../../page-header/title/TitleContext' +import useTranslator from '../../shared/hooks/useTranslator' +import InventoryItem from '../../shared/model/InventoryItem' +import useItem from '../hooks/useItem' +import useUpdateItem from '../hooks/useUpdateItem' +import ViewItemDetails from '../view/ViewItemDetails' + +const EditItem = () => { + const { t } = useTranslator() + const { id } = useParams() + + const updateTitle = useUpdateTitle() + updateTitle(t('inventory.items.edit')) + const history = useHistory() + + const [newItem, setItem] = useState({} as InventoryItem) + const { data: currentItem, isLoading: isLoadingItem } = useItem(id) + + const { + mutate: updateMutate, + isLoading: isLoadingUpdate, + isError: isErrorUpdate, + error: updateMutateError, + } = useUpdateItem(newItem) + + useAddBreadcrumbs([ + { + i18nKey: 'inventory.items.edit', + location: `/inventory/edit/${id}`, + }, + ]) + + useEffect(() => { + if (currentItem !== undefined) { + setItem(currentItem) + } + }, [currentItem]) + + const onCancel = () => { + history.push(`/inventory/${newItem.id}`) + } + + const onSave = () => { + if (_.isEmpty(updateMutateError) && !isErrorUpdate) { + updateMutate(newItem).then(() => { + Toast('success', t('states.success'), t('inventory.items.successfullyUpdated')) + history.push(`/inventory/${newItem.id}`) + }) + } + } + + const onFieldChange = (key: string, value: string | boolean) => { + setItem({ + ...newItem, + + [key]: value, + }) + } + + if (isLoadingItem || isLoadingUpdate) { + return + } + + return ( +
+ +
+
+ + +
+
+
+ ) +} + +export default EditItem diff --git a/src/inventory/hooks/useAddInventoryItem.tsx b/src/inventory/hooks/useAddInventoryItem.tsx new file mode 100644 index 0000000000..2e7939f601 --- /dev/null +++ b/src/inventory/hooks/useAddInventoryItem.tsx @@ -0,0 +1,31 @@ +import { isEmpty } from 'lodash' +import { queryCache, useMutation } from 'react-query' +import shortid from 'shortid' + +import InventoryRepository from '../../shared/db/InventoryRepository' +import InventoryItem from '../../shared/model/InventoryItem' +import validateItem from '../add/validate-inventory-item' + +const getItemID = (): string => `I-${shortid.generate()}` + +export function addInventoryItem(item: InventoryItem): Promise { + const error = validateItem(item) + if (isEmpty(error)) { + const updatedItem: InventoryItem = { + ...item, + id: getItemID(), + } + return InventoryRepository.save(updatedItem) + } + + throw error +} + +export default function useAddedItem() { + return useMutation(addInventoryItem, { + onSuccess: async () => { + await queryCache.invalidateQueries('items') + }, + throwOnError: true, + }) +} diff --git a/src/inventory/hooks/useDeleteItem.tsx b/src/inventory/hooks/useDeleteItem.tsx new file mode 100644 index 0000000000..20b64aa820 --- /dev/null +++ b/src/inventory/hooks/useDeleteItem.tsx @@ -0,0 +1,21 @@ +import { queryCache, useMutation } from 'react-query' + +import InventoryRepository from '../../shared/db/InventoryRepository' +import InventoryItem from '../../shared/model/InventoryItem' + +interface deleteItemRequest { + itemId: string +} + +async function deleteItem(request: deleteItemRequest): Promise { + const item = await InventoryRepository.find(request.itemId) + return InventoryRepository.delete(item) +} + +export default function useDeleteItem() { + return useMutation(deleteItem, { + onSuccess: async () => { + await queryCache.invalidateQueries('item') + }, + }) +} diff --git a/src/inventory/hooks/useInventory.tsx b/src/inventory/hooks/useInventory.tsx new file mode 100644 index 0000000000..8ac0a7f56e --- /dev/null +++ b/src/inventory/hooks/useInventory.tsx @@ -0,0 +1,16 @@ +import { useQuery } from 'react-query' + +import InventoryRepository from '../../shared/db/InventoryRepository' +import InventoryItem from '../../shared/model/InventoryItem' +import InventorySearchRequest from '../model/InventorySearchRequest' + +function fetchInventory( + _: string, + searchRequest: InventorySearchRequest, +): Promise { + return InventoryRepository.search(searchRequest) +} + +export default function useInventory(searchRequest: InventorySearchRequest) { + return useQuery(['inventory', searchRequest], fetchInventory) +} diff --git a/src/inventory/hooks/useItem.tsx b/src/inventory/hooks/useItem.tsx new file mode 100644 index 0000000000..ddd8bc821d --- /dev/null +++ b/src/inventory/hooks/useItem.tsx @@ -0,0 +1,12 @@ +import { useQuery } from 'react-query' + +import InventoryRepository from '../../shared/db/InventoryRepository' +import InventoryItem from '../../shared/model/InventoryItem' + +function fetchInventoryItemById(_: any, itemId: string): Promise { + return InventoryRepository.find(itemId) +} + +export default function useItem(itemId: string) { + return useQuery(['item', itemId], fetchInventoryItemById) +} diff --git a/src/inventory/hooks/useUpdateItem.tsx b/src/inventory/hooks/useUpdateItem.tsx new file mode 100644 index 0000000000..2f05ed1f62 --- /dev/null +++ b/src/inventory/hooks/useUpdateItem.tsx @@ -0,0 +1,33 @@ +import { MutateFunction, queryCache, useMutation } from 'react-query' + +import InventoryRepository from '../../shared/db/InventoryRepository' +import InventoryItem from '../../shared/model/InventoryItem' +import validateItem, { InventoryItemError } from '../add/validate-inventory-item' + +interface updateItemResult { + mutate: MutateFunction + isLoading: boolean + isError: boolean + error: InventoryItemError +} + +async function updateItem(item: InventoryItem): Promise { + return InventoryRepository.saveOrUpdate(item) +} + +export default function useUpdateItem(item: InventoryItem): updateItemResult { + const updateItemError = validateItem(item) + const [mutate, { isLoading, isError }] = useMutation(updateItem, { + onSuccess: async () => { + await queryCache.invalidateQueries('item') + }, + throwOnError: true, + }) + const result: updateItemResult = { + mutate, + isLoading, + isError, + error: updateItemError, + } + return result +} diff --git a/src/inventory/model/InventoryFilter.ts b/src/inventory/model/InventoryFilter.ts new file mode 100644 index 0000000000..6909f6b4b7 --- /dev/null +++ b/src/inventory/model/InventoryFilter.ts @@ -0,0 +1,8 @@ +enum InventoryFilter { + clothing = 'clothing', + equipment = 'equipment', + medication = 'medication', + all = 'all', +} + +export default InventoryFilter diff --git a/src/inventory/model/InventorySearchRequest.ts b/src/inventory/model/InventorySearchRequest.ts new file mode 100644 index 0000000000..339454a74d --- /dev/null +++ b/src/inventory/model/InventorySearchRequest.ts @@ -0,0 +1,6 @@ +import { ItemType } from './ItemType' + +export default interface InventorySearchRequest { + text: string + type: ItemType +} diff --git a/src/inventory/model/ItemType.ts b/src/inventory/model/ItemType.ts new file mode 100644 index 0000000000..85139ac6dd --- /dev/null +++ b/src/inventory/model/ItemType.ts @@ -0,0 +1 @@ +export type ItemType = 'clothing' | 'equipment' | 'medication' | 'all' diff --git a/src/inventory/view/InventorySearch.tsx b/src/inventory/view/InventorySearch.tsx new file mode 100644 index 0000000000..09c66072e7 --- /dev/null +++ b/src/inventory/view/InventorySearch.tsx @@ -0,0 +1,66 @@ +import React, { ChangeEvent } from 'react' + +import SelectWithLabelFormGroup, { + Option, +} from '../../shared/components/input/SelectWithLabelFormGroup' +import TextInputWithLabelFormGroup from '../../shared/components/input/TextInputWithLabelFormGroup' +import useTranslator from '../../shared/hooks/useTranslator' +import InventoryFilter from '../model/InventoryFilter' +import InventorySearchRequest from '../model/InventorySearchRequest' +import { ItemType } from '../model/ItemType' + +interface Props { + searchRequest: InventorySearchRequest + onChange: (newSearchRequest: InventorySearchRequest) => void +} + +const InventorySearch = (props: Props) => { + const { searchRequest, onChange } = props + const { t } = useTranslator() + const filterOptions: Option[] = Object.values(InventoryFilter).map((filter) => ({ + label: t(`inventory.type.${filter}`), + value: `${filter}`, + })) + + const onSearchQueryChange = (event: ChangeEvent) => { + const query = event.target.value + onChange({ + ...searchRequest, + text: query, + }) + } + + const onFilterChange = (filter: ItemType) => { + onChange({ + ...searchRequest, + type: filter, + }) + } + + return ( +
+
+ value === searchRequest.type)} + onChange={(values) => onFilterChange(values[0] as ItemType)} + isEditable + /> +
+
+ +
+
+ ) +} + +export default InventorySearch diff --git a/src/inventory/view/ViewInventory.tsx b/src/inventory/view/ViewInventory.tsx new file mode 100644 index 0000000000..fb6c29a90a --- /dev/null +++ b/src/inventory/view/ViewInventory.tsx @@ -0,0 +1,69 @@ +import { Button } from '@hospitalrun/components' +import React, { useEffect, useCallback, useState } from 'react' +import { useSelector } from 'react-redux' +import { useHistory } from 'react-router-dom' + +import { useButtonToolbarSetter } from '../../page-header/button-toolbar/ButtonBarProvider' +import { useUpdateTitle } from '../../page-header/title/TitleContext' +import useTranslator from '../../shared/hooks/useTranslator' +import Permissions from '../../shared/model/Permissions' +import { RootState } from '../../shared/store' +import InventorySearchRequest from '../model/InventorySearchRequest' +import InventorySearch from './InventorySearch' +import ViewInventoryTable from './ViewInventoryTable' + +const ViewInventory = () => { + const { t } = useTranslator() + const history = useHistory() + const setButtons = useButtonToolbarSetter() + const updateTitle = useUpdateTitle() + useEffect(() => { + updateTitle(t('inventory.items.label')) + }) + const { permissions } = useSelector((state: RootState) => state.user) + + const getButtons = useCallback(() => { + const buttons: React.ReactNode[] = [] + + if (permissions.includes(Permissions.AddItem)) { + buttons.push( + , + ) + } + + return buttons + }, [permissions, history, t]) + + useEffect(() => { + setButtons(getButtons()) + return () => { + setButtons([]) + } + }, [getButtons, setButtons]) + + const [searchRequest, setSearchRequest] = useState({ + text: '', + type: 'all', + }) + + const onSearchRequestChange = (newSearchRequest: InventorySearchRequest) => { + setSearchRequest(newSearchRequest) + } + + return ( + <> + + + + ) +} + +export default ViewInventory diff --git a/src/inventory/view/ViewInventoryTable.tsx b/src/inventory/view/ViewInventoryTable.tsx new file mode 100644 index 0000000000..be14ffba2d --- /dev/null +++ b/src/inventory/view/ViewInventoryTable.tsx @@ -0,0 +1,62 @@ +import { Spinner, Table } from '@hospitalrun/components' +import React from 'react' +import { useHistory } from 'react-router' + +import useTranslator from '../../shared/hooks/useTranslator' +import useInventory from '../hooks/useInventory' +import InventorySearchRequest from '../model/InventorySearchRequest' + +interface Props { + searchRequest: InventorySearchRequest +} + +function ViewInventoryTable(props: Props) { + const { searchRequest } = props + const { t } = useTranslator() + const history = useHistory() + const { data, isLoading } = useInventory(searchRequest) + + if (data === undefined || isLoading) { + return + } + + return ( + <> + row.id} + data={data} + columns={[ + { + label: t('inventory.items.name'), + key: 'name', + }, + { + label: t('inventory.items.type'), + key: 'type', + }, + { + label: t('inventory.items.reorderPoint'), + key: 'reorderPoint', + }, + { + label: t('inventory.items.distributionUnit'), + key: 'distributionUnit', + }, + { + label: t('inventory.items.pricePerUnit'), + key: 'pricePerUnit', + }, + ]} + actionsHeaderText={t('actions.label')} + actions={[ + { + label: t('actions.view'), + action: (row) => history.push(`inventory/${row.id}`), + }, + ]} + /> + + ) +} + +export default ViewInventoryTable diff --git a/src/inventory/view/ViewItem.tsx b/src/inventory/view/ViewItem.tsx new file mode 100644 index 0000000000..00b2e2fa2b --- /dev/null +++ b/src/inventory/view/ViewItem.tsx @@ -0,0 +1,111 @@ +import { Spinner, Button, Modal, Toast } from '@hospitalrun/components' +import React, { useCallback, useEffect, useState } from 'react' +import { useSelector } from 'react-redux' +import { useHistory, useParams } from 'react-router-dom' + +import useAddBreadcrumbs from '../../page-header/breadcrumbs/useAddBreadcrumbs' +import { useButtonToolbarSetter } from '../../page-header/button-toolbar/ButtonBarProvider' +import { useUpdateTitle } from '../../page-header/title/TitleContext' +import useTranslator from '../../shared/hooks/useTranslator' +import Permissions from '../../shared/model/Permissions' +import { RootState } from '../../shared/store' +import useDeleteItem from '../hooks/useDeleteItem' +import useItem from '../hooks/useItem' +import ViewItemDetails from './ViewItemDetails' + +const ViewItem = () => { + const { t } = useTranslator() + const updateTitle = useUpdateTitle() + updateTitle(t('inventory.items.view')) + const { id } = useParams() + const history = useHistory() + const [deleteMutate] = useDeleteItem() + const [showDeleteConfirmation, setShowDeleteConfirmation] = useState(false) + const setButtonToolBar = useButtonToolbarSetter() + const { permissions } = useSelector((state: RootState) => state.user) + + const { data: item } = useItem(id) + useAddBreadcrumbs([ + { + i18nKey: 'inventory.items.view', + location: `/inventory/${id}`, + }, + ]) + + const onDeleteRequest = (event: React.MouseEvent) => { + event.preventDefault() + setShowDeleteConfirmation(true) + } + + const onDeleteConfirmation = () => { + if (!item) { + return + } + + deleteMutate({ itemId: item.id }).then(() => { + history.push('/inventory') + Toast('success', t('states.success'), t('inventory.items.successfullyDeleted')) + }) + setShowDeleteConfirmation(false) + } + + const getButtons = useCallback(() => { + const buttons: React.ReactNode[] = [] + if (item && permissions.includes(Permissions.AddItem)) { + buttons.push( + <> + + + , + ) + } + + return buttons + }, [item, history, permissions, t]) + + useEffect(() => { + setButtonToolBar(getButtons()) + + return () => { + setButtonToolBar([]) + } + }, [getButtons, setButtonToolBar]) + + return ( + <> + {item ? ( +
+ + setShowDeleteConfirmation(false)} + /> +
+ ) : ( + + )} + + ) +} + +export default ViewItem diff --git a/src/inventory/view/ViewItemDetails.tsx b/src/inventory/view/ViewItemDetails.tsx new file mode 100644 index 0000000000..270c584d45 --- /dev/null +++ b/src/inventory/view/ViewItemDetails.tsx @@ -0,0 +1,155 @@ +import { Alert } from '@hospitalrun/components' +import React from 'react' + +import SelectWithLabelFormGroup, { + Option, +} from '../../shared/components/input/SelectWithLabelFormGroup' +import TextFieldWithLabelFormGroup from '../../shared/components/input/TextFieldWithLabelFormGroup' +import TextInputWithLabelFormGroup from '../../shared/components/input/TextInputWithLabelFormGroup' +import useTranslator from '../../shared/hooks/useTranslator' +import InventoryItem from '../../shared/model/InventoryItem' + +interface Props { + item: InventoryItem + isEditable: boolean + error?: any + onFieldChange?: (key: string, value: string | boolean) => void +} + +const ViewItemDetails = (props: Props) => { + const { onFieldChange, item, isEditable, error } = props + const { t } = useTranslator() + + const onInputElementChange = (event: React.ChangeEvent, fieldName: string) => + onFieldChange && onFieldChange(fieldName, event.target.value) + + const typeOptions: Option[] = [ + { label: t('inventory.type.clothing'), value: 'clothing' }, + { label: t('inventory.type.equipment'), value: 'equipment' }, + { label: t('inventory.type.medication'), value: 'medication' }, + ] + + const distributionUnitOptions: Option[] = [ + { label: t('inventory.distributionUnit.ampoule'), value: 'ampoule' }, + { label: t('inventory.distributionUnit.bag'), value: 'bag' }, + { label: t('inventory.distributionUnit.bottle'), value: 'bottle' }, + { label: t('inventory.distributionUnit.box'), value: 'box' }, + { label: t('inventory.distributionUnit.bundle'), value: 'bundle' }, + { label: t('inventory.distributionUnit.capsule'), value: 'capsule' }, + { label: t('inventory.distributionUnit.case'), value: 'case' }, + { label: t('inventory.distributionUnit.container'), value: 'container' }, + { label: t('inventory.distributionUnit.cream'), value: 'cream' }, + { label: t('inventory.distributionUnit.each'), value: 'each' }, + { label: t('inventory.distributionUnit.gel'), value: 'gel' }, + { label: t('inventory.distributionUnit.nebule'), value: 'nebule' }, + { label: t('inventory.distributionUnit.ointment'), value: 'ointment' }, + { label: t('inventory.distributionUnit.pack'), value: 'pack' }, + { label: t('inventory.distributionUnit.pair'), value: 'pair' }, + { label: t('inventory.distributionUnit.pallet'), value: 'pallet' }, + { label: t('inventory.distributionUnit.patch'), value: 'patch' }, + { label: t('inventory.distributionUnit.pcs'), value: 'pcs' }, + { label: t('inventory.distributionUnit.pill'), value: 'pill' }, + { label: t('inventory.distributionUnit.plastic'), value: 'plastic' }, + { label: t('inventory.distributionUnit.polyamp'), value: 'polyamp' }, + { label: t('inventory.distributionUnit.rollset'), value: 'rollset' }, + { label: t('inventory.distributionUnit.spray'), value: 'spray' }, + { label: t('inventory.distributionUnit.suppository'), value: 'suppository' }, + { label: t('inventory.distributionUnit.suspension'), value: 'suspension' }, + { label: t('inventory.distributionUnit.syrup'), value: 'syrup' }, + { label: t('inventory.distributionUnit.tablet'), value: 'tablet' }, + { label: t('inventory.distributionUnit.tray'), value: 'tray' }, + { label: t('inventory.distributionUnit.tube'), value: 'tube' }, + { label: t('inventory.distributionUnit.vial'), value: 'vial' }, + ] + + return ( + <> + {error?.message && } + onInputElementChange(event, 'name')} + /> + onInputElementChange(event, 'rank')} + /> +
+ value === item.type)} + onChange={(values) => onFieldChange && onFieldChange('type', values[0])} + isEditable={isEditable} + /> +
+ onInputElementChange(event, 'crossReference')} + /> + onInputElementChange(event, 'reorderPoint')} + isInvalid={!!error?.reorderPointError} + feedback={t(error?.reorderPointError as string)} + /> +
+ value === item.distributionUnit, + )} + onChange={(values) => onFieldChange && onFieldChange('distributionUnit', values[0])} + isEditable={isEditable} + /> +
+ onInputElementChange(event, 'pricePerUnit')} + isInvalid={!!error?.pricePerUnitError} + feedback={t(error?.pricePerUnitError as string)} + /> +
+ onFieldChange && onFieldChange('note', event.currentTarget.value)} + /> +
+ + ) +} + +export default ViewItemDetails diff --git a/src/shared/components/Sidebar.tsx b/src/shared/components/Sidebar.tsx index 0ed2ca4aba..e97de41ee5 100644 --- a/src/shared/components/Sidebar.tsx +++ b/src/shared/components/Sidebar.tsx @@ -48,6 +48,8 @@ const Sidebar = () => { ? 'incidents' : splittedPath[1].includes('imagings') ? 'imagings' + : splittedPath[1].includes('inventory') + ? 'inventory' : 'none', ) @@ -412,6 +414,56 @@ const Sidebar = () => { ) + const getInventoryLinks = () => ( + <> + { + navigateTo('/inventory') + setExpansion('inventory') + }} + className="nav-item" + style={listItemStyle} + > + + {!sidebarCollapsed && t('inventory.label')} + + {splittedPath[1].includes('inventory') && expandedItem === 'inventory' && ( + + {permissions.includes(Permissions.AddItem) && ( + navigateTo('/inventory/new')} + active={splittedPath[1].includes('inventory') && splittedPath.length > 2} + > + + {!sidebarCollapsed && t('inventory.items.new')} + + )} + {permissions.includes(Permissions.ViewInventory) && ( + navigateTo('/inventory')} + active={splittedPath[1].includes('inventory') && splittedPath.length < 3} + > + + {!sidebarCollapsed && t('inventory.items.label')} + + )} + + )} + + ) + return ( diff --git a/src/shared/components/navbar/Navbar.tsx b/src/shared/components/navbar/Navbar.tsx index 6096259f2a..ccfadd0a1a 100644 --- a/src/shared/components/navbar/Navbar.tsx +++ b/src/shared/components/navbar/Navbar.tsx @@ -25,6 +25,7 @@ const Navbar = () => { 'incidents.reports.new', 'imagings.requests.new', 'settings.label', + 'inventory.items.new', ] function getDropdownListOfPages(pages: Page[]) { @@ -52,6 +53,7 @@ const Navbar = () => { pageMap.newLab, pageMap.newImaging, pageMap.newIncident, + pageMap.newItem, ] return ( diff --git a/src/shared/components/navbar/pageMap.tsx b/src/shared/components/navbar/pageMap.tsx index fcdeca9513..3988172bea 100644 --- a/src/shared/components/navbar/pageMap.tsx +++ b/src/shared/components/navbar/pageMap.tsx @@ -89,6 +89,30 @@ const pageMap: { path: '/incidents/visualize', icon: 'incident', }, + newItem: { + permission: Permissions.AddItem, + label: 'inventory.items.new', + path: '/inventory/new', + icon: 'add', + }, + viewInventory: { + permission: Permissions.ViewInventory, + label: 'inventory.items.label', + path: '/inventory', + icon: 'lab', + }, + 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/config/pouchdb.ts b/src/shared/config/pouchdb.ts index e7eb77c722..13faee81cf 100644 --- a/src/shared/config/pouchdb.ts +++ b/src/shared/config/pouchdb.ts @@ -63,6 +63,10 @@ export const schema = [ plural: 'medications', relations: { patient: { belongsTo: 'patient' } }, }, + { + singular: 'inventory', + plural: 'inventories', + }, ] export const relationalDb = localDb.setSchema(schema) export const remoteDb = serverDb as PouchDB.Database diff --git a/src/shared/db/InventoryRepository.ts b/src/shared/db/InventoryRepository.ts new file mode 100644 index 0000000000..8eacfe805e --- /dev/null +++ b/src/shared/db/InventoryRepository.ts @@ -0,0 +1,38 @@ +import { ItemType } from '../../inventory/model/ItemType' +import { relationalDb } from '../config/pouchdb' +import InventoryItem from '../model/InventoryItem' +import Repository from './Repository' + +interface SearchOptions { + text: string + type: ItemType +} + +class InventoryRepository extends Repository { + constructor() { + super('inventory', relationalDb) + } + + async search(container: SearchOptions): Promise { + const searchValue = { $regex: RegExp(container.text, 'i') } + const typeFilter = container.type !== 'all' ? [{ 'data.type': container.type }] : [undefined] + const selector = { + $and: [ + { + 'data.name': searchValue, + }, + ...typeFilter, + ].filter((x) => x !== undefined), + } + + return super.search({ + selector, + }) + } + + async save(entity: InventoryItem): Promise { + return super.save(entity) + } +} + +export default new InventoryRepository() diff --git a/src/shared/locales/enUs/translations/index.ts b/src/shared/locales/enUs/translations/index.ts index 5995498562..1d24bad509 100644 --- a/src/shared/locales/enUs/translations/index.ts +++ b/src/shared/locales/enUs/translations/index.ts @@ -3,6 +3,7 @@ import bloodType from './blood-type' import dashboard from './dashboard' import imagings from './imagings' import incidents from './incidents' +import inventory from './inventory' import labs from './labs' import medications from './medications' import networkStatus from './network-status' @@ -30,4 +31,5 @@ export default { ...user, ...bloodType, ...imagings, + ...inventory, } diff --git a/src/shared/locales/enUs/translations/inventory/index.ts b/src/shared/locales/enUs/translations/inventory/index.ts new file mode 100644 index 0000000000..ebe7eb6fd7 --- /dev/null +++ b/src/shared/locales/enUs/translations/inventory/index.ts @@ -0,0 +1,78 @@ +export default { + inventory: { + filterTitle: ' Filter by type', + label: 'Inventory', + actions: { + add: 'Add Item', + search: 'Search Inventory', + }, + type: { + clothing: 'Clothing', + equipment: 'Equipment', + medication: 'Medication', + all: 'All Types', + }, + distributionUnit: { + ampoule: 'Ampoule', + bag: 'Bag', + bottle: 'Bottle', + box: 'Box', + bundle: 'Bundle', + capsule: 'Capsule', + case: 'Case', + container: 'Container', + cream: 'Cream', + each: 'Each', + gel: 'Gel', + nebule: 'Nebule', + ointment: 'Ointment', + pack: 'Pack', + pair: 'Pair', + pallet: 'Pallet', + patch: 'Patch', + pcs: 'Pcs', + pill: 'Pill', + plastic: 'Plastic', + polyamp: 'Polyamp', + rollset: 'Rollset', + spray: 'Spray', + suppository: 'Suppository', + suspension: 'Suspension', + syrup: 'Syrup', + tablet: 'Tablet', + tray: 'Tray', + tube: 'Tube', + vial: 'Vial', + }, + items: { + label: 'Inventory Items', + new: 'Add Inventory Item', + delete: 'Delete Item', + successfullyDeleted: 'Successfully Deleted', + deleteConfirmationMessage: 'Are you sure you would like to delete this item?', + edit: 'Edit Item', + successfullyUpdated: 'Successfully Updated', + view: 'View Item', + name: 'Name', + rank: 'Rank', + type: 'Type', + crossReference: 'Cross Reference', + reorderPoint: 'Reorder Point', + distributionUnit: 'Distribution Unit', + pricePerUnit: 'Price per Unit', + note: 'Note', + error: { + nameRequired: 'Name is required.', + rankRequired: 'Rank is required.', + typeRequired: 'Type is required', + crossReferenceRequired: 'Cross Reference is required', + reorderPointRequired: 'Reorder Point is required', + reorderPointNaN: 'Reorder Point must be a number', + distributionUnitRequired: 'Distribution Unit is required', + pricePerUnitRequired: 'Price per Unit is required', + pricePerUnitNaN: 'Price per Unit must be a number', + negative: 'Input must be nonnegative', + }, + }, + }, +} diff --git a/src/shared/model/InventoryItem.ts b/src/shared/model/InventoryItem.ts new file mode 100644 index 0000000000..55695c9e8f --- /dev/null +++ b/src/shared/model/InventoryItem.ts @@ -0,0 +1,43 @@ +import AbstractDBModel from './AbstractDBModel' + +export default interface InventoryItem extends AbstractDBModel { + id: string + name: string + rank: string + type: 'clothing' | 'equipment' | 'medication' + crossReference: string + reorderPoint: string + distributionUnit: + | 'ampoule' + | 'bag' + | 'bottle' + | 'box' + | 'bundle' + | 'capsule' + | 'case' + | 'container' + | 'cream' + | 'each' + | 'gel' + | 'nebule' + | 'ointment' + | 'pack' + | 'pair' + | 'pallet' + | 'patch' + | 'pcs' + | 'pill' + | 'plastic' + | 'polyamp' + | 'rollset' + | 'spray' + | 'suppository' + | 'suspension' + | 'syrup' + | 'tablet' + | 'tray' + | 'tube' + | 'vial' + pricePerUnit: number + note: string +} diff --git a/src/shared/model/Permissions.ts b/src/shared/model/Permissions.ts index d9532b6b36..2372626cd8 100644 --- a/src/shared/model/Permissions.ts +++ b/src/shared/model/Permissions.ts @@ -29,6 +29,9 @@ enum Permissions { RequestImaging = 'write:imaging', ViewImagings = 'read:imagings', ViewIncidentWidgets = 'read:incident_widgets', + ViewInventory = 'read:inventory', + AddItem = 'write:item', + ViewItem = 'read:item', } export default Permissions diff --git a/src/user/user-slice.ts b/src/user/user-slice.ts index 6d72c8f806..48d63e9e2c 100644 --- a/src/user/user-slice.ts +++ b/src/user/user-slice.ts @@ -56,6 +56,9 @@ const initialState: UserState = { Permissions.ReadVisits, Permissions.ViewImagings, Permissions.RequestImaging, + Permissions.ViewInventory, + Permissions.AddItem, + Permissions.ViewItem, ], }