From f4c59eae88d866e16ca81829851dae901c82989d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 02:50:22 +0000 Subject: [PATCH 01/12] OBLS-837 Add discrete picking order list screen Add a new "Discrete Picking" Dashboard entry that lists all open orders ready for picking across every queue type, with real-time search and queue-type filter chips. Tapping an order seeds the picking session and reuses the existing Pick Location flow, one order at a time. - apis/picking: allow getPickTasksApi to run unfiltered; add getOpenPickTasksApi for all open (PENDING/PICKING) tasks - redux: add getOpenPickTasksAction + saga + watcher - types: add DiscretePickingOrder view model - PickingContext: add startOrderSession(requisitionId) to seed a single-order session via getPickTasksByRequisitionAction - screens/Picking: add DiscretePickingListScreen, order card, skeleton, styles, and grouping/sort/filter helpers - wire route in Main and add Dashboard entry The multi-field filter panel (Destination/Queue Type/Assignee/Status) is intentionally deferred to a follow-up. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_011zmhJ6yv58QsHzj8EwAnZb --- src/Main.tsx | 6 + src/apis/picking.ts | 25 +++- src/redux/actions/picking.ts | 23 +++ src/redux/sagas/picking.ts | 26 ++++ src/screens/Dashboard/dashboardData.ts | 8 + .../Picking/DiscretePickingCardSkeleton.tsx | 51 +++++++ .../Picking/DiscretePickingListScreen.tsx | 139 ++++++++++++++++++ .../Picking/DiscretePickingOrderCard.tsx | 64 ++++++++ src/screens/Picking/PickingContext.tsx | 29 ++++ src/screens/Picking/discretePickingLib.ts | 107 ++++++++++++++ src/screens/Picking/discretePickingStyles.ts | 88 +++++++++++ src/types/picking.ts | 18 +++ 12 files changed, 579 insertions(+), 5 deletions(-) create mode 100644 src/screens/Picking/DiscretePickingCardSkeleton.tsx create mode 100644 src/screens/Picking/DiscretePickingListScreen.tsx create mode 100644 src/screens/Picking/DiscretePickingOrderCard.tsx create mode 100644 src/screens/Picking/discretePickingLib.ts create mode 100644 src/screens/Picking/discretePickingStyles.ts diff --git a/src/Main.tsx b/src/Main.tsx index a9cf8813..b237e788 100644 --- a/src/Main.tsx +++ b/src/Main.tsx @@ -68,6 +68,7 @@ import CycleCountListEntry from './screens/CycleCount/CycleCountListEntry'; import CycleCountLocation from './screens/CycleCount/CycleCountLocation'; import CycleCountProduct from './screens/CycleCount/CycleCountProduct'; import CycleCountQuantityAvailable from './screens/CycleCount/CycleCountQuantityAvailable'; +import DiscretePickingListScreen from './screens/Picking/DiscretePickingListScreen'; import PickingPickTypeScreen from './screens/Picking/PickingPickTypeScreen'; import PickingPickLocationScreen from './screens/Picking/PickingPickLocationScreen'; import PickingPickProductScreen from './screens/Picking/PickingPickProductScreen'; @@ -338,6 +339,11 @@ class Main extends Component { component={PutawayQuantityScreen} options={{ title: 'Putaway Details' }} /> + ) { + const query: string[] = []; + + if (params?.deliveryTypeCode) { + query.push(`deliveryTypeCode=${encodeURIComponent(params.deliveryTypeCode)}`); + } + + if (params?.ordersCount !== undefined && params?.ordersCount !== null) { + query.push(`ordersCount=${encodeURIComponent(params.ordersCount)}`); + } + + const queryString = query.length > 0 ? `?${query.join('&')}` : ''; + + return ApiClient.get(`/facilities/${facilityId}/pick-tasks${queryString}`); +} + +// Fetch all open pick tasks (across every queue type) for the discrete picking order list. +export function getOpenPickTasksApi(facilityId: string) { + const statuses = ['PENDING', 'PICKING']; return ApiClient.get( - `/facilities/${facilityId}/pick-tasks?deliveryTypeCode=${encodeURIComponent( - deliveryTypeCode - )}&ordersCount=${encodeURIComponent(ordersCount)}` + `/facilities/${facilityId}/pick-tasks?${statuses.map((status) => `status=${encodeURIComponent(status)}`).join('&')}` ); } diff --git a/src/redux/actions/picking.ts b/src/redux/actions/picking.ts index e3d76b12..e80fb8b3 100644 --- a/src/redux/actions/picking.ts +++ b/src/redux/actions/picking.ts @@ -5,6 +5,10 @@ export const GET_PICK_TASKS_REQUEST = 'GET_PICK_TASKS_REQUEST'; export const GET_PICK_TASKS_REQUEST_SUCCESS = 'GET_PICK_TASKS_REQUEST_SUCCESS'; export const GET_PICK_TASKS_REQUEST_FAIL = 'GET_PICK_TASKS_REQUEST_FAIL'; +export const GET_OPEN_PICK_TASKS_REQUEST = 'GET_OPEN_PICK_TASKS_REQUEST'; +export const GET_OPEN_PICK_TASKS_REQUEST_SUCCESS = 'GET_OPEN_PICK_TASKS_REQUEST_SUCCESS'; +export const GET_OPEN_PICK_TASKS_REQUEST_FAIL = 'GET_OPEN_PICK_TASKS_REQUEST_FAIL'; + export const GET_PICK_TASK_COUNTS_REQUEST = 'GET_PICK_TASK_COUNTS_REQUEST'; export const GET_PICK_TASK_COUNTS_REQUEST_SUCCESS = 'GET_PICK_TASK_COUNTS_REQUEST_SUCCESS'; export const GET_PICK_TASK_COUNTS_REQUEST_FAIL = 'GET_PICK_TASK_COUNTS_REQUEST_FAIL'; @@ -66,6 +70,25 @@ export function getPickTasksAction( }; } +export function getOpenPickTasksAction( + callback: (response: { + response?: { + data: PickTask[]; + errorCode?: string; + message?: string; + max?: number; + offset?: number; + totalCount?: number; + }; + errorMessage?: string; + }) => void +) { + return { + type: GET_OPEN_PICK_TASKS_REQUEST, + callback + }; +} + export function getPickTaskCountsAction( callback: (response: { response?: { data: DeliveryTypeOrderCount[] }; errorMessage?: string }) => void ) { diff --git a/src/redux/sagas/picking.ts b/src/redux/sagas/picking.ts index 3509e190..cf7469ea 100644 --- a/src/redux/sagas/picking.ts +++ b/src/redux/sagas/picking.ts @@ -15,6 +15,9 @@ import { GET_PICK_TASKS_BY_REQUISITION_REQUEST, GET_PICK_TASKS_BY_REQUISITION_REQUEST_SUCCESS, GET_PICK_TASKS_BY_REQUISITION_REQUEST_FAIL, + GET_OPEN_PICK_TASKS_REQUEST, + GET_OPEN_PICK_TASKS_REQUEST_FAIL, + GET_OPEN_PICK_TASKS_REQUEST_SUCCESS, GET_PICK_TASK_COUNTS_REQUEST, GET_PICK_TASK_COUNTS_REQUEST_FAIL, GET_PICK_TASK_COUNTS_REQUEST_SUCCESS, @@ -61,6 +64,28 @@ function* getPickTasksAction(action: any) { } } +function* getOpenPickTasksAction(action: any) { + try { + // @ts-ignore + const currentLocation = yield select(userLocation); + if (!currentLocation) { + throw new Error('User Location Not Found'); + } + yield put(showScreenLoading('Fetching Orders...')); + // Call API to get all open pick tasks across every queue type + // @ts-ignore + const response = yield call(api.getOpenPickTasksApi, currentLocation.id); + yield action.callback({ response }); + yield put({ type: GET_OPEN_PICK_TASKS_REQUEST_SUCCESS, payload: response.data }); + yield put(hideScreenLoading()); + } catch (error) { + const errorMessage = (error as any)?.message || 'Error Fetching Orders'; + yield put({ type: GET_OPEN_PICK_TASKS_REQUEST_FAIL, payload: errorMessage }); + yield action.callback({ errorMessage }); + yield put(hideScreenLoading()); + } +} + function* getPickTaskCountsAction(action: any) { try { // @ts-ignore @@ -346,6 +371,7 @@ function* reallocatePickTaskAction(action: any) { export default function* watcher() { yield takeLatest(GET_PICK_TASKS_REQUEST, getPickTasksAction); + yield takeLatest(GET_OPEN_PICK_TASKS_REQUEST, getOpenPickTasksAction); yield takeLatest(GET_PICK_TASK_COUNTS_REQUEST, getPickTaskCountsAction); yield takeLatest(START_PICK_TASK_REQUEST, startPickTaskAction); yield takeLatest(PICK_PICK_TASK_REQUEST, pickPickTaskAction); diff --git a/src/screens/Dashboard/dashboardData.ts b/src/screens/Dashboard/dashboardData.ts index ecdad280..ec8f5d8f 100644 --- a/src/screens/Dashboard/dashboardData.ts +++ b/src/screens/Dashboard/dashboardData.ts @@ -85,6 +85,14 @@ const dashboardEntries: DashboardEntry[] = [ navigationScreenName: 'PickingPickType', group: 'OUTBOUND' }, + { + key: 'discretePicking', + screenName: 'Discrete Picking', + entryDescription: 'Find and pick a single open order', + icon: IconPicking, + navigationScreenName: 'DiscretePickingList', + group: 'OUTBOUND' + }, { key: 'packing', screenName: 'Packing', diff --git a/src/screens/Picking/DiscretePickingCardSkeleton.tsx b/src/screens/Picking/DiscretePickingCardSkeleton.tsx new file mode 100644 index 00000000..a670d7ac --- /dev/null +++ b/src/screens/Picking/DiscretePickingCardSkeleton.tsx @@ -0,0 +1,51 @@ +import React from 'react'; +import { StyleSheet, View } from 'react-native'; +import { Card } from 'react-native-paper'; + +import { ShimmerBlock, SkeletonDivider } from '../../components/ContentSkeleton'; +import Theme from '../../utils/Theme'; + +export default function DiscretePickingCardSkeleton() { + return ( + + + + + + + + + + + + + + + + + ); +} + +const styles = StyleSheet.create({ + wrapper: { + marginHorizontal: Theme.spacing.medium, + marginBottom: Theme.spacing.small - 2 + }, + card: { + borderRadius: Theme.roundness, + backgroundColor: 'white', + borderWidth: 1, + borderColor: '#ddd' + }, + headerRow: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: Theme.spacing.small - 2 + }, + orderNumber: { width: 140, height: 22, borderRadius: 4 }, + statusChip: { width: 90, height: 24, borderRadius: Theme.roundness }, + destination: { width: '70%', height: 16, borderRadius: 4, marginTop: 6 }, + metaRow: { flexDirection: 'row', marginTop: Theme.spacing.small }, + metaChip: { width: 90, height: 24, borderRadius: Theme.roundness, marginRight: Theme.spacing.small } +}); diff --git a/src/screens/Picking/DiscretePickingListScreen.tsx b/src/screens/Picking/DiscretePickingListScreen.tsx new file mode 100644 index 00000000..725330fe --- /dev/null +++ b/src/screens/Picking/DiscretePickingListScreen.tsx @@ -0,0 +1,139 @@ +import { useFocusEffect } from '@react-navigation/native'; +import React, { useCallback, useMemo, useState } from 'react'; +import { FlatList, ScrollView, View } from 'react-native'; +import { Chip } from 'react-native-paper'; +import { useDispatch } from 'react-redux'; + +import BarcodeSearchHeader from '../../components/BarcodeSearchHeader/BarcodeSearchHeader'; +import EmptyView from '../../components/EmptyView'; +import ListLoadingSkeleton from '../../components/ListLoadingSkeleton'; +import { navigate } from '../../NavigationService'; +import { getOpenPickTasksAction } from '../../redux/actions/picking'; +import { DiscretePickingOrder, PickTask } from '../../types/picking'; +import { emptyStateMessage } from '../../utils/emptyStateMessage'; +import { usePickingContext } from './PickingContext'; +import { DELIVERY_TYPES } from './constants'; +import DiscretePickingCardSkeleton from './DiscretePickingCardSkeleton'; +import DiscretePickingOrderCard from './DiscretePickingOrderCard'; +import { + ALL_QUEUE_TYPES, + filterOrders, + groupTasksIntoOrders, + QueueTypeFilter, + queueChipCounts, + sortOrders +} from './discretePickingLib'; +import styles from './discretePickingStyles'; + +export default function DiscretePickingListScreen() { + const dispatch = useDispatch(); + const { startOrderSession } = usePickingContext(); + + const [tasks, setTasks] = useState([]); + const [searchTerm, setSearchTerm] = useState(''); + const [selectedQueueType, setSelectedQueueType] = useState(ALL_QUEUE_TYPES); + const [isRefreshing, setIsRefreshing] = useState(false); + const [hasLoaded, setHasLoaded] = useState(false); + + const fetchOrders = useCallback(() => { + setIsRefreshing(true); + dispatch( + getOpenPickTasksAction(({ response, errorMessage }) => { + if (!errorMessage && response?.data) { + setTasks(response.data); + } + setIsRefreshing(false); + setHasLoaded(true); + }) + ); + }, [dispatch]); + + useFocusEffect( + useCallback(() => { + fetchOrders(); + }, [fetchOrders]) + ); + + // Group tasks into orders and sort by priority once per fetch. + const sortedOrders = useMemo(() => sortOrders(groupTasksIntoOrders(tasks)), [tasks]); + + // Chip counts are derived from the full (unfiltered) order list. + const counts = useMemo(() => queueChipCounts(sortedOrders), [sortedOrders]); + + // Real-time filter by search term and selected queue type. + const visibleOrders = useMemo( + () => filterOrders(sortedOrders, { search: searchTerm, queueType: selectedQueueType }), + [sortedOrders, searchTerm, selectedQueueType] + ); + + const handleOrderPress = (order: DiscretePickingOrder) => { + startOrderSession(order.requisitionId).then((success) => { + if (success) { + navigate('PickingPickLocation'); + } + }); + }; + + const chips: { value: QueueTypeFilter; label: string; count: number }[] = [ + { value: ALL_QUEUE_TYPES, label: 'All', count: sortedOrders.length }, + ...DELIVERY_TYPES.map((type) => ({ value: type.code, label: type.label, count: counts[type.code] ?? 0 })) + ]; + + return ( + + setSearchTerm('')} + accessibilityLabel="Search open orders" + onSearchTermSubmit={setSearchTerm} + /> + + + {chips.map((chip) => { + const selected = chip.value === selectedQueueType; + return ( + setSelectedQueueType(chip.value)} + > + {`${chip.label} (${chip.count})`} + + ); + })} + + + {!hasLoaded ? ( + + ) : ( + order.requisitionId} + renderItem={({ item }) => } + keyboardShouldPersistTaps="handled" + keyboardDismissMode="on-drag" + contentContainerStyle={styles.listContent} + ItemSeparatorComponent={() => } + refreshing={isRefreshing} + ListEmptyComponent={ + + } + onRefresh={fetchOrders} + /> + )} + + ); +} diff --git a/src/screens/Picking/DiscretePickingOrderCard.tsx b/src/screens/Picking/DiscretePickingOrderCard.tsx new file mode 100644 index 00000000..f54f7484 --- /dev/null +++ b/src/screens/Picking/DiscretePickingOrderCard.tsx @@ -0,0 +1,64 @@ +import React from 'react'; +import { TouchableOpacity, View } from 'react-native'; +import { Caption, Card, Chip, Divider, Text } from 'react-native-paper'; + +import { HYPHEN } from '../../constants'; +import { DiscretePickingOrder } from '../../types/picking'; +import { DELIVERY_TYPES } from './constants'; +import styles from './discretePickingStyles'; + +const DELIVERY_TYPE_LABELS: Record = DELIVERY_TYPES.reduce>((acc, type) => { + acc[type.code] = type.label; + return acc; +}, {}); + +type Props = { + order: DiscretePickingOrder; + onPress: (order: DiscretePickingOrder) => void; +}; + +export default function DiscretePickingOrderCard({ order, onPress }: Props) { + const queueLabel = order.deliveryTypeCode + ? DELIVERY_TYPE_LABELS[order.deliveryTypeCode] ?? order.deliveryTypeCode + : null; + const lineLabel = `${order.taskCount} ${order.taskCount === 1 ? 'line' : 'lines'}`; + + return ( + onPress(order)}> + + + + + {order.requisitionNumber ?? HYPHEN} + + + {order.inProgress ? 'In progress' : 'Ready'} + + + + + + + {order.destination ?? HYPHEN} + + {order.destinationLocationType ? {order.destinationLocationType} : null} + + + {queueLabel ? ( + + {queueLabel} + + ) : null} + + {lineLabel} + + + + + + ); +} diff --git a/src/screens/Picking/PickingContext.tsx b/src/screens/Picking/PickingContext.tsx index 3e26603e..a3298937 100644 --- a/src/screens/Picking/PickingContext.tsx +++ b/src/screens/Picking/PickingContext.tsx @@ -29,6 +29,8 @@ type PickingContextType = { allTasksCount: number; /** 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 */ + startOrderSession: (requisitionId: string) => Promise; /** Completes the current pick task. */ pickCurrentTask: (outboundContainerId: string, callback: (response: { errorMessage?: string }) => void) => void; /** Resets state to initial values */ @@ -98,6 +100,32 @@ export function PickingProvider({ children }: { children: React.ReactNode }) { }); }; + const startOrderSession = async (requisitionId: string): Promise => { + return new Promise((resolve) => { + dispatch( + getPickTasksByRequisitionAction(requisitionId, (res) => { + if ('errorMessage' in res || !res.response?.data) { + Alert.alert('Error', 'Failed to load pick tasks for the selected order.'); + resolve(false); + return; + } + + const orderTasks = res.response.data; + + if (orderTasks.length === 0) { + Alert.alert('No Tasks', 'No open pick tasks were found for the selected order.'); + resolve(false); + return; + } + + setTasks(orderTasks); + setCurrentTaskIndex(0); + resolve(true); + }) + ); + }); + }; + const startPickTask = (callback: (response: { errorMessage?: string }) => void) => { if (!currentTask) { return; @@ -261,6 +289,7 @@ export function PickingProvider({ children }: { children: React.ReactNode }) { currentTask, allTasksCount, startSession, + startOrderSession, pickCurrentTask, shortPickTask, resetSession, diff --git a/src/screens/Picking/discretePickingLib.ts b/src/screens/Picking/discretePickingLib.ts new file mode 100644 index 00000000..2383e7fb --- /dev/null +++ b/src/screens/Picking/discretePickingLib.ts @@ -0,0 +1,107 @@ +import { DeliveryTypeCode, DiscretePickingOrder, PickTask, PickTaskStatus } from '../../types/picking'; +import { DELIVERY_TYPES } from './constants'; + +export const ALL_QUEUE_TYPES = 'ALL' as const; +export type QueueTypeFilter = DeliveryTypeCode | typeof ALL_QUEUE_TYPES; + +// Lookup from delivery type code to its (client-side) queue priority. Lower = higher priority. +const DELIVERY_TYPE_PRIORITY: Record = DELIVERY_TYPES.reduce>((acc, type) => { + acc[type.code] = type.priority; + return acc; +}, {}); + +const LOWEST_PRIORITY = Number.MAX_SAFE_INTEGER; + +function deliveryTypePriority(code?: DeliveryTypeCode): number { + return code && DELIVERY_TYPE_PRIORITY[code] !== undefined ? DELIVERY_TYPE_PRIORITY[code] : LOWEST_PRIORITY; +} + +/** + * Group a flat list of open pick tasks into orders. Tasks that share a requisition + * belong to the same order; order-level fields are taken from the first task seen. + */ +export function groupTasksIntoOrders(tasks: PickTask[]): DiscretePickingOrder[] { + const ordersById = new Map(); + const productNamesById = new Map>(); + + tasks.forEach((task) => { + const requisitionId = task.requisitionId; + if (!requisitionId) { + return; + } + + const productName = task.product?.name; + + if (!ordersById.has(requisitionId)) { + ordersById.set(requisitionId, { + requisitionId, + requisitionNumber: task.requisitionNumber, + destination: task.destination, + destinationLocationType: task.destinationLocationType, + deliveryTypeCode: task.deliveryTypeCode, + priority: task.priority, + taskCount: 0, + inProgress: false, + searchIndex: '' + }); + productNamesById.set(requisitionId, new Set()); + } + + const order = ordersById.get(requisitionId) as DiscretePickingOrder; + order.taskCount += 1; + if (task.status === PickTaskStatus.PICKING) { + order.inProgress = true; + } + if (productName) { + productNamesById.get(requisitionId)?.add(productName); + } + }); + + return Array.from(ordersById.values()).map((order) => { + const productNames = Array.from(productNamesById.get(order.requisitionId) ?? []); + order.searchIndex = [order.requisitionNumber, order.destination, ...productNames] + .filter(Boolean) + .join(' ') + .toLowerCase(); + return order; + }); +} + +/** + * Sort orders by requisition priority (ascending, lower = higher priority), then by + * queue-type priority. Orders without a priority sort to the bottom. + */ +export function sortOrders(orders: DiscretePickingOrder[]): DiscretePickingOrder[] { + return [...orders].sort((a, b) => { + const priorityA = a.priority ?? LOWEST_PRIORITY; + const priorityB = b.priority ?? LOWEST_PRIORITY; + if (priorityA !== priorityB) { + return priorityA - priorityB; + } + return deliveryTypePriority(a.deliveryTypeCode) - deliveryTypePriority(b.deliveryTypeCode); + }); +} + +/** Real-time client-side filter across the search term and the selected queue-type chip. */ +export function filterOrders( + orders: DiscretePickingOrder[], + { search, queueType }: { search?: string; queueType: QueueTypeFilter } +): DiscretePickingOrder[] { + const term = search?.trim().toLowerCase(); + + return orders.filter((order) => { + const matchesQueue = queueType === ALL_QUEUE_TYPES || order.deliveryTypeCode === queueType; + const matchesSearch = !term || order.searchIndex.includes(term); + return matchesQueue && matchesSearch; + }); +} + +/** Count of orders per queue type, used for the chip badges. */ +export function queueChipCounts(orders: DiscretePickingOrder[]): Record { + return orders.reduce>((acc, order) => { + if (order.deliveryTypeCode) { + acc[order.deliveryTypeCode] = (acc[order.deliveryTypeCode] ?? 0) + 1; + } + return acc; + }, {}); +} diff --git a/src/screens/Picking/discretePickingStyles.ts b/src/screens/Picking/discretePickingStyles.ts new file mode 100644 index 00000000..125e490e --- /dev/null +++ b/src/screens/Picking/discretePickingStyles.ts @@ -0,0 +1,88 @@ +import { StyleSheet } from 'react-native'; + +import Theme from '../../utils/Theme'; + +export default StyleSheet.create({ + screenContainer: { + flex: 1, + backgroundColor: '#f9f9f9' + }, + chipRow: { + paddingHorizontal: Theme.spacing.medium, + paddingVertical: Theme.spacing.small + }, + chipRowContent: { + alignItems: 'center', + paddingRight: Theme.spacing.medium + }, + queueChip: { + marginRight: Theme.spacing.small, + backgroundColor: '#fff', + borderColor: '#ddd', + borderWidth: 1 + }, + queueChipSelected: { + backgroundColor: Theme.colors.primary + }, + queueChipText: { + fontSize: 12, + color: Theme.colors.primary + }, + queueChipTextSelected: { + color: '#fff' + }, + listContent: { + paddingHorizontal: Theme.spacing.medium, + paddingBottom: Theme.spacing.large + }, + itemSeparator: { + height: Theme.spacing.small - 2 + }, + + cardTouchable: { + borderRadius: Theme.roundness + }, + card: { + borderRadius: Theme.roundness, + backgroundColor: 'white', + borderWidth: 1, + borderColor: '#ddd' + }, + cardHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: Theme.spacing.small - 2 + }, + orderNumber: { + fontSize: 16, + fontWeight: '600', + flexShrink: 1, + marginRight: Theme.spacing.small + }, + cardDivider: { + marginVertical: Theme.spacing.small / 2 + }, + destination: { + fontSize: 14, + color: '#333', + marginTop: Theme.spacing.small / 2 + }, + metaRow: { + flexDirection: 'row', + alignItems: 'center', + flexWrap: 'wrap', + marginTop: Theme.spacing.small + }, + metaChip: { + height: 24, + justifyContent: 'center', + borderRadius: Theme.roundness, + marginRight: Theme.spacing.small, + marginTop: Theme.spacing.small / 2, + backgroundColor: Theme.colors.secondaryBackground + }, + metaChipText: { + fontSize: 12 + } +}); diff --git a/src/types/picking.ts b/src/types/picking.ts index 2983ce30..c7b7fda2 100644 --- a/src/types/picking.ts +++ b/src/types/picking.ts @@ -75,6 +75,24 @@ export type DeliveryType = { code: DeliveryTypeCode; }; +// A single open order in the Discrete Picking list, derived by grouping open pick +// tasks that share a requisition. +export type DiscretePickingOrder = { + requisitionId: string; + requisitionNumber?: string; + destination?: string; + destinationLocationType?: string; + deliveryTypeCode?: DeliveryTypeCode; + /** requisition.priority (lower = higher priority) */ + priority?: number; + /** number of open pick tasks (line items) in this order */ + taskCount: number; + /** true when at least one task is already being picked */ + inProgress: boolean; + /** lowercased blob of order number, destination and product names for real-time search */ + searchIndex: string; +}; + export type DeliveryTypeOrderCount = { deliveryTypeCode: DeliveryTypeCode; availableCount: number; From 8e571de375b66eaf6fe638286f332cbcce985e12 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 02:55:53 +0000 Subject: [PATCH 02/12] OBLS-837 Enable Appetize previews for PRs into develop Build an APK and deploy it to Appetize for every pull request targeting develop, so reviewers can preview changes in the browser. - Enable the pull_request_target_branch: develop trigger (was disabled), routing PR builds through the existing android-only workflow - Point appetize-deploy at a stable shared preview app via APPETIZE_APP_ID so the preview URL stays constant across builds (falls back to creating a new app per build when the var is unset) Requires (Bitrise dashboard, one-time): enable Pull Request builds for the app, and set APPETIZE_APP_ID to a preview app's public key. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_011zmhJ6yv58QsHzj8EwAnZb --- .bitrise/bitrise.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.bitrise/bitrise.yml b/.bitrise/bitrise.yml index a42c9c6f..e85eff20 100644 --- a/.bitrise/bitrise.yml +++ b/.bitrise/bitrise.yml @@ -8,8 +8,8 @@ trigger_map: workflow: android-only - push_branch: master workflow: app_distribution_to_uat_testers +# Build a preview (APK + Appetize deploy) for every pull request into develop. - pull_request_target_branch: develop - enabled: false workflow: android-only - pull_request_source_branch: "*" enabled: false @@ -137,6 +137,11 @@ step_bundles: inputs: - app_path: $BITRISE_DEPLOY_DIR/openboxes_test_b$BITRISE_BUILD_NUMBER.apk - appetize_token: $APPETIZE_API_TOKEN + # Update a single shared preview app so the URL stays stable across + # builds. Set APPETIZE_APP_ID in Bitrise (App env vars / Secrets) to the + # public key of a preview app created once in Appetize. If left empty, + # appetize-deploy creates a new app per build instead. + - app_id: $APPETIZE_APP_ID release-steps-android-signed: description: |- Assemble the Android app and copy the signed APK to $BITRISE_DEPLOY_DIR. From 87d4c081f11b7a605254df0e09179340e92d97f5 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 03:05:39 +0000 Subject: [PATCH 03/12] OBLS-837 Use appetize-deploy public_key input for shared preview app The appetize-deploy step's input is public_key, not app_id; the previous key was ignored, so every build created a new Appetize app. Use public_key: $APPETIZE_PUBLIC_KEY so builds update one stable preview app. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_011zmhJ6yv58QsHzj8EwAnZb --- .bitrise/bitrise.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.bitrise/bitrise.yml b/.bitrise/bitrise.yml index e85eff20..72aa5968 100644 --- a/.bitrise/bitrise.yml +++ b/.bitrise/bitrise.yml @@ -138,10 +138,10 @@ step_bundles: - app_path: $BITRISE_DEPLOY_DIR/openboxes_test_b$BITRISE_BUILD_NUMBER.apk - appetize_token: $APPETIZE_API_TOKEN # Update a single shared preview app so the URL stays stable across - # builds. Set APPETIZE_APP_ID in Bitrise (App env vars / Secrets) to the - # public key of a preview app created once in Appetize. If left empty, - # appetize-deploy creates a new app per build instead. - - app_id: $APPETIZE_APP_ID + # builds. Set APPETIZE_PUBLIC_KEY in Bitrise (App env vars / Secrets) to + # the public key of a preview app created once in Appetize. If left + # empty, appetize-deploy creates a new app per build instead. + - public_key: $APPETIZE_PUBLIC_KEY release-steps-android-signed: description: |- Assemble the Android app and copy the signed APK to $BITRISE_DEPLOY_DIR. From 2a34ae4e828fe56752ae6bd63478c8377d98f6d5 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 03:46:54 +0000 Subject: [PATCH 04/12] OBLS-837 Revert appetize-deploy to known-good config (drop public_key) Remove the public_key input added earlier. With APPETIZE_PUBLIC_KEY unset it resolved to empty, and this appetize-deploy version may mishandle an empty public_key. Match the proven develop config (no public_key input), which creates a new preview app per build. The stable shared-app key can be reintroduced once the pipeline is green and a key exists. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_011zmhJ6yv58QsHzj8EwAnZb --- .bitrise/bitrise.yml | 5 ----- 1 file changed, 5 deletions(-) diff --git a/.bitrise/bitrise.yml b/.bitrise/bitrise.yml index 72aa5968..8b687dc3 100644 --- a/.bitrise/bitrise.yml +++ b/.bitrise/bitrise.yml @@ -137,11 +137,6 @@ step_bundles: inputs: - app_path: $BITRISE_DEPLOY_DIR/openboxes_test_b$BITRISE_BUILD_NUMBER.apk - appetize_token: $APPETIZE_API_TOKEN - # Update a single shared preview app so the URL stays stable across - # builds. Set APPETIZE_PUBLIC_KEY in Bitrise (App env vars / Secrets) to - # the public key of a preview app created once in Appetize. If left - # empty, appetize-deploy creates a new app per build instead. - - public_key: $APPETIZE_PUBLIC_KEY release-steps-android-signed: description: |- Assemble the Android app and copy the signed APK to $BITRISE_DEPLOY_DIR. From adc6a11723d00910cedf4aa2a591f271dfd7d0b0 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 04:37:13 +0000 Subject: [PATCH 05/12] OBLS-837 Route Appetize deploys to per-build-type apps PR builds now deploy to a dedicated PR preview Appetize app instead of overwriting the main app used by develop. A script selects the target public key by build type ($BITRISE_PULL_REQUEST distinguishes PR from push builds) and appetize-deploy uses it. A run_if guard skips the deploy entirely when no key is configured, so it never falls back to creating a new app. Requires two Bitrise env vars: APPETIZE_DEVELOP_PUBLIC_KEY (main app) and APPETIZE_PR_PUBLIC_KEY (PR preview app). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_011zmhJ6yv58QsHzj8EwAnZb --- .bitrise/bitrise.yml | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/.bitrise/bitrise.yml b/.bitrise/bitrise.yml index 8b687dc3..ca7d8fd6 100644 --- a/.bitrise/bitrise.yml +++ b/.bitrise/bitrise.yml @@ -133,10 +133,29 @@ step_bundles: - notify_email_list: "$TESTERS" - notify_user_groups: owner, admins, developers is_always_run: false + # Pick which Appetize app to deploy to based on build type: PR builds go to + # the PR preview app, everything else (e.g. develop push) to the main app. + # BITRISE_PULL_REQUEST is set (to the PR number) only on pull request builds. + - script@1: + title: Select Appetize app by build type + inputs: + - content: |- + set -euo pipefail + if [ -n "${BITRISE_PULL_REQUEST:-}" ]; then + echo "PR build - deploying to PR preview app" + envman add --key APPETIZE_PUBLIC_KEY --value "$APPETIZE_PR_PUBLIC_KEY" + else + echo "Push build - deploying to main app" + envman add --key APPETIZE_PUBLIC_KEY --value "$APPETIZE_DEVELOP_PUBLIC_KEY" + fi - appetize-deploy@0: + # Skip the deploy when no target app key is configured, so we never fall + # back to creating a new app (which would overwrite the main app). + run_if: '{{enveq "APPETIZE_PUBLIC_KEY" "" | not}}' inputs: - app_path: $BITRISE_DEPLOY_DIR/openboxes_test_b$BITRISE_BUILD_NUMBER.apk - appetize_token: $APPETIZE_API_TOKEN + - public_key: $APPETIZE_PUBLIC_KEY release-steps-android-signed: description: |- Assemble the Android app and copy the signed APK to $BITRISE_DEPLOY_DIR. From a4ce4769249e7449c6a640988c88a1183a8b14af Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 04:39:37 +0000 Subject: [PATCH 06/12] OBLS-837 Add Appetize app public keys to bitrise env Set APPETIZE_DEVELOP_PUBLIC_KEY to the main app used by develop. Leave APPETIZE_PR_PUBLIC_KEY empty until the PR preview app is created; the appetize-deploy run_if guard skips the deploy while it is empty, so PR builds cannot overwrite the main app in the meantime. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_011zmhJ6yv58QsHzj8EwAnZb --- .bitrise/bitrise.yml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/.bitrise/bitrise.yml b/.bitrise/bitrise.yml index ca7d8fd6..bd6fc75a 100644 --- a/.bitrise/bitrise.yml +++ b/.bitrise/bitrise.yml @@ -302,6 +302,17 @@ app: - opts: is_expand: false VARIANT: staging + # Appetize app public keys (from the app's share URL: appetize.io/app/). + # These are not secrets. The main app is used by develop; PR builds deploy to a + # separate PR preview app. Leave APPETIZE_PR_PUBLIC_KEY empty until the PR + # preview app exists — the appetize-deploy run_if guard skips the deploy while + # it is empty, so PR builds never overwrite the main app. + - opts: + is_expand: false + APPETIZE_DEVELOP_PUBLIC_KEY: 67t5r4akxrtgvujo7adbiifau4 + - opts: + is_expand: false + APPETIZE_PR_PUBLIC_KEY: "" meta: bitrise.io: From aff1cfd2e985edfa0fb643206a6d2db18cdc1ac8 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 04:45:08 +0000 Subject: [PATCH 07/12] OBLS-837 Harden Appetize app-select script against unset vars The select-app script runs under set -u, so referencing an unset APPETIZE_*_PUBLIC_KEY aborted the step (as happened before the keys were defined, and would recur if Bitrise omits an empty-valued env var). Use ${VAR:-} defaults so the script tolerates unset or empty keys; the run_if guard on appetize-deploy still skips the deploy when the resolved key is empty. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_011zmhJ6yv58QsHzj8EwAnZb --- .bitrise/bitrise.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.bitrise/bitrise.yml b/.bitrise/bitrise.yml index bd6fc75a..84346b04 100644 --- a/.bitrise/bitrise.yml +++ b/.bitrise/bitrise.yml @@ -143,10 +143,10 @@ step_bundles: set -euo pipefail if [ -n "${BITRISE_PULL_REQUEST:-}" ]; then echo "PR build - deploying to PR preview app" - envman add --key APPETIZE_PUBLIC_KEY --value "$APPETIZE_PR_PUBLIC_KEY" + envman add --key APPETIZE_PUBLIC_KEY --value "${APPETIZE_PR_PUBLIC_KEY:-}" else echo "Push build - deploying to main app" - envman add --key APPETIZE_PUBLIC_KEY --value "$APPETIZE_DEVELOP_PUBLIC_KEY" + envman add --key APPETIZE_PUBLIC_KEY --value "${APPETIZE_DEVELOP_PUBLIC_KEY:-}" fi - appetize-deploy@0: # Skip the deploy when no target app key is configured, so we never fall From f55d5013ee8b924fdd78297518398fcad0058b20 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 18:10:55 +0000 Subject: [PATCH 08/12] OBLS-837 Tag PR preview builds as experimental in the app name On PR builds only, append "(Experimental)" to the Android app_name after branding runs, so the PR preview app is clearly distinguishable from the stable build in Appetize (which shows the app name from the APK manifest). Guarded by BITRISE_PULL_REQUEST so stable/develop builds keep the plain name. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_011zmhJ6yv58QsHzj8EwAnZb --- .bitrise/bitrise.yml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/.bitrise/bitrise.yml b/.bitrise/bitrise.yml index 84346b04..23f9e7c0 100644 --- a/.bitrise/bitrise.yml +++ b/.bitrise/bitrise.yml @@ -248,6 +248,20 @@ workflows: - bundle::setup-steps: {} - bundle::setup-steps-android: {} - bundle::branding: {} + # On PR builds only, append "(Experimental)" to the Android app name so the + # preview app is clearly distinguishable in Appetize from the stable build. + # Runs after branding (which sets app_name) and before the release assemble. + - script@1: + title: Mark PR builds as experimental + run_if: '{{getenv "BITRISE_PULL_REQUEST" | ne ""}}' + inputs: + - content: |- + set -euo pipefail + STRINGS_FILE="android/app/src/main/res/values/strings.xml" + sed -i.bak -E 's#()([^<]*)()#\1\2 (Experimental)\3#' "$STRINGS_FILE" + rm -f "$STRINGS_FILE.bak" + echo "app_name is now:" + grep app_name "$STRINGS_FILE" - bundle::release-steps-android: {} triggers: enabled: false From dc555eec5fe6208e6edb00d6d41d17e1e06ce934 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 18:48:47 +0000 Subject: [PATCH 09/12] OBLS-837 Give PR builds a distinct applicationId for Appetize Appetize keys apps by applicationId, so a same-package PR APK merged into the stable app instead of creating a separate one. On PR builds, suffix the applicationId with .experimental (alongside the app-name tag) so the PR build uploads as its own Appetize app with its own URL. No package-tied services (Firebase/Maps/google-services) exist, so the suffix is safe. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_011zmhJ6yv58QsHzj8EwAnZb --- .bitrise/bitrise.yml | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/.bitrise/bitrise.yml b/.bitrise/bitrise.yml index 23f9e7c0..b4764212 100644 --- a/.bitrise/bitrise.yml +++ b/.bitrise/bitrise.yml @@ -248,20 +248,26 @@ workflows: - bundle::setup-steps: {} - bundle::setup-steps-android: {} - bundle::branding: {} - # On PR builds only, append "(Experimental)" to the Android app name so the - # preview app is clearly distinguishable in Appetize from the stable build. - # Runs after branding (which sets app_name) and before the release assemble. + # On PR builds only, mark the build as experimental: append "(Experimental)" + # to the app name AND give it a distinct applicationId. Appetize keys apps by + # applicationId, so the suffix makes the PR build a separate Appetize app + # (its own URL) instead of overwriting the stable app. Runs after branding + # (which sets app_name) and before the release assemble. - script@1: - title: Mark PR builds as experimental + title: Mark PR builds as experimental (name + applicationId) run_if: '{{getenv "BITRISE_PULL_REQUEST" | ne ""}}' inputs: - content: |- set -euo pipefail STRINGS_FILE="android/app/src/main/res/values/strings.xml" + GRADLE_FILE="android/app/build.gradle" sed -i.bak -E 's#()([^<]*)()#\1\2 (Experimental)\3#' "$STRINGS_FILE" rm -f "$STRINGS_FILE.bak" - echo "app_name is now:" + sed -i.bak -E 's#(applicationId "com\.openboxes\.android)(")#\1.experimental\2#' "$GRADLE_FILE" + rm -f "$GRADLE_FILE.bak" + echo "app_name and applicationId are now:" grep app_name "$STRINGS_FILE" + grep applicationId "$GRADLE_FILE" - bundle::release-steps-android: {} triggers: enabled: false From 4cc943bc4011e2d1c8dda6cb752a7a823e75d82c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 19:17:55 +0000 Subject: [PATCH 10/12] OBLS-837 Point PR builds at the dedicated Appetize preview app Set APPETIZE_PR_PUBLIC_KEY to the PR preview app's key so PR builds now deploy the experimental APK to their own Appetize app instead of skipping the deploy. For the v1 API the appetize-deploy step uses, the build id and public key are interchangeable. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_011zmhJ6yv58QsHzj8EwAnZb --- .bitrise/bitrise.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.bitrise/bitrise.yml b/.bitrise/bitrise.yml index b4764212..03c55422 100644 --- a/.bitrise/bitrise.yml +++ b/.bitrise/bitrise.yml @@ -332,7 +332,7 @@ app: APPETIZE_DEVELOP_PUBLIC_KEY: 67t5r4akxrtgvujo7adbiifau4 - opts: is_expand: false - APPETIZE_PR_PUBLIC_KEY: "" + APPETIZE_PR_PUBLIC_KEY: "b_wchsvah7t6ytekpcvd2pvqavty" meta: bitrise.io: From 7fb59578e61e0e7f6c319c0c76c1678611d302ac Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 19:38:18 +0000 Subject: [PATCH 11/12] OBLS-837 Auto-set Appetize note with build context After deploying, POST a note to the Appetize v1 API for the deployed app with PR number, branch, short commit, and build number, so each preview is self-documenting in the dashboard. The appetize-deploy step can't set notes itself. The v1 API has no tags field, so tags stay manual. The call is non-fatal so a note failure never reds the build. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_011zmhJ6yv58QsHzj8EwAnZb --- .bitrise/bitrise.yml | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/.bitrise/bitrise.yml b/.bitrise/bitrise.yml index 03c55422..6b4dcb9d 100644 --- a/.bitrise/bitrise.yml +++ b/.bitrise/bitrise.yml @@ -156,6 +156,31 @@ step_bundles: - app_path: $BITRISE_DEPLOY_DIR/openboxes_test_b$BITRISE_BUILD_NUMBER.apk - appetize_token: $APPETIZE_API_TOKEN - public_key: $APPETIZE_PUBLIC_KEY + # Annotate the deployed Appetize app with build context (PR/branch/commit). + # The appetize-deploy step can't set a note, so call the v1 API directly. + # v1 supports "note" but not "tags"; failures here are non-fatal. + - script@1: + title: Set Appetize note with build context + run_if: '{{enveq "APPETIZE_PUBLIC_KEY" "" | not}}' + inputs: + - content: |- + set -euo pipefail + SHA="${BITRISE_GIT_COMMIT:-}" + SHA="${SHA:0:8}" + if [ -n "${BITRISE_PULL_REQUEST:-}" ]; then + NOTE="PR #${BITRISE_PULL_REQUEST} - ${BITRISE_GIT_BRANCH:-} - ${SHA} - build ${BITRISE_BUILD_NUMBER:-}" + else + NOTE="${BITRISE_GIT_BRANCH:-} - ${SHA} - build ${BITRISE_BUILD_NUMBER:-}" + fi + BODY="$(jq -n --arg note "$NOTE" '{note: $note}')" + if curl -fsS -X POST "https://api.appetize.io/v1/apps/${APPETIZE_PUBLIC_KEY}" \ + -H "X-API-KEY: ${APPETIZE_API_TOKEN}" \ + -H "Content-Type: application/json" \ + -d "$BODY" > /dev/null; then + echo "Set Appetize note: ${NOTE}" + else + echo "Warning: failed to set Appetize note (non-fatal)" + fi release-steps-android-signed: description: |- Assemble the Android app and copy the signed APK to $BITRISE_DEPLOY_DIR. From a41d6a29a468b000516c128eb39024ef66f82eb8 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 18:35:12 +0000 Subject: [PATCH 12/12] OBLS-837 Address review: surface error message; drop jq dependency - startOrderSession: surface the callback's errorMessage in the alert (consistent with startSession) instead of a generic message, so network/auth/backend failures are distinguishable. - Appetize note step: build the JSON body with printf/sed instead of jq, so the step is self-contained and doesn't fail if the skippable jq brew-install was skipped. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_011zmhJ6yv58QsHzj8EwAnZb --- .bitrise/bitrise.yml | 5 ++++- src/screens/Picking/PickingContext.tsx | 4 ++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/.bitrise/bitrise.yml b/.bitrise/bitrise.yml index 6b4dcb9d..94aee240 100644 --- a/.bitrise/bitrise.yml +++ b/.bitrise/bitrise.yml @@ -172,7 +172,10 @@ step_bundles: else NOTE="${BITRISE_GIT_BRANCH:-} - ${SHA} - build ${BITRISE_BUILD_NUMBER:-}" fi - BODY="$(jq -n --arg note "$NOTE" '{note: $note}')" + # Build the JSON body without jq (which may be skipped) so this step + # is self-contained. Escape backslashes and double quotes for JSON. + ESCAPED="$(printf '%s' "$NOTE" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g')" + BODY="{\"note\":\"${ESCAPED}\"}" if curl -fsS -X POST "https://api.appetize.io/v1/apps/${APPETIZE_PUBLIC_KEY}" \ -H "X-API-KEY: ${APPETIZE_API_TOKEN}" \ -H "Content-Type: application/json" \ diff --git a/src/screens/Picking/PickingContext.tsx b/src/screens/Picking/PickingContext.tsx index a3298937..f58e507d 100644 --- a/src/screens/Picking/PickingContext.tsx +++ b/src/screens/Picking/PickingContext.tsx @@ -104,8 +104,8 @@ export function PickingProvider({ children }: { children: React.ReactNode }) { return new Promise((resolve) => { dispatch( getPickTasksByRequisitionAction(requisitionId, (res) => { - if ('errorMessage' in res || !res.response?.data) { - Alert.alert('Error', 'Failed to load pick tasks for the selected order.'); + if (res.errorMessage || !res.response?.data) { + Alert.alert('Error', res.errorMessage ?? 'Failed to load pick tasks for the selected order.'); resolve(false); return; }