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 (
+
+ );
+};
+
+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) =
-