From 87e81ed8963f0cc7c726635f91928637bfe988c6 Mon Sep 17 00:00:00 2001 From: olewandowski1 Date: Thu, 29 Jan 2026 15:14:59 +0100 Subject: [PATCH] OBLS-500 new sortation flow init --- src/Main.tsx | 2 + src/components/ScannerInput.tsx | 60 ++++- src/redux/sagas/putaway.ts | 5 +- src/screens/Dashboard/dashboardData.ts | 7 + src/screens/SortationNew/EntryScreen.tsx | 45 ++++ src/screens/SortationNew/SortationForm.tsx | 104 +++++++ src/screens/SortationNew/SortationSummary.tsx | 72 +++++ src/screens/SortationNew/StepInput.tsx | 82 ++++++ src/screens/SortationNew/styles.ts | 94 +++++++ src/screens/SortationNew/useSortation.ts | 253 ++++++++++++++++++ src/utils/Theme.ts | 4 +- 11 files changed, 712 insertions(+), 16 deletions(-) create mode 100644 src/screens/SortationNew/EntryScreen.tsx create mode 100644 src/screens/SortationNew/SortationForm.tsx create mode 100644 src/screens/SortationNew/SortationSummary.tsx create mode 100644 src/screens/SortationNew/StepInput.tsx create mode 100644 src/screens/SortationNew/styles.ts create mode 100644 src/screens/SortationNew/useSortation.ts diff --git a/src/Main.tsx b/src/Main.tsx index 8862c79c..731b74d5 100644 --- a/src/Main.tsx +++ b/src/Main.tsx @@ -81,6 +81,7 @@ import TransferDetails from './screens/TransfersDetails'; import ViewAvailableItem from './screens/ViewAvailableItem'; import ApiClient from './utils/ApiClient'; import Theme from './utils/Theme'; +import EntryScreen from './screens/SortationNew/EntryScreen'; const Stack = createStackNavigator(); export interface OwnProps { @@ -291,6 +292,7 @@ class Main extends Component { options={{ title: 'Packing Location' }} /> + ( value, onChange, onSubmit, - label = 'Scan Barcode', + label = 'Barcode', style, isEnabled = true, - autoSubmitTimeout = appConfig.DEFAULT_DEBOUNCE_TIME + placeholder = 'Scan...', + leftIcon = 'barcode', + autoSubmitTimeout = appConfig.DEFAULT_DEBOUNCE_TIME, + left, + right, + theme, + keyboardType }, ref ) => { @@ -121,8 +135,8 @@ export const ScannerInput = forwardRef( // Auto-submit after timeout useEffect(() => { - // Don't auto-submit if disabled - if (!autoSubmitTimeout) { + // Don't auto-submit if disabled or screen not focused + if (!autoSubmitTimeout || !shouldBeFocused) { return; } @@ -140,14 +154,14 @@ export const ScannerInput = forwardRef( const timer = setTimeout(() => { const trimmed = value.trim(); - if (trimmed && trimmed !== lastSubmittedValue.current) { + if (trimmed && trimmed !== lastSubmittedValue.current && shouldBeFocused) { lastSubmittedValue.current = trimmed; onSubmit(trimmed); } }, autoSubmitTimeout); return () => clearTimeout(timer); - }, [value, autoSubmitTimeout, onSubmit]); + }, [value, autoSubmitTimeout, onSubmit, shouldBeFocused]); const handleBlur = () => { if (shouldBeFocused) { @@ -204,18 +218,42 @@ export const ScannerInput = forwardRef( label={label} value={value} style={style} + theme={theme} // Keep keyboard hidden showSoftInputOnFocus={showKeyboard} autoCorrect={false} autoCompleteType="off" + placeholder={placeholder} + keyboardType={keyboardType} importantForAutofill="no" blurOnSubmit={false} + disabled={!isEnabled} returnKeyType="done" - // @ts-ignore - left={ } />} + left={ + left || ( + // @ts-ignore + + ) + } right={ - // @ts-ignore - } onPress={handleKeyboardPress} /> + right || ( + // @ts-ignore + ( + + )} + onPress={handleKeyboardPress} + /> + ) } onBlur={handleBlur} onFocus={() => {}} diff --git a/src/redux/sagas/putaway.ts b/src/redux/sagas/putaway.ts index a3bb8dac..f3d693d4 100644 --- a/src/redux/sagas/putaway.ts +++ b/src/redux/sagas/putaway.ts @@ -101,17 +101,14 @@ function* createPutawayOder(action: any) { function* patchPutawayTask(action: any) { try { - yield put(showScreenLoading('Submitting...')); const { facilityId, putawayItemId, payload } = action.payload; const response = yield call(api.patchPutawayTask, facilityId, putawayItemId, payload); yield put({ type: PATCH_PUTAWAY_TASK_REQUEST_SUCCESS, payload: response.data }); - yield put(hideScreenLoading()); yield action.callback({ success: true, data: response.data }); } catch (error) { - yield put(hideScreenLoading()); yield action.callback({ error: true, errorMessage: error.message @@ -121,7 +118,7 @@ function* patchPutawayTask(action: any) { function* getPutawayDetailsByContainerId(action: any) { try { - const location = yield select(userLocation) + const location = yield select(userLocation); if (!location || !location.id) { return; } diff --git a/src/screens/Dashboard/dashboardData.ts b/src/screens/Dashboard/dashboardData.ts index c001f3d0..d3d0aaa4 100644 --- a/src/screens/Dashboard/dashboardData.ts +++ b/src/screens/Dashboard/dashboardData.ts @@ -32,6 +32,13 @@ const dashboardEntries: DashboardEntry[] = [ icon: IconSortation, navigationScreenName: 'Sortation' }, + { + key: 'new-sortation', + screenName: 'Sortation (New)', + entryDescription: 'Manage sortation tasks and workflows', + icon: IconSortation, + navigationScreenName: 'NewSortation' + }, { key: 'putaway', screenName: 'Putaway', diff --git a/src/screens/SortationNew/EntryScreen.tsx b/src/screens/SortationNew/EntryScreen.tsx new file mode 100644 index 00000000..04030503 --- /dev/null +++ b/src/screens/SortationNew/EntryScreen.tsx @@ -0,0 +1,45 @@ +import { useNavigation } from '@react-navigation/native'; +import React from 'react'; + +import SortationForm from './SortationForm'; +import SortationSummary from './SortationSummary'; +import { useSortation } from './useSortation'; + +export default function EntryScreen() { + const navigation = useNavigation(); + const { state, actions } = useSortation(); + + if (state.isSorted) { + return ( + navigation.navigate('Dashboard' as never)} + /> + ); + } + + return ( + + ); +} diff --git a/src/screens/SortationNew/SortationForm.tsx b/src/screens/SortationNew/SortationForm.tsx new file mode 100644 index 00000000..7018e51f --- /dev/null +++ b/src/screens/SortationNew/SortationForm.tsx @@ -0,0 +1,104 @@ +import React from 'react'; +import { ScrollView, View } from 'react-native'; +import { Button } from 'react-native-paper'; +import { ScannerInput } from '../../components/ScannerInput'; +import { SortationTask } from '../../types/sortation'; +import { StepInput } from './StepInput'; +import styles from './styles'; + +type SortationFormProps = { + currentStep: number; + productBarcode: string; + setProductBarcode: (v: string) => void; + handleProductScan: (v: string) => void; + quantity: string; + setQuantity: (v: string) => void; + setCurrentStep: (v: number) => void; + containerBarcode: string; + setContainerBarcode: (v: string) => void; + storageLocationBarcode: string; + handleSubmit: () => void; + handleBack: () => void; + handleNext: () => void; + loading: boolean; + error: string | null; + task: SortationTask | null; +}; + +const SortationForm: React.FC = ({ + currentStep, + productBarcode, + setProductBarcode, + handleProductScan, + quantity, + setQuantity, + containerBarcode, + setContainerBarcode, + storageLocationBarcode, + handleBack, + handleNext, + loading, + error, + task +}) => { + return ( + + + + + + + + + {}} + onSubmit={() => {}} + /> + + + + + + + + ); +}; + +export default SortationForm; diff --git a/src/screens/SortationNew/SortationSummary.tsx b/src/screens/SortationNew/SortationSummary.tsx new file mode 100644 index 00000000..4c76ee5b --- /dev/null +++ b/src/screens/SortationNew/SortationSummary.tsx @@ -0,0 +1,72 @@ +import React from 'react'; +import { ScrollView, View } from 'react-native'; +import { Avatar, Button, Chip, Divider, Text } from 'react-native-paper'; +import styles from './styles'; + +type SortationSummaryProps = { + productBarcode: string; + quantity: string; + containerBarcode: string; + storageLocationBarcode: string; + onReset: () => void; + onToDashboard: () => void; +}; + +type SummaryRowProps = { + icon: string; + label: string; + value: string; + showSortedTag?: boolean; +}; + +const SummaryRow = ({ icon, label, value, showSortedTag = false }: SummaryRowProps) => ( + <> + {label} + + + {value} + {showSortedTag && ( + + Sorted + + )} + + +); + +const SortationSummary: React.FC = ({ + productBarcode, + quantity, + containerBarcode, + storageLocationBarcode, + onReset, + onToDashboard +}) => { + return ( + + + + + + + + + + + + + + + + + + + + ); +}; + +export default SortationSummary; diff --git a/src/screens/SortationNew/StepInput.tsx b/src/screens/SortationNew/StepInput.tsx new file mode 100644 index 00000000..1fe63fc7 --- /dev/null +++ b/src/screens/SortationNew/StepInput.tsx @@ -0,0 +1,82 @@ +import React from 'react'; +import { KeyboardTypeOptions, View } from 'react-native'; +import { HelperText } from 'react-native-paper'; +import { IconSource } from 'react-native-paper/lib/typescript/components/Icon'; +import { ScannerInput } from '../../components/ScannerInput'; +import Theme from '../../utils/Theme'; +import styles from './styles'; + +const highlightedTheme = { + ...Theme, + colors: { + ...Theme.colors, + primary: Theme.colors.highlight, + outline: Theme.colors.highlight, + onSurfaceVariant: Theme.colors.highlight, + surface: Theme.colors.highlightBackground + } +}; + +const errorTheme = { + ...Theme, + colors: { + ...Theme.colors, + primary: Theme.colors.danger, + outline: Theme.colors.danger, + onSurfaceVariant: Theme.colors.danger, + surface: '#FFF2F2' + } +}; + +type StepInputProps = { + label: string; + value: string; + isActive: boolean; + onChange: (v: string) => void; + onSubmit: (v: string) => void; + isEnabled?: boolean; + theme?: any; + icon: IconSource; + error?: string | null; + placeholder?: string; + keyboardType?: KeyboardTypeOptions; +}; + +export const StepInput = ({ + label, + value, + isActive, + onChange, + onSubmit, + isEnabled = true, + icon = 'barcode', + theme = {}, + error = null, + placeholder, + keyboardType +}: StepInputProps) => { + const isError = isActive && !!error; + const currentTheme = isError ? errorTheme : isActive ? highlightedTheme : theme; + + return ( + + + {isError && ( + + {error} + + )} + + ); +}; diff --git a/src/screens/SortationNew/styles.ts b/src/screens/SortationNew/styles.ts new file mode 100644 index 00000000..b8f4018f --- /dev/null +++ b/src/screens/SortationNew/styles.ts @@ -0,0 +1,94 @@ +import { StyleSheet } from 'react-native'; +import Theme from '../../utils/Theme'; + +export default StyleSheet.create({ + screen: { + flex: 1, + backgroundColor: '#FFFFFF' + }, + contentDivider: { + marginVertical: 8 + }, + topSpace: { marginTop: Theme.spacing.small }, + formRow: { + padding: Theme.spacing.large, + backgroundColor: 'transparent' + }, + highlightedRow: { + backgroundColor: Theme.colors.highlightBackground, + borderTopColor: Theme.colors.highlight, + borderTopWidth: 1, + borderBottomColor: Theme.colors.highlight, + borderBottomWidth: 1 + }, + highlightedInput: { + backgroundColor: Theme.colors.highlightBackground + }, + errorRow: { + backgroundColor: '#FFF2F2', + borderTopColor: Theme.colors.danger, + borderTopWidth: 1, + borderBottomColor: Theme.colors.danger, + borderBottomWidth: 1 + }, + errorInput: { + backgroundColor: '#FFF2F2' + }, + errorText: { + color: Theme.colors.danger, + fontSize: 14, + marginTop: 2, + fontWeight: '700', + paddingHorizontal: 0 + }, + buttonRow: { + flexDirection: 'row', + justifyContent: 'space-between', + backgroundColor: '#fff', + padding: Theme.spacing.large + }, + buttonLeft: { + flex: 1, + marginRight: 4 + }, + buttonRight: { + flex: 1, + marginLeft: 4 + }, + summaryContainer: { + flex: 1, + backgroundColor: '#fff' + }, + summaryContent: { + padding: Theme.spacing.large, + flex: 1 + }, + summaryLabel: { + fontSize: 12, + color: '#888', + fontWeight: 'bold', + textTransform: 'uppercase', + marginTop: Theme.spacing.medium + }, + summaryValueRow: { + flexDirection: 'row', + alignItems: 'center', + marginVertical: 4 + }, + summaryValue: { + fontSize: 28, + color: '#555', + fontWeight: 'bold', + marginLeft: 12 + }, + sortedTag: { + backgroundColor: Theme.colors.success, + fontSize: 12, + fontWeight: 'bold', + marginLeft: 12 + }, + summaryFooter: { + padding: Theme.spacing.large, + backgroundColor: '#fff' + } +}); diff --git a/src/screens/SortationNew/useSortation.ts b/src/screens/SortationNew/useSortation.ts new file mode 100644 index 00000000..c4029454 --- /dev/null +++ b/src/screens/SortationNew/useSortation.ts @@ -0,0 +1,253 @@ +import { useCallback, useState, useMemo } from 'react'; +import { useDispatch } from 'react-redux'; +import { EMPTY_STRING, HYPHEN } from '../../constants'; +import { getSortationDetailsByBarcode } from '../../redux/actions/products'; +import { patchPutawayTaskAction } from '../../redux/actions/putaways'; +import { SortationProduct, SortationTask } from '../../types/sortation'; + +export type SortationState = { + productBarcode: string; + quantity: string; + containerBarcode: string; + storageLocationBarcode: string; + loading: boolean; + currentStep: number; + isSorted: boolean; + error: string | null; + task: SortationTask | null; +}; + +export type SortationActions = { + setProductBarcode: (v: string) => void; + setQuantity: (v: string) => void; + setContainerBarcode: (v: string) => void; + setStorageLocationBarcode: (v: string) => void; + handleProductScan: (v: string) => void; + onReset: () => void; + handleSubmit: () => void; + handleNext: () => void; + handleBack: () => void; + setCurrentStep: (v: number) => void; + setError: (v: string | null) => void; +}; + +export type UseSortationReturn = { + state: SortationState; + actions: SortationActions; +}; + +const ALLOWED_STATUSES = ['PENDING', 'STARTED']; + +export const useSortation = (): UseSortationReturn => { + const [productBarcode, setProductBarcode] = useState(EMPTY_STRING); + const [quantity, setQuantity] = useState(EMPTY_STRING); + const [containerBarcode, setContainerBarcode] = useState(EMPTY_STRING); + const [storageLocationBarcode, setStorageLocationBarcode] = useState(EMPTY_STRING); + + const [product, setProduct] = useState(null); + const [task, setTask] = useState(null); + const [loading, setLoading] = useState(false); + const [currentStep, setCurrentStep] = useState(1); + const [isSorted, setIsSorted] = useState(false); + const [error, setError] = useState(null); + + const dispatch = useDispatch(); + + const validateQuantity = useCallback((qtyStr: string, maxQty: number) => { + const qty = parseInt(qtyStr, 10); + if (isNaN(qty) || qty <= 0) { + return 'Please enter a valid quantity.'; + } + if (qty > maxQty) { + return `Quantity cannot exceed task quantity (${maxQty}).`; + } + return null; + }, []); + + const validateContainer = useCallback((barcode: string, expectedBarcode?: string) => { + if (!barcode) { + return 'Please scan an outbound container.'; + } + if (expectedBarcode && barcode !== expectedBarcode) { + return `Invalid container. Expected: ${expectedBarcode}`; + } + return null; + }, []); + + const getDestinationDisplay = useCallback((destination: any) => { + if (!destination) { + return EMPTY_STRING; + } + return `${destination.zoneName ?? HYPHEN} / ${destination.name ?? HYPHEN}`; + }, []); + + const handleProductScanResponse = useCallback( + (response: any) => { + setLoading(false); + if (response && !response.error) { + const { product: responseProduct, tasks } = response; + const filteredTasks = (tasks || []).filter((t: SortationTask) => ALLOWED_STATUSES.includes(t.status)); + + if (filteredTasks.length === 0) { + setError('No pending tasks found for this product.'); + setProductBarcode(EMPTY_STRING); + } else { + setProduct(responseProduct); + const firstTask = filteredTasks[0]; + setTask(firstTask); + setStorageLocationBarcode(getDestinationDisplay(firstTask.destination)); + setCurrentStep(2); + } + } else { + setError(response?.errorMessage || 'Product not found.'); + setProductBarcode(EMPTY_STRING); + } + }, + [getDestinationDisplay] + ); + + const handleProductScan = useCallback( + (code: string) => { + if (code === EMPTY_STRING) { + return; + } + setLoading(true); + setError(null); + dispatch(getSortationDetailsByBarcode(code, handleProductScanResponse)); + }, + [dispatch, handleProductScanResponse] + ); + + const onReset = useCallback(() => { + setProductBarcode(EMPTY_STRING); + setQuantity(EMPTY_STRING); + setContainerBarcode(EMPTY_STRING); + setStorageLocationBarcode(EMPTY_STRING); + setProduct(null); + setTask(null); + setCurrentStep(1); + setIsSorted(false); + setError(null); + }, []); + + const handleSubmit = useCallback(() => { + if (!task || !product) { + setError('Please scan a valid product first.'); + setProductBarcode(EMPTY_STRING); + setCurrentStep(1); + return; + } + + const validationError = validateQuantity(quantity, task.quantity); + if (validationError) { + setError(validationError); + setQuantity(EMPTY_STRING); + setCurrentStep(2); + return; + } + + const containerError = validateContainer(containerBarcode, task?.container?.locationNumber); + if (containerError) { + setError(containerError); + setContainerBarcode(EMPTY_STRING); + setCurrentStep(3); + return; + } + + setLoading(true); + setError(null); + const payload = { + action: 'load', + quantity: parseInt(quantity, 10), + container: containerBarcode, + override: true + }; + + dispatch( + patchPutawayTaskAction(task.facility.id, task.id, payload, (response) => { + setLoading(false); + if (response && !response.error) { + setCurrentStep(4); + } else { + setError(response?.errorMessage || 'Failed to complete sortation.'); + setContainerBarcode(EMPTY_STRING); + setCurrentStep(3); + } + }) + ); + }, [task, product, quantity, containerBarcode, dispatch, validateQuantity, validateContainer]); + + const handleNext = useCallback(() => { + setError(null); + if (currentStep === 1) { + handleProductScan(productBarcode); + } else if (currentStep === 2) { + const validationError = task ? validateQuantity(quantity, task.quantity) : 'No task selected'; + if (!validationError) { + setCurrentStep(3); + } else { + setError(validationError); + setQuantity(EMPTY_STRING); + } + } else if (currentStep === 3) { + handleSubmit(); + } else if (currentStep === 4) { + setIsSorted(true); + } + }, [currentStep, handleProductScan, productBarcode, quantity, task, validateQuantity, handleSubmit]); + + const handleBack = useCallback(() => { + if (currentStep === 4) { + return; + } + setError(null); + if (currentStep > 1) { + setCurrentStep(currentStep - 1); + } else { + onReset(); + } + }, [currentStep, onReset]); + + const state = useMemo( + () => ({ + productBarcode, + quantity, + containerBarcode, + storageLocationBarcode, + loading, + currentStep, + isSorted, + error, + task + }), + [productBarcode, quantity, containerBarcode, storageLocationBarcode, loading, currentStep, isSorted, error, task] + ); + + const actions = useMemo( + () => ({ + setProductBarcode: (v: string) => { + setProductBarcode(v); + setError(null); + }, + setQuantity: (v: string) => { + setQuantity(v); + setError(null); + }, + setContainerBarcode: (v: string) => { + setContainerBarcode(v); + setError(null); + }, + setStorageLocationBarcode, + handleProductScan, + onReset, + handleSubmit, + handleNext, + handleBack, + setCurrentStep, + setError + }), + [handleProductScan, onReset, handleSubmit, handleNext, handleBack] + ); + + return { state, actions }; +}; diff --git a/src/utils/Theme.ts b/src/utils/Theme.ts index 60d29c43..351990e1 100644 --- a/src/utils/Theme.ts +++ b/src/utils/Theme.ts @@ -16,7 +16,9 @@ export default { warningText: '#8a6d3b', danger: '#FF5630', success: '#22bb33', - info: '#00B8D9' + info: '#00B8D9', + highlight: '#007AFF', + highlightBackground: '#EEF7FF' }, spacing: { small: 8,