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;