@burdenoff/microfe-movethewheels 2026.1017.11 → 2026.1017.13

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 (29) hide show
  1. package/dist/MoveTheWheelsRoot.js +7 -0
  2. package/dist/MoveTheWheelsRoutes.js +39 -35
  3. package/dist/components/pallet-preview/PalletPreviewResults.js +189 -0
  4. package/dist/generated/pallet-preview-operations.d.ts +273 -0
  5. package/dist/hooks/usePalletPreview.js +86 -0
  6. package/dist/movethewheels/components/pallet-preview/PalletPreviewResults.d.ts +6 -0
  7. package/dist/movethewheels/hooks/usePalletPreview.d.ts +98 -0
  8. package/dist/movethewheels/pages/PalletPreviewPage.d.ts +1 -0
  9. package/dist/movethewheels/utils/graphqlClient.d.ts +4 -0
  10. package/dist/movethewheels/utils/palletPreviewInput.d.ts +54 -0
  11. package/dist/movethewheels/utils/palletPreviewResponse.d.ts +7 -0
  12. package/dist/pages/PalletPreviewPage.js +501 -0
  13. package/dist/pages/WarehousesPage.js +83 -78
  14. package/dist/src/generated/pallet-preview-operations.js +594 -0
  15. package/dist/utils/graphqlClient.js +27 -19
  16. package/dist/utils/palletPreviewInput.js +138 -0
  17. package/dist/utils/palletPreviewResponse.js +191 -0
  18. package/package.json +5 -4
  19. package/src/generated/pallet-preview-operations.ts +204 -0
  20. package/src/movethewheels/MoveTheWheelsRoot.tsx +7 -0
  21. package/src/movethewheels/MoveTheWheelsRoutes.tsx +2 -0
  22. package/src/movethewheels/components/pallet-preview/PalletPreviewResults.tsx +220 -0
  23. package/src/movethewheels/hooks/usePalletPreview.ts +114 -0
  24. package/src/movethewheels/pages/PalletPreviewPage.tsx +588 -0
  25. package/src/movethewheels/pages/WarehousesPage.tsx +9 -1
  26. package/src/movethewheels/utils/graphqlClient.ts +19 -2
  27. package/src/movethewheels/utils/palletPreviewInput.ts +199 -0
  28. package/src/movethewheels/utils/palletPreviewResponse.ts +256 -0
  29. package/src/operations/pallet-preview/preview.graphql +26 -0
@@ -0,0 +1,588 @@
1
+ import { useEffect, useId, useRef, useState } from 'react';
2
+ import { Boxes } from 'lucide-react';
3
+ import { FormSection } from '@burdenoff/fe-libs/form';
4
+ import { OptionalFields } from '@burdenoff/fe-libs/ui';
5
+ import {
6
+ Button,
7
+ Checkbox,
8
+ Input,
9
+ Label,
10
+ Select,
11
+ SelectContent,
12
+ SelectItem,
13
+ SelectTrigger,
14
+ SelectValue,
15
+ } from '../components/ui';
16
+ import { PageLayout } from '../components/PageLayout';
17
+ import { PalletPreviewResults } from '../components/pallet-preview/PalletPreviewResults';
18
+ import { useTr } from '../hooks';
19
+ import { usePalletPreview } from '../hooks/usePalletPreview';
20
+ import { useMoveTheWheels } from '../providers/MoveTheWheelsProvider';
21
+ import {
22
+ newPalletPreviewDraft,
23
+ newPalletPackage,
24
+ palletPreviewInput,
25
+ PalletInputError,
26
+ type PalletPreviewDraft,
27
+ type SourceDraft,
28
+ } from '../utils/palletPreviewInput';
29
+
30
+ const fieldLabels = {
31
+ widthMm: 'Width (mm)',
32
+ lengthMm: 'Length (mm)',
33
+ heightMm: 'Height (mm)',
34
+ grossMassG: 'Gross mass per package (g)',
35
+ tareMassG: 'Pallet tare mass (g)',
36
+ maxPayloadMassG: 'Pallet payload rating (g)',
37
+ maxHeightMm: 'Reported maximum height (mm)',
38
+ maxGrossMassG: 'Gross mass rating (g)',
39
+ massG: 'Additional packing mass (g)',
40
+ perimeterMm: 'Perimeter clearance (mm)',
41
+ betweenPackagesMm: 'Between-package clearance (mm)',
42
+ topMm: 'Top clearance (mm)',
43
+ };
44
+ type Measurement = keyof typeof fieldLabels;
45
+ function FactField({
46
+ id,
47
+ label,
48
+ value,
49
+ onChange,
50
+ required = false,
51
+ }: {
52
+ id: string;
53
+ label: string;
54
+ value: string;
55
+ onChange: (value: string) => void;
56
+ required?: boolean;
57
+ }) {
58
+ return (
59
+ <div className="min-w-0 space-y-1">
60
+ <Label htmlFor={id}>{label}</Label>
61
+ <Input
62
+ id={id}
63
+ type="text"
64
+ inputMode="numeric"
65
+ autoComplete="off"
66
+ value={value}
67
+ onChange={(e) => onChange(e.target.value)}
68
+ required={required}
69
+ className="min-h-11"
70
+ />
71
+ </div>
72
+ );
73
+ }
74
+ function SourceFields({
75
+ id,
76
+ value,
77
+ onChange,
78
+ }: {
79
+ id: string;
80
+ value: SourceDraft;
81
+ onChange: (source: SourceDraft) => void;
82
+ }) {
83
+ const tr = useTr();
84
+ return (
85
+ <div className="grid gap-3 sm:grid-cols-2">
86
+ <div>
87
+ <Label htmlFor={`${id}-reference`}>
88
+ {tr('movethewheels.palletPreview.sourceReference', 'Source reference')}
89
+ </Label>
90
+ <Input
91
+ id={`${id}-reference`}
92
+ value={value.reference}
93
+ maxLength={255}
94
+ onChange={(e) => onChange({ ...value, reference: e.target.value })}
95
+ className="min-h-11"
96
+ />
97
+ </div>
98
+ <div>
99
+ <Label htmlFor={`${id}-version`}>
100
+ {tr('movethewheels.palletPreview.sourceVersion', 'Source version')}
101
+ </Label>
102
+ <Input
103
+ id={`${id}-version`}
104
+ value={value.version}
105
+ maxLength={120}
106
+ onChange={(e) => onChange({ ...value, version: e.target.value })}
107
+ className="min-h-11"
108
+ />
109
+ </div>
110
+ </div>
111
+ );
112
+ }
113
+ function Choice({
114
+ id,
115
+ label,
116
+ value,
117
+ choices,
118
+ onChange,
119
+ }: {
120
+ id: string;
121
+ label: string;
122
+ value: string;
123
+ choices: readonly { value: string; label: string }[];
124
+ onChange: (value: string) => void;
125
+ }) {
126
+ return (
127
+ <div className="min-w-0 space-y-1">
128
+ <Label htmlFor={id}>{label}</Label>
129
+ <Select value={value} onValueChange={onChange}>
130
+ <SelectTrigger id={id} className="min-h-11">
131
+ <SelectValue />
132
+ </SelectTrigger>
133
+ <SelectContent>
134
+ {choices.map((choice) => (
135
+ <SelectItem key={choice.value} value={choice.value}>
136
+ {choice.label}
137
+ </SelectItem>
138
+ ))}
139
+ </SelectContent>
140
+ </Select>
141
+ </div>
142
+ );
143
+ }
144
+ export default function PalletPreviewPage() {
145
+ const { workspaceId, tenantId, currentUser, apiGatewayUrl } = useMoveTheWheels();
146
+ return (
147
+ <PalletPreviewWorkbench
148
+ key={JSON.stringify([workspaceId, tenantId, currentUser?.id, apiGatewayUrl])}
149
+ />
150
+ );
151
+ }
152
+ function PalletPreviewWorkbench() {
153
+ const tr = useTr(),
154
+ prefix = useId();
155
+ const [draft, setDraft] = useState(newPalletPreviewDraft);
156
+ const [edited, setEdited] = useState(false);
157
+ const [error, setError] = useState<PalletInputError | null>(null);
158
+ const errorRef = useRef<HTMLDivElement>(null),
159
+ nextKey = useRef(2);
160
+ const preview = usePalletPreview();
161
+ useEffect(() => {
162
+ if (error) {
163
+ const field =
164
+ document.getElementById(`${prefix}-${error.field}`) ??
165
+ document.getElementById(`${prefix}-${error.field}-reference`);
166
+ if (field instanceof HTMLElement) field.focus();
167
+ else errorRef.current?.focus();
168
+ }
169
+ }, [error, prefix]);
170
+ const edit = (update: (old: PalletPreviewDraft) => PalletPreviewDraft) => {
171
+ preview.invalidate();
172
+ setEdited(true);
173
+ setError(null);
174
+ setDraft(update);
175
+ };
176
+ const measurement = (
177
+ group: string,
178
+ key: Measurement,
179
+ value: string,
180
+ onChange: (value: string) => void
181
+ ) => (
182
+ <FactField
183
+ key={key}
184
+ id={`${prefix}-${group}-${key}`}
185
+ label={tr(`movethewheels.palletPreview.fields.${key}`, fieldLabels[key])}
186
+ value={value}
187
+ onChange={onChange}
188
+ />
189
+ );
190
+ const unknown = tr('movethewheels.palletPreview.unknown', 'Unknown');
191
+ const errorCopy =
192
+ error?.kind === 'MASS'
193
+ ? tr(
194
+ 'movethewheels.palletPreview.massError',
195
+ 'Use whole grams, without separators or a decimal point (0–1000000000000 g).'
196
+ )
197
+ : error?.kind === 'SOURCE'
198
+ ? tr(
199
+ 'movethewheels.palletPreview.sourceError',
200
+ 'Provide both a source reference and version, or leave both blank.'
201
+ )
202
+ : error?.kind === 'IDENTITY'
203
+ ? tr(
204
+ 'movethewheels.palletPreview.identityError',
205
+ 'Use a distinct, nonblank package identifier without surrounding whitespace.'
206
+ )
207
+ : error?.kind === 'COUNT'
208
+ ? tr(
209
+ 'movethewheels.palletPreview.countError',
210
+ 'Use 1–100 package lines and at most 1000 total units.'
211
+ )
212
+ : tr(
213
+ 'movethewheels.palletPreview.wholeError',
214
+ 'Use bounded whole millimetres or counts without separators, leading zeros or a decimal point. Dimensions must be positive; clearances may be zero.'
215
+ );
216
+ return (
217
+ <PageLayout
218
+ title={tr('movethewheels.palletPreview.title', 'Pallet preview')}
219
+ subtitle={tr(
220
+ 'movethewheels.palletPreview.purpose',
221
+ 'Inspect an arrangement against supplied measurements.'
222
+ )}
223
+ icon={<Boxes aria-hidden="true" />}
224
+ headerActions={
225
+ <Button
226
+ type="submit"
227
+ form={`${prefix}-form`}
228
+ disabled={preview.loading || !preview.ready}
229
+ className="min-h-11"
230
+ >
231
+ {preview.loading
232
+ ? tr('movethewheels.palletPreview.loading', 'Previewing…')
233
+ : tr('movethewheels.palletPreview.submit', 'Preview arrangement')}
234
+ </Button>
235
+ }
236
+ >
237
+ <p className="mb-4 text-sm">
238
+ {tr(
239
+ 'movethewheels.palletPreview.boundary',
240
+ 'Geometric proposal only. Not a packing approval, reservation or handling instruction.'
241
+ )}
242
+ </p>
243
+ {!preview.ready && (
244
+ <p role="status" className="mb-4">
245
+ {tr(
246
+ 'movethewheels.palletPreview.contextRequired',
247
+ 'A current workspace and signed-in profile are required.'
248
+ )}
249
+ </p>
250
+ )}
251
+ <div aria-live="polite" className="mb-4">
252
+ {edited && tr('movethewheels.palletPreview.stale', 'Inputs changed; preview again.')}
253
+ {preview.loading && tr('movethewheels.palletPreview.loading', 'Previewing…')}
254
+ </div>
255
+ {error && (
256
+ <div
257
+ role="alert"
258
+ ref={errorRef}
259
+ tabIndex={-1}
260
+ className="mb-4 border border-border rounded-lg p-3"
261
+ >
262
+ {errorCopy}
263
+ </div>
264
+ )}
265
+ {preview.failed && (
266
+ <p role="alert" className="mb-4">
267
+ {tr(
268
+ 'movethewheels.palletPreview.readError',
269
+ 'Preview could not be verified. Review the current context and try Preview arrangement again.'
270
+ )}
271
+ </p>
272
+ )}
273
+ <form
274
+ id={`${prefix}-form`}
275
+ noValidate
276
+ onSubmit={(e) => {
277
+ e.preventDefault();
278
+ if (preview.loading || !preview.ready) return;
279
+ try {
280
+ const input = palletPreviewInput(draft);
281
+ setError(null);
282
+ setEdited(false);
283
+ void preview.run(input);
284
+ } catch (failure) {
285
+ preview.invalidate();
286
+ setError(
287
+ failure instanceof PalletInputError
288
+ ? failure
289
+ : new PalletInputError('packages', 'COUNT')
290
+ );
291
+ }
292
+ }}
293
+ >
294
+ <fieldset disabled={!preview.ready} className="space-y-5 min-w-0">
295
+ <legend className="sr-only">
296
+ {tr('movethewheels.palletPreview.facts', 'Supplied pallet preview facts')}
297
+ </legend>
298
+ <FormSection
299
+ title={tr('movethewheels.palletPreview.packages', 'Packages')}
300
+ description={tr(
301
+ 'movethewheels.palletPreview.blankMeaning',
302
+ 'Leave unavailable measurements blank. Blank is unknown, not zero.'
303
+ )}
304
+ actions={
305
+ <Button
306
+ type="button"
307
+ variant="outline"
308
+ disabled={draft.packages.length >= 100}
309
+ onClick={() => {
310
+ const key = nextKey.current++;
311
+ edit((d) => ({ ...d, packages: [...d.packages, newPalletPackage(key)] }));
312
+ }}
313
+ className="min-h-11"
314
+ >
315
+ {tr('movethewheels.palletPreview.addPackage', 'Add package line')}
316
+ </Button>
317
+ }
318
+ >
319
+ <div className="space-y-5">
320
+ {draft.packages.map((p, index) => (
321
+ <section
322
+ key={p.key}
323
+ className="rounded-lg border border-border p-4 space-y-4"
324
+ aria-label={tr(
325
+ 'movethewheels.palletPreview.packageNumber',
326
+ 'Package {{number}}',
327
+ { number: index + 1 }
328
+ )}
329
+ >
330
+ <div className="flex flex-wrap gap-3 items-center justify-between">
331
+ <h2 className="font-semibold">
332
+ {tr('movethewheels.palletPreview.packageNumber', 'Package {{number}}', {
333
+ number: index + 1,
334
+ })}
335
+ </h2>
336
+ <Button
337
+ type="button"
338
+ variant="ghost"
339
+ disabled={draft.packages.length === 1}
340
+ onClick={() =>
341
+ edit((d) => ({
342
+ ...d,
343
+ packages: d.packages.filter((row) => row.key !== p.key),
344
+ }))
345
+ }
346
+ >
347
+ {tr('movethewheels.palletPreview.remove', 'Remove line')}
348
+ </Button>
349
+ </div>
350
+ <div className="grid gap-3 sm:grid-cols-2">
351
+ <div>
352
+ <Label htmlFor={`${prefix}-package-${p.key}-id`}>
353
+ {tr('movethewheels.palletPreview.packageId', 'Package identifier')}
354
+ </Label>
355
+ <Input
356
+ id={`${prefix}-package-${p.key}-id`}
357
+ value={p.id}
358
+ required
359
+ maxLength={255}
360
+ onChange={(e) =>
361
+ edit((d) => ({
362
+ ...d,
363
+ packages: d.packages.map((row) =>
364
+ row.key === p.key ? { ...row, id: e.target.value } : row
365
+ ),
366
+ }))
367
+ }
368
+ className="min-h-11"
369
+ />
370
+ </div>
371
+ <FactField
372
+ id={`${prefix}-package-${p.key}-quantity`}
373
+ label={tr('movethewheels.palletPreview.quantity', 'Required quantity')}
374
+ value={p.quantity}
375
+ required
376
+ onChange={(value) =>
377
+ edit((d) => ({
378
+ ...d,
379
+ packages: d.packages.map((row) =>
380
+ row.key === p.key ? { ...row, quantity: value } : row
381
+ ),
382
+ }))
383
+ }
384
+ />
385
+ </div>
386
+ <div className="grid gap-3 sm:grid-cols-2">
387
+ {(['widthMm', 'lengthMm', 'heightMm', 'grossMassG'] as const).map((key) =>
388
+ measurement(`package-${p.key}`, key, p[key], (value) =>
389
+ edit((d) => ({
390
+ ...d,
391
+ packages: d.packages.map((row) =>
392
+ row.key === p.key ? { ...row, [key]: value } : row
393
+ ),
394
+ }))
395
+ )
396
+ )}
397
+ </div>
398
+ <div className="grid gap-3 sm:grid-cols-2">
399
+ <Choice
400
+ id={`${prefix}-package-${p.key}-upright`}
401
+ label={tr(
402
+ 'movethewheels.palletPreview.upright',
403
+ 'Upright handling permission'
404
+ )}
405
+ value={p.upright}
406
+ choices={[
407
+ { value: 'UNKNOWN', label: unknown },
408
+ {
409
+ value: 'YES',
410
+ label: tr('movethewheels.palletPreview.permitted', 'Permitted'),
411
+ },
412
+ {
413
+ value: 'NO',
414
+ label: tr('movethewheels.palletPreview.denied', 'Not permitted'),
415
+ },
416
+ ]}
417
+ onChange={(value) => {
418
+ if (value === 'UNKNOWN' || value === 'YES' || value === 'NO')
419
+ edit((d) => ({
420
+ ...d,
421
+ packages: d.packages.map((row) =>
422
+ row.key === p.key ? { ...row, upright: value } : row
423
+ ),
424
+ }));
425
+ }}
426
+ />
427
+ <Choice
428
+ id={`${prefix}-package-${p.key}-yaw`}
429
+ label={tr('movethewheels.palletPreview.yaw', 'Allowed upright yaw')}
430
+ value={p.yaw}
431
+ choices={[
432
+ { value: 'UNKNOWN', label: unknown },
433
+ {
434
+ value: 'NONE',
435
+ label: tr('movethewheels.palletPreview.noYaw', 'None permitted'),
436
+ },
437
+ { value: '0', label: '0°' },
438
+ { value: '90', label: '90°' },
439
+ { value: 'BOTH', label: '0° / 90°' },
440
+ ]}
441
+ onChange={(value) => {
442
+ if (
443
+ value === 'UNKNOWN' ||
444
+ value === 'NONE' ||
445
+ value === '0' ||
446
+ value === '90' ||
447
+ value === 'BOTH'
448
+ )
449
+ edit((d) => ({
450
+ ...d,
451
+ packages: d.packages.map((row) =>
452
+ row.key === p.key ? { ...row, yaw: value } : row
453
+ ),
454
+ }));
455
+ }}
456
+ />
457
+ </div>
458
+ <SourceFields
459
+ id={`${prefix}-package-${p.key}-source`}
460
+ value={p.source}
461
+ onChange={(source) =>
462
+ edit((d) => ({
463
+ ...d,
464
+ packages: d.packages.map((row) =>
465
+ row.key === p.key ? { ...row, source } : row
466
+ ),
467
+ }))
468
+ }
469
+ />
470
+ </section>
471
+ ))}
472
+ </div>
473
+ </FormSection>
474
+ <p className="text-sm text-text-secondary">
475
+ {tr(
476
+ 'movethewheels.palletPreview.sourceBoundary',
477
+ 'Your declared measurement sources; not independent verification.'
478
+ )}
479
+ </p>
480
+ <FormSection
481
+ title={tr('movethewheels.palletPreview.pallet', 'Pallet — dimensions and ratings')}
482
+ >
483
+ <div className="grid gap-3 sm:grid-cols-2">
484
+ {(['widthMm', 'lengthMm', 'heightMm', 'tareMassG', 'maxPayloadMassG'] as const).map(
485
+ (key) =>
486
+ measurement('pallet', key, draft.pallet[key], (value) =>
487
+ edit((d) => ({ ...d, pallet: { ...d.pallet, [key]: value } }))
488
+ )
489
+ )}
490
+ </div>
491
+ <SourceFields
492
+ id={`${prefix}-pallet-source`}
493
+ value={draft.pallet.source}
494
+ onChange={(source) => edit((d) => ({ ...d, pallet: { ...d.pallet, source } }))}
495
+ />
496
+ </FormSection>
497
+ <FormSection title={tr('movethewheels.palletPreview.clearance', 'Clearance')}>
498
+ <div className="grid gap-3 sm:grid-cols-3">
499
+ {(['perimeterMm', 'betweenPackagesMm', 'topMm'] as const).map((key) =>
500
+ measurement('clearance', key, draft.clearance[key], (value) =>
501
+ edit((d) => ({ ...d, clearance: { ...d.clearance, [key]: value } }))
502
+ )
503
+ )}
504
+ </div>
505
+ <SourceFields
506
+ id={`${prefix}-clearance-source`}
507
+ value={draft.clearance.source}
508
+ onChange={(source) => edit((d) => ({ ...d, clearance: { ...d.clearance, source } }))}
509
+ />
510
+ </FormSection>
511
+ <FormSection
512
+ title={tr(
513
+ 'movethewheels.palletPreview.additionalPacking',
514
+ 'Additional packing — mass only'
515
+ )}
516
+ >
517
+ <p className="text-sm mb-2">
518
+ {tr(
519
+ 'movethewheels.palletPreview.packingUnknown',
520
+ 'Packing geometry remains unknown, even when its mass is supplied.'
521
+ )}
522
+ </p>
523
+ {measurement('packing', 'massG', draft.additionalPacking.massG, (value) =>
524
+ edit((d) => ({ ...d, additionalPacking: { ...d.additionalPacking, massG: value } }))
525
+ )}
526
+ <SourceFields
527
+ id={`${prefix}-packing-source`}
528
+ value={draft.additionalPacking.source}
529
+ onChange={(source) =>
530
+ edit((d) => ({ ...d, additionalPacking: { ...d.additionalPacking, source } }))
531
+ }
532
+ />
533
+ </FormSection>
534
+ <OptionalFields
535
+ title={tr(
536
+ 'movethewheels.palletPreview.envelope',
537
+ 'Optional supplied load envelope and rating'
538
+ )}
539
+ description={tr(
540
+ 'movethewheels.palletPreview.envelopeBoundary',
541
+ 'An inner envelope does not prove door passage.'
542
+ )}
543
+ >
544
+ <div className="flex items-center gap-3 mb-3">
545
+ <Checkbox
546
+ id={`${prefix}-include-envelope`}
547
+ checked={draft.includeEnvelope}
548
+ onCheckedChange={(value) =>
549
+ edit((d) => ({ ...d, includeEnvelope: value === true }))
550
+ }
551
+ />
552
+ <Label htmlFor={`${prefix}-include-envelope`}>
553
+ {tr(
554
+ 'movethewheels.palletPreview.includeEnvelope',
555
+ 'Include supplied load envelope'
556
+ )}
557
+ </Label>
558
+ </div>
559
+ <fieldset disabled={!draft.includeEnvelope} className="space-y-3">
560
+ <legend className="sr-only">
561
+ {tr('movethewheels.palletPreview.envelopeFacts', 'Load envelope facts')}
562
+ </legend>
563
+ <div className="grid gap-3 sm:grid-cols-2">
564
+ {(['widthMm', 'lengthMm', 'maxHeightMm', 'maxGrossMassG'] as const).map((key) =>
565
+ measurement('envelope', key, draft.loadEnvelope[key], (value) =>
566
+ edit((d) => ({ ...d, loadEnvelope: { ...d.loadEnvelope, [key]: value } }))
567
+ )
568
+ )}
569
+ </div>
570
+ <SourceFields
571
+ id={`${prefix}-envelope-source`}
572
+ value={draft.loadEnvelope.source}
573
+ onChange={(source) =>
574
+ edit((d) => ({ ...d, loadEnvelope: { ...d.loadEnvelope, source } }))
575
+ }
576
+ />
577
+ </fieldset>
578
+ </OptionalFields>
579
+ </fieldset>
580
+ </form>
581
+ <div className="mt-8">
582
+ {preview.result && preview.input && (
583
+ <PalletPreviewResults result={preview.result} input={preview.input} />
584
+ )}
585
+ </div>
586
+ </PageLayout>
587
+ );
588
+ }
@@ -3,6 +3,7 @@
3
3
  */
4
4
 
5
5
  import { useState } from 'react';
6
+ import { useTr } from '../hooks';
6
7
  import { Warehouse, Plus, RefreshCw, CircleCheck, Gauge, Filter } from 'lucide-react';
7
8
  import { PageLayout } from '../components/PageLayout';
8
9
  import { MetricCard, MetricGrid } from '../components/MetricCard';
@@ -33,6 +34,7 @@ const STATUS_OPTIONS = [
33
34
  ];
34
35
 
35
36
  export default function WarehousesPage() {
37
+ const tr = useTr();
36
38
  const { basePath, navigate } = useMoveTheWheels();
37
39
  const [page, setPage] = useState(0);
38
40
  const [pageSize, setPageSize] = useState(25);
@@ -146,7 +148,13 @@ export default function WarehousesPage() {
146
148
  icon: <Plus size={16} />,
147
149
  onSelect: () => goTo('inventory/warehouses/new'),
148
150
  }}
149
- actions={[{ label: 'Refresh', icon: <RefreshCw size={16} />, onSelect: () => refetch() }]}
151
+ actions={[
152
+ {
153
+ label: tr('movethewheels.palletPreview.title', 'Pallet preview'),
154
+ onSelect: () => goTo('inventory/warehouses/pallet-preview'),
155
+ },
156
+ { label: 'Refresh', icon: <RefreshCw size={16} />, onSelect: () => refetch() },
157
+ ]}
150
158
  />
151
159
  }
152
160
  >
@@ -10,6 +10,10 @@ interface GraphQLRequestOptions {
10
10
  workspaceId?: string;
11
11
  tenantId?: string;
12
12
  actorId?: string;
13
+ /** Optional caller-owned cancellation; omitted preserves legacy retry behavior. */
14
+ signal?: AbortSignal;
15
+ /** Optional owning read-envelope guard; no effect on existing callers. */
16
+ validateResponse?: (value: unknown) => void;
13
17
  }
14
18
 
15
19
  export function resolveGraphqlUrl(apiGatewayUrl?: string): string {
@@ -119,8 +123,10 @@ export async function executeGraphQL<TData>(
119
123
  const request = { ...options };
120
124
  const body = JSON.stringify({ query, variables });
121
125
  const url = resolveGraphqlUrl(request.apiGatewayUrl);
126
+ if (request.signal?.aborted) throw new DOMException('Request cancelled', 'AbortError');
122
127
  await acquireSlot();
123
128
  try {
129
+ if (request.signal?.aborted) throw new DOMException('Request cancelled', 'AbortError');
124
130
  // Server-side callers retain explicit-token behavior; active browser requests
125
131
  // use the shared owning profile/context lifecycle before product dispatch.
126
132
  const authority =
@@ -155,7 +161,9 @@ export async function executeGraphQL<TData>(
155
161
  body,
156
162
  attemptPolicy === 'SINGLE_ATTEMPT' ? 0 : MAX_RETRIES,
157
163
  authority?.assertCurrent ?? (() => {}),
158
- attemptPolicy === 'SINGLE_ATTEMPT' ? holdRefusalField : undefined
164
+ attemptPolicy === 'SINGLE_ATTEMPT' ? holdRefusalField : undefined,
165
+ request.signal,
166
+ request.validateResponse
159
167
  );
160
168
  } finally {
161
169
  releaseSlot();
@@ -201,7 +209,9 @@ async function runWithRetries<TData>(
201
209
  body: string,
202
210
  retries: number,
203
211
  assertCurrent: () => void,
204
- holdRefusalField?: VehicleHoldMutation
212
+ holdRefusalField?: VehicleHoldMutation,
213
+ signal?: AbortSignal,
214
+ validateResponse?: (value: unknown) => void
205
215
  ): Promise<TData> {
206
216
  let lastError: Error = new Error('GraphQL request failed');
207
217
 
@@ -211,10 +221,14 @@ async function runWithRetries<TData>(
211
221
  if (attempt > 0)
212
222
  await sleep(Math.min(250 * 2 ** (attempt - 1), 2000) + Math.floor(Math.random() * 150));
213
223
 
224
+ if (signal?.aborted) throw new DOMException('Request cancelled', 'AbortError');
214
225
  assertCurrent();
215
226
  const controller = new AbortController();
216
227
  const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
228
+ const abort = () => controller.abort();
229
+ signal?.addEventListener('abort', abort, { once: true });
217
230
  try {
231
+ if (signal?.aborted) throw new DOMException('Request cancelled', 'AbortError');
218
232
  const response = await fetch(url, {
219
233
  method: 'POST',
220
234
  headers,
@@ -239,6 +253,7 @@ async function runWithRetries<TData>(
239
253
  errors?: Array<{ message?: string }>;
240
254
  };
241
255
 
256
+ validateResponse?.(payload);
242
257
  const refusal = vehicleHoldRefusal(payload, response.status, holdRefusalField);
243
258
  if (refusal) throw refusal;
244
259
  if (holdRefusalField && !validWriteEnvelope(payload, holdRefusalField))
@@ -263,6 +278,7 @@ async function runWithRetries<TData>(
263
278
 
264
279
  return payload.data;
265
280
  } catch (error) {
281
+ if (signal?.aborted) throw new DOMException('Request cancelled', 'AbortError');
266
282
  const aborted = isAbortError(error);
267
283
  // A thrown fetch is a network error (TypeError "Failed to fetch") or our
268
284
  // own timeout abort — both transient and safe to retry on a fresh
@@ -279,6 +295,7 @@ async function runWithRetries<TData>(
279
295
  throw lastError;
280
296
  } finally {
281
297
  clearTimeout(timeoutId);
298
+ signal?.removeEventListener('abort', abort);
282
299
  }
283
300
  }
284
301