diff --git a/src/Main.tsx b/src/Main.tsx index a9cf8813..81d23d0b 100644 --- a/src/Main.tsx +++ b/src/Main.tsx @@ -84,6 +84,11 @@ import { ReplenishmentStagingLocationScreen } from './screens/Replenishment/Repl import Transfer from './screens/Transfer'; import Transfers from './screens/Transfers'; import TransferDetails from './screens/TransfersDetails'; +import CreateTransferEntryScreen from './screens/CreateTransfer/CreateTransferEntryScreen'; +import CreateTransferItemListScreen from './screens/CreateTransfer/CreateTransferItemListScreen'; +import CreateTransferQuantityScreen from './screens/CreateTransfer/CreateTransferQuantityScreen'; +import CreateTransferDestinationScreen from './screens/CreateTransfer/CreateTransferDestinationScreen'; +import CreateTransferCompleteScreen from './screens/CreateTransfer/CreateTransferCompleteScreen'; import ViewAvailableItem from './screens/ViewAvailableItem'; import ApiClient from './utils/ApiClient'; import Theme from './utils/Theme'; @@ -270,6 +275,31 @@ class Main extends Component { /> + + + + + , + callback: SearchCallback, + failureMessage: string +) { + if (response?.error) { + callback({ error: response.errorMessage || failureMessage }); + return; + } + callback({ results: (response?.data || []).map(locationToSearchResult) }); +} + function createLocationAction(term: string, callback: SearchCallback) { return searchInternalLocations( term, null, - (response: SearchResponse) => { + (response: SearchResponse) => + handleLocationSearchResponse(response, callback, 'Failed to search locations.'), + true + ); +} + +function createProductOrLocationAction(term: string, callback: SearchCallback) { + return searchProductOrLocationAction( + term, + (response: { products?: Product[]; locations?: Location[]; error?: boolean; errorMessage?: string }) => { if (response?.error) { - callback({ error: response.errorMessage || 'Failed to search locations.' }); + callback({ error: response.errorMessage || 'Failed to search.' }); return; } - callback({ - results: (response?.data || []).map((item) => ({ - id: item.id, - label: item.locationNumber || item.name, - subtitle: item.name, - value: item.locationNumber - })) - }); + const productResults: SearchResult[] = (response?.products || []).map((item) => ({ + id: `product:${item.id}`, + label: item.productCode || item.name, + subtitle: `Product · ${item.name}`, + value: item.productCode + })); + + const locationResults: SearchResult[] = (response?.locations || []).map((item) => ({ + id: `location:${item.id}`, + label: item.locationNumber || item.name, + subtitle: `Bin · ${item.name}`, + value: item.locationNumber + })); + + callback({ results: [...productResults, ...locationResults] }); }, true ); } +function createDestinationBinAction(term: string, callback: SearchCallback) { + return searchDestinationBinsAction( + term, + (response: SearchResponse) => + handleLocationSearchResponse(response, callback, 'Failed to search destination bins.'), + true + ); +} + export const searchProviders = { product: { title: 'Search Product', @@ -88,6 +135,18 @@ export const searchProviders = { placeholder: 'Search by name or number...', inputLabel: 'Container ID', createAction: createLocationAction + }, + destinationBin: { + title: 'Search Destination Bin', + placeholder: 'Search by name or number...', + inputLabel: 'Destination Bin', + createAction: createDestinationBinAction + }, + productOrLocation: { + title: 'Search Product or Bin', + placeholder: 'Product or bin code...', + inputLabel: 'Product or Bin', + createAction: createProductOrLocationAction } }; diff --git a/src/redux/actions/createTransfer.ts b/src/redux/actions/createTransfer.ts new file mode 100644 index 00000000..ffa4c5f5 --- /dev/null +++ b/src/redux/actions/createTransfer.ts @@ -0,0 +1,68 @@ +export const GET_STOCK_TRANSFER_CANDIDATES = 'CREATE_TRANSFER/GET_CANDIDATES'; +export const LOOKUP_TRANSFER_BARCODE = 'CREATE_TRANSFER/LOOKUP_BARCODE'; +export const LOOKUP_LOCATION_BY_CODE = 'CREATE_TRANSFER/LOOKUP_LOCATION'; +export const SEARCH_PRODUCT_OR_LOCATION = 'CREATE_TRANSFER/SEARCH_PRODUCT_OR_LOCATION'; +export const SEARCH_DESTINATION_BINS = 'CREATE_TRANSFER/SEARCH_DESTINATION_BINS'; +export const SUBMIT_CREATE_TRANSFER = 'CREATE_TRANSFER/SUBMIT'; +export const COMPLETE_CREATE_TRANSFER = 'CREATE_TRANSFER/COMPLETE'; + +type Callback = (data: T) => void; + +export function getStockTransferCandidatesAction(facilityId: string, callback: Callback, suppressLoading?: boolean) { + return { + type: GET_STOCK_TRANSFER_CANDIDATES, + payload: { facilityId }, + callback, + suppressLoading + }; +} + +export function lookupTransferBarcodeAction(code: string, callback: Callback) { + return { + type: LOOKUP_TRANSFER_BARCODE, + payload: { code }, + callback + }; +} + +export function lookupLocationByCodeAction(code: string, callback: Callback) { + return { + type: LOOKUP_LOCATION_BY_CODE, + payload: { code }, + callback + }; +} + +export function searchProductOrLocationAction(searchTerm: string, callback: Callback, suppressLoading?: boolean) { + return { + type: SEARCH_PRODUCT_OR_LOCATION, + payload: { searchTerm }, + callback, + suppressLoading + }; +} + +export function searchDestinationBinsAction(searchTerm: string, callback: Callback, suppressLoading?: boolean) { + return { + type: SEARCH_DESTINATION_BINS, + payload: { searchTerm }, + callback, + suppressLoading + }; +} + +export function submitCreateTransferAction(payload: any, callback: Callback) { + return { + type: SUBMIT_CREATE_TRANSFER, + payload, + callback + }; +} + +export function completeCreateTransferAction(transferId: string, callback: Callback) { + return { + type: COMPLETE_CREATE_TRANSFER, + payload: { transferId }, + callback + }; +} diff --git a/src/redux/sagas/createTransfer.ts b/src/redux/sagas/createTransfer.ts new file mode 100644 index 00000000..32cecee9 --- /dev/null +++ b/src/redux/sagas/createTransfer.ts @@ -0,0 +1,135 @@ +import { all, call, takeLatest } from 'redux-saga/effects'; + +import * as api from '../../apis'; +import { parseResponse } from '../../utils/utils'; +import { + COMPLETE_CREATE_TRANSFER, + GET_STOCK_TRANSFER_CANDIDATES, + LOOKUP_LOCATION_BY_CODE, + LOOKUP_TRANSFER_BARCODE, + SEARCH_DESTINATION_BINS, + SEARCH_PRODUCT_OR_LOCATION, + SUBMIT_CREATE_TRANSFER +} from '../actions/createTransfer'; +import { STOCK_TRANSFER_STATUS } from '../../screens/CreateTransfer/utils'; +import Product from '../../data/product/Product'; +import Location from '../../data/location/Location'; + +function* getCandidates(action: any) { + try { + const response = yield call(api.getStockTransferCandidates, action.payload.facilityId); + // /candidates returns flat dot-notation keys; expand to nested before use. + yield action.callback({ data: parseResponse(response?.data) }); + } catch (e: any) { + yield action.callback({ error: true, errorMessage: e?.message }); + } +} + +function* lookupBarcode(action: any) { + // BE wraps "not found" as various non-404 statuses; swallow per-leg. + let product: Product | null = null; + try { + const productResp = yield call(api.lookupBarcodeProduct, action.payload.code); + product = productResp?.data; + } catch (_e) { + // No-op; we'll try location next. + } + if (product) { + yield action.callback({ kind: 'product', product }); + return; + } + + let location: Location | null = null; + try { + const locResp = yield call(api.lookupLocationByCode, action.payload.code); + location = locResp?.data; + } catch (_e) { + // No-op; we'll return "not found" next. + } + if (location) { + yield action.callback({ kind: 'location', location }); + return; + } + + yield action.callback({ kind: 'none' }); +} + +function* lookupLocation(action: any) { + try { + const resp = yield call(api.lookupLocationByCode, action.payload.code); + yield action.callback(resp); + } catch (e: any) { + yield action.callback({ error: true, errorMessage: e?.message }); + } +} + +function* safeCall(fn: any, ...args: any[]): any { + try { + return yield call(fn, ...args); + } catch (_e) { + return null; + } +} + +function* searchProductOrLocation(action: any) { + const term = action.payload.searchTerm; + // Parallel, per-leg fault-tolerant: a flaky leg shouldn't blank out the other. + const [productResp, locResp] = yield all([ + call(safeCall, api.searchProductGlobally, term), + call(safeCall, api.searchInternalLocations, term, null) + ]); + if (!productResp && !locResp) { + yield action.callback({ error: true, errorMessage: 'Search failed.' }); + return; + } + yield action.callback({ + products: productResp?.data ?? [], + locations: locResp?.data ?? [] + }); +} + +function* searchDestBins(action: any) { + try { + const response = yield call(api.searchDestinationBins, action.payload.searchTerm); + yield action.callback(response); + } catch (e: any) { + yield action.callback({ error: true, errorMessage: e?.message }); + } +} + +function* submitCreateTransfer(action: any) { + try { + const response = yield call(api.postStockTransfer, action.payload); + yield action.callback(response); + } catch (e: any) { + yield action.callback({ error: true, errorMessage: e?.message }); + } +} + +function* completeCreateTransfer(action: any) { + const { transferId } = action.payload; + try { + const getResp = yield call(api.getStockTransferById, transferId); + // BE iterates items expecting nested keys; expand the flat-key GET body. + const transferBody = parseResponse(getResp?.data); + if (!transferBody) { + yield action.callback({ error: true, errorMessage: 'Transfer not found.' }); + return; + } + const completedBody = { ...transferBody, status: STOCK_TRANSFER_STATUS.COMPLETED }; + const putResp = yield call(api.putStockTransfer, transferId, completedBody); + yield action.callback(putResp); + } catch (e: any) { + yield action.callback({ error: true, errorMessage: e?.message }); + } +} + +export default function* watcher() { + yield takeLatest(GET_STOCK_TRANSFER_CANDIDATES, getCandidates); + yield takeLatest(LOOKUP_TRANSFER_BARCODE, lookupBarcode); + yield takeLatest(LOOKUP_LOCATION_BY_CODE, lookupLocation); + yield takeLatest(SEARCH_PRODUCT_OR_LOCATION, searchProductOrLocation); + yield takeLatest(SEARCH_DESTINATION_BINS, searchDestBins); + yield takeLatest(SUBMIT_CREATE_TRANSFER, submitCreateTransfer); + yield takeLatest(COMPLETE_CREATE_TRANSFER, completeCreateTransfer); +} diff --git a/src/redux/sagas/index.ts b/src/redux/sagas/index.ts index fe687f94..320e2f2a 100644 --- a/src/redux/sagas/index.ts +++ b/src/redux/sagas/index.ts @@ -11,6 +11,7 @@ import transfers from './transfers'; import packing from './packing'; import others from './others'; import picking from './picking'; +import createTransfer from './createTransfer'; export default function* root() { const sagas = [ @@ -25,7 +26,8 @@ export default function* root() { packing, lpn, others, - picking + picking, + createTransfer ]; yield all(sagas.map(fork)); } diff --git a/src/screens/CreateTransfer/CreateTransferCompleteScreen.tsx b/src/screens/CreateTransfer/CreateTransferCompleteScreen.tsx new file mode 100644 index 00000000..b40bbc52 --- /dev/null +++ b/src/screens/CreateTransfer/CreateTransferCompleteScreen.tsx @@ -0,0 +1,120 @@ +import { RouteProp, useRoute } from '@react-navigation/native'; +import React, { useMemo, useState } from 'react'; +import { Alert, ScrollView, View } from 'react-native'; +import { Caption, Divider, Text } from 'react-native-paper'; +import { useDispatch } from 'react-redux'; + +import Button from '../../components/Button'; +import { QuantityIcon } from '../../components/Icons'; +import { resetToRoutes } from '../../NavigationService'; +import { completeCreateTransferAction } from '../../redux/actions/createTransfer'; +import { DetailChip } from '../../types/sortation'; +import CreateTransferDetailsPanel from './CreateTransferDetailsPanel'; +import styles from './styles'; +import { CreateTransferStackParams } from './types'; +import { formatLotExpiry } from './utils'; + +type CompleteRouteProp = RouteProp; + +type CompletionState = 'idle' | 'completing' | 'completed'; + +const renderQuantityIcon = () => ; + +export default function CreateTransferCompleteScreen() { + const { params } = useRoute(); + const { transferId, stockTransferNumber, item, quantity, destination } = params; + const dispatch = useDispatch(); + + const [completionState, setCompletionState] = useState('idle'); + + function handleComplete() { + setCompletionState('completing'); + dispatch( + completeCreateTransferAction(transferId, (response: any) => { + if (response && !response.error) { + setCompletionState('completed'); + } else { + setCompletionState('idle'); + Alert.alert('Complete Transfer Failed', response?.errorMessage || 'Failed to complete transfer.'); + } + }) + ); + } + + function handleNewTransfer() { + resetToRoutes([{ name: 'Drawer', params: { screen: 'Dashboard' } }, { name: 'CreateTransferEntry' }]); + } + + const detailsChips = useMemo( + () => [ + { + icon: 'airplane-takeoff', + label: 'From', + value: item.originBinLocation.locationNumber || item.originBinLocation.name + }, + { + icon: 'airplane-landing', + label: 'To', + value: destination.locationNumber || destination.name, + isActive: true + }, + { + icon: 'tag', + label: 'Lot', + value: formatLotExpiry(item.lotNumber, item.expirationDate) + }, + { + icon: renderQuantityIcon, + label: 'Quantity', + value: quantity + } + ], + [ + item.originBinLocation.locationNumber, + item.originBinLocation.name, + destination.locationNumber, + destination.name, + item.lotNumber, + item.expirationDate, + quantity + ] + ); + + const isCompleting = completionState === 'completing'; + const isCompleted = completionState === 'completed'; + + return ( + + + + + + + + + {isCompleted && ( + + Reference Number + {stockTransferNumber} + + )} + + {isCompleted ? ( +