@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.
Files changed (30) hide show
  1. package/dist/MoveTheWheelsRoot.js +5 -0
  2. package/dist/MoveTheWheelsRoutes.js +54 -50
  3. package/dist/generated/shipment-status-authority-operations.d.ts +4473 -0
  4. package/dist/hooks/useEpod.js +85 -77
  5. package/dist/hooks/useShipmentStatusAuthority.js +127 -0
  6. package/dist/hooks/useTracking.js +15 -14
  7. package/dist/hooks/useTrackingEventQuality.js +43 -17
  8. package/dist/movethewheels/hooks/useEpod.d.ts +23 -0
  9. package/dist/movethewheels/hooks/useShipmentStatusAuthority.d.ts +23 -0
  10. package/dist/movethewheels/hooks/useTrackingEventQuality.d.ts +5 -0
  11. package/dist/movethewheels/pages/ShipmentStatusAuthorityPage.d.ts +1 -0
  12. package/dist/movethewheels/utils/shipmentStatusDraft.d.ts +45 -0
  13. package/dist/pages/DeliveryCapturePage.js +181 -116
  14. package/dist/pages/ShipmentStatusAuthorityPage.js +563 -0
  15. package/dist/pages/TrackingEventQualityPage.js +6 -1
  16. package/dist/src/generated/shipment-status-authority-operations.js +86 -0
  17. package/dist/utils/shipmentStatusDraft.js +41 -0
  18. package/package.json +5 -4
  19. package/src/generated/shipment-status-authority-operations.ts +5503 -0
  20. package/src/movethewheels/MoveTheWheelsRoot.tsx +6 -0
  21. package/src/movethewheels/MoveTheWheelsRoutes.tsx +2 -0
  22. package/src/movethewheels/hooks/useEpod.ts +47 -12
  23. package/src/movethewheels/hooks/useShipmentStatusAuthority.ts +179 -0
  24. package/src/movethewheels/hooks/useTracking.ts +27 -12
  25. package/src/movethewheels/hooks/useTrackingEventQuality.ts +42 -0
  26. package/src/movethewheels/pages/DeliveryCapturePage.tsx +113 -6
  27. package/src/movethewheels/pages/ShipmentStatusAuthorityPage.tsx +639 -0
  28. package/src/movethewheels/pages/TrackingEventQualityPage.tsx +13 -2
  29. package/src/movethewheels/utils/shipmentStatusDraft.ts +118 -0
  30. 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 graphQLOptions = useGraphQLOptions();
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 executeGraphQL<{ captureEpod: ApiProofOfDelivery }>(
283
- graphQLOptions,
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
- [graphQLOptions]
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: latest.eventType.toLowerCase(),
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<{ deliveryJobTracking: ApiTrackingEvent[] }>(
476
- graphQLOptions,
477
- DELIVERY_JOB_TRACKING_QUERY,
478
- {
479
- deliveryJobId: resolvedOrderId,
480
- pagination: { limit: 50, offset: 0 },
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(mapTrackingEventsToTracking(events, resolvedOrderId));
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(shipment.trackingEvents ?? [], deliveryJob.id),
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
- const uploaded = await uploadPhoto(photoFile);
117
- photoFileIds = [uploaded.fileId];
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 type="button" onClick={() => void handleSubmit()} disabled={submitting}>
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)}