@burdenoff/microfe-movethewheels 2026.1012.1 → 2026.1013.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@burdenoff/microfe-movethewheels",
3
- "version": "2026.1012.1",
3
+ "version": "2026.1013.1",
4
4
  "description": "Burdenoff MoveTheWheels Microfrontend - Logistics & Supply Chain Management Platform",
5
5
  "type": "module",
6
6
  "files": [
@@ -36,7 +36,7 @@
36
36
  "format": "prettier --write \"src/**/*.{ts,tsx}\"",
37
37
  "type:check": "bash scripts/build-limits.sh tsc --noEmit",
38
38
  "validate:schema-drift": "graphql-schema-drift",
39
- "sanity": "bash scripts/build-limits.sh --gate bash -c 'bun run codegen && bun run validate:schema-drift && bun run lint:sanity && bun run format && bun run type:check && bun run verify:live-logistics-contract && bun run verify:empty-reuse-retry && bun run verify:empty-reuse-recovery && bun run verify:trip-utilization-input && bun run verify:trip-draft-context && bun run verify:stock-binding-input && bun run verify:stock-observation-input && bun run verify:pending-pick-input && bun run verify:freight-input && bun run verify:freight-recovery && bun run verify:procedure-input && bun run verify:consolidation && bun run verify:multimodal-single-attempt && bun run verify:workspace-predispatch && bun run verify:tracking-quality-outcomes && bun run verify:shipment-status-authority && bun run verify:braces-depth && bun run build && bun run build-storybook'",
39
+ "sanity": "bash scripts/build-limits.sh --gate bash -c 'bun run codegen && bun run validate:schema-drift && bun run lint:sanity && bun run format && bun run type:check && bun run verify:live-logistics-contract && bun run verify:empty-reuse-retry && bun run verify:empty-reuse-recovery && bun run verify:trip-utilization-input && bun run verify:trip-draft-context && bun run verify:stock-binding-input && bun run verify:stock-observation-input && bun run verify:pending-pick-input && bun run verify:freight-input && bun run verify:freight-recovery && bun run verify:procedure-input && bun run verify:consolidation && bun run verify:multimodal-single-attempt && bun run verify:workspace-predispatch && bun run verify:tracking-quality-outcomes && bun run verify:shipment-status-authority && bun run verify:free-time-permissions && bun run verify:braces-depth && bun run build && bun run build-storybook'",
40
40
  "storybook": "storybook dev -p 6006",
41
41
  "build-storybook": "storybook build",
42
42
  "test:storybook": "test-storybook",
@@ -64,7 +64,8 @@
64
64
  "verify:consolidation": "bun scripts/verify-consolidation-input.mjs && bun scripts/verify-consolidation-recovery-hook.mjs && bun scripts/verify-consolidation-mounted-hook.mjs",
65
65
  "verify:tracking-quality-outcomes": "bun test scripts/verify-tracking-quality-outcomes.test.ts",
66
66
  "verify:braces-depth": "bun test scripts/braces-depth-patch.test.ts",
67
- "verify:shipment-status-authority": "bun scripts/verify-shipment-status-command.mjs"
67
+ "verify:shipment-status-authority": "bun scripts/verify-shipment-status-command.mjs",
68
+ "verify:free-time-permissions": "bun scripts/verify-free-time-permission-gates.mjs"
68
69
  },
69
70
  "dependencies": {
70
71
  "@apollo/client": "^4.0.11",
@@ -64,7 +64,7 @@ function describeError(error: unknown): string {
64
64
  }
65
65
 
66
66
  /** Source-labelled physical equipment; cargo shipment completion does not close a cycle. */
67
- export function useContainerLifecycle() {
67
+ export function useContainerLifecycle(readEnabled = true) {
68
68
  const { apiGatewayUrl, authToken, workspaceToken, workspaceId, tenantId, currentUser } =
69
69
  useMoveTheWheels();
70
70
  const options = useMemo(
@@ -103,6 +103,10 @@ export function useContainerLifecycle() {
103
103
  setError(null);
104
104
  setContainers([]);
105
105
  setHasMore(false);
106
+ if (!readEnabled) {
107
+ setLoading(false);
108
+ return;
109
+ }
106
110
  void executeGraphQL<ContainerRegistryQuery>(options, print(ContainerRegistryDocument), {
107
111
  pagination: { limit: PAGE_SIZE, offset: page * PAGE_SIZE },
108
112
  })
@@ -121,7 +125,7 @@ export function useContainerLifecycle() {
121
125
  return () => {
122
126
  cancelled = true;
123
127
  };
124
- }, [options, page, revision]);
128
+ }, [options, page, revision, readEnabled]);
125
129
 
126
130
  // Clear selections whenever the security/workspace context changes.
127
131
  useEffect(() => {
@@ -137,7 +141,7 @@ export function useContainerLifecycle() {
137
141
  setCycles([]);
138
142
  setHasMoreCycles(false);
139
143
  setCycleError(null);
140
- if (!containerId) {
144
+ if (!readEnabled || !containerId) {
141
145
  setCyclesLoading(false);
142
146
  return;
143
147
  }
@@ -161,14 +165,14 @@ export function useContainerLifecycle() {
161
165
  return () => {
162
166
  cancelled = true;
163
167
  };
164
- }, [options, containerId, cyclePage, revision]);
168
+ }, [options, containerId, cyclePage, revision, readEnabled]);
165
169
 
166
170
  useEffect(() => {
167
171
  let cancelled = false;
168
172
  setEvents([]);
169
173
  setHasMoreEvents(false);
170
174
  setEventError(null);
171
- if (!cycleId) {
175
+ if (!readEnabled || !cycleId) {
172
176
  setEventsLoading(false);
173
177
  return;
174
178
  }
@@ -192,7 +196,7 @@ export function useContainerLifecycle() {
192
196
  return () => {
193
197
  cancelled = true;
194
198
  };
195
- }, [options, cycleId, eventPage, revision]);
199
+ }, [options, cycleId, eventPage, revision, readEnabled]);
196
200
 
197
201
  const refresh = useCallback(() => setRevision((value) => value + 1), []);
198
202
  // Keep the same key after an uncertain response so a manual retry replays the
@@ -11,7 +11,7 @@
11
11
  * Deferred (see BOFF-4399 follow-up notes): barcode/RFID capture and
12
12
  * multi-language delivery instructions.
13
13
  */
14
- import { useCallback, useEffect, useMemo, useState } from 'react';
14
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
15
15
  import { parse } from 'graphql';
16
16
  import { useShipmentStatusCommand } from './useShipmentStatusAuthority';
17
17
  import { useMoveTheWheels } from '../providers/MoveTheWheelsProvider';
@@ -212,51 +212,127 @@ interface ApiPendingJob {
212
212
  export function useMyPendingEpodDeliveryJobs(options: { limit?: number; offset?: number } = {}) {
213
213
  const { limit = 20, offset = 0 } = options;
214
214
  const graphQLOptions = useGraphQLOptions();
215
- const [jobs, setJobs] = useState<PendingEpodDeliveryJob[]>([]);
216
- const [totalJobs, setTotalJobs] = useState(0);
217
- const [hasMore, setHasMore] = useState(false);
218
- const [isLoading, setIsLoading] = useState(false);
219
- const [error, setError] = useState<Error | null>(null);
215
+ const scopeKey = JSON.stringify([
216
+ graphQLOptions.apiGatewayUrl,
217
+ graphQLOptions.workspaceId,
218
+ graphQLOptions.tenantId,
219
+ graphQLOptions.actorId,
220
+ ]);
221
+ const readKey = JSON.stringify([scopeKey, limit, offset]);
222
+ // A validated capture ACK makes this stop ineligible for capture immediately.
223
+ // Keep that fact for this actor/scope while a pending-list refresh is delayed.
224
+ // This is an in-memory UI exclusion, never a fabricated server job update.
225
+ const acknowledgedJobs = useRef({ scopeKey, ids: new Set<string>() });
226
+ const requestId = useRef(0);
227
+ const [snapshot, setSnapshot] = useState<{
228
+ scopeKey: string;
229
+ readKey: string;
230
+ jobs: PendingEpodDeliveryJob[];
231
+ totalJobs: number;
232
+ hasMore: boolean;
233
+ isLoading: boolean;
234
+ error: Error | null;
235
+ }>({ scopeKey, readKey, jobs: [], totalJobs: 0, hasMore: false, isLoading: true, error: null });
220
236
 
221
237
  const load = useCallback(async () => {
222
- setIsLoading(true);
223
- setError(null);
238
+ const request = ++requestId.current;
239
+ if (acknowledgedJobs.current.scopeKey !== scopeKey) {
240
+ acknowledgedJobs.current = { scopeKey, ids: new Set<string>() };
241
+ }
242
+ setSnapshot((previous) => ({
243
+ scopeKey,
244
+ readKey,
245
+ jobs: previous.readKey === readKey ? previous.jobs : [],
246
+ totalJobs: previous.readKey === readKey ? previous.totalJobs : 0,
247
+ hasMore: previous.readKey === readKey ? previous.hasMore : false,
248
+ isLoading: true,
249
+ error: null,
250
+ }));
224
251
  try {
225
252
  const data = await executeGraphQL<{
226
253
  myPendingEpodDeliveryJobs: ApiPendingJob[];
227
254
  myPendingEpodDeliveryJobsCount: number;
228
255
  }>(graphQLOptions, MY_PENDING_EPOD_JOBS_QUERY, { pagination: { limit, offset } });
229
- setTotalJobs(data.myPendingEpodDeliveryJobsCount ?? 0);
230
- setHasMore(
231
- offset + data.myPendingEpodDeliveryJobs.length < data.myPendingEpodDeliveryJobsCount
232
- );
233
- setJobs(
234
- data.myPendingEpodDeliveryJobs.map((job) => ({
235
- id: job.id,
236
- deliveryJobNumber: job.deliveryJobNumber,
237
- status: job.status,
238
- priority: job.priority,
239
- deliveryAddress: job.deliveryAddress,
240
- scheduledDelivery: job.scheduledDelivery ? new Date(job.scheduledDelivery) : undefined,
241
- customerName: job.customer?.name ?? undefined,
242
- customerPhone: job.customer?.phone ?? undefined,
243
- }))
244
- );
256
+ if (request !== requestId.current) return;
257
+ const rows = data.myPendingEpodDeliveryJobs;
258
+ const total = data.myPendingEpodDeliveryJobsCount ?? 0;
259
+ const excluded = rows.filter((job) => acknowledgedJobs.current.ids.has(job.id)).length;
260
+ setSnapshot({
261
+ scopeKey,
262
+ readKey,
263
+ totalJobs: Math.max(0, total - excluded),
264
+ // Pagination still follows the server page, including any excluded row.
265
+ hasMore: offset + rows.length < total,
266
+ jobs: rows
267
+ .filter((job) => !acknowledgedJobs.current.ids.has(job.id))
268
+ .map((job) => ({
269
+ id: job.id,
270
+ deliveryJobNumber: job.deliveryJobNumber,
271
+ status: job.status,
272
+ priority: job.priority,
273
+ deliveryAddress: job.deliveryAddress,
274
+ scheduledDelivery: job.scheduledDelivery ? new Date(job.scheduledDelivery) : undefined,
275
+ customerName: job.customer?.name ?? undefined,
276
+ customerPhone: job.customer?.phone ?? undefined,
277
+ })),
278
+ isLoading: false,
279
+ error: null,
280
+ });
245
281
  } catch (fetchError) {
246
- setError(
247
- fetchError instanceof Error ? fetchError : new Error('Failed to load pending deliveries')
248
- );
249
- setJobs([]);
250
- } finally {
251
- setIsLoading(false);
282
+ if (request !== requestId.current) return;
283
+ setSnapshot({
284
+ scopeKey,
285
+ readKey,
286
+ jobs: [],
287
+ totalJobs: 0,
288
+ hasMore: false,
289
+ isLoading: false,
290
+ error:
291
+ fetchError instanceof Error ? fetchError : new Error('Failed to load pending deliveries'),
292
+ });
252
293
  }
253
- }, [graphQLOptions, limit, offset]);
294
+ }, [graphQLOptions, limit, offset, scopeKey, readKey]);
295
+
296
+ const cancelPendingRead = useCallback(() => {
297
+ requestId.current++;
298
+ }, []);
254
299
 
255
300
  useEffect(() => {
256
301
  void load();
257
- }, [load]);
302
+ return cancelPendingRead;
303
+ }, [load, cancelPendingRead]);
304
+
305
+ const retireAcknowledgedJob = useCallback(
306
+ (deliveryJobId: string) => {
307
+ if (
308
+ acknowledgedJobs.current.scopeKey !== scopeKey ||
309
+ acknowledgedJobs.current.ids.has(deliveryJobId)
310
+ )
311
+ return;
312
+ acknowledgedJobs.current.ids.add(deliveryJobId);
313
+ setSnapshot((previous) => {
314
+ if (previous.scopeKey !== scopeKey) return previous;
315
+ const removed = previous.jobs.some((job) => job.id === deliveryJobId);
316
+ return {
317
+ ...previous,
318
+ jobs: previous.jobs.filter((job) => job.id !== deliveryJobId),
319
+ totalJobs: Math.max(0, previous.totalJobs - (removed ? 1 : 0)),
320
+ };
321
+ });
322
+ },
323
+ [scopeKey]
324
+ );
258
325
 
259
- return { jobs, totalJobs, hasMore, isLoading, error, refetch: load };
326
+ const current = snapshot.readKey === readKey;
327
+ return {
328
+ jobs: current ? snapshot.jobs : [],
329
+ totalJobs: current ? snapshot.totalJobs : 0,
330
+ hasMore: current && snapshot.hasMore,
331
+ isLoading: !current || snapshot.isLoading,
332
+ error: current ? snapshot.error : null,
333
+ refetch: load,
334
+ retireAcknowledgedJob,
335
+ };
260
336
  }
261
337
 
262
338
  export interface CaptureEpodArgs {
@@ -0,0 +1,38 @@
1
+ import { useCallback, useLayoutEffect, useRef } from 'react';
2
+ import { usePermissions } from '@burdenoff/fe-libs/shared/providers/shell';
3
+ import { useMoveTheWheels } from '../providers/MoveTheWheelsProvider';
4
+
5
+ /** Match ordinary effective permissions to this page's current actor and workspace. */
6
+ export function useShipmentPermissions() {
7
+ const permissions = usePermissions();
8
+ const { workspaceId, currentUser } = useMoveTheWheels();
9
+ const currentScope = Boolean(
10
+ workspaceId &&
11
+ currentUser?.id &&
12
+ permissions.scopeId === workspaceId &&
13
+ (permissions.isSuperAdmin ||
14
+ (permissions.permissions?.scopeId === workspaceId &&
15
+ permissions.permissions.actorId === currentUser.id))
16
+ );
17
+ const ready = currentScope && !permissions.isLoading && !permissions.error;
18
+ const canRead = ready && permissions.hasPermission('shipment:read');
19
+ const canCreate = canRead && permissions.hasPermission('shipment:create');
20
+ const canUpdate = canRead && permissions.hasPermission('shipment:update');
21
+ const current = useRef({ create: false, update: false });
22
+ useLayoutEffect(() => {
23
+ current.current = { create: canCreate, update: canUpdate };
24
+ return () => {
25
+ // Retained callbacks from a revoked or unmounted scope cannot submit a command.
26
+ current.current = { create: false, update: false };
27
+ };
28
+ }, [canCreate, canUpdate]);
29
+ const allows = useCallback((action: 'create' | 'update') => current.current[action], []);
30
+ return {
31
+ canRead,
32
+ canCreate,
33
+ canUpdate,
34
+ allows,
35
+ loading: permissions.isLoading,
36
+ failed: Boolean(permissions.error),
37
+ };
38
+ }