Skip to content

OBPIH-6999 Fill expiration date when select known lot in new row in count and recount workflow#5496

Merged
alannadolny merged 9 commits intodevelopfrom
OBPIH-6999
Sep 19, 2025
Merged

OBPIH-6999 Fill expiration date when select known lot in new row in count and recount workflow#5496
alannadolny merged 9 commits intodevelopfrom
OBPIH-6999

Conversation

@SebastianLib
Copy link
Collaborator

✨ Description of Change

Link to GitHub issue or Jira ticket:
https://pihemr.atlassian.net/browse/OBPIH-7126

Description:
image
image


📷 Screenshots & Recordings (optional)

@github-actions github-actions bot added domain: frontend Changes or discussions relating to the frontend UI domain: backend Changes or discussions relating to the backend server labels Sep 15, 2025
action = [POST: "importCsv"]
}

"/api/products/getLotNumbersWithExpirationDate" {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That URL naming doesn't seem right to me. @jmiranda said a lot about the idea of fetching the expiration date and filling the field after entering the lot, so maybe he has any thoughts about it.

Copy link
Member

@ewaterman ewaterman Sep 15, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah a few thoughts:

If you were fetching for a single product, it makes sense to make it product centered: /api/products/$productId/inventoryItems or /api/products/$productId/lots.

I originally was thinking you'd want to switch it to be inventory item centric: /api/inventoryItems?productIds=1,2,3. And then you could mask what the frontend is trying to do by turning this into just a basic list endpoint on inventory items:

[{
  'productId': ...,
  'lotNumber': ...,
  'expirationDate': ...,
  'quantityOnHand': ...,
  ...
}]

But that's annoying for you because you want this keyed on product, which is something we reasonably want to support as an API.

So since you're keying on product in the response, it really does make sense to be product centered still: /api/products/inventoryItems?productIds=1,2,3 or /api/products/lots?productIds=1,2,3. It's kinda clunky since you have products twice, once in the uri and once in the query params, but if you think of it as /api/products/inventoryItems fetching all inventory items for all products, then adding productIds=1,2,3 is the filter on that "resource".

And then there's another conversation to be had about whether we want to bother filtering down the fields. Why not just return everything:

{
  'productId123': {
    'productId': 'productId123',
    'lotNumber': ...,
    'expirationDate': ...,
    'quantityOnHand': ...,
  },
  'productId456': { ... },
  ...
}

Not filtering out the other fields by default could make this endpoint more re-usable. If we are worried about performance or payload size (I don't think we are in this case) then we could add a fields=lotNumber,expirationDate query param to filter the selected fields by. But I think that's overkill for now.

Idk, I'm also curious what Justin has to say.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@alannadolny the idea is to have a select for lots, so that you don't have to fill it manually, hence fetching the expiration date after filling in the lot does not satisfy the requirements.

Speaking about the URL, I don't have a strong opinion, but definetely it should contain the inventoryItem, so for now make it at least:

"/api/products/inventoryItems/getLotNumbersWithExpirationDate"

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am thinking about getting rid of get from getLotNumbersWithExpirationDate, because the GET method already says that we are getting the data.

Comment on lines 1573 to 1585
products.collect { product ->
List<Map<String, Date>> lotNumbers = product.inventoryItems
.findAll { it.lotNumber }
.collect {
[
lotNumber : it.lotNumber,
expirationDate: it.expirationDate
]
}
.unique { it.lotNumber }

[productId: product.id, lotNumbers: lotNumbers]
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Move that to the createCriteria. You can use projections

});

export const fetchLotNumbersByProductIds = (productIds) => async (dispatch) => {
const lotNumbers = await getLotNumbersByProductIds(productIds);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's not only lotNumbers, those are lotNumbers combined with expirationDates

paramsSerializer: (parameters) => queryString.stringify(parameters),
}),
getLotNumbersByProductIds: (productIds) =>
apiClient.get(`${PRODUCT_API}/getLotNumbersWithExpirationDate`, {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

${PRODUCT_API}/getLotNumbersWithExpirationDate
☝️ this should be in the URLs file

printCountForm={printCountForm}
next={() => validateExistenceOfCycleCounts(next)}
save={() => validateExistenceOfCycleCounts(save)}
save={() => validateExistenceOfCycleCounts(() => save({ shouldRefetchLotNumbers: true }))}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks weird, move that to a separate function definition or use higher order functions

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can't it be done by passing the appropriate prop to the SelectField component? like: selectLot: true instead of creating a new component?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same question as @alannadolny - how does it differ from the regular selects that it requires a separate component to be created instead of passing proper props?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I’ve been thinking for a while about whether this new component makes sense. In a comment under the ticket, Evan suggested trying a new component that we could start using right away. I think I might have overcomplicated things here and it’s unnecessary, since we can simply pass the required props to the existing component

Comment on lines 83 to 89
const productIds = Array.from(
new Set(
tableData.current
.flatMap((cycleCount) => cycleCount.cycleCountItems)
.map((item) => item.product?.id),
),
);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why are you casting the type from set to array?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would suggest changing the name to include "unique" word in some way. I'd also suggest adding a comment explaining that you are collecting unique product ids.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@alannadolny Simply for convenience, because I wanted the unique product IDs as an array rather than a Set, which is an object

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not a change request: I am not sure how the type casting works in JS under the hood, I would rather use lodash unique, but I am fine with that version

action = [POST: "importCsv"]
}

"/api/products/getLotNumbersWithExpirationDate" {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@alannadolny the idea is to have a select for lots, so that you don't have to fill it manually, hence fetching the expiration date after filling in the lot does not satisfy the requirements.

Speaking about the URL, I don't have a strong opinion, but definetely it should contain the inventoryItem, so for now make it at least:

"/api/products/inventoryItems/getLotNumbersWithExpirationDate"

def getLotNumbersWithExpirationDate() {
List<String> productIds = params.list("productIds")

List<Map<String, Object>> lotNumbersWithExpiration = productService.getLotNumbersWithExpirationDate(productIds)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

include product in the variable name - you return product with lot numbers

}
.unique { it.lotNumber }

[productId: product.id, lotNumbers: lotNumbers]
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

use return expliciltly + I have a question if it should not return results grouped by product id?
You might have X inventory items per product, hence wouldn't we want to group the lot numbers by the product id, to make it look like:

"10001": [<lot_date_1>, <lot_date_2>],
"10002": [<lot_date_1>, <lot_date_2>],

I think this is what I was suggesting on Slack some time ago

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same question as @alannadolny - how does it differ from the regular selects that it requires a separate component to be created instead of passing proper props?

label: item.lotNumber,
value: item.lotNumber,
}))}
creatable
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what does this prop do? I haven't seen it previously

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The prop creatable is a boolean that determines which SelectType we want to use. When creatable is passed as true, we use Creatable, which allows us to create new options that are not already in the list.

Example usage:

const SelectType = (() => {
  if (creatable) {
    return Creatable;
  }
  if (async) {
    return Async;
  }
  return ReactSelect;
})();

if (isStepEditable && productIds.length > 0) {
dispatch(fetchLotNumbersByProductIds(productIds));
}
}, [JSON.stringify(productIds), isStepEditable]);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question, no requested change - why JSON.stringify?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did it this way because without it productIds is an array, and arrays are compared by reference, so I stringify productIds to trigger the effect only when its contents change, not every time a new array instance is created.

const save = async (shouldSetDefaultAssignee = false) => {
const save = async ({
shouldSetDefaultAssignee = false,
shouldRefetchLotNumbers = false,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

when don't we want to refetch the lot numbers then?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For now I implemented it so that we refetch the lot numbers only when entering or returning to the first step, or when clicking Save Progress. In other cases the refetch isn’t needed, because on the second step we can’t change or add a lot number anyway

}, [rowIndex, columnId]);

const handleLotNumberChange = (selectedLotNumber) => {
const isLotAlreadyExist = lotNumbers.find(lot => lot.lotNumber === selectedLotNumber);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is is redundant here.
Boolean fields should be named without is in that case, so: lotAlreadyExist is enough. It's method names that include is,
e.g. if you named a field isAvailable, you would have to name the getter getIsAvailable() which is not right. It should be available for the field, and isAvailable() for the method.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

moreover if this variable is supposed to return boolean, make it boolean already instead of parsing it via !! later

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

moreover if this variable is supposed to return boolean, make it boolean already instead of parsing it via !! later

I might have made a mistake here because I actually intended this to be an object that includes both the boolean value and other properties at the same time

setDisabledExpirationDateFields(prev => ({
...prev,
[original.id]: !!isLotAlreadyExist,
}));
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

add comment what is going on here


import { eraseDraft, fetchBinLocations, fetchUsers } from 'actions';
import {
eraseDraft, fetchBinLocations, fetchLotNumbersByProductIds, fetchUsers,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

doesn't eslint shout that it's in one line?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

eslint was screaming earlier, so I had to set it this way

Comment on lines +341 to +359
const handleArrowNavigation = (e) => {
// Before calling handleKeyDown, we check two cases where arrow navigation should be blocked
// these cases are not handled inside useArrowsNavigation
if (
e.key === navigationKey.ARROW_UP
&& columnPath === cycleCountColumn.EXPIRATION_DATE
&& disabledExpirationDateFields[tableData[index - 1]?.id]
) {
return;
}
if (
e.key === navigationKey.ARROW_DOWN
&& columnPath === cycleCountColumn.EXPIRATION_DATE
&& disabledExpirationDateFields[tableData[index + 1]?.id]
) {
return;
}
handleKeyDown(e, index, columnPath);
};
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here it was hard for me to decide whether these two cases should be handled inside useArrowNavigation. I figured it’s probably better to extract them here to avoid making that hook more complicated, and in other places where we’ll use arrow navigation we likely won’t have situations like this, where a field in the middle of a row might be disabled.

@SebastianLib SebastianLib added the type: feature A new piece of functionality for the app label Sep 17, 2025
rowIndex: PropTypes.string,
columnId: PropTypes.string,
}),
creatable: PropTypes.bool,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it used in that component?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need to pass creatable as true in the useCountStepTable and useResolveStepTable hooks.
Example:

if (fieldName === cycleCountColumn.LOT_NUMBER) {
  return {
    placeholder: isFieldDisabled && translate('react.cycleCount.emptyLotNumber.label', 'NO LOT'),
    options: lotNumbersWithExpiration.map((item) => ({
      id: item.lotNumber,
      name: item.lotNumber,
      label: item.lotNumber,
      value: item.lotNumber,
    })),
    creatable: true,
  };
}

We need to pass this to the SelectField component because our lot number uses SelectField, which internally uses Select.jsx.

In Select.jsx, we use the creatable boolean here:

const SelectType = (() => {
  if (creatable) {
    return Creatable;
  }
  if (async) {
    return Async;
  }
  return ReactSelect;
})();

This allows us to create new options in the dropdown.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So add a comment explaining what this prop is, + make it visible in component props instead of catching it by the spread operator

@alannadolny alannadolny merged commit c15c574 into develop Sep 19, 2025
2 of 5 checks passed
@alannadolny alannadolny deleted the OBPIH-6999 branch September 19, 2025 08:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

domain: backend Changes or discussions relating to the backend server domain: frontend Changes or discussions relating to the frontend UI type: feature A new piece of functionality for the app

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants