Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/apis/picking.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ export type PickTaskDropParams = {
stagingLocationId: string;
// User Id
stagedById: string;
// Provided when the caller is overriding a previously reported staging location zone mismatch
overrideReasonCode?: string;
overrideComment?: string;
};

export function getPickTasksApi(facilityId: string, params?: Partial<PickTaskParams>) {
Expand Down
14 changes: 12 additions & 2 deletions src/redux/actions/picking.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,11 +158,21 @@ export function shortPickTaskAction(
export function dropPickTaskAction(
outboundContainerId: string,
stagingLocationId: string,
callback?: (response: { errorMessage?: string }) => void
callback?: (response: {
errorMessage?: string;
errorCode?: string;
expectedZones?: { id: string; name: string }[];
}) => void,
override?: { reasonCode?: string; comment?: string }
) {
return {
type: DROP_PICK_TASK_REQUEST,
payload: { outboundContainerId, stagingLocationId },
payload: {
outboundContainerId,
stagingLocationId,
overrideReasonCode: override?.reasonCode,
overrideComment: override?.comment
},
callback
};
}
Expand Down
15 changes: 12 additions & 3 deletions src/redux/sagas/picking.ts
Original file line number Diff line number Diff line change
Expand Up @@ -228,17 +228,26 @@ function* dropPickTaskAction(action: any) {
yield call(api.dropPickTaskApi, currentLocation.id, action.payload.outboundContainerId, {
action: 'drop',
stagingLocationId: action.payload.stagingLocationId,
stagedById: session.user.id
stagedById: session.user.id,
overrideReasonCode: action.payload.overrideReasonCode,
overrideComment: action.payload.overrideComment
});
yield put({ type: DROP_PICK_TASK_REQUEST_SUCCESS });
yield action.callback({});
yield put(hideScreenLoading());
} catch (error) {
const errorMessage = (error as any)?.message || 'Error Dropping Pick Task';
const errorData = (error as any)?.data;
const isZoneMismatch = errorData?.errorCode === 'STAGING_LOCATION_ZONE_MISMATCH';
yield put({
type: DROP_PICK_TASK_REQUEST_FAIL,
payload: (error as any)?.message || 'Error Dropping Pick Task'
payload: errorMessage
});
yield action.callback({
errorMessage,
errorCode: isZoneMismatch ? errorData.errorCode : undefined,
expectedZones: isZoneMismatch ? errorData.expectedZones : undefined
});
yield action.callback({ errorMessage: (error as any)?.message || 'Error Dropping Pick Task' });
yield put(hideScreenLoading());
}
}
Expand Down
41 changes: 15 additions & 26 deletions src/screens/Picking/PickingContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,13 +58,16 @@ type PickingContextType = {
revalidateTasksForRequisition: (requisitionId: string | undefined, callback?: () => void) => void;
/** Start the pick task (API call) */
startPickTask: (callback: (response: { errorMessage?: string }) => void) => void;
/** Drop the current pick task at the system-suggested staging location */
dropCurrentTask: (task: PickTask, callback?: (response: { errorMessage?: string }) => void) => void;
/** Drop the current pick task at the given staging location */
dropCurrentTaskAtStagingLocation: (
task: PickTask,
stagingLocationId: string,
callback?: (response: { errorMessage?: string }) => void
callback?: (response: {
errorMessage?: string;
errorCode?: string;
expectedZones?: { id: string; name: string }[];
}) => void,
override?: { reasonCode?: string; comment?: string }
) => void;
/** Revalidates the current pick task details from the server */
revalidateCurrentTask: (callback?: (task: PickTask | undefined) => void) => void;
Expand Down Expand Up @@ -248,30 +251,17 @@ export function PickingProvider({ children }: { children: React.ReactNode }) {
);
};

const dropCurrentTask = (task: PickTask, callback?: (response: { errorMessage?: string }) => void) => {
if (!task) {
Alert.alert('Task Missing', 'No current task to drop.');
return;
}

if (!task.stagingLocation?.id) {
Alert.alert('Missing Input', 'Current task is missing a valid Staging Location.');
return;
}

if (!task.outboundContainer?.id) {
Alert.alert('Error', 'Current task does not have a valid Outbound Container.');
return;
}

dispatch(dropPickTaskAction(task.outboundContainer.id, task.stagingLocation.id, callback));
};

// Like dropCurrentTask but drops at the given (scanned) staging location instead of the pick task's suggested one
// Drops the current task at the given (scanned) staging location; the server validates it
// against the task's delivery type/zone (see PickTaskService.validateStagingLocationZone).
const dropCurrentTaskAtStagingLocation = (
task: PickTask,
stagingLocationId: string,
callback?: (response: { errorMessage?: string }) => void
callback?: (response: {
errorMessage?: string;
errorCode?: string;
expectedZones?: { id: string; name: string }[];
}) => void,
override?: { reasonCode?: string; comment?: string }
) => {
if (!task) {
Alert.alert('Task Missing', 'No current task to drop.');
Expand All @@ -288,7 +278,7 @@ export function PickingProvider({ children }: { children: React.ReactNode }) {
return;
}

dispatch(dropPickTaskAction(task.outboundContainer.id, stagingLocationId, callback));
dispatch(dropPickTaskAction(task.outboundContainer.id, stagingLocationId, callback, override));
};

const resetSession = () => {
Expand All @@ -314,7 +304,6 @@ export function PickingProvider({ children }: { children: React.ReactNode }) {
resetSession,
revalidateTasksForRequisition,
startPickTask,
dropCurrentTask,
dropCurrentTaskAtStagingLocation,
revalidateCurrentTask,
goToNextTask
Expand Down
166 changes: 93 additions & 73 deletions src/screens/Picking/PickingPickStagingLocationScreen.tsx
Original file line number Diff line number Diff line change
@@ -1,29 +1,46 @@
import * as React from 'react';
import { Alert, ScrollView, View } from 'react-native';
import { Divider, Paragraph, Subheading } from 'react-native-paper';
import { useDispatch } from 'react-redux';

import PickingStagingLocationZoneMismatchModal from '../../components/PickingStagingLocationZoneMismatchModal';
import { ProductDetails } from '../../components/ProductDetails';
import { ScannerInput } from '../../components/ScannerInput';
import { SearchButton } from '../../components/SearchButton';
import { useSearchButton } from '../../components/SearchButton/useSearchButton';
import { EMPTY_STRING, HYPHEN } from '../../constants';
import { resetToRoutes } from '../../NavigationService';
import { getReasonCodesAction } from '../../redux/actions/others';
import { ReasonCode } from '../../types/picking';
import { parseFromISODateToLocaleString } from '../../utils/utils';
import { CustomerDetails } from './CustomerDetails';
import { usePickingContext } from './PickingContext';
import styles from './styles';

// Lets the user stage at any scanned location. Set to false to require the scanned location to
// match the one suggested by the pick task.
const SKIP_STAGING_LOCATION_VALIDATION = true;

export default function PickingPickStagingLocationScreen() {
const { tasks, dropCurrentTask, dropCurrentTaskAtStagingLocation, resetSession, setCurrentTaskIndex, homeRoute } =
usePickingContext();
const { tasks, dropCurrentTaskAtStagingLocation, resetSession, setCurrentTaskIndex, homeRoute } = usePickingContext();
const dispatch = useDispatch();
const [stagingLocationNumber, setStagingLocationNumber] = React.useState(EMPTY_STRING);
const [currentUniqueIndex, setCurrentUniqueIndex] = React.useState(0);
const { isSearchOpen, searchButtonProps } = useSearchButton({ onSelect: setStagingLocationNumber });

const [reasonCodes, setReasonCodes] = React.useState<ReasonCode[]>([]);
const [selectedReasonCode, setSelectedReasonCode] = React.useState<ReasonCode | undefined>(undefined);
const [overrideComment, setOverrideComment] = React.useState(EMPTY_STRING);
const [isOverrideModalVisible, setIsOverrideModalVisible] = React.useState(false);
const [pendingLocationId, setPendingLocationId] = React.useState(EMPTY_STRING);
const [expectedZoneNames, setExpectedZoneNames] = React.useState<string[]>([]);

React.useEffect(() => {
dispatch(
getReasonCodesAction('VALIDATE_STAGING_LOCATION_ZONE', (data: any) => {
if (!data?.error) {
setReasonCodes(data);
}
})
);
}, [dispatch]);

// Memoize unique tasks based on outbound container ID
const uniqueTasks = React.useMemo(() => {
const tasksWithContainers = tasks.filter((t) => t.outboundContainer?.id);
Expand All @@ -41,90 +58,78 @@ export default function PickingPickStagingLocationScreen() {
}
}, [currentTask, tasks.length, setCurrentTaskIndex, uniqueTasks.length, tasks, homeRoute]);

// Requires the scanned location to match the one suggested by the task. Used when SKIP_STAGING_LOCATION_VALIDATION is false.
function handleScan(locationId: string) {
const expected = currentTask.stagingLocation?.locationNumber;

if (!expected || locationId !== expected) {
Alert.alert(
'Invalid Staging Location',
`Expected: ${expected ?? '-'}, but got: ${locationId}. Please try again.`
);
setStagingLocationNumber(EMPTY_STRING);
return;
}

dropCurrentTask(currentTask, (response) => {
if (response.errorMessage) {
Alert.alert('Error', response.errorMessage);
setStagingLocationNumber(EMPTY_STRING);
return;
}

const nextIndex = currentUniqueIndex + 1;
if (nextIndex < uniqueTasks.length) {
Alert.alert('Success', 'Staging Location confirmed. Proceeding to the next container.', [
{
text: 'OK',
onPress: () => {
setCurrentUniqueIndex(nextIndex);
setStagingLocationNumber(EMPTY_STRING);
}
function advanceOrComplete() {
const nextIndex = currentUniqueIndex + 1;
if (nextIndex < uniqueTasks.length) {
Alert.alert('Success', 'Staging Location confirmed. Proceeding to the next container.', [
{
text: 'OK',
onPress: () => {
setCurrentUniqueIndex(nextIndex);
setStagingLocationNumber(EMPTY_STRING);
}
]);
} else {
Alert.alert('Picking Session Complete', 'You have completed all staging confirmations.', [
{
text: 'OK',
onPress: () => {
resetSession();
resetToRoutes([{ name: 'Drawer', params: { screen: 'Dashboard' } }, { name: homeRoute }]);
}
}
]);
} else {
Alert.alert('Picking Session Complete', 'You have completed all staging confirmations.', [
{
text: 'OK',
onPress: () => {
resetSession();
resetToRoutes([{ name: 'Drawer', params: { screen: 'Dashboard' } }, { name: homeRoute }]);
}
]);
}
});
}
]);
}
}

// Drops at whatever location the user scans, without checking it against the task's suggestion.
// Used when SKIP_STAGING_LOCATION_VALIDATION is true.
function handleScanWithoutValidation(locationId: string) {
// Always defers to the server: it's the authority on whether the scanned location is valid for
// this delivery type's zone (per-facility configurable). A mismatch surfaces the override modal
// rather than a plain error, since the mismatch is expected to be overridable.
function handleScan(locationId: string) {
if (!locationId) {
return;
}

dropCurrentTaskAtStagingLocation(currentTask, locationId, (response) => {
if (response.errorCode === 'STAGING_LOCATION_ZONE_MISMATCH') {
setPendingLocationId(locationId);
setExpectedZoneNames(response.expectedZones?.map((zone) => zone.name) ?? []);
setSelectedReasonCode(undefined);
setOverrideComment(EMPTY_STRING);
setIsOverrideModalVisible(true);
return;
}

if (response.errorMessage) {
Alert.alert('Error', response.errorMessage);
setStagingLocationNumber(EMPTY_STRING);
return;
}

const nextIndex = currentUniqueIndex + 1;
if (nextIndex < uniqueTasks.length) {
Alert.alert('Success', 'Staging Location confirmed. Proceeding to the next container.', [
{
text: 'OK',
onPress: () => {
setCurrentUniqueIndex(nextIndex);
setStagingLocationNumber(EMPTY_STRING);
}
}
]);
} else {
Alert.alert('Picking Session Complete', 'You have completed all staging confirmations.', [
{
text: 'OK',
onPress: () => {
resetSession();
resetToRoutes([{ name: 'Drawer', params: { screen: 'Dashboard' } }, { name: homeRoute }]);
}
}
]);
}
advanceOrComplete();
});
}

function handleOverrideConfirm(reasonCode: ReasonCode | undefined, comment: string) {
setIsOverrideModalVisible(false);

dropCurrentTaskAtStagingLocation(
currentTask,
pendingLocationId,
(response) => {
if (response.errorMessage) {
Alert.alert('Error', response.errorMessage);
setStagingLocationNumber(EMPTY_STRING);
return;
}

advanceOrComplete();
},
{ reasonCode: reasonCode?.id, comment }
);
}

// If no task is selected yet, return null to avoid rendering
// ProductDetails with undefined data while useEffect runs
if (!currentTask) {
Expand Down Expand Up @@ -200,12 +205,27 @@ export default function PickingPickStagingLocationScreen() {
value={stagingLocationNumber}
isEnabled={!isSearchOpen}
onChange={setStagingLocationNumber}
onSubmit={SKIP_STAGING_LOCATION_VALIDATION ? handleScanWithoutValidation : handleScan}
onSubmit={handleScan}
/>
<SearchButton searchType="location" {...searchButtonProps} />
</View>
</View>
</ProductDetails.Provider>

<PickingStagingLocationZoneMismatchModal
visible={isOverrideModalVisible}
reasonCodes={reasonCodes}
selectedReasonCode={selectedReasonCode}
setSelectedReasonCode={setSelectedReasonCode}
comment={overrideComment}
setComment={setOverrideComment}
expectedZoneNames={expectedZoneNames}
onDismiss={() => {
setIsOverrideModalVisible(false);
setStagingLocationNumber(EMPTY_STRING);
}}
onConfirm={handleOverrideConfirm}
/>
</ScrollView>
);
}
5 changes: 3 additions & 2 deletions src/utils/ApiClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ class _ApiClient {
message = message ?? 'Not found';
break;
case 409:
message = error.response?.data ?? 'Conflict: Resource Already Exists';
message = message ?? 'Conflict: Resource Already Exists';
break;
case 500:
message = message ?? 'Internal Server Error';
Expand All @@ -68,7 +68,8 @@ class _ApiClient {
}
return Promise.reject({
message: message,
code: code
code: code,
data: error.response?.data
});
};
}
Expand Down