diff --git a/src/screens/Picking/DiscretePickingListScreen.tsx b/src/screens/Picking/DiscretePickingListScreen.tsx index 8d037117..5a3b02be 100644 --- a/src/screens/Picking/DiscretePickingListScreen.tsx +++ b/src/screens/Picking/DiscretePickingListScreen.tsx @@ -38,6 +38,7 @@ export default function DiscretePickingListScreen() { const [isRefreshing, setIsRefreshing] = useState(false); const [isPullRefreshing, setIsPullRefreshing] = useState(false); const [hasLoaded, setHasLoaded] = useState(false); + const [isStartingOrder, setIsStartingOrder] = useState(false); const fetchOrders = useCallback( (excludeAssignedRequisitionsParam: boolean, fromPull = false) => { @@ -61,6 +62,7 @@ export default function DiscretePickingListScreen() { useFocusEffect( useCallback(() => { + setIsStartingOrder(false); fetchOrders(excludeAssignedRequisitions); }, [fetchOrders, excludeAssignedRequisitions]) ); @@ -75,14 +77,19 @@ export default function DiscretePickingListScreen() { ); const handleOrderPress = (order: DiscretePickingOrder) => { + // startOrderSession shows a full-screen loader, so suppress the search bar spinner. + setIsStartingOrder(true); startOrderSession(order.requisitionId).then((success) => { if (success) { navigate('PickingPickLocation'); + return; } + setIsStartingOrder(false); }); }; - const isLoadingList = !hasLoaded || isPullRefreshing; + // Skeleton covers the first load only, later refreshes keep the list on screen. + const isLoadingList = !hasLoaded; const chips: { value: QueueTypeFilter; label: string; count: number }[] = [ { value: ALL_QUEUE_TYPES, label: 'All', count: sortedOrders.length }, @@ -97,7 +104,7 @@ export default function DiscretePickingListScreen() { placeholder="Search by order, customer, or product" resetSearch={() => setSearchTerm('')} accessibilityLabel="Search open orders" - loading={hasLoaded && isRefreshing} + loading={hasLoaded && isRefreshing && !isPullRefreshing && !isStartingOrder} onSearchTermSubmit={setSearchTerm} /> @@ -134,7 +141,7 @@ export default function DiscretePickingListScreen() { {!isLoadingList && ( setExcludeAssignedRequisitions(!value)} /> @@ -149,14 +156,16 @@ export default function DiscretePickingListScreen() { data={visibleOrders} keyExtractor={(order) => order.requisitionId} renderItem={({ item }) => ( - + )} keyboardShouldPersistTaps="handled" keyboardDismissMode="on-drag" contentContainerStyle={styles.listContent} - // The skeleton above owns the pull-to-refresh loading state, so the spinner - // only needs to retract once the gesture hands off to it. - refreshing={false} + refreshing={isPullRefreshing} ListEmptyComponent={ = DELIVERY_TYPES.reduce + + {inProgress ? 'In progress' : 'Ready'} + + + ); +} + type Props = { order: DiscretePickingOrder; + showAssignee?: boolean; onPress: (order: DiscretePickingOrder) => void; }; -export default function DiscretePickingOrderCard({ order, onPress }: Props) { +export default function DiscretePickingOrderCard({ order, showAssignee = false, onPress }: Props) { const deliveryTypeLabel = order.deliveryTypeCode ? DELIVERY_TYPE_LABELS[order.deliveryTypeCode] ?? order.deliveryTypeCode : null; const lineCountLabel = order.taskCount === 1 ? 'Line' : 'Lines'; + const lineCountValue = + order.openTaskCount < order.taskCount ? `${order.openTaskCount} / ${order.taskCount} Left` : `${order.taskCount}`; const assigneeName = order.assignee ? `${order.assignee.firstName} ${order.assignee.lastName}`.trim() : null; return ( @@ -33,25 +56,7 @@ export default function DiscretePickingOrderCard({ order, onPress }: Props) { {order.requisitionNumber ?? HYPHEN} - - - {order.inProgress ? 'In progress' : 'Ready'} - - + @@ -73,13 +78,13 @@ export default function DiscretePickingOrderCard({ order, onPress }: Props) { ) : null} - {lineCountLabel}: {order.taskCount} + {lineCountLabel}: {lineCountValue} - {assigneeName ? ( + {showAssignee && assigneeName ? ( - Assigned to: {assigneeName} + Assigned To: {assigneeName} ) : null} diff --git a/src/screens/Picking/PickingContext.tsx b/src/screens/Picking/PickingContext.tsx index f58e507d..83dafdce 100644 --- a/src/screens/Picking/PickingContext.tsx +++ b/src/screens/Picking/PickingContext.tsx @@ -14,6 +14,14 @@ import { } from '../../redux/actions/picking'; import { DeliveryType, PickTask } from '../../types/picking'; +// Which screen the session was started from, used to send the picker back there when it ends. +export type PickingEntryPoint = 'BATCH' | 'DISCRETE'; + +const HOME_ROUTE_BY_ENTRY_POINT: Record = { + BATCH: 'PickingPickType', + DISCRETE: 'DiscretePickingList' +}; + type PickingContextType = { /** The list of all tasks for this session */ tasks: PickTask[]; @@ -27,6 +35,10 @@ type PickingContextType = { currentTask: PickTask | undefined; /** Total number of tasks in the session */ allTasksCount: number; + /** The screen this session was started from */ + entryPoint: PickingEntryPoint; + /** Route to return to when the session ends, derived from the entry point */ + homeRoute: string; /** Starts a new picking session, returns whether it was successful */ startSession: (deliveryType: DeliveryType, ordersCount: number) => Promise; /** Starts a picking session for a single order (discrete picking), returns whether it was successful */ @@ -65,11 +77,14 @@ const PickingContext = React.createContext(undef export function PickingProvider({ children }: { children: React.ReactNode }) { const [tasks, setTasks] = React.useState([]); const [currentTaskIndex, setCurrentTaskIndex] = React.useState(0); + const [entryPoint, setEntryPoint] = React.useState('BATCH'); const dispatch = useDispatch(); + const homeRoute = HOME_ROUTE_BY_ENTRY_POINT[entryPoint]; const allTasksCount = tasks.length; const currentTask = allTasksCount > 0 && currentTaskIndex < allTasksCount ? tasks[currentTaskIndex] : undefined; const startSession = async (deliveryType: DeliveryType, ordersCount: number): Promise => { + setEntryPoint('BATCH'); return new Promise((resolve) => { dispatch( getPickTasksAction({ deliveryTypeCode: deliveryType.code, ordersCount }, ({ response, errorMessage }) => { @@ -101,6 +116,7 @@ export function PickingProvider({ children }: { children: React.ReactNode }) { }; const startOrderSession = async (requisitionId: string): Promise => { + setEntryPoint('DISCRETE'); return new Promise((resolve) => { dispatch( getPickTasksByRequisitionAction(requisitionId, (res) => { @@ -210,6 +226,7 @@ export function PickingProvider({ children }: { children: React.ReactNode }) { onPress: () => resetToRoutes([ { name: 'Drawer', params: { screen: 'Dashboard' } }, + { name: homeRoute }, { name: 'PickingPickStagingLocation' } ]) } @@ -288,6 +305,8 @@ export function PickingProvider({ children }: { children: React.ReactNode }) { setCurrentTaskIndex, currentTask, allTasksCount, + entryPoint, + homeRoute, startSession, startOrderSession, pickCurrentTask, diff --git a/src/screens/Picking/PickingPickLocationScreen.tsx b/src/screens/Picking/PickingPickLocationScreen.tsx index 7d7582a0..1f771fa0 100644 --- a/src/screens/Picking/PickingPickLocationScreen.tsx +++ b/src/screens/Picking/PickingPickLocationScreen.tsx @@ -8,7 +8,7 @@ import { ScannerInput } from '../../components/ScannerInput'; import { SearchButton } from '../../components/SearchButton'; import { useSearchButton } from '../../components/SearchButton/useSearchButton'; import { EMPTY_STRING, HYPHEN } from '../../constants'; -import { navigate } from '../../NavigationService'; +import { navigate, resetToRoutes } from '../../NavigationService'; import { RootState } from '../../redux/reducers'; import { parseFromISODateToLocaleString } from '../../utils/utils'; import { CustomerDetails } from './CustomerDetails'; @@ -17,8 +17,15 @@ import { ReallocateModal } from './ReallocateModal'; import styles from './styles'; export default function PickingPickLocationScreen() { - const { currentTask, currentTaskIndex, allTasksCount, startPickTask, revalidateCurrentTask, resetSession } = - usePickingContext(); + const { + currentTask, + currentTaskIndex, + allTasksCount, + startPickTask, + revalidateCurrentTask, + resetSession, + homeRoute + } = usePickingContext(); const [pickLocationBarcode, setPickLocationBarcode] = React.useState(EMPTY_STRING); const [isReallocateModalOpen, setIsReallocateModalOpen] = React.useState(false); const { allowReallocationDuringPicking } = useSelector((state: RootState) => state.settingsReducer); @@ -150,7 +157,8 @@ export default function PickingPickLocationScreen() { onAllocated={() => { setIsReallocateModalOpen(false); resetSession(); - navigate('PickingPickType'); + // Reset so the finished task screens are not left behind the back arrow. + resetToRoutes([{ name: 'Drawer', params: { screen: 'Dashboard' } }, { name: homeRoute }]); }} /> )} diff --git a/src/screens/Picking/PickingPickOutboundContainerScreen.tsx b/src/screens/Picking/PickingPickOutboundContainerScreen.tsx index 47a9ebd0..27dbc6ad 100644 --- a/src/screens/Picking/PickingPickOutboundContainerScreen.tsx +++ b/src/screens/Picking/PickingPickOutboundContainerScreen.tsx @@ -30,7 +30,8 @@ export default function PickingPickOutboundContainerScreen() { allTasksCount, revalidateCurrentTask, goToNextTask, - revalidateTasksForRequisition + revalidateTasksForRequisition, + homeRoute } = usePickingContext(); const { params } = useRoute(); const parsedQuantityPicked = params?.quantityPicked ? Number(params.quantityPicked) : undefined; @@ -72,13 +73,14 @@ export default function PickingPickOutboundContainerScreen() { const omitStagingLocationStep = currentTask.quantityPicked + parsedQuantityPicked < currentTask.quantityRequired; - revalidateTaskAndProceed( + revalidateTaskAndProceed({ revalidateCurrentTask, currentTaskIndex, allTasksCount, goToNextTask, + homeRoute, omitStagingLocationStep - ); + }); } }, params?.reasonCode?.name @@ -93,7 +95,7 @@ export default function PickingPickOutboundContainerScreen() { return; } - revalidateTaskAndProceed(revalidateCurrentTask, currentTaskIndex, allTasksCount, goToNextTask); + revalidateTaskAndProceed({ revalidateCurrentTask, currentTaskIndex, allTasksCount, goToNextTask, homeRoute }); }); setOutboundContainerId(EMPTY_STRING); diff --git a/src/screens/Picking/PickingPickQuantityScreen.tsx b/src/screens/Picking/PickingPickQuantityScreen.tsx index be49010f..723ba768 100644 --- a/src/screens/Picking/PickingPickQuantityScreen.tsx +++ b/src/screens/Picking/PickingPickQuantityScreen.tsx @@ -19,7 +19,8 @@ import { usePickingContext } from './PickingContext'; import styles from './styles'; export default function PickingPickQuantityScreen() { - const { tasks, currentTask, currentTaskIndex, allTasksCount, shortPickTask, goToNextTask } = usePickingContext(); + const { tasks, currentTask, currentTaskIndex, allTasksCount, shortPickTask, goToNextTask, homeRoute } = + usePickingContext(); const dispatch = useDispatch(); const isFocused = useIsFocused(); @@ -105,7 +106,13 @@ export default function PickingPickQuantityScreen() { index !== currentTaskIndex && task.quantityPicked < task.quantityRequired && !task.reasonCode ); // Skip revalidation: task is closed server-side, and GET /pick-tasks/:id 404s if the requisition is canceled. - proceedToNextOrComplete(currentTaskIndex, allTasksCount, goToNextTask, omitStagingLocationStep); + proceedToNextOrComplete({ + currentTaskIndex, + allTasksCount, + goToNextTask, + homeRoute, + omitStagingLocationStep + }); }, reasonCode?.name ); diff --git a/src/screens/Picking/PickingPickStagingLocationScreen.tsx b/src/screens/Picking/PickingPickStagingLocationScreen.tsx index 86fd8820..ff790fa0 100644 --- a/src/screens/Picking/PickingPickStagingLocationScreen.tsx +++ b/src/screens/Picking/PickingPickStagingLocationScreen.tsx @@ -18,7 +18,7 @@ import styles from './styles'; const SKIP_STAGING_LOCATION_VALIDATION = true; export default function PickingPickStagingLocationScreen() { - const { tasks, dropCurrentTask, dropCurrentTaskAtStagingLocation, resetSession, setCurrentTaskIndex } = + const { tasks, dropCurrentTask, dropCurrentTaskAtStagingLocation, resetSession, setCurrentTaskIndex, homeRoute } = usePickingContext(); const [stagingLocationNumber, setStagingLocationNumber] = React.useState(EMPTY_STRING); const [currentUniqueIndex, setCurrentUniqueIndex] = React.useState(0); @@ -37,9 +37,9 @@ export default function PickingPickStagingLocationScreen() { if (!currentTask) { // No tasks left at all, return to home Alert.alert('Staging', 'No more tasks available for staging drop.'); - resetToRoutes([{ name: 'Drawer', params: { screen: 'Dashboard' } }, { name: 'PickingPickType' }]); + resetToRoutes([{ name: 'Drawer', params: { screen: 'Dashboard' } }, { name: homeRoute }]); } - }, [currentTask, tasks.length, setCurrentTaskIndex, uniqueTasks.length, tasks]); + }, [currentTask, tasks.length, setCurrentTaskIndex, uniqueTasks.length, tasks, homeRoute]); // Requires the scanned location to match the one suggested by the task. Used when SKIP_STAGING_LOCATION_VALIDATION is false. function handleScan(locationId: string) { @@ -78,7 +78,7 @@ export default function PickingPickStagingLocationScreen() { text: 'OK', onPress: () => { resetSession(); - resetToRoutes([{ name: 'Drawer', params: { screen: 'Dashboard' } }, { name: 'PickingPickType' }]); + resetToRoutes([{ name: 'Drawer', params: { screen: 'Dashboard' } }, { name: homeRoute }]); } } ]); @@ -117,7 +117,7 @@ export default function PickingPickStagingLocationScreen() { text: 'OK', onPress: () => { resetSession(); - resetToRoutes([{ name: 'Drawer', params: { screen: 'Dashboard' } }, { name: 'PickingPickType' }]); + resetToRoutes([{ name: 'Drawer', params: { screen: 'Dashboard' } }, { name: homeRoute }]); } } ]); diff --git a/src/screens/Picking/discretePickingLib.ts b/src/screens/Picking/discretePickingLib.ts index 70816d6c..82f5b4af 100644 --- a/src/screens/Picking/discretePickingLib.ts +++ b/src/screens/Picking/discretePickingLib.ts @@ -1,4 +1,10 @@ -import { DeliveryTypeCode, DiscretePickingOrder, PickTask, PickTaskStatus } from '../../types/picking'; +import { + DeliveryTypeCode, + DiscretePickingOrder, + OrderPickStatusCode, + PickTask, + PickTaskStatus +} from '../../types/picking'; import { DELIVERY_TYPES } from './constants'; export const ALL_QUEUE_TYPES = 'ALL' as const; @@ -22,7 +28,17 @@ function deliveryTypePriority(code?: DeliveryTypeCode): number { */ export function groupTasksIntoOrders(tasks: PickTask[]): DiscretePickingOrder[] { const ordersById = new Map(); - const productNamesById = new Map>(); + const searchTokensById = new Map>(); + const returnedTaskCountById = new Map(); + + const collectSearchTokens = (requisitionId: string, task: PickTask) => { + const tokens = searchTokensById.get(requisitionId); + [task.product?.name, task.product?.productCode].forEach((token) => { + if (token) { + tokens?.add(token); + } + }); + }; tasks.forEach((task) => { const requisitionId = task.requisitionId; @@ -30,8 +46,6 @@ export function groupTasksIntoOrders(tasks: PickTask[]): DiscretePickingOrder[] return; } - const productName = task.product?.name; - if (!ordersById.has(requisitionId)) { ordersById.set(requisitionId, { requisitionId, @@ -41,26 +55,35 @@ export function groupTasksIntoOrders(tasks: PickTask[]): DiscretePickingOrder[] deliveryTypeCode: task.deliveryTypeCode, assignee: task.assignee, priority: task.priority, - taskCount: 0, + // Prefer the order wide counts, since this response only carries the open lines. + taskCount: task.orderTotalTaskCount ?? 0, + openTaskCount: task.orderOpenTaskCount ?? 0, inProgress: false, searchIndex: '' }); - productNamesById.set(requisitionId, new Set()); + searchTokensById.set(requisitionId, new Set()); + returnedTaskCountById.set(requisitionId, 0); } const order = ordersById.get(requisitionId) as DiscretePickingOrder; - order.taskCount += 1; - if (task.status === PickTaskStatus.PICKING) { - order.inProgress = true; + returnedTaskCountById.set(requisitionId, (returnedTaskCountById.get(requisitionId) ?? 0) + 1); + // Tasks are only assigned once picking starts, so take the first assignee found. + if (!order.assignee && task.assignee) { + order.assignee = task.assignee; } - if (productName) { - productNamesById.get(requisitionId)?.add(productName); + if (task.status === PickTaskStatus.PICKING || task.orderPickStatusCode === OrderPickStatusCode.PARTIALLY_PICKED) { + order.inProgress = true; } + collectSearchTokens(requisitionId, task); }); return Array.from(ordersById.values()).map((order) => { - const productNames = Array.from(productNamesById.get(order.requisitionId) ?? []); - order.searchIndex = [order.requisitionNumber, order.destination, ...productNames] + const returnedTaskCount = returnedTaskCountById.get(order.requisitionId) ?? 0; + order.taskCount = order.taskCount || returnedTaskCount; + order.openTaskCount = order.openTaskCount || returnedTaskCount; + + const searchTokens = Array.from(searchTokensById.get(order.requisitionId) ?? []); + order.searchIndex = [order.requisitionNumber, order.destination, ...searchTokens] .filter(Boolean) .join(' ') .toLowerCase(); diff --git a/src/screens/Picking/discretePickingStyles.ts b/src/screens/Picking/discretePickingStyles.ts index 1f6c0ea7..67f97725 100644 --- a/src/screens/Picking/discretePickingStyles.ts +++ b/src/screens/Picking/discretePickingStyles.ts @@ -46,7 +46,8 @@ export default StyleSheet.create({ overflow: 'hidden' }, showAssignedToggle: { - paddingHorizontal: Theme.spacing.medium + paddingHorizontal: Theme.spacing.medium, + marginTop: -Theme.spacing.small }, filterChipSkeleton: { height: 32, diff --git a/src/screens/Picking/lib.ts b/src/screens/Picking/lib.ts index 02751cc6..1892a6d5 100644 --- a/src/screens/Picking/lib.ts +++ b/src/screens/Picking/lib.ts @@ -2,12 +2,39 @@ import { Alert } from 'react-native'; import { navigate, resetToRoutes } from '../../NavigationService'; import { PickTask } from '../../types/picking'; -export function proceedToNextOrComplete( - currentTaskIndex: number, - allTasksCount: number, - goToNextTask: () => void, - omitStagingLocationStep?: boolean -) { +type PickingFlowNavigation = { + currentTaskIndex: number; + allTasksCount: number; + goToNextTask: () => void; + /** Screen the session started from, reset to when it ends so finished screens are unreachable */ + homeRoute: string; + omitStagingLocationStep?: boolean; +}; + +function returnHome(homeRoute: string) { + resetToRoutes([{ name: 'Drawer', params: { screen: 'Dashboard' } }, { name: homeRoute }]); +} + +function alertShortPickWithoutReasonCode(homeRoute: string) { + Alert.alert( + 'Short Pick Without Reason Code', + 'You have completed all picks with a short pick without a reason code. This task will remain available to pick.', + [ + { + text: 'OK', + onPress: () => returnHome(homeRoute) + } + ] + ); +} + +export function proceedToNextOrComplete({ + currentTaskIndex, + allTasksCount, + goToNextTask, + homeRoute, + omitStagingLocationStep +}: PickingFlowNavigation) { if (currentTaskIndex + 1 < allTasksCount) { goToNextTask(); navigate('PickingPickLocation'); @@ -15,16 +42,7 @@ export function proceedToNextOrComplete( } if (omitStagingLocationStep) { - Alert.alert( - 'Short Pick Without Reason Code', - 'You have completed all picks with a short pick without a reason code. This task will remain available to pick. You will be redirected to the Pick Type screen.', - [ - { - text: 'OK', - onPress: () => navigate('PickingPickType') - } - ] - ); + alertShortPickWithoutReasonCode(homeRoute); return; } @@ -34,20 +52,23 @@ export function proceedToNextOrComplete( onPress: () => resetToRoutes([ { name: 'Drawer', params: { screen: 'Dashboard' } }, - { name: 'PickingPickType' }, + { name: homeRoute }, { name: 'PickingPickStagingLocation' } ]) } ]); } -export function revalidateTaskAndProceed( - revalidateCurrentTask: (callback: (revalidatedTask: PickTask | undefined) => void) => void, - currentTaskIndex: number, - allTasksCount: number, - goToNextTask: () => void, - omitStagingLocationStep?: boolean -) { +export function revalidateTaskAndProceed({ + revalidateCurrentTask, + currentTaskIndex, + allTasksCount, + goToNextTask, + homeRoute, + omitStagingLocationStep +}: PickingFlowNavigation & { + revalidateCurrentTask: (callback: (revalidatedTask: PickTask | undefined) => void) => void; +}) { revalidateCurrentTask((revalidatedTask) => { if (!revalidatedTask) { Alert.alert('Error', 'Failed to revalidate the current pick task after picking.'); @@ -57,19 +78,10 @@ export function revalidateTaskAndProceed( const isLastTask = currentTaskIndex + 1 >= allTasksCount; if (isLastTask && omitStagingLocationStep) { - Alert.alert( - 'Short Pick Without Reason Code', - 'You have completed all picks with a short pick without a reason code. This task will remain available to pick. You will be redirected to the Pick Type screen.', - [ - { - text: 'OK', - onPress: () => navigate('PickingPickType') - } - ] - ); + alertShortPickWithoutReasonCode(homeRoute); return; } - proceedToNextOrComplete(currentTaskIndex, allTasksCount, goToNextTask); + proceedToNextOrComplete({ currentTaskIndex, allTasksCount, goToNextTask, homeRoute }); }); } diff --git a/src/types/picking.ts b/src/types/picking.ts index 1abfd9f8..269cbb70 100644 --- a/src/types/picking.ts +++ b/src/types/picking.ts @@ -19,6 +19,12 @@ export enum DeliveryTypeCode { DEFAULT = 'DEFAULT' } +export enum OrderPickStatusCode { + NOT_PICKED = 'NOT_PICKED', + PARTIALLY_PICKED = 'PARTIALLY_PICKED', + PICKED = 'PICKED' +} + export enum PickTaskStatus { PENDING = 'PENDING', PICKING = 'PICKING', @@ -71,6 +77,11 @@ export type PickTask = { reasonCode?: string | null; status: PickTaskStatus; + /** Progress of the whole order, counting lines this status filtered response leaves out */ + orderTotalTaskCount?: number | null; + orderOpenTaskCount?: number | null; + orderPickStatusCode?: OrderPickStatusCode | null; + dateRequested?: string | null; dateAssigned?: string | null; dateStarted?: string | null; @@ -97,11 +108,13 @@ export type DiscretePickingOrder = { assignee?: Person | null; /** requisition.priority (lower = higher priority) */ priority?: number; - /** number of open pick tasks (line items) in this order */ + /** total number of pick tasks (line items) in this order */ taskCount: number; - /** true when at least one task is already being picked */ + /** number of pick tasks (line items) still left to pick */ + openTaskCount: number; + /** true when at least one line of this order has already been started or picked */ inProgress: boolean; - /** lowercased blob of order number, destination and product names for real-time search */ + /** lowercased blob of order number, destination, product names and product codes for search */ searchIndex: string; };