Skip to content

Commit

Permalink
Add location inventory (#1353)
Browse files Browse the repository at this point in the history
* 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 <[email protected]>

* 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 <[email protected]>
  • Loading branch information
ciremusyoka and peterMuriuki authored Mar 26, 2024
1 parent 07c9026 commit 1f3f43c
Show file tree
Hide file tree
Showing 34 changed files with 2,512 additions and 36 deletions.
1 change: 1 addition & 0 deletions .github/workflows/automation-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions app/.env.sample
Original file line number Diff line number Diff line change
Expand Up @@ -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
21 changes: 21 additions & 0 deletions app/src/App/fhir-apps.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ import {
patientProps,
fhirCreateEditUserProps,
commmodityProps,
fhirCreateEditLocationProps,
} from './utils';
import './App.css';
import {
Expand All @@ -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';
Expand Down Expand Up @@ -456,6 +459,24 @@ const FHIRApps = () => {
permissions={['Group.create']}
component={CommodityAddEdit}
/>
<PrivateComponent
redirectPath={APP_CALLBACK_URL}
disableLoginProtection={DISABLE_LOGIN_PROTECTION}
path={`${ADD_LOCATION_INVENTORY}/:servicePointId`}
{...fhirCreateEditLocationProps}
exact
permissions={['Group.create']}
component={AddLocationInventory}
/>
<PrivateComponent
redirectPath={APP_CALLBACK_URL}
disableLoginProtection={DISABLE_LOGIN_PROTECTION}
path={`${ADD_LOCATION_INVENTORY}/:servicePointId/:inventoryId`}
{...fhirCreateEditLocationProps}
exact
permissions={['Group.create']}
component={AddLocationInventory}
/>
<PrivateComponent
redirectPath={APP_CALLBACK_URL}
disableLoginProtection={DISABLE_LOGIN_PROTECTION}
Expand Down
7 changes: 7 additions & 0 deletions app/src/App/utils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
DISABLE_TEAM_MEMBER_REASSIGNMENT,
FHIR_API_BASE_URL,
KEYCLOAK_USERS_PAGE_SIZE,
FHIR_INVENTORY_LIST_ID,
} from '../configs/env';

export const BaseProps = {
Expand Down Expand Up @@ -114,3 +115,9 @@ export const patientProps = {
export const commmodityProps = {
listId: COMMODITIES_LIST_RESOURCE_ID,
};

export const fhirCreateEditLocationProps = {
...BaseProps,
listId: FHIR_INVENTORY_LIST_ID,
commodityListId: COMMODITIES_LIST_RESOURCE_ID,
}
2 changes: 2 additions & 0 deletions app/src/configs/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,8 @@ export const FHIR_PATIENT_BUNDLE_SIZE = Number(setEnv('REACT_APP_FHIR_PATIENT_BU

export const FHIR_ROOT_LOCATION_ID = setEnv('REACT_APP_FHIR_ROOT_LOCATION_ID', '');

export const FHIR_INVENTORY_LIST_ID = setEnv('REACT_APP_FHIR_INVENTORY_LIST_ID', '')

export const OPENSRP_WEB_VERSION = setEnv('REACT_APP_OPENSRP_WEB_VERSION', '');

export const SENTRY_CONFIGS = JSON.parse(setEnv('REACT_APP_SENTRY_CONFIG_JSON', '{}'));
Expand Down
1 change: 1 addition & 0 deletions docker/config.js.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ window._env_ = {
REACT_APP_SENTRY_CONFIG_JSON: "{{ getv "/react/app/opensrp/sentry/config/json" "" }}",
REACT_APP_ENABLE_FHIR_LOCATIONS: "{{ getv "/react/app/enable/fhir/locations" "false" }}",
REACT_APP_FHIR_ROOT_LOCATION_ID: "{{ getv "react/app/fhir/root/location/id" "" }}",
REACT_APP_FHIR_INVENTORY_LIST_ID: "{{ getv "react/app/fhir/inventory/list/id" "" }}",
REACT_APP_ENABLE_QUEST: "{{ getv "react/app/enable/quest" "false" }}",
REACT_APP_ENABLE_FHIR_HEALTHCARE_SERVICES: "{{ getv "/react/app/enable/fhir/healthcare/services" "false" }}",
REACT_APP_ENABLE_FHIR_GROUP: "{{ getv "/react/app/enable/fhir/group" "false" }}",
Expand Down
4 changes: 4 additions & 0 deletions docs/env.md
Original file line number Diff line number Diff line change
Expand Up @@ -261,3 +261,7 @@ Below is a list of currently supported environment variables:
- defines which authorization strategy to use. This affects how roles and permissions fetched from the Authorization server are parsed and used in the web app.Currently only keycloak is supported which means Role based acces will only work when using keycloak as the IAM server.
- **Required**(`keycloak`).
- default: `keycloak`

- **REACT_APP_FHIR_INVENTORY_LIST_ID
- Id of List that will hold all created inventories for a given web instance
- **Optional**_(`string`)_
1 change: 1 addition & 0 deletions docs/fhir-web-docker-deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ We use different technologies to deploy fhir-web. This documentation will focus
// UUID's
REACT_APP_FHIR_ROOT_LOCATION_ID: '<id-of-the-root-location-on-the-HAPI-server>',
REACT_APP_COMMODITIES_LIST_RESOURCE_ID: '<id-of-a-list-on-HAPI-fhir-server>',
REACT_APP_FHIR_INVENTORY_LIST_ID: '<id-of-inventory-list-resource-on-HAPI--fhir-server>',
// toggle fhir-web modules
REACT_APP_ENABLE_FHIR_CARE_TEAM: 'false',
Expand Down
Original file line number Diff line number Diff line change
@@ -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<boolean>(
isAttractiveProduct(inventoryResourceObj)
);
const [accounterbilityMonths, setAccounterbilityMonths] = useState<number>();
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<IGroup> | ProductSelectOption<IGroup>[]
) => {
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 (
<Form
form={form}
requiredMark={false}
{...formItemLayout}
onFinish={(values: GroupFormFields) => {
mutate(values);
}}
initialValues={initialValues}
>
<FormItem id="project" name={product} label={t('Product name')}>
<PaginatedAsyncSelect<IGroup>
baseUrl={fhirBaseURL}
resourceType={groupResourceType}
transformOption={processProductOptions}
extraQueryParams={productQueryFilters}
showSearch={true}
placeholder={t('Select product')}
getFullOptionOnChange={productChangeHandler}
disabled={editMode}
/>
</FormItem>

<FormItem id="quantity" name={quantity} label={t('Quantity')}>
<Input placeholder={t('Quantity')} type="number" />
</FormItem>

<FormItem
id="deliveryDate"
rules={validationRules[deliveryDate]}
name={deliveryDate}
label={t('Delivery date')}
>
<DatePicker
placeholder={t('Delivery date')}
onChange={delveryDateChangeHandler}
disabledDate={handleDisabledFutureDates}
/>
</FormItem>

<FormItem
id="accounterbilityEndDate"
name={accountabilityEndDate}
label={t('Accountability end date')}
rules={validationRules[accountabilityEndDate]}
>
<DatePicker placeholder={t('End date')} disabledDate={handleDisabledPastDates} />
</FormItem>

<FormItem hidden id="expiryDate" name={expiryDate} label={t('Expiry date')}>
<DatePicker placeholder={t('Expiry date')} disabledDate={handleDisabledPastDates} />
</FormItem>

<FormItem
id={unicefSection}
name={unicefSection}
label={t('UNICEF section')}
rules={validationRules[unicefSection]}
>
<ValueSetAsyncSelect
placeholder={t('Select UNICEF section')}
showSearch={true}
valueSetId={unicefSectionValueSetId}
fhirBaseUrl={fhirBaseURL}
/>
</FormItem>

<FormItem
id="serialNumber"
rules={serialNumebrRule}
name={serialNumber}
label={t('Serial number')}
>
<Input disabled={!attractiveProduct} type="number" placeholder={t('Serial number')} />
</FormItem>

<FormItem id={donor} name={donor} label={t('Donor')}>
<ValueSetAsyncSelect
placeholder={t('Select donor')}
showSearch={true}
valueSetId={unicefSectionValueSetId}
fhirBaseUrl={fhirBaseURL}
/>
</FormItem>

<FormItem
id="poNumber"
rules={validationRules[PONumber]}
name={PONumber}
label={t('PO number')}
>
<Input type="number" placeholder={t('PO number')} />
</FormItem>

{/* start hidden fields */}
<FormItem hidden={true} id="id" name={id} label={t('product Id')}>
<Input disabled={true} />
</FormItem>
<FormItem hidden={true} id="active" name={active} label={t('Active')}>
<Switch checked={initialValues.active} disabled={true} />
</FormItem>
<FormItem hidden={true} id="actual" name={actual} label={t('Actual')}>
<Switch checked={initialValues.actual} disabled={true} />
</FormItem>
<FormItem hidden={true} id="name" name={name} label={t('Name')}>
<Input disabled={true} />
</FormItem>
<FormItem hidden={true} id="type" name={type} label={t('Type')}>
<Input disabled={true} />
</FormItem>
{/* End hidden fields */}

<FormItem {...tailLayout}>
<Space>
<Button type="primary" id="submit-button" disabled={isLoading} htmlType="submit">
{isLoading ? t('Saving') : t('save')}
</Button>
<Button id="cancel-button" onClick={() => history.goBack()}>
{t('Cancel')}
</Button>
</Space>
</FormItem>
</Form>
);
};

AddLocationInventoryForm.defaultProps = defaultProps;

export { AddLocationInventoryForm };
Loading

0 comments on commit 1f3f43c

Please sign in to comment.