@burdenoff/microfe-movethewheels 2026.1011.1 → 2026.1011.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/MoveTheWheelsRoot.js +5 -0
- package/dist/MoveTheWheelsRoutes.js +54 -50
- package/dist/generated/shipment-status-authority-operations.d.ts +4473 -0
- package/dist/hooks/useEpod.js +85 -77
- package/dist/hooks/useShipmentStatusAuthority.js +127 -0
- package/dist/hooks/useTracking.js +15 -14
- package/dist/hooks/useTrackingEventQuality.js +43 -17
- package/dist/movethewheels/hooks/useEpod.d.ts +23 -0
- package/dist/movethewheels/hooks/useShipmentStatusAuthority.d.ts +23 -0
- package/dist/movethewheels/hooks/useTrackingEventQuality.d.ts +5 -0
- package/dist/movethewheels/pages/ShipmentStatusAuthorityPage.d.ts +1 -0
- package/dist/movethewheels/utils/shipmentStatusDraft.d.ts +45 -0
- package/dist/pages/DeliveryCapturePage.js +181 -116
- package/dist/pages/ShipmentStatusAuthorityPage.js +563 -0
- package/dist/pages/TrackingEventQualityPage.js +6 -1
- package/dist/src/generated/shipment-status-authority-operations.js +86 -0
- package/dist/utils/shipmentStatusDraft.js +41 -0
- package/package.json +5 -4
- package/src/generated/shipment-status-authority-operations.ts +5503 -0
- package/src/movethewheels/MoveTheWheelsRoot.tsx +6 -0
- package/src/movethewheels/MoveTheWheelsRoutes.tsx +2 -0
- package/src/movethewheels/hooks/useEpod.ts +47 -12
- package/src/movethewheels/hooks/useShipmentStatusAuthority.ts +179 -0
- package/src/movethewheels/hooks/useTracking.ts +27 -12
- package/src/movethewheels/hooks/useTrackingEventQuality.ts +42 -0
- package/src/movethewheels/pages/DeliveryCapturePage.tsx +113 -6
- package/src/movethewheels/pages/ShipmentStatusAuthorityPage.tsx +639 -0
- package/src/movethewheels/pages/TrackingEventQualityPage.tsx +13 -2
- package/src/movethewheels/utils/shipmentStatusDraft.ts +118 -0
- package/src/operations/shipment-status-authority/status.graphql +80 -0
|
@@ -99,6 +99,12 @@ const NavRegistrar: FC<Pick<MoveTheWheelsRootProps, 'registerNavItems' | 'basePa
|
|
|
99
99
|
path: joinPath(basePath, 'tracking/event-quality'),
|
|
100
100
|
icon: <Map size={16} />,
|
|
101
101
|
},
|
|
102
|
+
{
|
|
103
|
+
id: 'mtw-status-authority',
|
|
104
|
+
label: tr('movethewheels.root.nav.statusAuthority', 'Shipment status authority'),
|
|
105
|
+
path: joinPath(basePath, 'shipments/status-authority'),
|
|
106
|
+
icon: <ClipboardList size={16} />,
|
|
107
|
+
},
|
|
102
108
|
],
|
|
103
109
|
},
|
|
104
110
|
{
|
|
@@ -56,6 +56,7 @@ const OrderDetailsPage = lazy(() => import('./pages/OrderDetailsPage'));
|
|
|
56
56
|
const CreateOrderPage = lazy(() => import('./pages/CreateOrderPage'));
|
|
57
57
|
const TrackingPage = lazy(() => import('./pages/TrackingPage'));
|
|
58
58
|
const TrackingEventQualityPage = lazy(() => import('./pages/TrackingEventQualityPage'));
|
|
59
|
+
const ShipmentStatusAuthorityPage = lazy(() => import('./pages/ShipmentStatusAuthorityPage'));
|
|
59
60
|
const LiveTrackingPage = lazy(() => import('./pages/LiveTrackingPage'));
|
|
60
61
|
const FleetPage = lazy(() => import('./pages/FleetPage'));
|
|
61
62
|
const VehiclesPage = lazy(() => import('./pages/VehiclesPage'));
|
|
@@ -110,6 +111,7 @@ export function MoveTheWheelsRoutes() {
|
|
|
110
111
|
<Route path="tracking" element={<TrackingPage />} />
|
|
111
112
|
<Route path="tracking/live" element={<LiveTrackingPage />} />
|
|
112
113
|
<Route path="tracking/event-quality" element={<TrackingEventQualityPage />} />
|
|
114
|
+
<Route path="shipments/status-authority" element={<ShipmentStatusAuthorityPage />} />
|
|
113
115
|
|
|
114
116
|
{/* Driver ePOD capture (BOFF-4399) */}
|
|
115
117
|
<Route path="deliveries/mine" element={<DeliveryCapturePage />} />
|
|
@@ -12,11 +12,13 @@
|
|
|
12
12
|
* multi-language delivery instructions.
|
|
13
13
|
*/
|
|
14
14
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
15
|
+
import { parse } from 'graphql';
|
|
16
|
+
import { useShipmentStatusCommand } from './useShipmentStatusAuthority';
|
|
15
17
|
import { useMoveTheWheels } from '../providers/MoveTheWheelsProvider';
|
|
16
18
|
import type { Order, ProofOfDelivery, SignatureType } from '../types';
|
|
17
19
|
import { executeGraphQL } from '../utils/graphqlClient';
|
|
18
20
|
|
|
19
|
-
interface ApiProofOfDelivery {
|
|
21
|
+
export interface ApiProofOfDelivery {
|
|
20
22
|
id: string;
|
|
21
23
|
deliveryJobId: string;
|
|
22
24
|
shipmentId?: string | null;
|
|
@@ -81,6 +83,7 @@ const CAPTURE_EPOD_MUTATION = `
|
|
|
81
83
|
}
|
|
82
84
|
}
|
|
83
85
|
`;
|
|
86
|
+
const CAPTURE_EPOD_DOCUMENT = parse(CAPTURE_EPOD_MUTATION);
|
|
84
87
|
|
|
85
88
|
const INITIATE_UPLOAD_MUTATION = `
|
|
86
89
|
mutation EpodInitiateUpload($input: InitiateUploadInput!) {
|
|
@@ -257,6 +260,9 @@ export function useMyPendingEpodDeliveryJobs(options: { limit?: number; offset?:
|
|
|
257
260
|
}
|
|
258
261
|
|
|
259
262
|
export interface CaptureEpodArgs {
|
|
263
|
+
expectedShipmentId: string | null;
|
|
264
|
+
expectedShipmentStatusRevision?: number;
|
|
265
|
+
completionAttestation: boolean;
|
|
260
266
|
deliveryJobId: string;
|
|
261
267
|
recipientName: string;
|
|
262
268
|
signatureType: SignatureType;
|
|
@@ -268,22 +274,48 @@ export interface CaptureEpodArgs {
|
|
|
268
274
|
notes?: string;
|
|
269
275
|
}
|
|
270
276
|
|
|
277
|
+
export function epodAcknowledged(
|
|
278
|
+
actual: ApiProofOfDelivery | null | undefined,
|
|
279
|
+
expected: CaptureEpodArgs
|
|
280
|
+
): boolean {
|
|
281
|
+
return (
|
|
282
|
+
!!actual &&
|
|
283
|
+
typeof actual.id === 'string' &&
|
|
284
|
+
actual.id.trim().length > 0 &&
|
|
285
|
+
actual.deliveryJobId === expected.deliveryJobId &&
|
|
286
|
+
actual.shipmentId === expected.expectedShipmentId &&
|
|
287
|
+
actual.recipientName === expected.recipientName &&
|
|
288
|
+
actual.signatureType === toApiSignatureType(expected.signatureType) &&
|
|
289
|
+
actual.signatureData === expected.signatureData &&
|
|
290
|
+
typeof actual.capturedByDriverId === 'string' &&
|
|
291
|
+
actual.capturedByDriverId.trim().length > 0 &&
|
|
292
|
+
typeof actual.deliveredAt === 'string' &&
|
|
293
|
+
Number.isFinite(Date.parse(actual.deliveredAt)) &&
|
|
294
|
+
Array.isArray(actual.photoFileIds) &&
|
|
295
|
+
actual.photoFileIds.length === (expected.photoFileIds ?? []).length &&
|
|
296
|
+
actual.photoFileIds.every((id, index) => id === (expected.photoFileIds ?? [])[index]) &&
|
|
297
|
+
actual.latitude === (expected.latitude ?? null) &&
|
|
298
|
+
actual.longitude === (expected.longitude ?? null) &&
|
|
299
|
+
actual.locationAccuracy === (expected.locationAccuracy ?? null) &&
|
|
300
|
+
actual.notes === (expected.notes ?? null)
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
|
|
271
304
|
/** Driver-facing capture mutation. Identity/ownership is enforced server-side — this never sends a driver id. */
|
|
272
305
|
export function useCaptureEpod() {
|
|
273
|
-
const
|
|
274
|
-
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
306
|
+
const command = useShipmentStatusCommand();
|
|
275
307
|
const [error, setError] = useState<Error | null>(null);
|
|
276
308
|
|
|
277
309
|
const captureEpod = useCallback(
|
|
278
310
|
async (args: CaptureEpodArgs): Promise<ProofOfDelivery> => {
|
|
279
|
-
setIsSubmitting(true);
|
|
280
311
|
setError(null);
|
|
281
312
|
try {
|
|
282
|
-
const data = await
|
|
283
|
-
|
|
284
|
-
CAPTURE_EPOD_MUTATION,
|
|
313
|
+
const data = await command.run<{ captureEpod: ApiProofOfDelivery }>(
|
|
314
|
+
CAPTURE_EPOD_DOCUMENT,
|
|
285
315
|
{
|
|
286
316
|
input: {
|
|
317
|
+
expectedShipmentStatusRevision: args.expectedShipmentStatusRevision,
|
|
318
|
+
completionAttestation: args.completionAttestation,
|
|
287
319
|
deliveryJobId: args.deliveryJobId,
|
|
288
320
|
recipientName: args.recipientName,
|
|
289
321
|
signatureType: toApiSignatureType(args.signatureType),
|
|
@@ -294,8 +326,13 @@ export function useCaptureEpod() {
|
|
|
294
326
|
locationAccuracy: args.locationAccuracy,
|
|
295
327
|
notes: args.notes,
|
|
296
328
|
},
|
|
297
|
-
}
|
|
329
|
+
},
|
|
330
|
+
(data) => epodAcknowledged(data.captureEpod, args)
|
|
298
331
|
);
|
|
332
|
+
if (!data)
|
|
333
|
+
throw new Error(
|
|
334
|
+
'Capture was not acknowledged. Review the recorded status and the capture message before taking further action.'
|
|
335
|
+
);
|
|
299
336
|
return mapEpod(data.captureEpod);
|
|
300
337
|
} catch (captureError) {
|
|
301
338
|
const err =
|
|
@@ -304,14 +341,12 @@ export function useCaptureEpod() {
|
|
|
304
341
|
: new Error('Failed to capture proof of delivery');
|
|
305
342
|
setError(err);
|
|
306
343
|
throw err;
|
|
307
|
-
} finally {
|
|
308
|
-
setIsSubmitting(false);
|
|
309
344
|
}
|
|
310
345
|
},
|
|
311
|
-
[
|
|
346
|
+
[command]
|
|
312
347
|
);
|
|
313
348
|
|
|
314
|
-
return { captureEpod, isSubmitting, error };
|
|
349
|
+
return { captureEpod, isSubmitting: command.busy, error, captureFeedback: command.feedback };
|
|
315
350
|
}
|
|
316
351
|
|
|
317
352
|
export interface UploadedEpodPhoto {
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
2
|
+
import { print, type DocumentNode } from 'graphql';
|
|
3
|
+
import { graphqlFetch, clearGraphqlFetchResponseCache } from '@burdenoff/fe-libs/shared/graphql';
|
|
4
|
+
import { useMoveTheWheels } from '../providers/MoveTheWheelsProvider';
|
|
5
|
+
|
|
6
|
+
const KNOWN_REJECTIONS = new Set([
|
|
7
|
+
'STATUS_REVISION_CONFLICT',
|
|
8
|
+
'BAD_USER_INPUT',
|
|
9
|
+
'FORBIDDEN',
|
|
10
|
+
'UNAUTHENTICATED',
|
|
11
|
+
'NOT_FOUND',
|
|
12
|
+
]);
|
|
13
|
+
|
|
14
|
+
export class ShipmentStatusRequestError extends Error {
|
|
15
|
+
constructor(
|
|
16
|
+
message: string,
|
|
17
|
+
readonly code: string | null
|
|
18
|
+
) {
|
|
19
|
+
super(message);
|
|
20
|
+
this.name = 'ShipmentStatusRequestError';
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Normal shared gateway transport; mutation acknowledgements are never automatically retried. */
|
|
25
|
+
export function useShipmentStatusApi() {
|
|
26
|
+
const { apiGatewayUrl, authToken, workspaceToken, workspaceId, tenantId } = useMoveTheWheels();
|
|
27
|
+
const context = useMemo(
|
|
28
|
+
() => ({ apiGatewayUrl, authToken, workspaceToken, workspaceId, tenantId }),
|
|
29
|
+
[apiGatewayUrl, authToken, workspaceToken, workspaceId, tenantId]
|
|
30
|
+
);
|
|
31
|
+
return useCallback(
|
|
32
|
+
async <T>(
|
|
33
|
+
document: DocumentNode,
|
|
34
|
+
variables: Record<string, unknown>,
|
|
35
|
+
mutation = false,
|
|
36
|
+
signal?: AbortSignal
|
|
37
|
+
): Promise<T> => {
|
|
38
|
+
const response = await graphqlFetch<T>({
|
|
39
|
+
gateway: 'workspace',
|
|
40
|
+
query: print(document),
|
|
41
|
+
variables,
|
|
42
|
+
baseUrl: context.apiGatewayUrl,
|
|
43
|
+
authToken: context.authToken,
|
|
44
|
+
workspaceToken: context.workspaceToken || true,
|
|
45
|
+
workspaceId: context.workspaceId || true,
|
|
46
|
+
tenantId: context.tenantId || true,
|
|
47
|
+
organizationId: true,
|
|
48
|
+
signal,
|
|
49
|
+
skipAuthRetry: mutation,
|
|
50
|
+
});
|
|
51
|
+
if (response.errors?.length) {
|
|
52
|
+
// A single verified rejection is distinct from a transport failure or partial response.
|
|
53
|
+
const first = response.errors[0];
|
|
54
|
+
const code =
|
|
55
|
+
response.data === null &&
|
|
56
|
+
response.errors.length === 1 &&
|
|
57
|
+
typeof first?.extensions?.code === 'string'
|
|
58
|
+
? first.extensions.code
|
|
59
|
+
: null;
|
|
60
|
+
throw new ShipmentStatusRequestError(
|
|
61
|
+
first?.message ?? 'Unable to load the recorded status.',
|
|
62
|
+
code
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
if (response.data == null)
|
|
66
|
+
throw new ShipmentStatusRequestError('No acknowledgement was received.', null);
|
|
67
|
+
return response.data;
|
|
68
|
+
},
|
|
69
|
+
[context]
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function useShipmentStatusQuery<T>(
|
|
74
|
+
document: DocumentNode,
|
|
75
|
+
variables: Record<string, unknown> | null,
|
|
76
|
+
revision = 0
|
|
77
|
+
) {
|
|
78
|
+
const request = useShipmentStatusApi();
|
|
79
|
+
const key = JSON.stringify(variables);
|
|
80
|
+
const [state, setState] = useState<{
|
|
81
|
+
request: typeof request;
|
|
82
|
+
key: string;
|
|
83
|
+
revision: number;
|
|
84
|
+
data: T | null;
|
|
85
|
+
error: string | null;
|
|
86
|
+
loading: boolean;
|
|
87
|
+
} | null>(null);
|
|
88
|
+
useEffect(() => {
|
|
89
|
+
const controller = new AbortController();
|
|
90
|
+
const scope = { request, key, revision };
|
|
91
|
+
setState({ ...scope, data: null, error: null, loading: key !== 'null' });
|
|
92
|
+
if (key === 'null') return;
|
|
93
|
+
// An explicit review refresh must read the gateway rather than its short-lived response cache.
|
|
94
|
+
if (revision > 0) clearGraphqlFetchResponseCache();
|
|
95
|
+
const input: Record<string, unknown> = JSON.parse(key);
|
|
96
|
+
void request<T>(document, input, false, controller.signal)
|
|
97
|
+
.then((data) => {
|
|
98
|
+
if (!controller.signal.aborted) setState({ ...scope, data, error: null, loading: false });
|
|
99
|
+
})
|
|
100
|
+
.catch((failure: unknown) => {
|
|
101
|
+
if (!controller.signal.aborted)
|
|
102
|
+
setState({
|
|
103
|
+
...scope,
|
|
104
|
+
data: null,
|
|
105
|
+
error: failure instanceof Error ? failure.message : 'Unable to load recorded status.',
|
|
106
|
+
loading: false,
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
return () => controller.abort();
|
|
110
|
+
}, [document, request, key, revision]);
|
|
111
|
+
const current = state?.request === request && state.key === key && state.revision === revision;
|
|
112
|
+
return {
|
|
113
|
+
data: current ? state.data : null,
|
|
114
|
+
error: current ? state.error : null,
|
|
115
|
+
loading: current ? state.loading : key !== 'null',
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function useShipmentStatusCommand() {
|
|
120
|
+
const request = useShipmentStatusApi();
|
|
121
|
+
const lock = useRef(false);
|
|
122
|
+
const uncertain = useRef(false);
|
|
123
|
+
const [busy, setBusy] = useState(false);
|
|
124
|
+
const [feedback, setFeedback] = useState<{
|
|
125
|
+
kind: 'APPLIED' | 'REJECTED' | 'UNKNOWN';
|
|
126
|
+
message: string;
|
|
127
|
+
} | null>(null);
|
|
128
|
+
const [revision, setRevision] = useState(0);
|
|
129
|
+
const run = async <T>(
|
|
130
|
+
document: DocumentNode,
|
|
131
|
+
variables: Record<string, unknown>,
|
|
132
|
+
acknowledged: (result: T) => boolean
|
|
133
|
+
): Promise<T | null> => {
|
|
134
|
+
if (lock.current || uncertain.current) return null;
|
|
135
|
+
lock.current = true;
|
|
136
|
+
setBusy(true);
|
|
137
|
+
setFeedback(null);
|
|
138
|
+
try {
|
|
139
|
+
const result = await request<T>(document, variables, true);
|
|
140
|
+
if (!acknowledged(result))
|
|
141
|
+
throw new ShipmentStatusRequestError(
|
|
142
|
+
'The returned acknowledgement does not identify this decision.',
|
|
143
|
+
null
|
|
144
|
+
);
|
|
145
|
+
setFeedback({
|
|
146
|
+
kind: 'APPLIED',
|
|
147
|
+
message: 'Decision recorded. Review the refreshed status and history.',
|
|
148
|
+
});
|
|
149
|
+
setRevision((value) => value + 1);
|
|
150
|
+
return result;
|
|
151
|
+
} catch (failure) {
|
|
152
|
+
const known =
|
|
153
|
+
failure instanceof ShipmentStatusRequestError &&
|
|
154
|
+
failure.code !== null &&
|
|
155
|
+
KNOWN_REJECTIONS.has(failure.code);
|
|
156
|
+
uncertain.current = !known;
|
|
157
|
+
setFeedback({
|
|
158
|
+
kind: known ? 'REJECTED' : 'UNKNOWN',
|
|
159
|
+
message: known
|
|
160
|
+
? `${failure.message} Your input is preserved; refresh and review the recorded status before another action.`
|
|
161
|
+
: 'Acknowledgement is uncertain. Do not resend this action. Refresh the recorded status and review its decisions before beginning a separate new action.',
|
|
162
|
+
});
|
|
163
|
+
return null;
|
|
164
|
+
} finally {
|
|
165
|
+
lock.current = false;
|
|
166
|
+
setBusy(false);
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
return {
|
|
170
|
+
run,
|
|
171
|
+
busy,
|
|
172
|
+
feedback,
|
|
173
|
+
revision,
|
|
174
|
+
beginSeparateAction: () => {
|
|
175
|
+
uncertain.current = false;
|
|
176
|
+
setFeedback(null);
|
|
177
|
+
},
|
|
178
|
+
};
|
|
179
|
+
}
|
|
@@ -108,6 +108,10 @@ const LOOKUP_DELIVERY_JOBS_QUERY = `
|
|
|
108
108
|
|
|
109
109
|
const DELIVERY_JOB_TRACKING_QUERY = `
|
|
110
110
|
query GetDeliveryJobTracking($deliveryJobId: ID!, $pagination: PaginationInput) {
|
|
111
|
+
shipments(filter: { deliveryJobId: $deliveryJobId }, pagination: { limit: 2, offset: 0 }) {
|
|
112
|
+
id
|
|
113
|
+
status
|
|
114
|
+
}
|
|
111
115
|
deliveryJobTracking(deliveryJobId: $deliveryJobId, pagination: $pagination) {
|
|
112
116
|
id
|
|
113
117
|
shipmentId
|
|
@@ -232,7 +236,8 @@ function mapTrackingEventToLocation(event: ApiTrackingEvent): Location | null {
|
|
|
232
236
|
|
|
233
237
|
function mapTrackingEventsToTracking(
|
|
234
238
|
events: ApiTrackingEvent[],
|
|
235
|
-
entityId: string
|
|
239
|
+
entityId: string,
|
|
240
|
+
recordedShipmentStatus?: string
|
|
236
241
|
): Tracking | null {
|
|
237
242
|
const latest = events[0];
|
|
238
243
|
if (!latest) return null;
|
|
@@ -248,7 +253,8 @@ function mapTrackingEventsToTracking(
|
|
|
248
253
|
entityType: 'order',
|
|
249
254
|
currentLocation,
|
|
250
255
|
locationHistory,
|
|
251
|
-
status
|
|
256
|
+
// Observation history is not shipment-status authority. Ambiguous/unlinked shipments stay unknown.
|
|
257
|
+
status: recordedShipmentStatus?.toLowerCase() ?? 'unknown',
|
|
252
258
|
estimatedArrival: undefined,
|
|
253
259
|
nextCheckpoint: latest.details ?? undefined,
|
|
254
260
|
alerts: [],
|
|
@@ -472,18 +478,23 @@ export function useOrderTracking(orderLookup: string | undefined) {
|
|
|
472
478
|
setTrackingLoading(true);
|
|
473
479
|
setTrackingError(null);
|
|
474
480
|
try {
|
|
475
|
-
const data = await executeGraphQL<{
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
);
|
|
481
|
+
const data = await executeGraphQL<{
|
|
482
|
+
deliveryJobTracking: ApiTrackingEvent[];
|
|
483
|
+
shipments: Array<{ id: string; status: string }>;
|
|
484
|
+
}>(graphQLOptions, DELIVERY_JOB_TRACKING_QUERY, {
|
|
485
|
+
deliveryJobId: resolvedOrderId,
|
|
486
|
+
pagination: { limit: 50, offset: 0 },
|
|
487
|
+
});
|
|
483
488
|
const events = [...data.deliveryJobTracking].sort(
|
|
484
489
|
(first, second) => Date.parse(second.timestamp) - Date.parse(first.timestamp)
|
|
485
490
|
);
|
|
486
|
-
setTracking(
|
|
491
|
+
setTracking(
|
|
492
|
+
mapTrackingEventsToTracking(
|
|
493
|
+
events,
|
|
494
|
+
resolvedOrderId,
|
|
495
|
+
data.shipments.length === 1 ? data.shipments[0]?.status : undefined
|
|
496
|
+
)
|
|
497
|
+
);
|
|
487
498
|
} catch (fetchError) {
|
|
488
499
|
setTrackingError(
|
|
489
500
|
fetchError instanceof Error ? fetchError : new Error('Failed to load order tracking')
|
|
@@ -580,7 +591,11 @@ export function useLiveTracking() {
|
|
|
580
591
|
order: mapOrder(deliveryJob),
|
|
581
592
|
driver: deliveryJob.driver ? mapDriver(deliveryJob.driver) : null,
|
|
582
593
|
vehicle: deliveryJob.vehicle ? mapVehicle(deliveryJob.vehicle) : null,
|
|
583
|
-
tracking: mapTrackingEventsToTracking(
|
|
594
|
+
tracking: mapTrackingEventsToTracking(
|
|
595
|
+
shipment.trackingEvents ?? [],
|
|
596
|
+
deliveryJob.id,
|
|
597
|
+
shipment.status
|
|
598
|
+
),
|
|
584
599
|
estimatedArrival: shipment.estimatedDelivery
|
|
585
600
|
? new Date(shipment.estimatedDelivery)
|
|
586
601
|
: undefined,
|
|
@@ -16,9 +16,26 @@ export interface TrackingUpdateOutcome {
|
|
|
16
16
|
shipmentId: string | null;
|
|
17
17
|
status: 'ACCEPTED' | 'DUPLICATE' | 'CONFLICT' | 'REJECTED';
|
|
18
18
|
errorCode: string | null;
|
|
19
|
+
projection: {
|
|
20
|
+
status: 'APPLIED' | 'HELD' | 'LEGACY_NO_DECISION';
|
|
21
|
+
decisionId: string | null;
|
|
22
|
+
heldReason: string | null;
|
|
23
|
+
} | null;
|
|
19
24
|
}
|
|
20
25
|
|
|
21
26
|
const statuses = new Set(['ACCEPTED', 'DUPLICATE', 'CONFLICT', 'REJECTED']);
|
|
27
|
+
const heldReasons = new Set([
|
|
28
|
+
'LEGACY_AUTHORITY',
|
|
29
|
+
'MANUAL_AUTHORITY',
|
|
30
|
+
'NON_DESIGNATED_SOURCE',
|
|
31
|
+
'UNKNOWN_SOURCE_TIME',
|
|
32
|
+
'OLDER_SOURCE_TIME',
|
|
33
|
+
'EQUAL_SOURCE_TIME',
|
|
34
|
+
'SOURCE_TIME_TOO_OLD',
|
|
35
|
+
'SOURCE_TIME_IN_FUTURE',
|
|
36
|
+
'UNMAPPED_EVENT_TYPE',
|
|
37
|
+
'UNRECEIPTED_EVIDENCE',
|
|
38
|
+
]);
|
|
22
39
|
const codes = new Set([
|
|
23
40
|
'KEY_CONFLICT',
|
|
24
41
|
'BUDGET_EXHAUSTED',
|
|
@@ -54,6 +71,30 @@ export function trackingOutcomes(value: unknown): TrackingUpdateOutcome[] | null
|
|
|
54
71
|
) {
|
|
55
72
|
return null;
|
|
56
73
|
}
|
|
74
|
+
let projection: TrackingUpdateOutcome['projection'] = null;
|
|
75
|
+
const projectionStatus = row.projectionStatus;
|
|
76
|
+
const decisionId = row.projectionDecisionId ?? null;
|
|
77
|
+
const heldReason = row.projectionHeldReason ?? null;
|
|
78
|
+
if (projectionStatus !== undefined && projectionStatus !== null) {
|
|
79
|
+
if (row.status !== 'ACCEPTED' && row.status !== 'DUPLICATE') return null;
|
|
80
|
+
if (projectionStatus === 'LEGACY_NO_DECISION') {
|
|
81
|
+
if (row.status !== 'DUPLICATE' || decisionId !== null || heldReason !== null) return null;
|
|
82
|
+
} else if (projectionStatus === 'APPLIED' || projectionStatus === 'HELD') {
|
|
83
|
+
if (!bounded(decisionId, 255) || !decisionId) return null;
|
|
84
|
+
if (
|
|
85
|
+
projectionStatus === 'APPLIED'
|
|
86
|
+
? heldReason !== null
|
|
87
|
+
: typeof heldReason !== 'string' || !heldReasons.has(heldReason)
|
|
88
|
+
)
|
|
89
|
+
return null;
|
|
90
|
+
} else return null;
|
|
91
|
+
// The guarded finite status and bounded identifiers justify the JSON narrowing.
|
|
92
|
+
projection = {
|
|
93
|
+
status: projectionStatus,
|
|
94
|
+
decisionId: decisionId as string | null,
|
|
95
|
+
heldReason: heldReason as string | null,
|
|
96
|
+
};
|
|
97
|
+
} else if (decisionId !== null || heldReason !== null) return null;
|
|
57
98
|
// The finite status/code guards justify narrowing the untyped JSON scalar.
|
|
58
99
|
result.push({
|
|
59
100
|
index: row.index,
|
|
@@ -62,6 +103,7 @@ export function trackingOutcomes(value: unknown): TrackingUpdateOutcome[] | null
|
|
|
62
103
|
shipmentId: row.shipmentId,
|
|
63
104
|
status: row.status as TrackingUpdateOutcome['status'],
|
|
64
105
|
errorCode: row.errorCode,
|
|
106
|
+
projection,
|
|
65
107
|
});
|
|
66
108
|
}
|
|
67
109
|
return result;
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* driver app (BOFF-3991) — this page is the capture core, reachable from the
|
|
16
16
|
* existing web MFE.
|
|
17
17
|
*/
|
|
18
|
-
import { useRef, useState } from 'react';
|
|
18
|
+
import { useEffect, useRef, useState } from 'react';
|
|
19
19
|
import {
|
|
20
20
|
CheckCircle2,
|
|
21
21
|
Loader2,
|
|
@@ -52,6 +52,10 @@ import {
|
|
|
52
52
|
} from '../hooks/useEpod';
|
|
53
53
|
import { formatAddress, formatDate } from '../utils/formatters';
|
|
54
54
|
import type { SignatureType } from '../types';
|
|
55
|
+
import { useShipmentStatusQuery } from '../hooks/useShipmentStatusAuthority';
|
|
56
|
+
import { StatusAuthorityOwnedDeliveryDocument } from '../../generated/shipment-status-authority-operations';
|
|
57
|
+
import type { StatusAuthorityOwnedDeliveryQuery } from '../../generated/shipment-status-authority-operations';
|
|
58
|
+
import { useMoveTheWheels } from '../providers/MoveTheWheelsProvider';
|
|
55
59
|
|
|
56
60
|
/** Resolves the device's current position, if the browser grants it. Never blocks submission. */
|
|
57
61
|
function getCurrentPosition(): Promise<GeolocationPosition | null> {
|
|
@@ -77,7 +81,25 @@ function CaptureForm({
|
|
|
77
81
|
onCaptured: () => void;
|
|
78
82
|
onCancel: () => void;
|
|
79
83
|
}) {
|
|
80
|
-
const { captureEpod, isSubmitting, error: captureError } = useCaptureEpod();
|
|
84
|
+
const { captureEpod, isSubmitting, error: captureError, captureFeedback } = useCaptureEpod();
|
|
85
|
+
const [statusRefresh, setStatusRefresh] = useState(0);
|
|
86
|
+
const ownedStatus = useShipmentStatusQuery<StatusAuthorityOwnedDeliveryQuery>(
|
|
87
|
+
StatusAuthorityOwnedDeliveryDocument,
|
|
88
|
+
{ deliveryJobId: job.id },
|
|
89
|
+
statusRefresh
|
|
90
|
+
);
|
|
91
|
+
const [completionAttestation, setCompletionAttestation] = useState(false);
|
|
92
|
+
const uploadedPhoto = useRef<{ file: File; id: string } | null>(null);
|
|
93
|
+
const submissionLock = useRef(false);
|
|
94
|
+
const active = useRef(true);
|
|
95
|
+
useEffect(() => {
|
|
96
|
+
active.current = true;
|
|
97
|
+
return () => {
|
|
98
|
+
active.current = false;
|
|
99
|
+
};
|
|
100
|
+
}, []);
|
|
101
|
+
const [preparing, setPreparing] = useState(false);
|
|
102
|
+
const [requiredStatusRefresh, setRequiredStatusRefresh] = useState(0);
|
|
81
103
|
const { upload: uploadPhoto, isUploading, error: uploadError } = useEpodPhotoUpload();
|
|
82
104
|
const signaturePadRef = useRef<SignaturePadHandle>(null);
|
|
83
105
|
|
|
@@ -89,7 +111,21 @@ function CaptureForm({
|
|
|
89
111
|
const [validationError, setValidationError] = useState<string | null>(null);
|
|
90
112
|
|
|
91
113
|
const handleSubmit = async () => {
|
|
114
|
+
if (submissionLock.current) return;
|
|
92
115
|
setValidationError(null);
|
|
116
|
+
if (
|
|
117
|
+
ownedStatus.loading ||
|
|
118
|
+
ownedStatus.error ||
|
|
119
|
+
!ownedStatus.data ||
|
|
120
|
+
statusRefresh < requiredStatusRefresh ||
|
|
121
|
+
!completionAttestation ||
|
|
122
|
+
captureFeedback?.kind === 'UNKNOWN'
|
|
123
|
+
) {
|
|
124
|
+
setValidationError(
|
|
125
|
+
'Review the current linked shipment status and confirm completion before capture. Do not resend an uncertain capture.'
|
|
126
|
+
);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
93
129
|
|
|
94
130
|
const trimmedRecipient = recipientName.trim();
|
|
95
131
|
if (!trimmedRecipient) {
|
|
@@ -110,16 +146,27 @@ function CaptureForm({
|
|
|
110
146
|
return;
|
|
111
147
|
}
|
|
112
148
|
|
|
149
|
+
submissionLock.current = true;
|
|
150
|
+
setPreparing(true);
|
|
113
151
|
try {
|
|
114
152
|
let photoFileIds: string[] | undefined;
|
|
115
153
|
if (photoFile) {
|
|
116
|
-
|
|
117
|
-
|
|
154
|
+
if (uploadedPhoto.current?.file === photoFile) photoFileIds = [uploadedPhoto.current.id];
|
|
155
|
+
else {
|
|
156
|
+
const uploaded = await uploadPhoto(photoFile);
|
|
157
|
+
uploadedPhoto.current = { file: photoFile, id: uploaded.fileId };
|
|
158
|
+
photoFileIds = [uploaded.fileId];
|
|
159
|
+
}
|
|
118
160
|
}
|
|
119
161
|
|
|
162
|
+
if (!active.current) return;
|
|
120
163
|
const position = await getCurrentPosition();
|
|
164
|
+
if (!active.current) return;
|
|
121
165
|
|
|
122
166
|
await captureEpod({
|
|
167
|
+
expectedShipmentId: ownedStatus.data.myEpodShipmentStatus?.id ?? null,
|
|
168
|
+
expectedShipmentStatusRevision: ownedStatus.data.myEpodShipmentStatus?.statusRevision,
|
|
169
|
+
completionAttestation,
|
|
123
170
|
deliveryJobId: job.id,
|
|
124
171
|
recipientName: trimmedRecipient,
|
|
125
172
|
signatureType,
|
|
@@ -133,11 +180,16 @@ function CaptureForm({
|
|
|
133
180
|
|
|
134
181
|
onCaptured();
|
|
135
182
|
} catch {
|
|
183
|
+
setCompletionAttestation(false);
|
|
184
|
+
setRequiredStatusRefresh(statusRefresh + 1);
|
|
136
185
|
// captureError / uploadError already carry the message for display below.
|
|
186
|
+
} finally {
|
|
187
|
+
submissionLock.current = false;
|
|
188
|
+
setPreparing(false);
|
|
137
189
|
}
|
|
138
190
|
};
|
|
139
191
|
|
|
140
|
-
const submitting = isSubmitting || isUploading;
|
|
192
|
+
const submitting = isSubmitting || isUploading || preparing;
|
|
141
193
|
|
|
142
194
|
return (
|
|
143
195
|
<Card>
|
|
@@ -151,6 +203,47 @@ function CaptureForm({
|
|
|
151
203
|
{job.customerName && <p>Recipient on file: {job.customerName}</p>}
|
|
152
204
|
{job.deliveryAddress && <p>{formatAddress(job.deliveryAddress)}</p>}
|
|
153
205
|
</div>
|
|
206
|
+
<section aria-label="Linked shipment status" className="space-y-2">
|
|
207
|
+
<p>
|
|
208
|
+
Recorded shipment:{' '}
|
|
209
|
+
{ownedStatus.data?.myEpodShipmentStatus
|
|
210
|
+
? `${ownedStatus.data.myEpodShipmentStatus.status.toLowerCase().replaceAll('_', ' ')} · revision ${ownedStatus.data.myEpodShipmentStatus.statusRevision}`
|
|
211
|
+
: ownedStatus.data
|
|
212
|
+
? 'No linked shipment'
|
|
213
|
+
: 'Loading current record'}
|
|
214
|
+
</p>
|
|
215
|
+
{ownedStatus.error && <p role="alert">{ownedStatus.error}</p>}
|
|
216
|
+
<Button
|
|
217
|
+
variant="outline"
|
|
218
|
+
disabled={ownedStatus.loading || submitting}
|
|
219
|
+
onClick={() => {
|
|
220
|
+
setCompletionAttestation(false);
|
|
221
|
+
setStatusRefresh((value) => value + 1);
|
|
222
|
+
}}
|
|
223
|
+
>
|
|
224
|
+
Refresh linked status
|
|
225
|
+
</Button>
|
|
226
|
+
<label className="flex items-start gap-2">
|
|
227
|
+
<input
|
|
228
|
+
type="checkbox"
|
|
229
|
+
checked={completionAttestation}
|
|
230
|
+
disabled={
|
|
231
|
+
ownedStatus.loading ||
|
|
232
|
+
!!ownedStatus.error ||
|
|
233
|
+
submitting ||
|
|
234
|
+
statusRefresh < requiredStatusRefresh ||
|
|
235
|
+
captureFeedback?.kind === 'UNKNOWN'
|
|
236
|
+
}
|
|
237
|
+
onChange={(event) => setCompletionAttestation(event.target.checked)}
|
|
238
|
+
/>
|
|
239
|
+
I reviewed the recorded linked status and attest to this delivery completion.
|
|
240
|
+
</label>
|
|
241
|
+
<p>
|
|
242
|
+
This records an assigned driver's attestation; it does not independently verify physical
|
|
243
|
+
freight movement.
|
|
244
|
+
</p>
|
|
245
|
+
{captureFeedback && <p role="status">{captureFeedback.message}</p>}
|
|
246
|
+
</section>
|
|
154
247
|
|
|
155
248
|
<div className="space-y-1.5">
|
|
156
249
|
<Label htmlFor="epod-recipient-name">Recipient name</Label>
|
|
@@ -232,7 +325,19 @@ function CaptureForm({
|
|
|
232
325
|
<Button type="button" variant="outline" onClick={onCancel} disabled={submitting}>
|
|
233
326
|
Cancel
|
|
234
327
|
</Button>
|
|
235
|
-
<Button
|
|
328
|
+
<Button
|
|
329
|
+
type="button"
|
|
330
|
+
onClick={() => void handleSubmit()}
|
|
331
|
+
disabled={
|
|
332
|
+
submitting ||
|
|
333
|
+
ownedStatus.loading ||
|
|
334
|
+
!!ownedStatus.error ||
|
|
335
|
+
!ownedStatus.data ||
|
|
336
|
+
statusRefresh < requiredStatusRefresh ||
|
|
337
|
+
!completionAttestation ||
|
|
338
|
+
captureFeedback?.kind === 'UNKNOWN'
|
|
339
|
+
}
|
|
340
|
+
>
|
|
236
341
|
{submitting ? (
|
|
237
342
|
<>
|
|
238
343
|
<Loader2 size={14} className="animate-spin" /> Submitting…
|
|
@@ -250,6 +355,7 @@ function CaptureForm({
|
|
|
250
355
|
}
|
|
251
356
|
|
|
252
357
|
export default function DeliveryCapturePage() {
|
|
358
|
+
const { workspaceId, tenantId, currentUser, apiGatewayUrl } = useMoveTheWheels();
|
|
253
359
|
const [pageIndex, setPageIndex] = useState(0);
|
|
254
360
|
const pageSize = 20;
|
|
255
361
|
const { jobs, hasMore, isLoading, error, refetch } = useMyPendingEpodDeliveryJobs({
|
|
@@ -277,6 +383,7 @@ export default function DeliveryCapturePage() {
|
|
|
277
383
|
|
|
278
384
|
{selectedJob ? (
|
|
279
385
|
<CaptureForm
|
|
386
|
+
key={`${workspaceId}:${tenantId}:${currentUser?.id}:${apiGatewayUrl}:${selectedJob.id}`}
|
|
280
387
|
job={selectedJob}
|
|
281
388
|
onCaptured={handleCaptured}
|
|
282
389
|
onCancel={() => setSelectedJob(null)}
|