From 1f3f43cc3ae930c79af3ad2b810923cc8fcf2679 Mon Sep 17 00:00:00 2001 From: Eric Musyoka Date: Tue, 26 Mar 2024 16:39:02 +0300 Subject: [PATCH] Add location inventory (#1353) * Add initial form for adding location inventory * Add route for location inventory * Get projects from server - Todo: disable pagination * WIP: Async select * Use async select for all select components * Generate payload for location inventory resource * Add form validators * Update location inventory payload * Use paginated select on products dropdown * Make location list resource uuid configurable * Post location inventory resource * Add endpoint to edit location inventories * Update async select to show data loading * Update fhir select component to show placeholders * Finalize on editing location inventories * Update valueset ids * Test utils * Add valuesetAsync select wrapper (#1355) * Add valuesetAsync select wrapper * Group all async selects --------- Co-authored-by: Eric Musyoka * Link service point resource when creting location inventory resource * Add form tests * Update REACT_APP_FHIR_LOCATION_LIST_RESOURCE_ID env to REACT_APP_FHIR_INVENTORY_LIST_ID * Add commodity Id to products filters * Update success message on edit * Hook up inventory creation and editing * Fix linting * Add fhir-group-management package to fhir-location-management package --------- Co-authored-by: Peter Muriuki --- .github/workflows/automation-ci.yml | 1 + app/.env.sample | 1 + app/src/App/fhir-apps.tsx | 21 + app/src/App/utils.tsx | 7 + app/src/configs/env.ts | 2 + docker/config.js.tmpl | 1 + docs/env.md | 4 + docs/fhir-web-docker-deployment.md | 1 + .../src/components/LocationInventory/form.tsx | 288 ++++++++ .../components/LocationInventory/index.tsx | 97 +++ .../LocationInventory/tests/fixtures.ts | 264 ++++++++ .../LocationInventory/tests/form.test.tsx | 197 ++++++ .../LocationInventory/tests/index.test.tsx | 164 +++++ .../LocationInventory/tests/utils.test.tsx | 115 ++++ .../src/components/LocationInventory/types.ts | 40 ++ .../components/LocationInventory/utils.tsx | 639 ++++++++++++++++++ .../fhir-group-management/src/constants.tsx | 21 +- packages/fhir-group-management/src/index.tsx | 1 + .../fhir-helpers/src/constants/codeSystems.ts | 2 +- .../fhir-location-management/package.json | 1 + .../ViewDetails/DetailsTabs/Inventory.tsx | 49 +- .../DetailsTabs/tests/detailsTabs.test.tsx | 6 +- .../src/components/forms/UserForm/index.tsx | 6 +- .../AsyncSelect/BaseAsyncSelect/index.tsx | 48 ++ .../PaginatedAsyncSelect}/index.tsx | 13 +- .../PaginatedAsyncSelect}/tests/fixtures.ts | 0 .../tests/index.test.tsx | 6 +- .../PaginatedAsyncSelect}/utils.ts | 4 +- .../AsyncSelect/ValueSetAsyncSelect/index.tsx | 91 +++ .../ValueSetAsyncSelect/tests/fixtures.ts | 281 ++++++++ .../ValueSetAsyncSelect/tests/index.test.tsx | 171 +++++ .../src/components/AsyncSelect/index.tsx | 3 + packages/react-utils/src/index.tsx | 2 +- yarn.lock | 1 + 34 files changed, 2512 insertions(+), 36 deletions(-) create mode 100644 packages/fhir-group-management/src/components/LocationInventory/form.tsx create mode 100644 packages/fhir-group-management/src/components/LocationInventory/index.tsx create mode 100644 packages/fhir-group-management/src/components/LocationInventory/tests/fixtures.ts create mode 100644 packages/fhir-group-management/src/components/LocationInventory/tests/form.test.tsx create mode 100644 packages/fhir-group-management/src/components/LocationInventory/tests/index.test.tsx create mode 100644 packages/fhir-group-management/src/components/LocationInventory/tests/utils.test.tsx create mode 100644 packages/fhir-group-management/src/components/LocationInventory/types.ts create mode 100644 packages/fhir-group-management/src/components/LocationInventory/utils.tsx create mode 100644 packages/react-utils/src/components/AsyncSelect/BaseAsyncSelect/index.tsx rename packages/react-utils/src/components/{FhirSelect => AsyncSelect/PaginatedAsyncSelect}/index.tsx (94%) rename packages/react-utils/src/components/{FhirSelect => AsyncSelect/PaginatedAsyncSelect}/tests/fixtures.ts (100%) rename packages/react-utils/src/components/{FhirSelect => AsyncSelect/PaginatedAsyncSelect}/tests/index.test.tsx (97%) rename packages/react-utils/src/components/{FhirSelect => AsyncSelect/PaginatedAsyncSelect}/utils.ts (94%) create mode 100644 packages/react-utils/src/components/AsyncSelect/ValueSetAsyncSelect/index.tsx create mode 100644 packages/react-utils/src/components/AsyncSelect/ValueSetAsyncSelect/tests/fixtures.ts create mode 100644 packages/react-utils/src/components/AsyncSelect/ValueSetAsyncSelect/tests/index.test.tsx create mode 100644 packages/react-utils/src/components/AsyncSelect/index.tsx diff --git a/.github/workflows/automation-ci.yml b/.github/workflows/automation-ci.yml index 99406cb73..23d408f57 100644 --- a/.github/workflows/automation-ci.yml +++ b/.github/workflows/automation-ci.yml @@ -100,6 +100,7 @@ jobs: REACT_APP_ENABLE_FHIR_ORGANIZATION=true REACT_APP_ENABLE_FHIR_TEAMS=true REACT_APP_FHIR_ROOT_LOCATION_ID=eff94f33-c356-4634-8795-d52340706ba9 + REACT_APP_FHIR_INVENTORY_LIST_ID=81b674df-e958-4684-8931-8feefa74d6fb REACT_APP_FHIR_PATIENT_SORT_FIELDS=-_lastUpdated REACT_APP_FHIR_PATIENT_BUNDLE_SIZE=5000 REACT_APP_ENABLE_FHIR_HEALTHCARE_SERVICES=false diff --git a/app/.env.sample b/app/.env.sample index cbb9d4561..258a0214e 100644 --- a/app/.env.sample +++ b/app/.env.sample @@ -46,3 +46,4 @@ REACT_APP_COMMODITIES_LIST_RESOURCE_ID="uuid" REACT_APP_PRACTITIONER_TO_ORG_ASSIGNMENT_STRATEGY=ONE_TO_MANY REACT_APP_AUTHZ_STRATEGY=keycloak REACT_APP_FHIR_ROOT_LOCATION_ID=uuid +REACT_APP_FHIR_INVENTORY_LIST_ID=uuid diff --git a/app/src/App/fhir-apps.tsx b/app/src/App/fhir-apps.tsx index a4fa28c97..457f4a192 100644 --- a/app/src/App/fhir-apps.tsx +++ b/app/src/App/fhir-apps.tsx @@ -98,6 +98,7 @@ import { patientProps, fhirCreateEditUserProps, commmodityProps, + fhirCreateEditLocationProps, } from './utils'; import './App.css'; import { @@ -116,6 +117,8 @@ import { GroupList, LIST_COMMODITY_URL, LIST_GROUP_URL, + ADD_LOCATION_INVENTORY, + AddLocationInventory, } from '@opensrp/fhir-group-management'; import { useTranslation } from '../mls'; import '@opensrp/user-management/dist/index.css'; @@ -456,6 +459,24 @@ const FHIRApps = () => { permissions={['Group.create']} component={CommodityAddEdit} /> + + ', REACT_APP_COMMODITIES_LIST_RESOURCE_ID: '', + REACT_APP_FHIR_INVENTORY_LIST_ID: '', // toggle fhir-web modules REACT_APP_ENABLE_FHIR_CARE_TEAM: 'false', diff --git a/packages/fhir-group-management/src/components/LocationInventory/form.tsx b/packages/fhir-group-management/src/components/LocationInventory/form.tsx new file mode 100644 index 000000000..0070d306a --- /dev/null +++ b/packages/fhir-group-management/src/components/LocationInventory/form.tsx @@ -0,0 +1,288 @@ +import React, { useState } from 'react'; +import { Form, Button, Input, DatePicker, Space, Switch } from 'antd'; +import { + PaginatedAsyncSelect, + formItemLayout, + tailLayout, + SelectOption as ProductSelectOption, + ValueSetAsyncSelect, +} from '@opensrp/react-utils'; +import { useTranslation } from '../../mls'; +import { useQueryClient, useMutation } from 'react-query'; +import { supplyMgSnomedCode, snomedCodeSystem } from '../../helpers/utils'; +import { Rule } from 'rc-field-form/lib/interface'; +import { + sendSuccessNotification, + sendErrorNotification, + sendInfoNotification, +} from '@opensrp/notifications'; +import { + product, + quantity, + deliveryDate, + accountabilityEndDate, + expiryDate, + unicefSection, + serialNumber, + donor, + PONumber, + groupResourceType, + unicefSectionValueSetId, + id, + active, + name, + type, + actual, +} from '../../constants'; +import { + getLocationInventoryPayload, + handleDisabledFutureDates, + handleDisabledPastDates, + isAttractiveProduct, + postLocationInventory, + processProductOptions, + productAccountabilityMonths, + validationRulesFactory, +} from './utils'; +import { GroupFormFields } from './types'; +import { IGroup } from '@smile-cdr/fhirts/dist/FHIR-R4/interfaces/IGroup'; +import { useHistory } from 'react-router'; +import { Dayjs } from 'dayjs'; +import { ILocation } from '@smile-cdr/fhirts/dist/FHIR-R4/interfaces/ILocation'; +import { Dictionary } from '@onaio/utils'; + +const { Item: FormItem } = Form; + +export interface LocationInventoryFormProps { + fhirBaseURL: string; + initialValues: GroupFormFields; + listResourceId: string; + servicePointObj: ILocation; + cancelUrl?: string; + successUrl?: string; + inventoryId?: string; + inventoryResourceObj?: IGroup; + commodityListId?: string; +} + +const defaultProps = { + initialValues: {}, +}; + +const getProductQueryFilters = (listId?: string) => { + const listFilter: Dictionary = {}; + if (listId) { + listFilter['_has:List:item:_id'] = listId; + } + return { + code: `${snomedCodeSystem}|${supplyMgSnomedCode}`, + ...listFilter, + }; +}; + +/** + * Add location inventory form + * + * @param props - LocationInventoryFormProps component props + * @returns returns form to add location inventories + */ +const AddLocationInventoryForm = (props: LocationInventoryFormProps) => { + const { + fhirBaseURL, + initialValues, + inventoryId, + listResourceId, + inventoryResourceObj, + servicePointObj, + commodityListId, + } = props; + const [attractiveProduct, setAttractiveProduct] = useState( + isAttractiveProduct(inventoryResourceObj) + ); + const [accounterbilityMonths, setAccounterbilityMonths] = useState(); + const { t } = useTranslation(); + const queryClient = useQueryClient(); + const history = useHistory(); + const [form] = Form.useForm(); + const editMode = !!inventoryId; + + const { mutate, isLoading } = useMutation( + async (values: GroupFormFields) => { + const payload = getLocationInventoryPayload(values, editMode, inventoryResourceObj); + return postLocationInventory(fhirBaseURL, payload, editMode, listResourceId, servicePointObj); + }, + { + onError: (error: Error) => { + sendErrorNotification(error.message); + }, + onSuccess: () => { + const successMessage = editMode + ? t('Location inventory updated successfully') + : t('Location inventory created successfully'); + sendSuccessNotification(successMessage); + if (editMode) { + queryClient.invalidateQueries([fhirBaseURL, inventoryId]).catch(() => { + sendInfoNotification(t('Failed to refresh data, please refresh the page')); + }); + } else { + form.resetFields(); + } + }, + } + ); + + const productChangeHandler = ( + fullOption: ProductSelectOption | ProductSelectOption[] + ) => { + const product = Array.isArray(fullOption) ? fullOption[0] : fullOption; + const endDate = productAccountabilityMonths(product.ref); + setAttractiveProduct(isAttractiveProduct(product.ref)); + if (endDate) { + setAccounterbilityMonths(endDate); + } + }; + + const delveryDateChangeHandler = (selectedDate: Dayjs | null) => { + if (accounterbilityMonths && selectedDate) { + const newDate = selectedDate.add(accounterbilityMonths, 'month'); + form.setFieldValue(accountabilityEndDate, newDate); + } + }; + + const validationRules = validationRulesFactory(t); + let serialNumebrRule: Rule[] = [{ required: false }]; + if (attractiveProduct) { + serialNumebrRule = validationRules[serialNumber]; + } + + const productQueryFilters = getProductQueryFilters(commodityListId); + + return ( +
{ + mutate(values); + }} + initialValues={initialValues} + > + + + baseUrl={fhirBaseURL} + resourceType={groupResourceType} + transformOption={processProductOptions} + extraQueryParams={productQueryFilters} + showSearch={true} + placeholder={t('Select product')} + getFullOptionOnChange={productChangeHandler} + disabled={editMode} + /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {/* start hidden fields */} + + + + + + {/* End hidden fields */} + + + + + + + +
+ ); +}; + +AddLocationInventoryForm.defaultProps = defaultProps; + +export { AddLocationInventoryForm }; diff --git a/packages/fhir-group-management/src/components/LocationInventory/index.tsx b/packages/fhir-group-management/src/components/LocationInventory/index.tsx new file mode 100644 index 000000000..016e78649 --- /dev/null +++ b/packages/fhir-group-management/src/components/LocationInventory/index.tsx @@ -0,0 +1,97 @@ +import React, { useMemo } from 'react'; +import { useTranslation } from '../../mls'; +import { Helmet } from 'react-helmet'; +import { BrokenPage, FHIRServiceClass, PageHeader } from '@opensrp/react-utils'; +import { AddLocationInventoryForm } from './form'; +import { useParams } from 'react-router'; +import { groupResourceType, locationResourceType } from '../../constants'; +import { useQuery } from 'react-query'; +import { IGroup } from '@smile-cdr/fhirts/dist/FHIR-R4/interfaces/IGroup'; +import { getInventoryInitialValues } from './utils'; +import { GroupFormFields } from './types'; +import { Spin } from 'antd'; +import { ILocation } from '@smile-cdr/fhirts/dist/FHIR-R4/interfaces/ILocation'; + +interface AddLocationInventoryProps { + fhirBaseURL: string; + listId?: string; + commodityListId?: string; +} + +export interface RouteParams { + servicePointId: string; + inventoryId?: string; +} + +/** + * component to add location inventory + * + * @param props - AddLocationInventoryProps component props + * @returns returns form to add location inventories + */ +export const AddLocationInventory = (props: AddLocationInventoryProps) => { + const { fhirBaseURL, listId, commodityListId } = props; + const { t } = useTranslation(); + const { inventoryId, servicePointId } = useParams(); + const pageTitle = inventoryId ? t('Edit locations Inventory') : t('Add locations Inventory'); + + const inventoryResource = useQuery( + [fhirBaseURL, inventoryId], + async () => + await new FHIRServiceClass(fhirBaseURL, groupResourceType).read( + inventoryId as string + ), + { + enabled: !!inventoryId, + } + ); + + const servicePoint = useQuery( + [fhirBaseURL, servicePointId], + async () => + await new FHIRServiceClass(fhirBaseURL, locationResourceType).read( + servicePointId as string + ) + ); + + const error = inventoryResource.error || servicePoint.error; + const isLoading = inventoryResource.isLoading || servicePoint.isLoading; + + const initialValues = useMemo( + () => (inventoryResource.data ? getInventoryInitialValues(inventoryResource.data) : {}), + [inventoryResource.data] + ); + + if ( + (inventoryResource.error && !inventoryResource.data) || + (servicePoint.error && !servicePoint.data) + ) { + return ; + } + + const formProps = { + fhirBaseURL, + listResourceId: listId as string, + inventoryId, + initialValues: initialValues as GroupFormFields, + inventoryResourceObj: inventoryResource.data, + servicePointObj: servicePoint.data as ILocation, + commodityListId, + }; + + return ( +
+ + {pageTitle} + + +
+ {isLoading ? ( + + ) : ( + + )} +
+
+ ); +}; diff --git a/packages/fhir-group-management/src/components/LocationInventory/tests/fixtures.ts b/packages/fhir-group-management/src/components/LocationInventory/tests/fixtures.ts new file mode 100644 index 000000000..e016b5c82 --- /dev/null +++ b/packages/fhir-group-management/src/components/LocationInventory/tests/fixtures.ts @@ -0,0 +1,264 @@ +import { IValueSet } from '@smile-cdr/fhirts/dist/FHIR-R4/interfaces/IValueSet'; +import dayjs from 'dayjs'; + +export const productQuantity = 32; +export const mockResourceId = '67bb848e-f049-41f4-9c75-3b726664db67'; +export const servicePointId = '46bb8a3f-cf50-4cc2-b421-fe4f77c3e75d'; +export const mockProductId = '6f3980e0-d1d6-4a7a-a950-939f3ca7b301'; +export const mockUnicefSection = + '{"code":"ANC.End.26","display":"No complications","system":"http://smartregister.org/CodeSystem/eusm-unicef-sections"}'; +export const mockDonorOption = + '{"code":"ANC.donor","display":"Donor","system":"http://smartregister.org/CodeSystem/eusm-donors"}'; +export const servicePointDatum = { + id: servicePointId, + name: 'Service Point', +}; + +export const unicefValuesetConcept = { + system: 'http://smartregister.org/CodeSystem/eusm-unicef-sections', + code: 'ANC.End.26', + display: 'No complications', +}; + +export const donorValuesetConcept = { + system: 'http://smartregister.org/CodeSystem/eusm-donors', + code: 'ANC.donor', + display: 'Donor', +}; + +export const expandedValueSets = { + resourceType: 'ValueSet', + id: '2826', + url: 'http://fhir.org/guides/who/anc-cds/ValueSet/anc-end-26', + status: 'active', + compose: { + include: [ + { + system: 'http://smartregister.org/CodeSystem/eusm-unicef-sections', + concept: [ + { + code: 'ANC.End.26', + display: 'No complications', + }, + { + code: 'ANC.End.27', + display: 'Postpartum haemorrhage', + }, + ], + }, + ], + }, + expansion: { + offset: 0, + parameter: [ + { + name: 'offset', + valueInteger: 0, + }, + { + name: 'count', + valueInteger: 1000, + }, + ], + contains: [unicefValuesetConcept], + }, +} as IValueSet; + +export const productCharacteristics = [ + { + code: { + coding: [ + { + code: '98734231', + display: 'Unicef Section', + system: 'http://smartregister.org/codes', + }, + ], + }, + valueCodeableConcept: { + coding: [ + { + code: 'ANC.End.26', + display: 'No complications', + system: 'http://smartregister.org/CodeSystem/eusm-unicef-sections', + }, + ], + text: 'No complications', + }, + }, + { + code: { + coding: [ + { + code: '45981276', + display: 'Donor', + system: 'http://smartregister.org/codes', + }, + ], + }, + valueCodeableConcept: { + coding: [ + { + code: 'ANC.donor', + display: 'Donor', + system: 'http://smartregister.org/CodeSystem/eusm-donors', + }, + ], + text: 'Donor', + }, + }, + { + code: { + coding: [ + { + code: '33467722', + display: 'Quantity ', + system: 'http://smartregister.org/codes', + }, + ], + }, + valueQuantity: { + value: productQuantity, + }, + }, +]; + +export const formValues = { + product: mockProductId, + quantity: productQuantity, + deliveryDate: dayjs('2024-03-25T08:24:51.149Z'), + accountabilityEndDate: dayjs('2024-03-26T08:24:53.645Z'), + unicefSection: mockUnicefSection, + donor: mockDonorOption, + poNumber: '12345', + serialNumber: '890', +}; + +export const locationResourcePayload = { + resourceType: 'Group', + id: mockResourceId, + active: true, + actual: false, + type: 'substance', + identifier: [ + { + use: 'secondary', + type: { + coding: [ + { + system: 'http://smartregister.org/codes', + code: 'PONUM', + display: 'PO Number', + }, + ], + text: 'PO Number', + }, + value: '12345', + }, + { + type: { + coding: [ + { + code: 'SERNUM', + display: 'Serial Number', + system: 'http://smartregister.org/codes', + }, + ], + text: 'Serial Number', + }, + use: 'official', + value: '890', + }, + ], + member: [ + { + entity: { + reference: 'Group/6f3980e0-d1d6-4a7a-a950-939f3ca7b301', + }, + period: { + start: '2024-03-25T08:24:51.149Z', + end: '2024-03-26T08:24:53.645Z', + }, + inactive: false, + }, + ], + characteristic: productCharacteristics, + code: { + coding: [ + { + system: 'http://smartregister.org/codes', + code: '78991122', + display: 'Supply Inventory', + }, + ], + }, +}; + +export const locationInventoryList = { + resourceType: 'List', + id: '67bb848e-f049-41f4-9c75-3b726664db67', + identifier: [ + { + use: 'official', + value: '67bb848e-f049-41f4-9c75-3b726664db67', + }, + ], + status: 'current', + code: { + coding: [ + { + system: 'http://smartregister.org/codes', + code: '22138876', + display: 'Supply Inventory List', + }, + ], + text: 'Supply Inventory List', + }, + title: 'Service Point', + subject: { + reference: `Location/${servicePointId}`, + }, + entry: [ + { + flag: { + coding: [ + { + system: 'http://smartregister.org/codes', + code: '22138876', + display: 'Supply Inventory List', + }, + ], + text: 'Supply Inventory List', + }, + date: '2024-03-25T17:40:23.971Z', + item: { + reference: `Group/${mockResourceId}`, + }, + }, + ], +}; + +export const locationServicePointList = { + resourceType: 'List', + id: 'list-resource-id', + identifier: [ + { + use: 'official', + value: 'list-resource-id', + }, + ], + status: 'current', + code: { + coding: [ + { + system: 'http://smartregister.org/codes', + code: '22138876', + display: 'Supply Inventory List', + }, + ], + text: 'Supply Inventory List', + }, + mode: 'working', + title: 'Supply Chain commodities', + entry: [], +}; diff --git a/packages/fhir-group-management/src/components/LocationInventory/tests/form.test.tsx b/packages/fhir-group-management/src/components/LocationInventory/tests/form.test.tsx new file mode 100644 index 000000000..0533b0be7 --- /dev/null +++ b/packages/fhir-group-management/src/components/LocationInventory/tests/form.test.tsx @@ -0,0 +1,197 @@ +import React from 'react'; +import { QueryClient, QueryClientProvider } from 'react-query'; +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { Provider } from 'react-redux'; +import nock from 'nock'; +import { store } from '@opensrp/store'; +import { authenticateUser } from '@onaio/session-reducer'; +import * as notifications from '@opensrp/notifications'; +import { AddLocationInventoryForm } from '../form'; +import { + mockResourceId, + servicePointDatum, + formValues, + locationResourcePayload, + locationInventoryList, + locationServicePointList, +} from './fixtures'; + +jest.mock('@opensrp/notifications', () => ({ + __esModule: true, + ...Object.assign({}, jest.requireActual('@opensrp/notifications')), +})); + +jest.mock('fhirclient', () => { + return jest.requireActual('fhirclient/lib/entry/browser'); +}); + +jest.mock('uuid', () => { + const actual = jest.requireActual('uuid'); + return { + ...actual, + v4: () => mockResourceId, + }; +}); + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + cacheTime: 0, + }, + }, +}); + +const listResourceId = 'list-resource-id'; +const props = { + fhirBaseURL: 'http://test.server.org', + // eslint-disable-next-line @typescript-eslint/no-explicit-any + initialValues: {} as any, + inventoryId: undefined, + listResourceId, + inventoryResourceObj: undefined, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + servicePointObj: servicePointDatum as any, +}; + +beforeAll(() => { + nock.disableNetConnect(); + store.dispatch( + authenticateUser( + true, + { + email: 'bob@example.com', + name: 'Bobbie', + username: 'RobertBaratheon', + }, + { api_token: 'hunter2', oAuth2Data: { access_token: 'sometoken', state: 'abcde' } } + ) + ); +}); + +afterEach(() => { + cleanup(); + nock.cleanAll(); + jest.resetAllMocks(); +}); + +afterAll(() => { + nock.enableNetConnect(); +}); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const AppWrapper = (props: any) => { + return ( + + + + + + ); +}; + +test('form validation works', async () => { + render(); + + const submitBtn = screen.getByRole('button', { + name: /Save/i, + }); + + fireEvent.click(submitBtn); + + await waitFor(() => { + const atLeastOneError = document.querySelector('.ant-form-item-explain-error'); + expect(atLeastOneError).toBeInTheDocument(); + }); + + const errorNodes = [...document.querySelectorAll('.ant-form-item-explain-error')]; + const errorMsgs = errorNodes.map((node) => node.textContent); + + expect(errorMsgs).toEqual(['Required', 'Required', 'Required', 'Required']); +}); + +it('creates new product as expected', async () => { + props.initialValues = formValues; + + nock(props.fhirBaseURL) + .put(`/Group/${mockResourceId}`) + .reply(201, locationResourcePayload) + .persist(); + + nock(props.fhirBaseURL) + .put(`/List/${mockResourceId}`) + .reply(201, locationInventoryList) + .persist(); + + nock(props.fhirBaseURL) + .get(`/List/${listResourceId}`) + .reply(404, { message: 'Not found' }) + .persist(); + + nock(props.fhirBaseURL) + .put(`/List/${listResourceId}`) + .reply(201, locationServicePointList) + .persist(); + + nock(props.fhirBaseURL) + .put(`/List/${listResourceId}`) + .reply(201, locationServicePointList) + .persist(); + + const successNoticeMock = jest + .spyOn(notifications, 'sendSuccessNotification') + .mockImplementation(() => undefined); + + const errorNoticeMock = jest + .spyOn(notifications, 'sendErrorNotification') + .mockImplementation(() => undefined); + + render(); + + fireEvent.click(screen.getByRole('button', { name: /Save/i })); + + await waitFor(() => { + expect(errorNoticeMock).not.toHaveBeenCalled(); + expect(successNoticeMock.mock.calls).toEqual([['Location inventory created successfully']]); + }); +}); + +it('edits product as expected', async () => { + props.initialValues = formValues; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + props.inventoryId = mockResourceId as any; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + props.inventoryResourceObj = locationResourcePayload as any; + + nock(props.fhirBaseURL) + .put(`/Group/${mockResourceId}`) + .reply(201, locationResourcePayload) + .persist(); + + const successNoticeMock = jest + .spyOn(notifications, 'sendSuccessNotification') + .mockImplementation(() => undefined); + + const errorNoticeMock = jest + .spyOn(notifications, 'sendErrorNotification') + .mockImplementation(() => undefined); + + render(); + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const quantityField = document.querySelector(`input#quantity`)!; + userEvent.clear(quantityField); + expect(quantityField).toHaveValue(null); + userEvent.type(quantityField, '15'); + expect(quantityField).toHaveValue(15); + + fireEvent.click(screen.getByRole('button', { name: /Save/i })); + + await waitFor(() => { + expect(errorNoticeMock).not.toHaveBeenCalled(); + expect(successNoticeMock.mock.calls).toEqual([['Location inventory updated successfully']]); + }); + + expect(nock.isDone()).toBeTruthy(); +}); diff --git a/packages/fhir-group-management/src/components/LocationInventory/tests/index.test.tsx b/packages/fhir-group-management/src/components/LocationInventory/tests/index.test.tsx new file mode 100644 index 000000000..9f21aad64 --- /dev/null +++ b/packages/fhir-group-management/src/components/LocationInventory/tests/index.test.tsx @@ -0,0 +1,164 @@ +import React from 'react'; +import { Route, Router, Switch } from 'react-router'; +import { QueryClient, QueryClientProvider } from 'react-query'; +import { cleanup, render, waitForElementToBeRemoved, screen } from '@testing-library/react'; +import { Provider } from 'react-redux'; +import nock from 'nock'; +import { store } from '@opensrp/store'; +import { authenticateUser } from '@onaio/session-reducer'; +import { createMemoryHistory } from 'history'; +import { AddLocationInventory } from '..'; +import { + servicePointId, + servicePointDatum, + mockResourceId, + locationResourcePayload, +} from './fixtures'; + +jest.mock('@opensrp/notifications', () => ({ + __esModule: true, + ...Object.assign({}, jest.requireActual('@opensrp/notifications')), +})); + +jest.mock('fhirclient', () => { + return jest.requireActual('fhirclient/lib/entry/browser'); +}); + +const mockv4 = '9b782015-8392-4847-b48c-50c11638656b'; +jest.mock('uuid', () => { + const actual = jest.requireActual('uuid'); + return { + ...actual, + v4: () => mockv4, + }; +}); + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + cacheTime: 0, + }, + }, +}); + +const listResId = 'list-resource-id'; +const props = { + fhirBaseURL: 'http://test.server.org', + listId: listResId, +}; +const addLocationPath = `/location/inventory/${servicePointId}`; +const BasePath = `/location/inventory/:servicePointId`; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const AppWrapper = (props: any) => { + return ( + + + + + + + + + + + + + ); +}; + +afterEach(() => { + cleanup(); + nock.cleanAll(); + jest.resetAllMocks(); +}); + +beforeAll(() => { + nock.disableNetConnect(); + store.dispatch( + authenticateUser( + true, + { + email: 'bob@example.com', + name: 'Bobbie', + username: 'RobertBaratheon', + }, + { api_token: 'hunter2', oAuth2Data: { access_token: 'sometoken', state: 'abcde' } } + ) + ); +}); + +afterAll(() => { + nock.enableNetConnect(); +}); + +it('shows broken page', async () => { + const history = createMemoryHistory(); + history.push(addLocationPath); + nock(props.fhirBaseURL).get(`/Location/${servicePointId}`).replyWithError({ + message: 'something awful happened', + code: 'AWFUL_ERROR', + }); + + render( + + + + ); + await waitForElementToBeRemoved(document.querySelector('.ant-spin')); + expect(screen.getByText(/failed, reason: something awful happened/)).toBeInTheDocument(); +}); + +test('renders correctly', async () => { + const history = createMemoryHistory(); + history.push(addLocationPath); + nock(props.fhirBaseURL).get(`/Location/${servicePointId}`).reply(200, servicePointDatum); + + render( + + + + ); + + await waitForElementToBeRemoved(document.querySelector('.ant-spin')); + + screen.getByText('Add locations Inventory'); + // form items + screen.getByText('Product name'); + screen.getByText('Quantity'); + screen.getByText('Delivery date'); + screen.getByText('Accountability end date'); + screen.getByText('UNICEF section'); + screen.getByText('Serial number'); + screen.getByText('Donor'); + screen.getByText('PO number'); + screen.getByText('Expiry date'); + screen.getByText('product Id'); + screen.getByText('Active'); + screen.getByText('Actual'); + screen.getByText('Name'); + screen.getByText('Type'); +}); + +test('renders correctly on edit', async () => { + const history = createMemoryHistory(); + history.push(`${addLocationPath}/${mockResourceId}`); + nock(props.fhirBaseURL) + .get(`/Group/${mockResourceId}`) + .reply(200, locationResourcePayload) + .persist(); + nock(props.fhirBaseURL) + .get(`/Location/${servicePointId}`) + .reply(200, servicePointDatum) + .persist(); + + render( + + + + ); + + await waitForElementToBeRemoved(document.querySelector('.ant-spin')); + + screen.getByText('Edit locations Inventory'); +}); diff --git a/packages/fhir-group-management/src/components/LocationInventory/tests/utils.test.tsx b/packages/fhir-group-management/src/components/LocationInventory/tests/utils.test.tsx new file mode 100644 index 000000000..b97104ac5 --- /dev/null +++ b/packages/fhir-group-management/src/components/LocationInventory/tests/utils.test.tsx @@ -0,0 +1,115 @@ +import { + handleDisabledPastDates, + handleDisabledFutureDates, + getValuesetSelectOptions, + isAttractiveProduct, + productAccountabilityMonths, + getMember, + generateCharacteristics, + getLocationInventoryPayload, +} from '../utils'; +import dayjs from 'dayjs'; +import { + expandedValueSets, + unicefValuesetConcept, + donorValuesetConcept, + productCharacteristics, + formValues, + locationResourcePayload, + mockResourceId, + productQuantity, +} from './fixtures'; +import { commodity1 } from '../../CommodityAddEdit/Eusm/tests/fixtures'; +import { attractiveCharacteristicCode } from '../../../helpers/utils'; + +jest.mock('uuid', () => { + const actual = jest.requireActual('uuid'); + return { + ...actual, + v4: () => mockResourceId, + }; +}); + +describe('fhir-group-management/src/components/LocationInventory/utils', () => { + it('handleDisabledPastDates and handleDisabledFutureDates works as expected', () => { + const now = dayjs(); + const future = now.add(2, 'day'); + const past = now.subtract(2, 'day'); + // handleDisabledPastDates + expect(handleDisabledPastDates()).toEqual(false); + expect(handleDisabledPastDates(now)).toEqual(false); + expect(handleDisabledPastDates(future)).toEqual(false); + expect(handleDisabledPastDates(past)).toEqual(true); + // handleDisabledFutureDates + expect(handleDisabledFutureDates()).toEqual(false); + expect(handleDisabledFutureDates(now)).toEqual(true); + expect(handleDisabledFutureDates(future)).toEqual(true); + expect(handleDisabledFutureDates(past)).toEqual(false); + }); + + it('getValuesetSelectOptions works as expected', () => { + expect(getValuesetSelectOptions(expandedValueSets)).toEqual([ + { + label: 'No complications', + value: JSON.stringify({ + code: 'ANC.End.26', + display: 'No complications', + system: 'http://smartregister.org/CodeSystem/eusm-unicef-sections', + }), + }, + { + label: 'Postpartum haemorrhage', + value: JSON.stringify({ + code: 'ANC.End.27', + display: 'Postpartum haemorrhage', + system: 'http://smartregister.org/CodeSystem/eusm-unicef-sections', + }), + }, + ]); + }); + + it('get attractive items works as expected', () => { + const noAttractiveCharacteristic = commodity1.characteristic?.filter( + (char) => char.code.coding?.[0].code !== attractiveCharacteristicCode + ); + const newCommodity = { + ...commodity1, + characteristic: noAttractiveCharacteristic, + }; + expect(isAttractiveProduct()).toEqual(false); + expect(isAttractiveProduct(commodity1)).toEqual(true); + expect(isAttractiveProduct(newCommodity)).toEqual(false); + }); + + it('get item accounterbility months works as expected', () => { + expect(productAccountabilityMonths()).toEqual(undefined); + expect(productAccountabilityMonths(commodity1)).toEqual(12); + }); + + it('get resource member works as expected', () => { + const startDate = dayjs(); + const endDate = dayjs().add(2, 'day'); + expect(getMember('productId', startDate, endDate)).toEqual([ + { + entity: { + reference: 'Group/productId', + }, + period: { + start: new Date(startDate.toDate()).toISOString(), + end: new Date(endDate.toDate()).toISOString(), + }, + inactive: false, + }, + ]); + }); + + it('get resource characteristics works as expected', () => { + expect( + generateCharacteristics(unicefValuesetConcept, donorValuesetConcept, productQuantity) + ).toEqual(productCharacteristics); + }); + + it('generate location inventory payload works as expected', () => { + expect(getLocationInventoryPayload(formValues, false)).toEqual(locationResourcePayload); + }); +}); diff --git a/packages/fhir-group-management/src/components/LocationInventory/types.ts b/packages/fhir-group-management/src/components/LocationInventory/types.ts new file mode 100644 index 000000000..239eeafc1 --- /dev/null +++ b/packages/fhir-group-management/src/components/LocationInventory/types.ts @@ -0,0 +1,40 @@ +import { IGroup } from '@smile-cdr/fhirts/dist/FHIR-R4/interfaces/IGroup'; +import { + product, + quantity, + deliveryDate, + accountabilityEndDate, + expiryDate, + unicefSection, + serialNumber, + donor, + PONumber, + id, + active, + name, + type, + actual, +} from '../../constants'; +import { Group } from '@smile-cdr/fhirts/dist/FHIR-R4/classes/group'; +import { Dayjs } from 'dayjs'; + +export interface CommonGroupFormFields { + [id]?: string; + [active]?: boolean; + [actual]?: boolean; + [name]?: string; + [type]?: Group.TypeEnum; + [quantity]?: number; + [deliveryDate]: Dayjs; + [accountabilityEndDate]: Dayjs; + [expiryDate]?: Dayjs; + [serialNumber]: string; + [PONumber]: string; + [donor]?: string; + [unicefSection]: string; + [product]: string; +} + +export interface GroupFormFields extends CommonGroupFormFields { + initialObject?: InitialObjects; +} diff --git a/packages/fhir-group-management/src/components/LocationInventory/utils.tsx b/packages/fhir-group-management/src/components/LocationInventory/utils.tsx new file mode 100644 index 000000000..67d800730 --- /dev/null +++ b/packages/fhir-group-management/src/components/LocationInventory/utils.tsx @@ -0,0 +1,639 @@ +import { ValueSetContains } from '@smile-cdr/fhirts/dist/FHIR-R4/classes/valueSetContains'; +import { IValueSet } from '@smile-cdr/fhirts/dist/FHIR-R4/interfaces/IValueSet'; +import { DefaultOptionType } from 'antd/lib/select'; +import { Dictionary } from '@onaio/utils'; +import { IGroup } from '@smile-cdr/fhirts/dist/FHIR-R4/interfaces/IGroup'; +import { FHIRServiceClass, IdentifierUseCodes, SelectOption } from '@opensrp/react-utils'; +import { ValueSetConcept } from '@smile-cdr/fhirts/dist/FHIR-R4/classes/valueSetConcept'; +import { + PONumber, + accountabilityEndDate, + deliveryDate, + groupResourceType, + listResourceType, + product, + serialNumber, + unicefSection, +} from '../../constants'; +import { IList } from '@smile-cdr/fhirts/dist/FHIR-R4/interfaces/IList'; +import { cloneDeep } from 'lodash'; +import { GroupFormFields } from './types'; +import { GroupMember } from '@smile-cdr/fhirts/dist/FHIR-R4/classes/groupMember'; +import { v4 } from 'uuid'; +import dayjs, { Dayjs } from 'dayjs'; +import { TFunction } from '@opensrp/i18n'; +import { Rule } from 'rc-field-form/lib/interface'; +import { + attractiveCharacteristicCode, + accountabilityCharacteristicCode, +} from '../../helpers/utils'; +import { GroupCharacteristic } from '@smile-cdr/fhirts/dist/FHIR-R4/classes/groupCharacteristic'; +import { Identifier } from '@smile-cdr/fhirts/dist/FHIR-R4/classes/identifier'; +import { ListEntry } from '@smile-cdr/fhirts/dist/FHIR-R4/classes/listEntry'; +import { ILocation } from '@smile-cdr/fhirts/dist/FHIR-R4/interfaces/ILocation'; + +const codeSystem = 'http://smartregister.org/codes'; +const unicefCharacteristicCode = '98734231'; +const donorCharacteristicCode = '45981276'; +const quantityCharacteristicCode = '33467722'; +const supplyInventoryCode = '78991122'; +const poNumberDisplay = 'PO Number'; +const poNumberCode = 'PONUM'; +const serialNumberDisplay = 'Serial Number'; +const serialNumberCode = 'SERNUM'; +const listSupplyInventoryCode = '22138876'; + +/** + * Check if date in past + * + * @param current - given date + * @returns returns if date in past + */ +export const handleDisabledPastDates = (current?: Dayjs) => { + if (!current) return false; + const today = new Date(); + return current.valueOf() < today.valueOf(); +}; + +/** + * check if date in future + * + * @param current - given date + * @returns returns if date in future + */ +export const handleDisabledFutureDates = (current?: Dayjs) => { + if (!current) return false; + const today = new Date(); + return current.valueOf() >= today.valueOf(); +}; + +/** + * get select options value + * + * @param record - valuesets + * @returns returns select option value stringfied + */ +const getValueSetOptionsValue = (record: ValueSetContains) => { + return JSON.stringify({ + code: record.code, + display: record.display, + system: record.system, + }); +}; + +/** + * get options from valueset data + * + * @param data - valueset data + * @returns returns select options + */ +export function getValuesetSelectOptions(data: TData) { + const valuesetsByCode: Dictionary = {}; + data.compose?.include.forEach((item) => { + item.concept?.forEach((record) => { + const code = record.code as string; + valuesetsByCode[code] = { ...record, system: item.system }; + }); + }); + data.expansion?.contains?.forEach((item) => { + const code = item.code as string; + valuesetsByCode[code] = { ...item }; + }); + const valuesets = Object.values(valuesetsByCode); + const options: DefaultOptionType[] = valuesets.map((record) => ({ + value: getValueSetOptionsValue(record), + label: record.display, + })); + return options; +} + +/** + * check if product is an atrractive item + * + * @param product - product data + */ +export const isAttractiveProduct = (product?: IGroup) => { + if (!product) { + return false; + } + const isAttractive = product.characteristic?.some( + (char) => char.code.coding?.[0]?.code === attractiveCharacteristicCode + ); + return isAttractive as boolean; +}; + +/** + * check if product is an accounterbility period + * + * @param product - product data + */ +export const productAccountabilityMonths = (product?: IGroup) => { + if (!product) { + return undefined; + } + const characteristic = product.characteristic?.filter( + (char) => char.code.coding?.[0]?.code === accountabilityCharacteristicCode + ); + return characteristic?.[0]?.valueQuantity?.value; +}; + +/** + * get single product option + * + * @param product - product data + * @returns returns single select option + */ +export const processProductOptions = (product: IGroup) => { + return { + value: product.id, + label: product.name, + ref: product, + } as SelectOption; +}; + +/** + * get member data for group resource + * + * @param productId - selected products id + * @param startDate - selected start date + * @param endDate - selected end date + * @param expiryDate - selected expiry date + * @returns returns group member + */ +export const getMember = ( + productId: string, + startDate: Dayjs, + endDate: Dayjs, + expiryDate?: Dayjs +): GroupMember[] => { + const startDateToString = new Date(startDate.toDate()).toISOString(); + const endDateToString = new Date(endDate.toDate()).toISOString(); + const expiryDateToString = expiryDate ? new Date(expiryDate.toDate()).toISOString : ''; + return [ + { + entity: { + reference: `Group/${productId}`, + }, + period: { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + start: startDateToString as any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + end: (endDateToString || expiryDateToString) as any, + }, + inactive: false, + }, + ]; +}; + +/** + * get characteristic data for group resource + * + * @param unicefSection - selected unicef section + * @param donor - selected donor + * @param quantity - product quantity + * @param listResourceObj - resource object available on edit + * @returns returns characteristivs + */ +export const generateCharacteristics = ( + unicefSection: ValueSetConcept, + donor?: ValueSetConcept, + quantity?: number, + listResourceObj?: IGroup +): GroupCharacteristic[] => { + const knownCodes = [ + unicefCharacteristicCode, + donorCharacteristicCode, + quantityCharacteristicCode, + ]; + const unknownCharacteristics = + listResourceObj?.characteristic?.filter((char) => { + const code = char.code.coding?.[0].code; + return !(code && knownCodes.includes(code)); + }) || []; + const characteristics: GroupCharacteristic[] = [ + ...unknownCharacteristics, + { + code: { + coding: [ + { + system: codeSystem, + code: unicefCharacteristicCode, + display: 'Unicef Section', + }, + ], + }, + valueCodeableConcept: { + coding: [unicefSection], + text: unicefSection.display, + }, + }, + ]; + if (donor) { + characteristics.push({ + code: { + coding: [ + { + system: codeSystem, + code: donorCharacteristicCode, + display: 'Donor', + }, + ], + }, + valueCodeableConcept: { + coding: [donor], + text: donor.display, + }, + }); + } + if (quantity) { + characteristics.push({ + code: { + coding: [ + { + system: codeSystem, + code: quantityCharacteristicCode, + display: 'Quantity ', + }, + ], + }, + valueQuantity: { value: quantity }, + }); + } + return characteristics; +}; + +/** + * get identifier data for group resource + * + * @param poId - Po number + * @param serialId - serial number + * @param listResourceObj - resource object available on edit + * @returns returns group identifier + */ +export const generateIdentifier = ( + poId: string, + serialId?: string, + listResourceObj?: IGroup +): Identifier[] => { + const knownCodes = [poNumberCode, serialNumberCode]; + const unknownIdentifiers = + listResourceObj?.identifier?.filter((identifier) => { + const code = identifier.type?.coding?.[0].code; + return !(code && knownCodes.includes(code)); + }) || []; + const identifiers = [ + { + use: IdentifierUseCodes.SECONDARY, + type: { + coding: [ + { + system: codeSystem, + code: poNumberCode, + display: poNumberDisplay, + }, + ], + text: poNumberDisplay, + }, + value: poId, + }, + ...unknownIdentifiers, + ]; + if (serialId) { + identifiers.push({ + use: IdentifierUseCodes.OFFICIAL, + type: { + coding: [ + { + system: codeSystem, + code: serialNumberCode, + display: serialNumberDisplay, + }, + ], + text: serialNumberDisplay, + }, + value: serialId, + }); + } + return identifiers; +}; + +/** + * get payload data for group resource + * + * @param values - form values + * @param editMode - editing form? + * @param listResourceObj - resource object available on edit + * @returns returns group resource payload + */ +export const getLocationInventoryPayload = ( + values: GroupFormFields, + editMode: boolean, + listResourceObj?: IGroup +): IGroup => { + const donor = values.donor ? JSON.parse(values.donor) : values.donor; + const unicefSection = values.unicefSection ? JSON.parse(values.unicefSection) : {}; + const payload: IGroup = { + resourceType: groupResourceType, + id: values.id || v4(), + active: true, + actual: false, + type: 'substance', + identifier: generateIdentifier(values.poNumber, values.serialNumber, listResourceObj), + member: getMember(values.product, values.deliveryDate, values.accountabilityEndDate), + characteristic: generateCharacteristics(unicefSection, donor, values.quantity, listResourceObj), + code: { + coding: [ + { + system: codeSystem, + code: supplyInventoryCode, + display: 'Supply Inventory', + }, + ], + }, + }; + if (editMode) { + if (values.active) payload.active = values.active; + if (values.actual) payload.actual = values.actual; + if (values.type) payload.type = values.type; + if (values.name) payload.name = values.name; + } + return payload; +}; + +/** + * either posts or puts a location inventory group resource payload to fhir server + * + * @param baseUrl - server base url + * @param payload - location inventory payload + */ +export const postPutGroup = (baseUrl: string, payload: IGroup) => { + const serve = new FHIRServiceClass(baseUrl, groupResourceType); + return serve.update(payload); +}; + +/** + * Gets list resource for given id, create it if it does not exist + * + * @param baseUrl - api base url + * @param listId - list id + */ +export async function getOrCreateList(baseUrl: string, listId: string) { + const serve = new FHIRServiceClass(baseUrl, listResourceType); + return serve.read(listId).catch((err) => { + if (err.statusCode === 404) { + return createListResource(baseUrl, listId); + } + throw err; + }); +} + +/** + * Gets list resource for given id, create it if it does not exist + * + * @param baseUrl - api base url + * @param listId - list id + * @param entries - resource entries + * @param listResource - a list resource + */ +export async function createListResource( + baseUrl: string, + listId: string, + entries?: ListEntry[], + listResource?: IList +) { + const serve = new FHIRServiceClass(baseUrl, listResourceType); + const listResourceToUse = listResource || createLocationServicePointList(listId, entries); + return serve.update(listResourceToUse); +} + +/** + * save location resource and it's list resources + * + * @param baseUrl - api base url + * @param payload - location inventory payload + * @param editMode - editing data? + * @param listResourceId - env location list resource uuid + * @param servicePointObj - inventory service point object + */ +export async function postLocationInventory( + baseUrl: string, + payload: IGroup, + editMode: boolean, + listResourceId: string, + servicePointObj: ILocation +) { + const groupResource = await postPutGroup(baseUrl, payload); + if (!editMode) { + const groupResourceId = groupResource.id as string; + const listId = v4(); + const inventoryList = createLocationInventoryList(listId, groupResourceId, servicePointObj); + await createListResource(baseUrl, listId, undefined, inventoryList); + if (listResourceId) { + const combinedListResource = updateListReferencesFactory(baseUrl, listResourceId); + await combinedListResource(groupResourceId, listId, editMode); + } + } + return groupResource; +} + +/** + * @param baseUrl - the api base url + * @param listId - list resource id to add the group to + */ +export const updateListReferencesFactory = + (baseUrl: string, listId: string) => + async (groupResourceId: string, listResourceId: string, editingGroup: boolean) => { + const commoditiesListResource = await getOrCreateList(baseUrl, listId); + const payload = cloneDeep(commoditiesListResource); + + const existingEntries = payload.entry ?? []; + if (!editingGroup) { + existingEntries.push( + { item: { reference: `${groupResourceType}/${groupResourceId}` } }, + { item: { reference: `${listResourceType}/${listResourceId}` } } + ); + } + if (existingEntries.length) { + payload.entry = existingEntries; + } + + const serve = new FHIRServiceClass(baseUrl, listResourceType); + return serve.update(payload); + }; + +/** + * Creates an object of list resource keys + * + * @param id - externally defined id that will be the id of the new list resource + */ +function createCommonListResource(id: string): IList { + return { + resourceType: listResourceType, + id: id, + identifier: [ + { + use: IdentifierUseCodes.OFFICIAL, + value: id, + }, + ], + status: 'current', + code: { + coding: [ + { + system: codeSystem, + code: listSupplyInventoryCode, + display: 'Supply Inventory List', + }, + ], + text: 'Supply Inventory List', + }, + }; +} + +/** + * Creates a location inventory and service point list resource that will curate a set of commodities to be used on the client. + * This is so that the list resource can then be used when configuring the fhir mobile client + * + * @param id - externally defined id that will be the id of the new list resource + * @param entries - list of resource entries + */ +export function createLocationServicePointList(id: string, entries?: ListEntry[]): IList { + const commonResources = createCommonListResource(id); + return { + ...commonResources, + mode: 'working', + title: 'Supply Chain commodities', + entry: entries || [], + }; +} + +/** + * Creates a location inventory list resource that will curate a set of commodities to be used on the client. + * This is so that the list resource can then be used when configuring the fhir mobile client + * + * @param id - externally defined id that will be the id of the new list resource + * @param InventoryResourceId - location inventory id + * @param servicePoint - service point object + */ +export function createLocationInventoryList( + id: string, + InventoryResourceId: string, + servicePoint: ILocation +): IList { + const commonResources = createCommonListResource(id); + const { name, id: servicePointId } = servicePoint; + const now = new Date(); + const stringDate = now.toISOString(); + return { + ...commonResources, + title: name, + subject: { reference: `Location/${servicePointId}` }, + entry: [ + { + flag: { + coding: [ + { + system: codeSystem, + code: listSupplyInventoryCode, + display: 'Supply Inventory List', + }, + ], + text: 'Supply Inventory List', + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + date: stringDate as any, + item: { reference: `Group/${InventoryResourceId}` }, + }, + ], + }; +} + +/** + * generates form initial values + * + * @param inventory - location inventory group resource + */ +export const getInventoryInitialValues = (inventory: IGroup): GroupFormFields => { + const initialValues = { + id: inventory.id as string, + active: inventory.active, + type: inventory.type, + actual: inventory.actual, + name: inventory.name, + } as GroupFormFields; + inventory.identifier?.forEach((identifier) => { + const code = identifier.type?.coding?.[0].code; + if (code === poNumberCode) { + initialValues.poNumber = identifier.value as string; + } + if (code === serialNumberCode) { + initialValues.serialNumber = identifier.value as string; + } + }); + inventory.characteristic?.forEach((characteristic) => { + const code = characteristic.code.coding?.[0].code; + if (code === unicefCharacteristicCode) { + const coding = characteristic.valueCodeableConcept?.coding?.[0]; + if (coding) { + initialValues.unicefSection = getValueSetOptionsValue(coding); + } + } + if (code === donorCharacteristicCode) { + const coding = characteristic.valueCodeableConcept?.coding?.[0]; + if (coding) { + initialValues.donor = getValueSetOptionsValue(coding); + } + } + if (code === quantityCharacteristicCode) { + initialValues.quantity = characteristic.valueQuantity?.value; + } + }); + const member = inventory.member?.[0]; + const reference = member?.entity.reference; + const { start, end } = member?.period || {}; + if (end) { + initialValues.accountabilityEndDate = dayjs(end); + } + if (start) { + initialValues.deliveryDate = dayjs(start); + } + if (reference) { + const productId = reference.split('/')[1]; + initialValues.product = productId; + } + return initialValues; +}; + +/** + * factory for validation rules for GroupForm component + * + * @param t - the translator function + */ +export const validationRulesFactory = (t: TFunction) => { + return { + [product]: [ + { type: 'string', message: t('Must be a valid string') }, + { required: true, message: t('Required') }, + ] as Rule[], + [unicefSection]: [ + { type: 'string', message: t('Must be a valid string') }, + { required: true, message: t('Required') }, + ] as Rule[], + [deliveryDate]: [ + { type: 'date', message: t('Must be a valid date') }, + { required: true, message: t('Required') }, + ] as Rule[], + [accountabilityEndDate]: [ + { type: 'date', message: t('Must be a valid date') }, + { required: true, message: t('Required') }, + ] as Rule[], + [serialNumber]: [ + { type: 'string', message: t('Must be a valid string') }, + { required: true, message: t('Required') }, + ] as Rule[], + [PONumber]: [ + { type: 'string', message: t('Must be a valid string') }, + { required: true, message: t('Required') }, + ] as Rule[], + }; +}; diff --git a/packages/fhir-group-management/src/constants.tsx b/packages/fhir-group-management/src/constants.tsx index 4d6b7eb50..152a60d19 100644 --- a/packages/fhir-group-management/src/constants.tsx +++ b/packages/fhir-group-management/src/constants.tsx @@ -3,13 +3,20 @@ export const ADD_EDIT_GROUP_URL = '/group/add-edit'; export const ADD_EDIT_COMMODITY_URL = '/commodity/add-edit'; export const LIST_GROUP_URL = '/groups/list'; export const LIST_COMMODITY_URL = '/commodity/list'; +export const ADD_LOCATION_INVENTORY = '/location/inventory'; + +// unicef and donor endpoints +export const unicefSectionValueSetId = 'eusm-unicef-sections'; +export const unicefDonorValueSetId = 'eusm-donors'; // magic strings export const groupResourceType = 'Group'; export const listResourceType = 'List'; export const binaryResourceType = 'Binary'; +export const valuesetResourceType = 'ValueSet'; +export const locationResourceType = 'Location'; -// form constants +// product form constants export const id = 'id' as const; export const identifier = 'identifier' as const; export const name = 'name' as const; @@ -23,3 +30,15 @@ export const condition = 'condition' as const; export const appropriateUsage = 'appropriateUsage' as const; export const accountabilityPeriod = 'accountabilityPeriod' as const; export const productImage = 'productImage' as const; + +// location inventory form constants +export const product = 'product' as const; +export const quantity = 'quantity' as const; +export const deliveryDate = 'deliveryDate' as const; +export const accountabilityEndDate = 'accountabilityEndDate' as const; +export const expiryDate = 'expiryDate' as const; +export const unicefSection = 'unicefSection' as const; +export const serialNumber = 'serialNumber' as const; +export const donor = 'donor' as const; +export const PONumber = 'poNumber' as const; +export const actual = 'actual' as const; diff --git a/packages/fhir-group-management/src/index.tsx b/packages/fhir-group-management/src/index.tsx index ee498e577..f4a0515db 100644 --- a/packages/fhir-group-management/src/index.tsx +++ b/packages/fhir-group-management/src/index.tsx @@ -1,5 +1,6 @@ export * from './components/GroupList'; export * from './components/CommodityAddEdit'; export * from './components/CommodityList/'; +export * from './components/LocationInventory'; export * from './types'; export * from './constants'; diff --git a/packages/fhir-helpers/src/constants/codeSystems.ts b/packages/fhir-helpers/src/constants/codeSystems.ts index 0842603d8..a1cad4229 100644 --- a/packages/fhir-helpers/src/constants/codeSystems.ts +++ b/packages/fhir-helpers/src/constants/codeSystems.ts @@ -19,7 +19,7 @@ export const sectionCharacteristicCoding = { export const donorCharacteristicCoding = { system: smartregisterSystemUri, - code: '45647484', + code: '45981276', display: 'Donor', }; diff --git a/packages/fhir-location-management/package.json b/packages/fhir-location-management/package.json index 5286b12ba..cbfb2c14e 100644 --- a/packages/fhir-location-management/package.json +++ b/packages/fhir-location-management/package.json @@ -35,6 +35,7 @@ "@onaio/redux-reducer-registry": "^0.0.9", "@onaio/session-reducer": "0.0.12", "@onaio/utils": "^0.0.1", + "@opensrp/fhir-group-management": "^0.0.5", "@opensrp/fhir-helpers": "workspace:^", "@opensrp/notifications": "^0.0.5", "@opensrp/pkg-config": "^0.0.9", diff --git a/packages/fhir-location-management/src/components/ViewDetails/DetailsTabs/Inventory.tsx b/packages/fhir-location-management/src/components/ViewDetails/DetailsTabs/Inventory.tsx index 4bb9e968f..8dc0e98ea 100644 --- a/packages/fhir-location-management/src/components/ViewDetails/DetailsTabs/Inventory.tsx +++ b/packages/fhir-location-management/src/components/ViewDetails/DetailsTabs/Inventory.tsx @@ -22,9 +22,11 @@ import { productCoding, sectionCharacteristicCoding, quantityCharacteristicCoding, + serialNumberIdentifierCoding, } from '@opensrp/fhir-helpers'; import { hasCode } from '../utils'; import { Reference } from '@smile-cdr/fhirts/dist/FHIR-R4/classes/reference'; +import { ADD_LOCATION_INVENTORY } from '@opensrp/fhir-group-management'; export interface InventoryViewProps { fhirBaseUrl: string; @@ -37,13 +39,12 @@ export interface InventoryViewProps { * @param group - inventory group resource to be parsed */ function parseInventoryGroup(group: IGroup) { - const { member, characteristic, identifier } = group; - + const { member, characteristic, identifier, id } = group; const sanitizeDate = (dateString?: string) => { if (!dateString) return; const sampleDate = new Date(dateString); if (isNaN(sampleDate.getTime())) return; - return sampleDate; + return sampleDate.toLocaleDateString(); }; // invariant one member representing the product @@ -69,14 +70,23 @@ function parseInventoryGroup(group: IGroup) { quantityCharacteristicCoding ); + const serialNumberIdentifier = identifier?.filter((id) => { + const hasCoding = id.type?.coding?.some( + (coding) => coding.code === serialNumberIdentifierCoding.code + ); + return hasCoding; + }); + return { + id, productReference, - quantity: quantityCharacteristic?.valueQuantity?.value, + quantity: quantityCharacteristic.valueQuantity?.value, poNumber: poNumberIdentifier?.[0]?.value, deliveryDate: sanitizeDate(startDate), accountabilityEndDate: sanitizeDate(endDate), - unicefSection: sectionCharacteristic?.valueCodeableConcept?.text, - donor: donorCharacteristic?.valueCodeableConcept?.text, + unicefSection: sectionCharacteristic.valueCodeableConcept?.text, + donor: donorCharacteristic.valueCodeableConcept?.text, + serialNumber: serialNumberIdentifier?.[0]?.value, }; } @@ -86,14 +96,9 @@ function parseInventoryGroup(group: IGroup) { * @param group - group resource to be parsed */ function parseProductGroup(group: IGroup) { - const { name, identifier } = group; - - const serialNumberIdentifier = identifier?.filter((id) => { - return (id.type?.coding ?? []).indexOf(poNumberIdentifierCoding); - }); + const { name } = group; return { productName: name, - serialNumber: serialNumberIdentifier?.[0]?.value, }; } @@ -125,11 +130,11 @@ function isProduct(res: IGroup) { function dataTransformer(bundleResponse: IBundle) { const entry = (bundleResponse.entry ?? []).map((x) => x.resource) as IGroup[]; const tableDataByProductRef: Record | undefined> = {}; - + let productReference; for (const resource of entry) { if (isInventory(resource)) { const tableDataEntry = parseInventoryGroup(resource as IGroup); - const { productReference } = tableDataEntry; + productReference = tableDataEntry.productReference; if (productReference) { if (tableDataByProductRef[productReference] !== undefined) { tableDataByProductRef[productReference] = { @@ -144,7 +149,7 @@ function dataTransformer(bundleResponse: IBundle) { if (isProduct(resource)) { const thisResource = resource as IGroup; const tableDataEntry = parseProductGroup(thisResource); - const productReference = `${groupResourceType}/${thisResource.id}`; + productReference = `${groupResourceType}/${thisResource.id}`; if (tableDataByProductRef[productReference] === undefined) { tableDataByProductRef[productReference] = tableDataEntry; } else { @@ -154,6 +159,12 @@ function dataTransformer(bundleResponse: IBundle) { }; } } + if (productReference) { + tableDataByProductRef[productReference] = { + ...tableDataByProductRef[productReference], + id: resource.id, + }; + } } return Object.values(tableDataByProductRef as Record); } @@ -192,6 +203,8 @@ export const InventoryView = ({ fhirBaseUrl, locationId }: InventoryViewProps) = return {t('An error occurred while fetching inventory')}; } + const baseInventoryPath = `${ADD_LOCATION_INVENTORY}/${locationId}`; + const columns: Column[] = [ { title: t('Product name'), @@ -249,11 +262,11 @@ export const InventoryView = ({ fhirBaseUrl, locationId }: InventoryViewProps) = { title: t('Actions'), // eslint-disable-next-line react/display-name - render: (_: unknown) => ( + render: ({ id }: TableData) => ( <> - + {t('Edit')} @@ -279,7 +292,7 @@ export const InventoryView = ({ fhirBaseUrl, locationId }: InventoryViewProps) =
- diff --git a/packages/fhir-location-management/src/components/ViewDetails/DetailsTabs/tests/detailsTabs.test.tsx b/packages/fhir-location-management/src/components/ViewDetails/DetailsTabs/tests/detailsTabs.test.tsx index 57c3eed79..b8fb215ce 100644 --- a/packages/fhir-location-management/src/components/ViewDetails/DetailsTabs/tests/detailsTabs.test.tsx +++ b/packages/fhir-location-management/src/components/ViewDetails/DetailsTabs/tests/detailsTabs.test.tsx @@ -229,7 +229,7 @@ test('works correctly - physical location', async () => { const inventoryTab = document.querySelector('[data-testid="inventory-tab"]')!; // check records shown in table. let tableData = [...inventoryTab.querySelectorAll('table tbody tr')].map((tr) => tr.textContent); - expect(tableData).toEqual(['Bed nets606109db-5632-48c5-8710-b726e1b3addfHealthGAVIEdit']); + expect(tableData).toEqual(['Bed nets2/1/20242/1/2024HealthEdit']); // validate search works. const childLocationSearch = inventoryTab.querySelector('[data-testid="search-form"]')!; @@ -248,7 +248,9 @@ test('works correctly - physical location', async () => { const addLocationCta = screen.queryByText(/Add Inventory/i)!; userEvent.click(addLocationCta); - expect(history.location.pathname).toEqual('/profile/d9d7aa7b-7488-48e7-bae8-d8ac5bd09334'); + expect(history.location.pathname).toEqual( + '/location/inventory/d9d7aa7b-7488-48e7-bae8-d8ac5bd09334' + ); expect(nock.isDone()).toBeTruthy(); }); diff --git a/packages/keycloak-user-management/src/components/forms/UserForm/index.tsx b/packages/keycloak-user-management/src/components/forms/UserForm/index.tsx index d92459b82..c3fdd857c 100644 --- a/packages/keycloak-user-management/src/components/forms/UserForm/index.tsx +++ b/packages/keycloak-user-management/src/components/forms/UserForm/index.tsx @@ -22,7 +22,7 @@ import { import { SelectProps } from 'antd/lib/select'; import { useTranslation } from '../../../mls'; import { compositionResourceType, PRACTITIONER, SUPERVISOR } from '../../../constants'; -import { FhirSelect } from '@opensrp/react-utils'; +import { PaginatedAsyncSelect } from '@opensrp/react-utils'; import { IComposition } from '@smile-cdr/fhirts/dist/FHIR-R4/interfaces/IComposition'; const UserForm: FC = (props: UserFormProps) => { @@ -244,13 +244,13 @@ const UserForm: FC = (props: UserFormProps) => { rules={[{ required: true, message: t('Application Id is required') }]} data-testid="fhirCoreAppId" > - + baseUrl={baseUrl} resourceType={compositionResourceType} transformOption={getCompositionOptions} extraQueryParams={compositionUrlFilter} showSearch={true} - > + > ) : null} diff --git a/packages/react-utils/src/components/AsyncSelect/BaseAsyncSelect/index.tsx b/packages/react-utils/src/components/AsyncSelect/BaseAsyncSelect/index.tsx new file mode 100644 index 000000000..b5626f960 --- /dev/null +++ b/packages/react-utils/src/components/AsyncSelect/BaseAsyncSelect/index.tsx @@ -0,0 +1,48 @@ +import { Alert, Select } from 'antd'; +import { useMemo } from 'react'; +import React from 'react'; +import { SelectProps, DefaultOptionType } from 'antd/lib/select'; +import { useTranslation } from '../../../mls'; +import { UseQueryOptions, useQuery } from 'react-query'; + +export type RawValueType = string | number | (string | number)[]; + +/** props for custom select component */ +export interface AsyncSelectProps + extends SelectProps { + optionsGetter: (data: QueryProcessedData[]) => DefaultOptionType[]; + queryParams: UseQueryOptions; +} + +/** + * Renders data in async for select component + * + * @param props - AsyncSelect component props + */ +function BaseAsyncSelect( + props: AsyncSelectProps +) { + const { optionsGetter, queryParams: useQueryParams, ...restProps } = props; + + const { t } = useTranslation(); + + const { data, isLoading, error } = useQuery(useQueryParams); + + const options = useMemo(() => (data ? optionsGetter(data) : undefined), [data, optionsGetter]); + const singleSelectProps = { + dropdownRender: (menu: React.ReactNode) => ( + <> + {!error && data && menu} + {error && } + + ), + options, + loading: isLoading, + disabled: isLoading, + ...restProps, + }; + + return