diff --git a/src/Main.tsx b/src/Main.tsx index 5df88e80..28bb4f8c 100644 --- a/src/Main.tsx +++ b/src/Main.tsx @@ -7,6 +7,7 @@ import { SafeAreaView } from 'react-native'; import { Provider } from 'react-native-paper'; import SplashScreen from 'react-native-splash-screen'; import { connect } from 'react-redux'; + import FullScreenLoadingIndicator from './components/FullScreenLoadingIndicator'; import showPopup from './components/Popup'; import { appConfig } from './constants'; @@ -35,6 +36,8 @@ import OutboundStockDetails from './screens/OutboundStockDetails'; import OutboundStockList from './screens/OutboundStockList'; import PackingLocationPage from './screens/PackingLocationPage'; import PickOrderItem from './screens/PickList'; +import { PickUpEntryScreen } from './screens/PickUpAllocation/PickUpEntryScreen'; +import { PickUpOrderScreen } from './screens/PickUpAllocation/PickUpOrderScreen'; import Placeholder from './screens/Placeholder'; import ProductDetails from './screens/ProductDetails'; import Products from './screens/Products'; @@ -47,15 +50,9 @@ import PutawayList from './screens/PutawayList'; import Scan from './screens/Scan'; import Settings from './screens/Settings'; import ShipItemDetails from './screens/ShipItemDetails'; +import SortationContainerScreen from './screens/Sortation/SortationContainerScreen'; import SortationEntryScreen from './screens/Sortation/SortationEntryScreen'; import SortationQuantityScreen from './screens/Sortation/SortationQuantityScreen'; -import Transfer from './screens/Transfer'; -import Transfers from './screens/Transfers'; -import TransferDetails from './screens/TransfersDetails'; -import ViewAvailableItem from './screens/ViewAvailableItem'; -import ApiClient from './utils/ApiClient'; -import Theme from './utils/Theme'; -import SortationContainerScreen from './screens/Sortation/SortationContainerScreen'; import SortationTaskSelectionListScreen from './screens/Sortation/SortationTaskSelectionListScreen'; import PutawayEntryScreen from './screens/SortationPutaway/PutawayEntryScreen'; import PutawayLocationScanScreen from './screens/SortationPutaway/PutawayLocationScanScreen'; @@ -71,6 +68,12 @@ import PickingPickStagingLocationScreen from './screens/Picking/PickingPickStagi import PickingMoveToStagingScreen from './screens/Picking/PickingMoveToStaging'; import PickingStagingDropScreen from './screens/Picking/PickingStagingDrop'; import { PickingProvider } from './screens/Picking/PickingContext'; +import Transfer from './screens/Transfer'; +import Transfers from './screens/Transfers'; +import TransferDetails from './screens/TransfersDetails'; +import ViewAvailableItem from './screens/ViewAvailableItem'; +import ApiClient from './utils/ApiClient'; +import Theme from './utils/Theme'; const Stack = createStackNavigator(); export interface OwnProps { @@ -350,6 +353,16 @@ class Main extends Component { component={PickingStagingDropScreen} options={{ title: 'Staging Location Drop' }} /> + + diff --git a/src/apis/index.ts b/src/apis/index.ts index bb57f100..4cc5fb38 100644 --- a/src/apis/index.ts +++ b/src/apis/index.ts @@ -10,3 +10,4 @@ export * from './lpn'; export * from './transfers'; export * from './others'; export * from './picking'; +export * from './pua'; diff --git a/src/apis/pua.ts b/src/apis/pua.ts new file mode 100644 index 00000000..090581ed --- /dev/null +++ b/src/apis/pua.ts @@ -0,0 +1,24 @@ +import apiClient from '../utils/ApiClient'; + +export function getOutboundOrders() { + let url = + '/stockMovements?exclude=lineItems&direction=OUTBOUND&requisitionStatusCode=VERIFYING' + + '&sort=dateCreated&order=asc&deliveryTypeCode=PICK_UP'; + if (global.location) { + url += '&origin=' + global.location.id; + } + + return apiClient.get(url); +} + +export function getOutboundOrderDetails(id: string) { + return apiClient.get(`/outbound-orders/${id}`); +} + +export function allocate(orderId: string, lineItemId: string, payload: any) { + return apiClient.post(`/outbound-orders/${orderId}/items/${lineItemId}/allocations`, payload); +} + +export function updateOrderStatus(orderId: string, status: string) { + return apiClient.post(`/stockMovements/${orderId}/status`, { status }); +} diff --git a/src/data/location/Location.ts b/src/data/location/Location.ts index 0836973d..be8607df 100644 --- a/src/data/location/Location.ts +++ b/src/data/location/Location.ts @@ -18,6 +18,7 @@ interface Location { hasPartialReceivingSupport: boolean; locationType: LocationType; locationNumber: string; + isDisplay: boolean; } export default Location; diff --git a/src/screens/Dashboard/dashboardData.ts b/src/screens/Dashboard/dashboardData.ts index 6d1bf07a..e8ae0feb 100644 --- a/src/screens/Dashboard/dashboardData.ts +++ b/src/screens/Dashboard/dashboardData.ts @@ -45,6 +45,13 @@ const dashboardEntries: DashboardEntry[] = [ navigationScreenName: 'Orders', defaultVisible: false }, + { + key: 'pickUpAllocation', + screenName: 'Pick-Up Allocation', + entryDescription: 'Manage pick-up allocations and tasks', + icon: IconPicking, + navigationScreenName: 'PickUpEntryScreen' + }, { key: 'packing', screenName: 'Packing', diff --git a/src/screens/PickUpAllocation/PickUpEntryScreen.tsx b/src/screens/PickUpAllocation/PickUpEntryScreen.tsx new file mode 100644 index 00000000..add625be --- /dev/null +++ b/src/screens/PickUpAllocation/PickUpEntryScreen.tsx @@ -0,0 +1,88 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { Alert, FlatList, TouchableOpacity, View } from 'react-native'; +import { Caption, Card, Chip, Divider, Paragraph, Title } from 'react-native-paper'; + +import { navigate } from '../../NavigationService'; +import styles from './styles'; +import { AllocationOrder } from './types'; +import { getOutboundOrders } from '../../apis/pua'; +import { useFocusEffect } from '@react-navigation/native'; + +export function PickUpEntryScreen() { + const [orders, setOrders] = useState([]); + const [isLoading, setIsLoading] = useState(false); + + const fetchOrders = async () => { + try { + setIsLoading(true); + const response = await getOutboundOrders(); + setOrders(response.data || []); + } catch (error) { + Alert.alert('Error', 'Failed to fetch orders data'); + } finally { + setIsLoading(false); + } + }; + + // useFocusEffect usedto refresh when screen is focused after nagivation + useFocusEffect( + useCallback(() => { + fetchOrders(); + }, []) + ); + + return ( + + Outstanding Orders ({orders.length}) + + Select an order from the list to start the pick-up allocation process. + + + + + } + keyExtractor={(item: AllocationOrder) => item.id} + numColumns={1} + ItemSeparatorComponent={() => } + refreshing={isLoading} + onRefresh={fetchOrders} + /> + + ); +} + +function PickUpCard({ order }: { order: AllocationOrder }) { + const onPress = () => navigate('PickUpOrderScreen', { orderId: order.id }); + + return ( + + + + + + {order.identifier} + + + {`Lines: ${order.lineItemCount}`} + + + + + + {order.name} + + {`Order Date: ${new Date(order.dateTimeCreated).toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: '2-digit', + hour: '2-digit', + minute: '2-digit' + })}`} + + + + + ); +} diff --git a/src/screens/PickUpAllocation/PickUpOrderScreen.tsx b/src/screens/PickUpAllocation/PickUpOrderScreen.tsx new file mode 100644 index 00000000..6ee2a1db --- /dev/null +++ b/src/screens/PickUpAllocation/PickUpOrderScreen.tsx @@ -0,0 +1,554 @@ +import { RouteProp, useRoute } from '@react-navigation/native'; +import React, { useCallback, useEffect, useState } from 'react'; +import { Alert, Modal, ScrollView, View } from 'react-native'; +import { Button, DataTable, Headline, List, Paragraph, TextInput, Title } from 'react-native-paper'; + +import EmptyView from '../../components/EmptyView'; +import Theme from '../../utils/Theme'; +import styles from './styles'; +import { AllocationOrderLine, AllocationStrategy, AvailableItem } from './types'; +import { allocate, getOutboundOrderDetails, updateOrderStatus } from '../../apis'; +import { navigate } from '../../NavigationService'; + +type PickUpOrderRouteProp = RouteProp<{ PickUpOrderScreen: { orderId: string } }, 'PickUpOrderScreen'>; + +export function PickUpOrderScreen() { + const { params } = useRoute(); + const { orderId } = params; + + const [allAllocatedAlertShown, setAllAllocatedAlertShown] = useState(false); + const [fullOrder, setFullOrder] = useState(null); + + const fetchDetails = useCallback(async () => { + try { + const response = await getOutboundOrderDetails(orderId); + setFullOrder(response.data || []); + } catch (error) { + Alert.alert('Error', 'Failed to load order details.'); + } + }, [orderId]); + + useEffect(() => { + fetchDetails(); + }, [fetchDetails]); + + const handleFinishAllocation = useCallback( + async (navigateToPicking: boolean) => { + try { + await updateOrderStatus(orderId, 'PICKING'); + + if (navigateToPicking) { + navigate('PickingPickType'); + } else { + navigate('PickUpEntryScreen'); + } + } catch (error) { + Alert.alert('Error', 'Failed to update order status.'); + } + }, + [orderId] + ); + + const showAllPickedDialog = useCallback(() => { + Alert.alert( + 'All Lines Picked', + 'All lines for this order have been allocated. Would you like to self-pick this order?', + [ + { + text: 'No', + style: 'cancel', + onPress: () => handleFinishAllocation(false) + }, + { + text: 'Yes', + onPress: () => handleFinishAllocation(true) + } + ] + ); + }, [handleFinishAllocation]); + + const handleLineAllocated = useCallback(() => { + fetchDetails(); + }, [fetchDetails]); + + useEffect(() => { + if (!fullOrder || !fullOrder.lineItems || fullOrder.lineItems.length === 0) { + return; + } + + const areAllLinesFullyAllocated = fullOrder.lineItems.every((line: any) => { + const allocated = line.quantityAllocated || 0; + const required = line.quantityRequired || 0; + return allocated >= required; + }); + + if (areAllLinesFullyAllocated && !allAllocatedAlertShown) { + setAllAllocatedAlertShown(true); + showAllPickedDialog(); + } + + if (!areAllLinesFullyAllocated && allAllocatedAlertShown) { + setAllAllocatedAlertShown(false); + } + }, [fullOrder, allAllocatedAlertShown, showAllPickedDialog]); + + if (!fullOrder || !fullOrder.lineItems) { + return ( + + ); + } + + return ( + + Order Line Allocation ({fullOrder.lineItems.length}) + + + + {fullOrder.lineItems.map((line, index) => ( + + ))} + + + + ); +} + +function AllocationOrderItem({ + orderLine, + onAllocated, + orderId +}: { + orderLine: AllocationOrderLine; + onAllocated: () => void; + orderId: string; +}) { + const { product, quantityRequired, quantityAllocated } = orderLine; + + const isFullyAllocated = (quantityAllocated || 0) >= (quantityRequired || 0); + const isPartiallyAllocated = (quantityAllocated || 0) > 0 && (quantityAllocated || 0) < (quantityRequired || 0); + + const [expanded, setExpanded] = useState(!isFullyAllocated); + + useEffect(() => { + if (isFullyAllocated) { + setExpanded(false); + } + }, [isFullyAllocated]); + + const toggleExpanded = () => { + setExpanded(!expanded); + }; + + function handleMarkAllocated() { + setExpanded(false); + onAllocated?.(); + } + + return ( + ( + + )} + expanded={expanded} + // eslint-disable-next-line react-native/no-inline-styles + style={[styles.accordion, isFullyAllocated && { opacity: 0.5 }]} + titleStyle={styles.accordionTitle} + descriptionStyle={styles.accordionDescription} + onPress={toggleExpanded} + > + {!isFullyAllocated && ( + + + + )} + + ); +} + +function OrderLineController({ + onAllocated, + orderLine, + orderId +}: { + onAllocated: () => void; + orderId: string; + orderLine: AllocationOrderLine; +}) { + const [partialQuantity, setPartialQuantity] = useState(null); + const [isStockPickOpen, setIsStockPickOpen] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + + const handleAutoPick = async (strategy: AllocationStrategy) => { + try { + setIsSubmitting(true); + + const payload = { + mode: 'AUTO', + strategies: [strategy] + }; + + await allocate(orderId, orderLine.id, payload); + + Alert.alert('Success', 'Item allocated successfully', [{ text: 'OK', onPress: onAllocated }]); + } catch (error) { + Alert.alert('Error', 'Allocation failed'); + } finally { + setIsSubmitting(false); + } + }; + + const handleManualAllocation = async (rows: AvailableItem[]) => { + try { + setIsStockPickOpen(false); + setIsSubmitting(true); + + const itemsToAllocate = rows.filter((row) => { + const qty = parseInt(row.quantityAllocated, 10); + return !isNaN(qty) && qty > 0; + }); + + if (itemsToAllocate.length === 0) { + Alert.alert('No items selected', 'Please enter a quantity to allocate.'); + setIsSubmitting(false); + setIsStockPickOpen(true); + return; + } + + const allocationsPayload = itemsToAllocate.map((row) => ({ + id: row.id, + inventoryItemId: row['inventoryItem.id'], + binLocationId: row.binLocation.id, + quantity: parseInt(row.quantityAllocated, 10) + })); + + const payload = { + mode: 'MANUAL', + allocations: allocationsPayload + }; + + await allocate(orderId, orderLine.id, payload); + + Alert.alert('Success', 'Manual allocation saved', [{ text: 'OK', onPress: onAllocated }]); + } catch (error) { + Alert.alert('Error', 'Manual allocation failed'); + } finally { + setIsSubmitting(false); + } + }; + + function handleConfirm() { + if ( + !partialQuantity || + partialQuantity > orderLine.quantityRequired || + partialQuantity === null || + partialQuantity <= 0 + ) { + Alert.alert('Invalid Quantity', `Please enter a valid partial quantity (1 - ${orderLine.quantityRequired}).`); + return; + } + + Alert.alert( + 'Confirm Partial Quantity', + `Are you sure you want to confirm a partial quantity of ${partialQuantity ?? 0}?`, + [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Confirm', + onPress: onAllocated + } + ] + ); + } + + function handleWarehousePick() { + Alert.alert('Full Warehouse Pick', 'Confirm full warehouse pick?', [ + { text: 'Cancel', style: 'cancel' }, + { text: 'Confirm', onPress: () => handleAutoPick('WAREHOUSE_FIRST') } + ]); + } + + function handleDisplayPick() { + Alert.alert('Full Display Pick', 'Confirm full display pick?', [ + { text: 'Cancel', style: 'cancel' }, + { text: 'Confirm', onPress: () => handleAutoPick('DISPLAY_FIRST') } + ]); + } + + function handleStockPick() { + setIsStockPickOpen(true); + } + + return ( + + + + + + + + {/* Temporarily hidden because it probably won't be needed. To be removed/shown later. */} + {false && ( + + {/* Label + Input */} + + Enter Partial Quantity From Display: + + + { + const parsed = parseInt(text, 10); + setPartialQuantity(text && !isNaN(parsed) ? parsed : null); + }} + /> + + + + {/* Confirm button */} + + + )} + + setIsStockPickOpen(false)} + onConfirm={handleManualAllocation} + /> + + ); +} + +type StockPickModalProps = { + visible: boolean; + onDismiss: () => void; + onConfirm: (rows: AvailableItem[]) => void; + orderLine: AllocationOrderLine; +}; + +function StockPickModal({ visible, onDismiss: onClose, onConfirm: onSave, orderLine }: StockPickModalProps) { + const [rows, setRows] = useState([]); + + useEffect(() => { + if (visible && orderLine?.availableItems) { + const allocationsMap = new Map(); + + if (orderLine.allocations) { + orderLine.allocations.forEach((allocation: any) => { + const invId = allocation.inventoryItemId || ''; + const binId = allocation.binLocationId || ''; + const key = `${invId}-${binId}`; + allocationsMap.set(key, allocation); + }); + } + + const filteredItems = orderLine.availableItems.filter((item) => { + const invId = item['inventoryItem.id'] || ''; + const binId = item.binLocation?.id || ''; + const key = `${invId}-${binId}`; + + const hasAllocation = allocationsMap.has(key) && allocationsMap.get(key) > 0; + const hasAvailability = item.quantityAvailable > 0; + return hasAvailability || hasAllocation; + }); + + const preparedData = filteredItems.map((item, index) => { + const invId = item['inventoryItem.id'] || ''; + const binId = item.binLocation?.id || ''; + const key = `${invId}-${binId}`; + + const existingAllocation = allocationsMap.get(key); + const savedQuantity = existingAllocation ? existingAllocation.quantity : 0; + const allocationId = existingAllocation ? existingAllocation.id : undefined; + + return { + ...item, + _localId: `loc-${item.binLocation?.id || 'null'}-idx-${index}`, + id: allocationId, + quantityAllocated: savedQuantity ? String(savedQuantity) : '' + }; + }); + + setRows(preparedData); + } + }, [visible, orderLine]); + + const totalAllocated = rows.reduce((sum, row) => { + const qty = parseInt(row.quantityAllocated, 10); + return sum + (isNaN(qty) ? 0 : qty); + }, 0); + + const isQuantityRequiredExceeded = totalAllocated > orderLine.quantityRequired; + + function updateQty(localId: string, text: string) { + if (text === '') { + setRows((prev) => prev.map((row) => (row._localId === localId ? { ...row, quantityAllocated: '' } : row))); + return; + } + + const newValue = parseInt(text, 10); + if (isNaN(newValue)) { + return; + } + + setRows((prev) => + prev.map((row) => { + if (row._localId === localId) { + const cappedValue = newValue > row.quantityAvailable ? row.quantityAvailable : newValue; + + return { ...row, quantityAllocated: String(cappedValue) }; + } + return row; + }) + ); + } + + const displayLocations = rows.filter((row) => row.binLocation?.isDisplay); + const warehouseLocations = rows.filter((row) => !row.binLocation?.isDisplay); + + const renderRowsGroup = (items: AvailableItem[]) => { + return items.map((row) => ( + + {row.binLocation?.locationNumber ?? 'Default'} + + {row.quantityAvailable} + + + updateQty(row._localId, v)} + /> + + + )); + }; + + return ( + + + + Stock Pick + + + Product: {orderLine.product.productCode} | {orderLine.product.name} + + + Quantity Allocated: {totalAllocated} / {orderLine.quantityRequired} + + + + + Bin Location + Available + Allocated + + + + {displayLocations.length > 0 && ( + <> + + Display + + {renderRowsGroup(displayLocations)} + + )} + + {warehouseLocations.length > 0 && ( + <> + 0 ? 8 : 0 + }} + > + Warehouse + + {renderRowsGroup(warehouseLocations)} + + )} + + {rows.length === 0 && ( + // eslint-disable-next-line react-native/no-inline-styles + No available stock found. + )} + + + + + + + + + + + ); +} diff --git a/src/screens/PickUpAllocation/styles.ts b/src/screens/PickUpAllocation/styles.ts new file mode 100644 index 00000000..90a31ee8 --- /dev/null +++ b/src/screens/PickUpAllocation/styles.ts @@ -0,0 +1,146 @@ +import { StyleSheet } from 'react-native'; +import Theme from '../../utils/Theme'; + +export default StyleSheet.create({ + screenContainer: { + flex: 1, + backgroundColor: '#f9f9f9', + padding: Theme.spacing.large + }, + titleText: { + fontSize: 20, + fontWeight: '600', + marginBottom: Theme.spacing.small - 2 + }, + subtitleText: { + color: '#555' + }, + sectionDivider: { + marginTop: Theme.spacing.small - 2 + }, + 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 + }, + cardDivider: { + marginVertical: Theme.spacing.small / 2 + }, + chip: { + height: 24, + justifyContent: 'flex-start', + borderRadius: Theme.roundness, + alignItems: 'center', + paddingHorizontal: Theme.spacing.small + }, + chipText: { + fontSize: 12 + }, + + accordion: { + backgroundColor: '#fff', + borderRadius: Theme.roundness, + marginBottom: Theme.spacing.small, + elevation: 1, + borderWidth: 1, + borderColor: '#ddd' + }, + accordionTitle: { + fontWeight: '600' + }, + accordionDescription: { + color: '#666' + }, + accordionContent: { + paddingHorizontal: Theme.spacing.medium, + paddingBottom: Theme.spacing.medium + }, + productDescription: { + marginBottom: Theme.spacing.small + }, + + buttonRow: { + justifyContent: 'space-between' + }, + button: { + flex: 1, + marginBottom: Theme.spacing.small / 2, + flexGrow: 1 + }, + buttonText: { + fontSize: 12, + lineHeight: 16 + }, + paddingZero: { + paddingLeft: 0, + paddingRight: 0 + }, + + partialInputContainer: { + paddingVertical: Theme.spacing.small / 2 + }, + inputRow: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: Theme.spacing.small + }, + inputLabel: { + fontSize: 14, + flexShrink: 0, + marginRight: Theme.spacing.small, + width: 180 + }, + inputWrapper: { + flex: 1 + }, + input: { + justifyContent: 'center' + }, + confirmButton: { + alignSelf: 'stretch' + }, + modalOverlay: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + width: '100%', + height: '100%', + backgroundColor: 'rgba(0, 0, 0, 0.5)' + }, + modalContent: { + width: '90%', + backgroundColor: 'white', + padding: Theme.spacing.large, + borderRadius: Theme.roundness * 2 + }, + scrollableContent: { + maxHeight: 360 + }, + cellInput: { + width: 70, + height: 25 + }, + actionButtons: { + display: 'flex', + flexDirection: 'row', + justifyContent: 'flex-end', + marginTop: Theme.spacing.large + }, + leftMargin: { + marginLeft: Theme.spacing.small + } +}); diff --git a/src/screens/PickUpAllocation/types.ts b/src/screens/PickUpAllocation/types.ts new file mode 100644 index 00000000..d4b0d2a9 --- /dev/null +++ b/src/screens/PickUpAllocation/types.ts @@ -0,0 +1,40 @@ +import Location from '../../data/location/Location'; +import Product from '../../data/product/Product'; + +export type AvailableItem = { + _localId: string; + id?: string; + 'inventoryItem.id': string; + binLocation: Location; + quantityAvailable: number; + quantityAllocated: string; +}; + +export type AllocationOrderLine = { + id: string; + product: Product; + quantityRequired: number; + quantityAllocated: number; + availableItems: Array; + allocationStatus: AllocationStatus; + allocations: Array; +}; + +export type AllocationOrder = { + id: string; + name: string; + identifier: string; + lineItemCount: number; + dateTimeCreated: string; +}; + +export type AllocationStrategy = 'WAREHOUSE_FIRST' | 'DISPLAY_FIRST'; + +export type AllocationStatus = 'ALLOCATED' | 'PARTIALLY_ALLOCATED' | 'UNALLOCATED'; + +export type AllocationDto = { + id?: string; + inventoryItemId: string; + binLocationId: string; + quantity: number; +}; diff --git a/src/utils/Theme.ts b/src/utils/Theme.ts index 60d29c43..38785831 100644 --- a/src/utils/Theme.ts +++ b/src/utils/Theme.ts @@ -12,7 +12,7 @@ export default { colors: { ...DefaultTheme.colors, primary: '#20345c', - warning: '#FCFFC1', + warning: '#f7812d', warningText: '#8a6d3b', danger: '#FF5630', success: '#22bb33',