From 6ae3f13a97fc0c8e53ee1d0c8dd964f58e09a938 Mon Sep 17 00:00:00 2001 From: olewandowski1 Date: Fri, 21 Aug 2026 15:36:04 +0200 Subject: [PATCH] OBLS-940 Refresh putaway candidates and fix putaway location entry Putaway Candidates only fetched in componentDidMount, so returning from a completed putaway showed a stale list, and the filtered rows were held in state that a refresh never recomputed. Converted the screen to a function component so it refetches on focus and derives the visible list from the fetched data, which removes both stale paths. The unreachable "already in a pending putaway" alert is gone, since the READY filter already excludes every row that has an id. Putaway Item Detail could not complete a putaway at all. The scan field was rendered with editable false and the screen had no scan listener, so scannedPutawayLocation stayed empty and validatePutawayItem rejected every submission. It now uses ScannerInput for hardware scans, manual typing and the destination bin search, and Confirm is gated on a location being present instead of failing server side. The root cause of the dead search and Confirm buttons was the bare ScrollView, which defaults keyboardShouldPersistTaps to never, so the first tap was consumed dismissing the keyboard while the input held focus. Every sibling screen sets this prop; this one did not, and the previous non editable field hid it. Completing a putaway now drops the row immediately. The backend defers the product availability refresh, so the candidates response still reports the old quantity for a while after the transfer. An override keyed on bin, lot and product records the expected remaining quantity and is discarded once the server reports a quantity at or below it. --- src/redux/actions/putaways.ts | 8 + src/redux/reducers/putawayReducer.ts | 25 +- src/screens/PutawayCandidates/index.tsx | 334 ++++++++++++------------ src/screens/PutawayCandidates/types.ts | 36 +-- src/screens/PutawayItem/index.tsx | 3 +- src/screens/PutawayItemDetail/index.tsx | 42 +-- src/screens/PutawayItemDetail/styles.ts | 10 +- src/utils/putawayCandidate.ts | 3 + 8 files changed, 241 insertions(+), 220 deletions(-) create mode 100644 src/utils/putawayCandidate.ts diff --git a/src/redux/actions/putaways.ts b/src/redux/actions/putaways.ts index 6b575127..b6fa6081 100644 --- a/src/redux/actions/putaways.ts +++ b/src/redux/actions/putaways.ts @@ -8,6 +8,7 @@ export const SUBMIT_PUTAWAY_ITEM_BIN_LOCATION = 'SUBMIT_PUTAWAY_ITEM_BIN_LOCATIO export const SUBMIT_PUTAWAY_ITEM_BIN_LOCATION_SUCCESS = 'SUBMIT_PUTAWAY_ITEM_BIN_LOCATION_SUCCESS'; export const PATCH_PUTAWAY_TASK_REQUEST = 'PATCH_PUTAWAY_TASK_REQUEST'; export const PATCH_PUTAWAY_TASK_REQUEST_SUCCESS = 'PATCH_PUTAWAY_TASK_REQUEST_SUCCESS'; +export const PUTAWAY_CANDIDATE_PUT_AWAY = 'PUTAWAY_CANDIDATE_PUT_AWAY'; export const GET_PUTAWAY_DETAILS_BY_CONTAINER_ID_REQUEST = 'GET_PUTAWAY_DETAILS_BY_CONTAINER_ID_REQUEST'; export const GET_PUTAWAY_DETAILS_BY_CONTAINER_ID_REQUEST_SUCCESS = 'GET_PUTAWAY_DETAILS_BY_CONTAINER_ID_REQUEST_SUCCESS'; @@ -66,6 +67,13 @@ export function patchPutawayTaskAction( }; } +export function markCandidatePutAway(key: string, remainingQuantity: number) { + return { + type: PUTAWAY_CANDIDATE_PUT_AWAY, + payload: { key, remainingQuantity } + }; +} + export function getPutawayDetailsByContainerId(containerId: string, callback?: (data: any) => void) { return { type: GET_PUTAWAY_DETAILS_BY_CONTAINER_ID_REQUEST, diff --git a/src/redux/reducers/putawayReducer.ts b/src/redux/reducers/putawayReducer.ts index de27bf61..86b212d4 100644 --- a/src/redux/reducers/putawayReducer.ts +++ b/src/redux/reducers/putawayReducer.ts @@ -1,8 +1,10 @@ import { SortationTask } from '../../types/sortation'; +import { putawayCandidateKey } from '../../utils/putawayCandidate'; import { FETCH_PUTAWAY_FROM_ORDER_REQUEST_SUCCESS, GET_PUTAWAY_CANDIDATES_REQUEST_SUCCESS, GET_PUTAWAY_DETAILS_BY_CONTAINER_ID_REQUEST_SUCCESS, + PUTAWAY_CANDIDATE_PUT_AWAY, SUBMIT_PUTAWAY_ITEM_BIN_LOCATION_SUCCESS } from '../actions/putaways'; @@ -11,13 +13,15 @@ export interface State { putAwayItem: any; candidates: any; putawayTasks: SortationTask[]; + putAwayOverrides: { [key: string]: number }; } const initialState: State = { putAway: null, putAwayItem: null, candidates: [], - putawayTasks: [] + putawayTasks: [], + putAwayOverrides: {} }; function reducer(state = initialState, action: any) { @@ -28,10 +32,27 @@ function reducer(state = initialState, action: any) { putAway: action.payload.data }; } + case PUTAWAY_CANDIDATE_PUT_AWAY: { + const { key, remainingQuantity } = action.payload; + return { + ...state, + putAwayOverrides: { ...state.putAwayOverrides, [key]: remainingQuantity } + }; + } case GET_PUTAWAY_CANDIDATES_REQUEST_SUCCESS: { + const candidates = action.payload || []; + // Drop overrides the server has caught up with, so they cannot go stale + const putAwayOverrides = { ...state.putAwayOverrides }; + Object.keys(putAwayOverrides).forEach((key) => { + const match = candidates.find((candidate: any) => putawayCandidateKey(candidate) === key); + if (!match || Number(match.quantity) <= putAwayOverrides[key]) { + delete putAwayOverrides[key]; + } + }); return { ...state, - candidates: action.payload + candidates, + putAwayOverrides }; } case SUBMIT_PUTAWAY_ITEM_BIN_LOCATION_SUCCESS: { diff --git a/src/screens/PutawayCandidates/index.tsx b/src/screens/PutawayCandidates/index.tsx index 41db5dad..001f0e23 100644 --- a/src/screens/PutawayCandidates/index.tsx +++ b/src/screens/PutawayCandidates/index.tsx @@ -1,8 +1,8 @@ -import _ from 'lodash'; -import React, { Component } from 'react'; -import { Alert, FlatList, RefreshControl, SafeAreaView, View } from 'react-native'; +import { useFocusEffect, useNavigation } from '@react-navigation/native'; +import React, { useCallback, useMemo, useState } from 'react'; +import { FlatList, RefreshControl, SafeAreaView, View } from 'react-native'; import { Caption, Card, Chip, Divider, Subheading } from 'react-native-paper'; -import { connect } from 'react-redux'; +import { useDispatch, useSelector } from 'react-redux'; import { LayoutStyle } from '../../assets/styles'; import BarcodeSearchHeader from '../../components/BarcodeSearchHeader/BarcodeSearchHeader'; @@ -10,83 +10,134 @@ import Button from '../../components/Button'; import EmptyView from '../../components/EmptyView'; import ListLoadingSkeleton from '../../components/ListLoadingSkeleton'; import showPopup from '../../components/Popup'; +import { EMPTY_STRING } from '../../constants'; import { getCandidates } from '../../redux/actions/putaways'; import { RootState } from '../../redux/reducers'; import { emptyStateMessage } from '../../utils/emptyStateMessage'; +import { putawayCandidateKey } from '../../utils/putawayCandidate'; import PutawayCandidateCardSkeleton from './PutawayCandidateCardSkeleton'; import styles from './styles'; -import { DispatchProps, Props, State } from './types'; - -class PutawayCandidates extends Component { - constructor(props: Props) { - super(props); - - this.state = { - refreshing: false, - putawayCandidates: [], - filteredPutawayCandidates: [], - initialLoading: true, - searchTerm: '' - }; - } - - componentDidMount() { - this.getScreenData(); - } - - componentDidUpdate(prevProps: Props) { - if (prevProps.candidates !== this.props.candidates) { - const putawayCandidates = this.props.candidates - .filter((candidate: any) => candidate.putawayStatus === 'READY') - .sort((a: any, b: any) => - a['currentLocation.name'].toLowerCase().localeCompare(b['currentLocation.name'].toLowerCase()) - ); - - this.setState({ - refreshing: false, - initialLoading: false, - putawayCandidates - }); +import { PutawayCandidate } from './types'; + +const SKELETON_CARD_COUNT = 6; + +function matchesSearchTerm(candidate: PutawayCandidate, term: string): boolean { + return ( + (candidate['inventoryItem.lotNumber']?.toLowerCase().includes(term) ?? false) || + (candidate['currentLocation.name']?.toLowerCase().includes(term) ?? false) || + (candidate['currentLocation.id']?.toLowerCase().includes(term) ?? false) + ); +} + +export default function PutawayCandidates() { + const dispatch = useDispatch(); + const navigation = useNavigation(); + + const candidates = useSelector((state: RootState) => state.putawayReducer.candidates); + const putAwayOverrides = useSelector((state: RootState) => state.putawayReducer.putAwayOverrides); + const currentLocation = useSelector((state: RootState) => state.mainReducer.currentLocation); + const productSummaryConfig = useSelector((state: RootState) => state.settingsReducer.productSummaryConfig); + + const [searchTerm, setSearchTerm] = useState(EMPTY_STRING); + const [refreshing, setRefreshing] = useState(false); + const [initialLoading, setInitialLoading] = useState(true); + + const showLotNumber = productSummaryConfig?.lotNumber !== false; + const showExpirationDate = productSummaryConfig?.expirationDate !== false; + + const navigateToPutawayItem = useCallback( + (item: PutawayCandidate) => navigation.navigate('PutawayItem', { item }), + [navigation] + ); + + const getScreenData = useCallback(() => { + if (!currentLocation?.id) { + setRefreshing(false); + setInitialLoading(false); + return; } - } - - getScreenData = async () => { - this.setState({ refreshing: true }); - const { currentLocation } = this.props; - this.props.getCandidates( - currentLocation.id, - (data: any) => { - if (data?.error) { - this.setState({ refreshing: false, initialLoading: false }); - showPopup({ - title: 'Putaway Candidates', - message: data.errorMessage ?? 'Failed to load putaway candidates', - positiveButton: { - text: 'Retry', - callback: () => this.getScreenData() - }, - negativeButtonText: 'Cancel' - }); - } - }, - true + + setRefreshing(true); + dispatch( + getCandidates( + currentLocation.id, + (data: any) => { + setRefreshing(false); + setInitialLoading(false); + if (data?.error) { + showPopup({ + title: 'Putaway Candidates', + message: data.errorMessage ?? 'Failed to load putaway candidates', + positiveButton: { + text: 'Retry', + callback: () => getScreenData() + }, + negativeButtonText: 'Cancel' + }); + } + }, + true + ) ); - }; - - renderItem = (item: any) => { - const { productSummaryConfig } = this.props; - const showLotNumber = productSummaryConfig?.lotNumber !== false; - const showExpirationDate = productSummaryConfig?.expirationDate !== false; - - return ( - - item.id - ? Alert.alert('Item is already in a pending putaway') - : this.props.navigation.navigate('PutawayItem', { item }) - } - > + }, [dispatch, currentLocation?.id]); + + useFocusEffect( + useCallback(() => { + getScreenData(); + }, [getScreenData]) + ); + + const putawayCandidates = useMemo( + () => + (candidates ?? []) + .filter((candidate: PutawayCandidate) => candidate.putawayStatus === 'READY') + .map((candidate: PutawayCandidate) => { + const override = putAwayOverrides?.[putawayCandidateKey(candidate)]; + return override === undefined ? candidate : { ...candidate, quantity: override }; + }) + .filter((candidate: PutawayCandidate) => Number(candidate.quantity) > 0) + .sort((a: PutawayCandidate, b: PutawayCandidate) => + (a['currentLocation.name'] ?? '').toLowerCase().localeCompare((b['currentLocation.name'] ?? '').toLowerCase()) + ), + [candidates, putAwayOverrides] + ); + + const visibleData = useMemo(() => { + const term = searchTerm.trim().toLowerCase(); + if (!term) { + return putawayCandidates; + } + return putawayCandidates.filter((candidate) => matchesSearchTerm(candidate, term)); + }, [putawayCandidates, searchTerm]); + + const resetFiltering = useCallback(() => setSearchTerm(EMPTY_STRING), []); + + const onSearchTermSubmit = useCallback( + (query: string) => { + const term = query.trim().toLowerCase(); + if (!term) { + resetFiltering(); + return; + } + + const exactLotMatches = putawayCandidates.filter( + (candidate) => candidate['inventoryItem.lotNumber']?.toLowerCase() === term + ); + + if (exactLotMatches.length === 1) { + resetFiltering(); + navigateToPutawayItem(exactLotMatches[0]); + return; + } + + setSearchTerm(query); + }, + [putawayCandidates, navigateToPutawayItem, resetFiltering] + ); + + const renderItem = useCallback( + (item: PutawayCandidate) => ( + navigateToPutawayItem(item)}> @@ -102,7 +153,7 @@ class PutawayCandidates extends Component { {`${item['product.productCode']} - ${item['product.name']}`} {showLotNumber && ( - {`Lot Number: ${item?.['inventoryItem.lotNumber'] ?? 'Default'}`} + {`Lot Number: ${item['inventoryItem.lotNumber'] ?? 'Default'}`} )} @@ -117,103 +168,40 @@ class PutawayCandidates extends Component { - ); - }; - - navigateToPutawayItem = (item: any) => { - this.props.navigation.navigate('PutawayItem', { item }); - }; - - filterPutawayCandidates = (query: string) => { - this.setState({ searchTerm: query }); - if (query) { - const exactPutawayCandidate = _.filter( - this.state.putawayCandidates, - (putawayCandidate: any) => putawayCandidate['inventoryItem.lotNumber']?.toLowerCase() === query.toLowerCase() - ); - - if (exactPutawayCandidate.length === 1) { - this.resetFiltering(); - this.navigateToPutawayItem(exactPutawayCandidate[0]); - } else { - const filteredPutawayCandidates = _.filter( - this.state.putawayCandidates, - (putawayCandidate: any) => - putawayCandidate['inventoryItem.lotNumber']?.toLowerCase().includes(query.toLowerCase()) || - putawayCandidate['currentLocation.name']?.toLowerCase().includes(query.toLowerCase()) || - putawayCandidate['currentLocation.id']?.toLowerCase().includes(query.toLowerCase()) - ); - this.setState({ - ...this.state, - filteredPutawayCandidates - }); - } - - return; - } - - this.resetFiltering(); - }; - - resetFiltering = () => { - this.setState({ - ...this.state, - searchTerm: '', - filteredPutawayCandidates: [] - }); - }; - - render() { - const { filteredPutawayCandidates, putawayCandidates, initialLoading, refreshing, searchTerm } = this.state; - const visibleData = filteredPutawayCandidates.length > 0 ? filteredPutawayCandidates : putawayCandidates; - return ( - - -