diff --git a/.bitrise/bitrise.yml b/.bitrise/bitrise.yml
index a42c9c6f..94aee240 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
@@ -133,10 +133,57 @@ 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
+ # 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
+ # 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" \
+ -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.
@@ -229,6 +276,26 @@ workflows:
- bundle::setup-steps: {}
- bundle::setup-steps-android: {}
- bundle::branding: {}
+ # 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 (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"
+ 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
@@ -283,6 +350,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: "b_wchsvah7t6ytekpcvd2pvqavty"
meta:
bitrise.io:
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..f58e507d 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 (res.errorMessage || !res.response?.data) {
+ Alert.alert('Error', res.errorMessage ?? '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;