@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.
- package/dist/MoveTheWheelsRoot.js +7 -0
- package/dist/MoveTheWheelsRoutes.js +39 -35
- package/dist/components/pallet-preview/PalletPreviewResults.js +189 -0
- package/dist/generated/pallet-preview-operations.d.ts +273 -0
- package/dist/hooks/usePalletPreview.js +86 -0
- package/dist/movethewheels/components/pallet-preview/PalletPreviewResults.d.ts +6 -0
- package/dist/movethewheels/hooks/usePalletPreview.d.ts +98 -0
- package/dist/movethewheels/pages/PalletPreviewPage.d.ts +1 -0
- package/dist/movethewheels/utils/graphqlClient.d.ts +4 -0
- package/dist/movethewheels/utils/palletPreviewInput.d.ts +54 -0
- package/dist/movethewheels/utils/palletPreviewResponse.d.ts +7 -0
- package/dist/pages/PalletPreviewPage.js +501 -0
- package/dist/pages/WarehousesPage.js +83 -78
- package/dist/src/generated/pallet-preview-operations.js +594 -0
- package/dist/utils/graphqlClient.js +27 -19
- package/dist/utils/palletPreviewInput.js +138 -0
- package/dist/utils/palletPreviewResponse.js +191 -0
- package/package.json +5 -4
- package/src/generated/pallet-preview-operations.ts +204 -0
- package/src/movethewheels/MoveTheWheelsRoot.tsx +7 -0
- package/src/movethewheels/MoveTheWheelsRoutes.tsx +2 -0
- package/src/movethewheels/components/pallet-preview/PalletPreviewResults.tsx +220 -0
- package/src/movethewheels/hooks/usePalletPreview.ts +114 -0
- package/src/movethewheels/pages/PalletPreviewPage.tsx +588 -0
- package/src/movethewheels/pages/WarehousesPage.tsx +9 -1
- package/src/movethewheels/utils/graphqlClient.ts +19 -2
- package/src/movethewheels/utils/palletPreviewInput.ts +199 -0
- package/src/movethewheels/utils/palletPreviewResponse.ts +256 -0
- 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={[
|
|
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
|
|