@burdenoff/microfe-movethewheels 2026.1012.2 → 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.2",
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
@@ -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
+ }
@@ -1,5 +1,7 @@
1
1
  import { useState, type FormEvent } from 'react';
2
2
  import { Boxes, RefreshCw } from 'lucide-react';
3
+ import { PermissionProvider } from '@burdenoff/fe-libs/shared/providers/shell';
4
+ import { useShipmentPermissions } from '../hooks/useShipmentPermissions';
3
5
  import { PageLayout } from '../components/PageLayout';
4
6
  import { Button, Input, Label } from '../components/ui';
5
7
  import { useMoveTheWheels } from '../providers/MoveTheWheelsProvider';
@@ -23,12 +25,25 @@ const sectionClass = 'rounded-xl border bg-bg-surface p-5 space-y-4';
23
25
 
24
26
  /** Remount on identity/workspace changes so previous workspace records never flash. */
25
27
  export default function ContainersPage() {
26
- const { workspaceId, currentUser } = useMoveTheWheels();
27
- return <ContainerWorkbench key={`${workspaceId ?? ''}:${currentUser?.id ?? ''}`} />;
28
+ const { workspaceId, tenantId, currentUser, apiGatewayUrl } = useMoveTheWheels();
29
+ return (
30
+ <PermissionProvider
31
+ key={`${workspaceId}:${tenantId}:${currentUser?.id}:${apiGatewayUrl}`}
32
+ scopeId={workspaceId ?? null}
33
+ gateway="workspace"
34
+ >
35
+ <ContainerAccess />
36
+ </PermissionProvider>
37
+ );
38
+ }
39
+
40
+ function ContainerAccess() {
41
+ const access = useShipmentPermissions();
42
+ return <ContainerWorkbench access={access} />;
28
43
  }
29
44
 
30
- function ContainerWorkbench() {
31
- const data = useContainerLifecycle();
45
+ function ContainerWorkbench({ access }: { access: ReturnType<typeof useShipmentPermissions> }) {
46
+ const data = useContainerLifecycle(access.canRead);
32
47
  const [number, setNumber] = useState('');
33
48
  const [equipmentType, setEquipmentType] = useState<EquipmentType>('DRY_20');
34
49
  const [ownerName, setOwnerName] = useState('');
@@ -63,6 +78,7 @@ function ContainerWorkbench() {
63
78
  };
64
79
  const register = (event: FormEvent) => {
65
80
  event.preventDefault();
81
+ if (!access.allows('create')) return;
66
82
  void act(async () => {
67
83
  await data.register({
68
84
  containerNumber: number.trim(),
@@ -75,7 +91,7 @@ function ContainerWorkbench() {
75
91
  };
76
92
  const record = (event: FormEvent) => {
77
93
  event.preventDefault();
78
- if (!cycle) return;
94
+ if (!cycle || !access.allows('update')) return;
79
95
  void act(async () => {
80
96
  const timestamp = new Date(eventTime);
81
97
  if (!Number.isFinite(timestamp.getTime())) throw new Error('Enter a valid event time.');
@@ -94,6 +110,21 @@ function ContainerWorkbench() {
94
110
  }, 'Event recorded with its source and receipt time.');
95
111
  };
96
112
 
113
+ if (!access.canRead)
114
+ return (
115
+ <PageLayout
116
+ title="Containers"
117
+ subtitle="Physical equipment, usage cycles and sourced movement history"
118
+ >
119
+ <p role={access.failed ? 'alert' : 'status'}>
120
+ {access.loading
121
+ ? 'Checking shipment permissions…'
122
+ : access.failed
123
+ ? 'Shipment permissions are unavailable. Reload to retry.'
124
+ : 'Shipment read permission is required.'}
125
+ </p>
126
+ </PageLayout>
127
+ );
97
128
  return (
98
129
  <PageLayout
99
130
  title="Containers"
@@ -167,54 +198,56 @@ function ContainerWorkbench() {
167
198
  data.setPage(page);
168
199
  }}
169
200
  />
170
- <form onSubmit={register} className="space-y-3 border-t pt-4">
171
- <h3 className="font-semibold">Register container</h3>
172
- <Label htmlFor="container-number">ISO container number</Label>
173
- <Input
174
- id="container-number"
175
- value={number}
176
- onChange={(event) => setNumber(event.target.value)}
177
- required
178
- maxLength={20}
179
- placeholder="Four letters and seven digits"
180
- />
181
- <Label htmlFor="equipment-type">Physical equipment type</Label>
182
- <select
183
- id="equipment-type"
184
- className={selectClass}
185
- value={equipmentType}
186
- onChange={(event) =>
187
- setEquipmentType(
188
- EQUIPMENT_TYPES.find((value) => value === event.target.value) ?? 'OTHER'
189
- )
190
- }
191
- >
192
- {EQUIPMENT_TYPES.map((value) => (
193
- <option key={value} value={value}>
194
- {readable(value)}
195
- </option>
196
- ))}
197
- </select>
198
- <Label htmlFor="container-owner">Owner (optional)</Label>
199
- <Input
200
- id="container-owner"
201
- value={ownerName}
202
- onChange={(event) => setOwnerName(event.target.value)}
203
- maxLength={200}
204
- />
205
- <Label htmlFor="container-source">Information source</Label>
206
- <Input
207
- id="container-source"
208
- value={source}
209
- onChange={(event) => setSource(event.target.value)}
210
- required
211
- maxLength={120}
212
- placeholder="Equipment release document or carrier portal"
213
- />
214
- <Button type="submit" disabled={busy || !number.trim() || !source.trim()}>
215
- Register container
216
- </Button>
217
- </form>
201
+ {access.canCreate && (
202
+ <form onSubmit={register} className="space-y-3 border-t pt-4">
203
+ <h3 className="font-semibold">Register container</h3>
204
+ <Label htmlFor="container-number">ISO container number</Label>
205
+ <Input
206
+ id="container-number"
207
+ value={number}
208
+ onChange={(event) => setNumber(event.target.value)}
209
+ required
210
+ maxLength={20}
211
+ placeholder="Four letters and seven digits"
212
+ />
213
+ <Label htmlFor="equipment-type">Physical equipment type</Label>
214
+ <select
215
+ id="equipment-type"
216
+ className={selectClass}
217
+ value={equipmentType}
218
+ onChange={(event) =>
219
+ setEquipmentType(
220
+ EQUIPMENT_TYPES.find((value) => value === event.target.value) ?? 'OTHER'
221
+ )
222
+ }
223
+ >
224
+ {EQUIPMENT_TYPES.map((value) => (
225
+ <option key={value} value={value}>
226
+ {readable(value)}
227
+ </option>
228
+ ))}
229
+ </select>
230
+ <Label htmlFor="container-owner">Owner (optional)</Label>
231
+ <Input
232
+ id="container-owner"
233
+ value={ownerName}
234
+ onChange={(event) => setOwnerName(event.target.value)}
235
+ maxLength={200}
236
+ />
237
+ <Label htmlFor="container-source">Information source</Label>
238
+ <Input
239
+ id="container-source"
240
+ value={source}
241
+ onChange={(event) => setSource(event.target.value)}
242
+ required
243
+ maxLength={120}
244
+ placeholder="Equipment release document or carrier portal"
245
+ />
246
+ <Button type="submit" disabled={busy || !number.trim() || !source.trim()}>
247
+ Register container
248
+ </Button>
249
+ </form>
250
+ )}
218
251
  </section>
219
252
  <section className={sectionClass}>
220
253
  <h2 className="text-lg font-semibold">Usage cycles</h2>
@@ -266,11 +299,12 @@ function ContainerWorkbench() {
266
299
  }}
267
300
  />
268
301
  )}
269
- {data.containerId && (
302
+ {data.containerId && access.canCreate && (
270
303
  <form
271
304
  className="space-y-3 border-t pt-4"
272
305
  onSubmit={(event) => {
273
306
  event.preventDefault();
307
+ if (!access.allows('create')) return;
274
308
  void act(async () => {
275
309
  const timestamp = new Date(openingTime);
276
310
  if (!Number.isFinite(timestamp.getTime())) {
@@ -345,111 +379,116 @@ function ContainerWorkbench() {
345
379
  )}
346
380
  {cycle?.status === 'OPEN' && (
347
381
  <>
348
- <form onSubmit={record} className="space-y-3 border-t pt-4">
349
- <h3 className="font-semibold">Record event</h3>
350
- <Label htmlFor="container-event">Event</Label>
351
- <select
352
- id="container-event"
353
- className={selectClass}
354
- value={eventType}
355
- onChange={(event) =>
356
- setEventType(
357
- CONTAINER_EVENT_TYPES.find((value) => value === event.target.value) ??
358
- 'POSITION_UPDATE'
359
- )
360
- }
361
- >
362
- {CONTAINER_EVENT_TYPES.map((value) => (
363
- <option key={value} value={value}>
364
- {readable(value)}
365
- </option>
366
- ))}
367
- </select>
368
- <Label htmlFor="event-kind">Event certainty</Label>
369
- <select
370
- id="event-kind"
371
- className={selectClass}
372
- value={kind}
373
- onChange={(event) =>
374
- setKind(
375
- event.target.value === 'PLANNED'
376
- ? 'PLANNED'
377
- : event.target.value === 'ESTIMATED'
378
- ? 'ESTIMATED'
379
- : 'ACTUAL'
380
- )
381
- }
382
- >
383
- <option value="ACTUAL">Actual</option>
384
- <option value="PLANNED">Planned</option>
385
- <option value="ESTIMATED">Estimated</option>
386
- </select>
387
- <Label htmlFor="event-time">Event time (your local timezone)</Label>
388
- <Input
389
- id="event-time"
390
- type="datetime-local"
391
- step={1}
392
- value={eventTime}
393
- onChange={(event) => setEventTime(event.target.value)}
394
- required
395
- />
396
- <Label htmlFor="event-source">Event source</Label>
397
- <Input
398
- id="event-source"
399
- value={eventSource}
400
- onChange={(event) => setEventSource(event.target.value)}
401
- required
402
- maxLength={120}
403
- />
404
- <Label htmlFor="source-reference">Source reference (optional)</Label>
405
- <Input
406
- id="source-reference"
407
- value={sourceReference}
408
- onChange={(event) => setSourceReference(event.target.value)}
409
- maxLength={200}
410
- />
411
- <Label htmlFor="event-location">Location (optional)</Label>
412
- <Input
413
- id="event-location"
414
- value={location}
415
- onChange={(event) => setLocation(event.target.value)}
416
- maxLength={200}
417
- />
418
- <Label htmlFor="event-evidence">
419
- Evidence note
420
- {eventType === 'RETURN_ACCEPTED' && kind === 'ACTUAL'
421
- ? ' (required for acceptance)'
422
- : ' (optional)'}
423
- </Label>
424
- <Input
425
- id="event-evidence"
426
- value={evidence}
427
- onChange={(event) => setEvidence(event.target.value)}
428
- maxLength={2000}
429
- required={eventType === 'RETURN_ACCEPTED' && kind === 'ACTUAL'}
430
- />
431
- <Button type="submit" disabled={busy || !eventSource.trim()}>
432
- Record event
433
- </Button>
434
- </form>
435
- <div className="space-y-3 border-t pt-4">
436
- <p className="text-sm text-text-secondary">
437
- Closure checks the complete recorded history for actual empty return and
438
- acceptance. A planned return or delivered shipment is insufficient.
439
- </p>
440
- <Button
441
- variant="outline"
442
- disabled={busy || data.cyclesLoading || data.eventsLoading}
443
- onClick={() =>
444
- void act(
445
- () => data.closeCycle(cycle),
446
- 'Cycle closed after accepted empty return.'
447
- )
448
- }
449
- >
450
- Close cycle
451
- </Button>
452
- </div>
382
+ {access.canUpdate && (
383
+ <form onSubmit={record} className="space-y-3 border-t pt-4">
384
+ <h3 className="font-semibold">Record event</h3>
385
+ <Label htmlFor="container-event">Event</Label>
386
+ <select
387
+ id="container-event"
388
+ className={selectClass}
389
+ value={eventType}
390
+ onChange={(event) =>
391
+ setEventType(
392
+ CONTAINER_EVENT_TYPES.find((value) => value === event.target.value) ??
393
+ 'POSITION_UPDATE'
394
+ )
395
+ }
396
+ >
397
+ {CONTAINER_EVENT_TYPES.map((value) => (
398
+ <option key={value} value={value}>
399
+ {readable(value)}
400
+ </option>
401
+ ))}
402
+ </select>
403
+ <Label htmlFor="event-kind">Event certainty</Label>
404
+ <select
405
+ id="event-kind"
406
+ className={selectClass}
407
+ value={kind}
408
+ onChange={(event) =>
409
+ setKind(
410
+ event.target.value === 'PLANNED'
411
+ ? 'PLANNED'
412
+ : event.target.value === 'ESTIMATED'
413
+ ? 'ESTIMATED'
414
+ : 'ACTUAL'
415
+ )
416
+ }
417
+ >
418
+ <option value="ACTUAL">Actual</option>
419
+ <option value="PLANNED">Planned</option>
420
+ <option value="ESTIMATED">Estimated</option>
421
+ </select>
422
+ <Label htmlFor="event-time">Event time (your local timezone)</Label>
423
+ <Input
424
+ id="event-time"
425
+ type="datetime-local"
426
+ step={1}
427
+ value={eventTime}
428
+ onChange={(event) => setEventTime(event.target.value)}
429
+ required
430
+ />
431
+ <Label htmlFor="event-source">Event source</Label>
432
+ <Input
433
+ id="event-source"
434
+ value={eventSource}
435
+ onChange={(event) => setEventSource(event.target.value)}
436
+ required
437
+ maxLength={120}
438
+ />
439
+ <Label htmlFor="source-reference">Source reference (optional)</Label>
440
+ <Input
441
+ id="source-reference"
442
+ value={sourceReference}
443
+ onChange={(event) => setSourceReference(event.target.value)}
444
+ maxLength={200}
445
+ />
446
+ <Label htmlFor="event-location">Location (optional)</Label>
447
+ <Input
448
+ id="event-location"
449
+ value={location}
450
+ onChange={(event) => setLocation(event.target.value)}
451
+ maxLength={200}
452
+ />
453
+ <Label htmlFor="event-evidence">
454
+ Evidence note
455
+ {eventType === 'RETURN_ACCEPTED' && kind === 'ACTUAL'
456
+ ? ' (required for acceptance)'
457
+ : ' (optional)'}
458
+ </Label>
459
+ <Input
460
+ id="event-evidence"
461
+ value={evidence}
462
+ onChange={(event) => setEvidence(event.target.value)}
463
+ maxLength={2000}
464
+ required={eventType === 'RETURN_ACCEPTED' && kind === 'ACTUAL'}
465
+ />
466
+ <Button type="submit" disabled={busy || !eventSource.trim()}>
467
+ Record event
468
+ </Button>
469
+ </form>
470
+ )}
471
+ {access.canUpdate && (
472
+ <div className="space-y-3 border-t pt-4">
473
+ <p className="text-sm text-text-secondary">
474
+ Closure checks the complete recorded history for actual empty return and
475
+ acceptance. A planned return or delivered shipment is insufficient.
476
+ </p>
477
+ <Button
478
+ variant="outline"
479
+ disabled={busy || data.cyclesLoading || data.eventsLoading}
480
+ onClick={() =>
481
+ access.allows('update') &&
482
+ void act(
483
+ () => data.closeCycle(cycle),
484
+ 'Cycle closed after accepted empty return.'
485
+ )
486
+ }
487
+ >
488
+ Close cycle
489
+ </Button>
490
+ </div>
491
+ )}
453
492
  </>
454
493
  )}
455
494
  {cycle?.status === 'CLOSED' && (