From c4d0615dce48bc50f403174b87f90edd9db8e319 Mon Sep 17 00:00:00 2001 From: rsousaj Date: Fri, 11 Dec 2020 19:44:50 -0300 Subject: [PATCH 1/7] feat(billing): created model and repository (w tests) for pricing item --- .../shared/db/PricingItemRepository.test.ts | 264 ++++++++++++++++++ src/shared/config/pouchdb.ts | 4 + src/shared/db/PricingItemRepository.ts | 47 ++++ src/shared/model/PricingItem.ts | 11 + 4 files changed, 326 insertions(+) create mode 100644 src/__tests__/shared/db/PricingItemRepository.test.ts create mode 100644 src/shared/db/PricingItemRepository.ts create mode 100644 src/shared/model/PricingItem.ts diff --git a/src/__tests__/shared/db/PricingItemRepository.test.ts b/src/__tests__/shared/db/PricingItemRepository.test.ts new file mode 100644 index 0000000000..9c4012e214 --- /dev/null +++ b/src/__tests__/shared/db/PricingItemRepository.test.ts @@ -0,0 +1,264 @@ +import { getTime, isAfter } from 'date-fns' +import { capitalize } from 'lodash' +import { validate as uuidValidate, version as uuidVersion } from 'uuid' + +import { relationalDb } from '../../../shared/config/pouchdb' +import PricingItemRepository from '../../../shared/db/PricingItemRepository' +import { PricingItem } from '../../../shared/model/PricingItem' + +const uuidValidateV4 = (uuid: string) => uuidValidate(uuid) && uuidVersion(uuid) === 4 + +const removeAllDocs = async () => { + const docs = await relationalDb.rel.find('pricingItem') + docs.pricingItens.forEach(async (d: any) => { + await relationalDb.rel.del('pricingItem', d) + }) +} + +describe('Pricing Item Repository', () => { + describe('save', () => { + const expectedPricingItem = { + name: 'pricing item', + } as PricingItem + + afterEach(async () => { + await removeAllDocs() + }) + + it('should save pricing item', async () => { + const newPricingItem = await PricingItemRepository.save(expectedPricingItem) + + expect(newPricingItem).toMatchObject(expectedPricingItem) + }) + + it('should generate an id that is a valid uuid', async () => { + const newPricingItem = await PricingItemRepository.save(expectedPricingItem) + + expect(uuidValidateV4(newPricingItem.id)).toBeTruthy() + }) + + describe('if type not specified', () => { + // function for testing when type is not filled AND when category is one of the below + const itShouldSaveTypeAsCategoryProcedure = (category: string) => { + it(`should save type as '${capitalize(category)} Procedure'`, async () => { + const expectedPricingItemType = `${capitalize(category)} Procedure` + + const newPricingItem = await PricingItemRepository.save({ + ...expectedPricingItem, + category, + } as PricingItem) + + expect(newPricingItem.type).toEqual(expectedPricingItemType) + }) + } + + describe('imaging category', () => { + itShouldSaveTypeAsCategoryProcedure('imaging') + }) + + describe('lab category', () => { + itShouldSaveTypeAsCategoryProcedure('lab') + }) + }) + }) + + describe('saveOrUpdate', () => { + afterEach(async () => { + await removeAllDocs() + }) + + it('should save the pricing item if an id was not on the entity', async () => { + const newPricingItem = await PricingItemRepository.saveOrUpdate({ + name: 'name', + } as PricingItem) + + expect(newPricingItem.id).toBeDefined() + }) + + it('should update the pricing item if one was already existing', async () => { + const existingPricingItem = await PricingItemRepository.save({ + name: 'test', + } as PricingItem) + + const updatedPricingItem = await PricingItemRepository.saveOrUpdate(existingPricingItem) + + expect(updatedPricingItem.id).toEqual(existingPricingItem.id) + }) + + it('should update the existing fields', async () => { + const existingPricingItem = await PricingItemRepository.save({ + name: 'name', + } as PricingItem) + existingPricingItem.name = 'name changed' + + const updatedPricingItem = await PricingItemRepository.saveOrUpdate(existingPricingItem) + + expect(updatedPricingItem.name).toEqual('name changed') + }) + + it('should add new fields without changing existing fields', async () => { + const existingPricingItem = await PricingItemRepository.save({ + name: 'name', + } as PricingItem) + existingPricingItem.type = 'type' + + const updatedPricingItem = await PricingItemRepository.saveOrUpdate(existingPricingItem) + + expect(updatedPricingItem.name).toEqual(existingPricingItem.name) + expect(updatedPricingItem.type).toEqual('type') + }) + + it('should update the last updated date', async () => { + const time = new Date(2020, 1, 1).toISOString() + await relationalDb.rel.save('pricingItem', { id: 'id2', createdAt: time, updatedAt: time }) + const existingPricingItem = await PricingItemRepository.find('id2') + + const updatedPricingItem = await PricingItemRepository.saveOrUpdate(existingPricingItem) + + expect( + isAfter(new Date(updatedPricingItem.updatedAt), new Date(updatedPricingItem.createdAt)), + ).toBeTruthy() + expect(updatedPricingItem.updatedAt).not.toEqual(existingPricingItem.updatedAt) + }) + + it('should not update the created date', async () => { + const time = getTime(new Date(2020, 1, 1)) + await relationalDb.rel.save('pricingItem', { id: 'id1', createdAt: time, updatedAt: time }) + const existingPricingItem = await PricingItemRepository.find('id1') + const updatePricingItem = await PricingItemRepository.saveOrUpdate(existingPricingItem) + + expect(updatePricingItem.createdAt).toEqual(existingPricingItem.createdAt) + }) + }) + + describe('search', () => { + afterEach(async () => { + await removeAllDocs() + }) + + it('should return all records that name matches search text', async () => { + const expectedPricingItem = { + name: 'pricing item', + } as PricingItem + + await relationalDb.rel.save('pricingItem', expectedPricingItem) + + await relationalDb.rel.save('pricingItem', { + name: 'test', + } as PricingItem) + + const result = await PricingItemRepository.search({ + name: 'pricing item', + } as any) + + expect(result).toHaveLength(1) + expect(result[0]).toMatchObject(expectedPricingItem) + }) + + it('should return all records that name contains search text', async () => { + await relationalDb.rel.save('pricingItem', { + name: '123 test 456', + } as PricingItem) + + const result = await PricingItemRepository.search({ + name: 'test', + } as any) + + expect(result).toHaveLength(1) + expect(result[0].name).toEqual('123 test 456') + }) + + it('should match search criteria with case insensitive match', async () => { + const expectedPricingItem = { + name: 'pricing item', + } as PricingItem + + await relationalDb.rel.save('pricingItem', expectedPricingItem) + + const result = await PricingItemRepository.search({ + name: 'PRICING ITEM', + } as any) + + expect(result).toHaveLength(1) + expect(result[0]).toMatchObject(expectedPricingItem) + }) + + it('should be able to search by category', async () => { + const expectedPricingItem = { + name: 'expected name', + category: 'imaging', + } as PricingItem + + await relationalDb.rel.save('pricingItem', expectedPricingItem) + + await relationalDb.rel.save('pricingItem', { + name: 'test 2', + category: 'lab', + } as PricingItem) + + const result = await PricingItemRepository.search({ + category: 'imaging', + } as any) + + expect(result).toHaveLength(1) + expect(result[0]).toMatchObject(expectedPricingItem) + }) + }) + + describe('find', () => { + afterEach(async () => { + await removeAllDocs() + }) + + it('should return the correct pricing item', async () => { + await relationalDb.rel.save('pricingItem', { + id: 'id test 2', + name: 'name 2', + } as PricingItem) + + const expectedPricingItemName = 'expected name' + const expectedPricingItem = await relationalDb.rel.save('pricingItem', { + id: 'id test 1', + name: expectedPricingItemName, + } as PricingItem) + + const actualPricingItem = await PricingItemRepository.find('id test 1') + + expect(expectedPricingItem.id).toEqual(actualPricingItem.id) + expect(actualPricingItem.name).toEqual(expectedPricingItemName) + }) + }) + + describe('findAll', () => { + afterEach(async () => { + await removeAllDocs() + }) + + it('should find all pricing itens in the database sorted by their ids', async () => { + const expectedPricingItem2 = await relationalDb.rel.save('pricingItem', { id: '2' }) + const expectedPricingItem1 = await relationalDb.rel.save('pricingItem', { id: '1' }) + + const result = await PricingItemRepository.findAll() + + expect(result.length).toEqual(2) + expect(result[0].id).toEqual(expectedPricingItem1.id) + expect(result[1].id).toEqual(expectedPricingItem2.id) + }) + }) + + describe('delete', () => { + it('should delete the pricing item', async () => { + await relationalDb.rel.save('pricingItem', { + id: '1 teste', + name: 'name', + } as PricingItem) + + const pricingItemToDelete = await PricingItemRepository.find('1 teste') + + await PricingItemRepository.delete(pricingItemToDelete) + + const pricingItens = await PricingItemRepository.findAll() + expect(pricingItens).toHaveLength(0) + }) + }) +}) diff --git a/src/shared/config/pouchdb.ts b/src/shared/config/pouchdb.ts index e7eb77c722..575d6fb089 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: 'pricingItem', + plural: 'pricingItens', + }, ] export const relationalDb = localDb.setSchema(schema) export const remoteDb = serverDb as PouchDB.Database diff --git a/src/shared/db/PricingItemRepository.ts b/src/shared/db/PricingItemRepository.ts new file mode 100644 index 0000000000..0a9d23abef --- /dev/null +++ b/src/shared/db/PricingItemRepository.ts @@ -0,0 +1,47 @@ +import { capitalize } from 'lodash' + +import { relationalDb } from '../config/pouchdb' +import { PricingItem } from '../model/PricingItem' +import Repository from './Repository' +import SortRequest from './SortRequest' + +interface SearchContainer { + name: string + category: 'imaging' | 'lab' | 'procedure' | 'ward' | 'all' + defaultSortRequest: SortRequest +} + +class PricingItemRepository extends Repository { + constructor() { + super('pricingItem', relationalDb) + } + + async save(entity: PricingItem): Promise { + if (!entity.type) { + if (entity.category === 'imaging' || entity.category === 'lab') { + entity.type = `${capitalize(entity.category)} Procedure` + } + } + + return super.save(entity) + } + + async search(container: SearchContainer): Promise { + const selector = { + $and: [ + { + 'data.name': { + $regex: RegExp(container.name, 'i'), + }, + }, + ...(container.category !== 'all' ? [{ 'data.category': container.category }] : [undefined]), + ], + } + + return super.search({ + selector, + }) + } +} + +export default new PricingItemRepository() diff --git a/src/shared/model/PricingItem.ts b/src/shared/model/PricingItem.ts new file mode 100644 index 0000000000..3d6cb0a832 --- /dev/null +++ b/src/shared/model/PricingItem.ts @@ -0,0 +1,11 @@ +import AbstractDBModel from './AbstractDBModel' + +export interface PricingItem extends AbstractDBModel { + name: string + price: number + expenseTo: string + category: 'imaging' | 'lab' | 'procedure' | 'ward' + type: string + notes: string + createdBy: string +} From dd0b52a7a9af717fdda8087e07ad47911cce0f53 Mon Sep 17 00:00:00 2001 From: rsousaj Date: Mon, 14 Dec 2020 10:09:08 -0300 Subject: [PATCH 2/7] feat(billing): sidebar and routes implementations, also translations improvements --- src/HospitalRun.tsx | 2 + .../shared/components/Sidebar.test.tsx | 122 +++++++++++++++++- src/billings/Billing.tsx | 40 ++++++ src/billings/new/AddPricingItem.tsx | 17 +++ src/billings/view/ViewPricingItems.tsx | 17 +++ src/shared/components/Sidebar.tsx | 53 ++++++++ .../enUs/translations/billing/index.ts | 9 ++ src/shared/locales/enUs/translations/index.ts | 2 + src/shared/model/Permissions.ts | 2 + src/user/user-slice.ts | 2 + 10 files changed, 264 insertions(+), 2 deletions(-) create mode 100644 src/billings/Billing.tsx create mode 100644 src/billings/new/AddPricingItem.tsx create mode 100644 src/billings/view/ViewPricingItems.tsx create mode 100644 src/shared/locales/enUs/translations/billing/index.ts diff --git a/src/HospitalRun.tsx b/src/HospitalRun.tsx index 4bd60bdd01..8029d409bd 100644 --- a/src/HospitalRun.tsx +++ b/src/HospitalRun.tsx @@ -3,6 +3,7 @@ import React from 'react' import { useSelector } from 'react-redux' import { Route, Switch } from 'react-router-dom' +import Billing from './billings/Billing' import Dashboard from './dashboard/Dashboard' import Imagings from './imagings/Imagings' import Incidents from './incidents/Incidents' @@ -55,6 +56,7 @@ const HospitalRun = () => { + diff --git a/src/__tests__/shared/components/Sidebar.test.tsx b/src/__tests__/shared/components/Sidebar.test.tsx index 7602971847..b443d2365e 100644 --- a/src/__tests__/shared/components/Sidebar.test.tsx +++ b/src/__tests__/shared/components/Sidebar.test.tsx @@ -42,6 +42,8 @@ describe('Sidebar', () => { Permissions.AddVisit, Permissions.RequestImaging, Permissions.ViewImagings, + Permissions.ViewPricingItems, + Permissions.AddPricingItems, ] const store = mockStore({ components: { sidebarCollapsed: false }, @@ -522,7 +524,7 @@ describe('Sidebar', () => { const wrapper = setup('/incidents') const listItems = wrapper.find(ListItem) - expect(listItems.at(10).text().trim()).toEqual('incidents.visualize.label') + expect(listItems.at(11).text().trim()).toEqual('incidents.visualize.label') }) it('should not render the incidents visualize link when user does not have the view incident widgets privileges', () => { @@ -587,7 +589,7 @@ describe('Sidebar', () => { const listItems = wrapper.find(ListItem) act(() => { - const onClick = listItems.at(10).prop('onClick') as any + const onClick = listItems.at(11).prop('onClick') as any onClick() }) @@ -849,4 +851,120 @@ describe('Sidebar', () => { expect(history.location.pathname).toEqual('/medications') }) }) + + describe('billing links', () => { + it('should render the main billing links', () => { + const wrapper = setup('/billing') + + const listItems = wrapper.find(ListItem) + const billingIndex = getIndex(listItems, 'billing.requests.label') + + expect(billingIndex).not.toBe(-1) + }) + + it('should render the new pricing item link', () => { + const wrapper = setup('/billing') + + const listItems = wrapper.find(ListItem) + const newPricingItemIndex = getIndex(listItems, 'billing.requests.new') + + expect(newPricingItemIndex).not.toBe(-1) + }) + + it('should not render the new pricing item link when user does not have requested permissions', () => { + const wrapper = setupNoPermissions('/billing') + + const listItems = wrapper.find(ListItem) + const newPricingItemIndex = getIndex(listItems, 'billing.requests.new') + + expect(newPricingItemIndex).toBe(-1) + }) + + it('should render the pricing items link', () => { + const wrapper = setup('/billing') + + const listItems = wrapper.find(ListItem) + const pricingItemsIndex = getIndex(listItems, 'billing.requests.label') + + expect(pricingItemsIndex).not.toBe(-1) + }) + + it('should not render the pricing items link when user does not have view pricing privileges', () => { + const wrapper = setupNoPermissions('/billing') + + const listItems = wrapper.find(ListItem) + const pricingItemsIndex = getIndex(listItems, 'billing.requests.label') + + expect(pricingItemsIndex).toBe(-1) + }) + + it('main billing link should be active when the current path is /billing', () => { + const wrapper = setup('/billing') + + const listItems = wrapper.find(ListItem) + const billingIndex = getIndex(listItems, 'billing.requests.label') + + expect(listItems.at(billingIndex).prop('active')).toBeTruthy() + }) + + it('should navigate to /billing when main Billing link is clicked', () => { + const wrapper = setup('/billing') + + const listItems = wrapper.find(ListItem) + const billingIndex = getIndex(listItems, 'billing.requests.label') + + act(() => { + const onClick = listItems.at(billingIndex).prop('onClick') as any + onClick() + }) + + expect(history.location.pathname).toEqual('/billing') + }) + + it('should navigate to /billing/new when add pricing item is clicked', () => { + const wrapper = setup('/billing') + + const listItems = wrapper.find(ListItem) + const newPricingItemIndex = getIndex(listItems, 'billing.requests.new') + + act(() => { + const onClick = listItems.at(newPricingItemIndex).prop('onClick') as any + onClick() + }) + + expect(history.location.pathname).toEqual('/billing/new') + }) + + it('add pricing item link should be active when the current path is /billing/new', () => { + const wrapper = setup('/billing/new') + + const listItems = wrapper.find(ListItem) + const newPricingItemIndex = getIndex(listItems, 'billing.requests.new') + + expect(listItems.at(newPricingItemIndex).prop('active')).toBeTruthy() + }) + + it('should navigate to /billing when pricing items list link is clicked', () => { + const wrapper = setup('/billing/new') + + const listItems = wrapper.find(ListItem) + const pricingItemsIndex = getIndex(listItems, 'billing.requests.label') + + act(() => { + const onClick = listItems.at(pricingItemsIndex).prop('onClick') as any + onClick() + }) + + expect(history.location.pathname).toEqual('/billing') + }) + + it('pricing items link should be active when the current path is /billing', () => { + const wrapper = setup('/billing') + + const listItems = wrapper.find(ListItem) + const pricingItemsIndex = getIndex(listItems, 'billing.requests.label') + + expect(listItems.at(pricingItemsIndex).prop('active')).toBeTruthy() + }) + }) }) diff --git a/src/billings/Billing.tsx b/src/billings/Billing.tsx new file mode 100644 index 0000000000..c807933021 --- /dev/null +++ b/src/billings/Billing.tsx @@ -0,0 +1,40 @@ +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 AddPricingItem from './new/AddPricingItem' +import ViewPricingItems from './view/ViewPricingItems' + +const Billing = () => { + const { permissions } = useSelector((state: RootState) => state.user) + const breadcrumbs = [ + { + i18nKey: 'billing.label', + location: '/billing', + }, + ] + useAddBreadcrumbs(breadcrumbs, true) + + return ( + + + + + ) +} + +export default Billing diff --git a/src/billings/new/AddPricingItem.tsx b/src/billings/new/AddPricingItem.tsx new file mode 100644 index 0000000000..89e3abd3d4 --- /dev/null +++ b/src/billings/new/AddPricingItem.tsx @@ -0,0 +1,17 @@ +import React from 'react' + +import useAddBreadcrumbs from '../../page-header/breadcrumbs/useAddBreadcrumbs' + +const AddPricingItem = () => { + const breadcrumbs = [ + { + i18nKey: 'billing.requests.new', + location: '/billing/new', + }, + ] + useAddBreadcrumbs(breadcrumbs) + + return

+} + +export default AddPricingItem diff --git a/src/billings/view/ViewPricingItems.tsx b/src/billings/view/ViewPricingItems.tsx new file mode 100644 index 0000000000..d440238092 --- /dev/null +++ b/src/billings/view/ViewPricingItems.tsx @@ -0,0 +1,17 @@ +import React, { useEffect } from 'react' + +import { useUpdateTitle } from '../../page-header/title/TitleContext' +import useTranslator from '../../shared/hooks/useTranslator' + +const ViewPricingItems = () => { + const updateTitle = useUpdateTitle() + const { t } = useTranslator() + + useEffect(() => { + updateTitle(t('billing.label')) + }) + + return

A

+} + +export default ViewPricingItems diff --git a/src/shared/components/Sidebar.tsx b/src/shared/components/Sidebar.tsx index 0ed2ca4aba..4de9052b7c 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('billing') + ? 'billing' : 'none', ) @@ -412,6 +414,56 @@ const Sidebar = () => { ) + const getBillingLinks = () => ( + <> + { + navigateTo('/billing') + setExpansion('billing') + }} + className="nav-item" + style={listItemStyle} + > + + {!sidebarCollapsed && t('billing.label')} + + {splittedPath[1].includes('billing') && expandedItem === 'billing' && ( + + {permissions.includes(Permissions.AddPricingItems) && ( + navigateTo('/billing/new')} + active={splittedPath[1].includes('billing') && splittedPath.length > 2} + > + + {!sidebarCollapsed && t('billing.requests.new')} + + )} + {permissions.includes(Permissions.ViewPricingItems) && ( + navigateTo('/billing')} + active={splittedPath[1].includes('billing') && splittedPath.length < 3} + > + + {!sidebarCollapsed && t('billing.requests.label')} + + )} + + )} + + ) + return (