@burdenoff/microfe-movethewheels 2026.728.1 → 2026.731.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.
@@ -0,0 +1,552 @@
1
+ /**
2
+ * Multi-Modal Freight Shipment Page (BOFF-4398)
3
+ *
4
+ * Lets an operator plan a shipment composed of multiple real transport legs
5
+ * across ROAD/AIR/OCEAN/RAIL, previewing each leg's real mode-specific rate
6
+ * before booking, then lists previously planned multi-modal shipments.
7
+ */
8
+
9
+ import { useState } from 'react';
10
+ import { Ship, Plane, Truck, TrainFront, Plus, Trash2, Calculator } from 'lucide-react';
11
+ import { PageLayout, PageSection, EmptyState, LoadingState } from '../components/PageLayout';
12
+ import { FormError } from '../components/FormError';
13
+ import {
14
+ Card,
15
+ CardContent,
16
+ CardHeader,
17
+ CardTitle,
18
+ Button,
19
+ Badge,
20
+ Input,
21
+ Label,
22
+ Select,
23
+ SelectContent,
24
+ SelectItem,
25
+ SelectTrigger,
26
+ SelectValue,
27
+ Separator,
28
+ } from '../components/ui';
29
+ import { PagePurpose, EmphasisPanel } from '@burdenoff/fe-libs/ui';
30
+ import {
31
+ useMultiModalShipments,
32
+ type FreightRateQuote,
33
+ type OceanContainerType,
34
+ type ShipmentLegDraft,
35
+ type TransportMode,
36
+ } from '../hooks/useMultiModalShipments';
37
+
38
+ const MODE_ICON: Record<TransportMode, typeof Truck> = {
39
+ ROAD: Truck,
40
+ AIR: Plane,
41
+ OCEAN: Ship,
42
+ RAIL: TrainFront,
43
+ };
44
+
45
+ const MODE_LABEL: Record<TransportMode, string> = {
46
+ ROAD: 'Road',
47
+ AIR: 'Air',
48
+ OCEAN: 'Ocean',
49
+ RAIL: 'Rail',
50
+ };
51
+
52
+ function emptyLeg(mode: TransportMode = 'ROAD'): ShipmentLegDraft {
53
+ return { mode, origin: {}, destination: {} };
54
+ }
55
+
56
+ function formatMoney(
57
+ amount: number | null | undefined,
58
+ currency: string | null | undefined
59
+ ): string {
60
+ if (amount == null) return '—';
61
+ try {
62
+ return new Intl.NumberFormat('en-US', {
63
+ style: 'currency',
64
+ currency: currency ?? 'USD',
65
+ }).format(amount);
66
+ } catch {
67
+ return `${amount.toFixed(2)} ${currency ?? 'USD'}`;
68
+ }
69
+ }
70
+
71
+ export default function MultiModalShipmentPage() {
72
+ const { shipments, isLoading, error, calculateFreightRate, planMultiModalShipment } =
73
+ useMultiModalShipments();
74
+
75
+ const [shipmentNumber, setShipmentNumber] = useState('');
76
+ const [originAddress, setOriginAddress] = useState('');
77
+ const [destinationAddress, setDestinationAddress] = useState('');
78
+ const [legs, setLegs] = useState<ShipmentLegDraft[]>([emptyLeg()]);
79
+ const [quotes, setQuotes] = useState<Record<number, FreightRateQuote | null>>({});
80
+ const [quoteErrors, setQuoteErrors] = useState<Record<number, string | null>>({});
81
+ const [isSubmitting, setIsSubmitting] = useState(false);
82
+ const [formError, setFormError] = useState<string | null>(null);
83
+ const [successMessage, setSuccessMessage] = useState<string | null>(null);
84
+
85
+ const updateLeg = (index: number, patch: Partial<ShipmentLegDraft>) => {
86
+ setLegs((previous) => previous.map((leg, i) => (i === index ? { ...leg, ...patch } : leg)));
87
+ setQuotes((previous) => ({ ...previous, [index]: null }));
88
+ };
89
+
90
+ const addLeg = () => setLegs((previous) => [...previous, emptyLeg()]);
91
+
92
+ const removeLeg = (index: number) => {
93
+ setLegs((previous) => previous.filter((_, i) => i !== index));
94
+ setQuotes((previous) => {
95
+ const next = { ...previous };
96
+ delete next[index];
97
+ return next;
98
+ });
99
+ };
100
+
101
+ const previewLeg = async (index: number) => {
102
+ const leg = legs[index];
103
+ if (!leg) return;
104
+ setQuoteErrors((previous) => ({ ...previous, [index]: null }));
105
+ try {
106
+ const quote = await calculateFreightRate(leg);
107
+ setQuotes((previous) => ({ ...previous, [index]: quote }));
108
+ } catch (previewError) {
109
+ setQuoteErrors((previous) => ({
110
+ ...previous,
111
+ [index]: previewError instanceof Error ? previewError.message : 'Failed to calculate rate',
112
+ }));
113
+ }
114
+ };
115
+
116
+ const estimatedTotal = Object.values(quotes).reduce(
117
+ (sum, quote) => sum + (quote?.calculatedCost ?? 0),
118
+ 0
119
+ );
120
+ const modesUsed = new Set(legs.map((leg) => leg.mode));
121
+
122
+ const handleSubmit = async (event: React.FormEvent) => {
123
+ event.preventDefault();
124
+ setFormError(null);
125
+ setSuccessMessage(null);
126
+
127
+ if (!shipmentNumber.trim()) {
128
+ setFormError('Shipment number is required.');
129
+ return;
130
+ }
131
+ if (legs.length === 0) {
132
+ setFormError('Add at least one transport leg.');
133
+ return;
134
+ }
135
+
136
+ setIsSubmitting(true);
137
+ try {
138
+ const created = await planMultiModalShipment({
139
+ shipmentNumber: shipmentNumber.trim(),
140
+ origin: { address: originAddress },
141
+ destination: { address: destinationAddress },
142
+ legs,
143
+ });
144
+ setSuccessMessage(
145
+ `Shipment ${created.shipmentNumber} planned with ${created.legs.length} leg(s).`
146
+ );
147
+ setShipmentNumber('');
148
+ setOriginAddress('');
149
+ setDestinationAddress('');
150
+ setLegs([emptyLeg()]);
151
+ setQuotes({});
152
+ } catch (submitError) {
153
+ setFormError(submitError instanceof Error ? submitError.message : 'Failed to plan shipment');
154
+ } finally {
155
+ setIsSubmitting(false);
156
+ }
157
+ };
158
+
159
+ return (
160
+ <PageLayout
161
+ hero
162
+ titleAccent="Multi-Modal Freight"
163
+ title="Multi-Modal Freight"
164
+ subtitle="Plan a shipment across road, air, ocean and rail legs with real per-mode rate calculation"
165
+ icon={<Ship size={24} />}
166
+ >
167
+ <PagePurpose icon={<Ship size={18} />}>
168
+ Compose a single shipment from multiple real transport legs — each leg picks its own mode
169
+ (road/air/ocean/rail) and gets priced with that mode's real pricing driver (air: chargeable
170
+ weight; ocean: revenue-ton or per-container; rail: tonne-km; road: distance + weight).
171
+ Calculate a leg's rate before booking, then plan the full multi-leg shipment in one step.
172
+ </PagePurpose>
173
+
174
+ <EmphasisPanel>
175
+ Live carrier booking/API integration is out of scope for this core (it needs named-carrier
176
+ partnership agreements) — this plans the shipment and its legs with real calculated costs
177
+ that your ops team can then execute against your chosen carriers.
178
+ </EmphasisPanel>
179
+
180
+ <PageSection title="Plan a New Multi-Modal Shipment">
181
+ <Card>
182
+ <CardContent>
183
+ <form onSubmit={handleSubmit} className="space-y-6 pt-4">
184
+ <FormError message={formError} />
185
+ {successMessage && (
186
+ <div className="rounded-lg border border-status-success-border bg-status-success-bg-subtle px-4 py-3 text-sm text-status-success-text">
187
+ {successMessage}
188
+ </div>
189
+ )}
190
+
191
+ <div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
192
+ <div>
193
+ <Label htmlFor="shipmentNumber">Shipment Number</Label>
194
+ <Input
195
+ id="shipmentNumber"
196
+ value={shipmentNumber}
197
+ onChange={(e) => setShipmentNumber(e.target.value)}
198
+ placeholder="SHP-MM-0001"
199
+ required
200
+ />
201
+ </div>
202
+ <div>
203
+ <Label htmlFor="originAddress">Origin</Label>
204
+ <Input
205
+ id="originAddress"
206
+ value={originAddress}
207
+ onChange={(e) => setOriginAddress(e.target.value)}
208
+ placeholder="Shanghai, CN"
209
+ />
210
+ </div>
211
+ <div>
212
+ <Label htmlFor="destinationAddress">Destination</Label>
213
+ <Input
214
+ id="destinationAddress"
215
+ value={destinationAddress}
216
+ onChange={(e) => setDestinationAddress(e.target.value)}
217
+ placeholder="Los Angeles, US"
218
+ />
219
+ </div>
220
+ </div>
221
+
222
+ <Separator />
223
+
224
+ <div className="space-y-4">
225
+ {legs.map((leg, index) => {
226
+ const Icon = MODE_ICON[leg.mode];
227
+ const quote = quotes[index];
228
+ const quoteError = quoteErrors[index];
229
+ return (
230
+ <Card key={index} className="border-border-subtle">
231
+ <CardHeader className="flex flex-row items-center justify-between">
232
+ <CardTitle className="flex items-center gap-2 text-base">
233
+ <Icon size={16} /> Leg {index + 1}
234
+ </CardTitle>
235
+ {legs.length > 1 && (
236
+ <Button
237
+ type="button"
238
+ variant="ghost"
239
+ size="sm"
240
+ onClick={() => removeLeg(index)}
241
+ aria-label={`Remove leg ${index + 1}`}
242
+ >
243
+ <Trash2 size={14} />
244
+ </Button>
245
+ )}
246
+ </CardHeader>
247
+ <CardContent className="space-y-4">
248
+ <div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
249
+ <div>
250
+ <Label>Mode</Label>
251
+ <Select
252
+ value={leg.mode}
253
+ onValueChange={(value) =>
254
+ updateLeg(index, { mode: value as TransportMode })
255
+ }
256
+ >
257
+ <SelectTrigger>
258
+ <SelectValue />
259
+ </SelectTrigger>
260
+ <SelectContent>
261
+ {(['ROAD', 'AIR', 'OCEAN', 'RAIL'] as TransportMode[]).map(
262
+ (mode) => (
263
+ <SelectItem key={mode} value={mode}>
264
+ {MODE_LABEL[mode]}
265
+ </SelectItem>
266
+ )
267
+ )}
268
+ </SelectContent>
269
+ </Select>
270
+ </div>
271
+ <div>
272
+ <Label>Leg Origin</Label>
273
+ <Input
274
+ value={leg.origin.address ?? ''}
275
+ onChange={(e) =>
276
+ updateLeg(index, { origin: { address: e.target.value } })
277
+ }
278
+ placeholder="Origin port/airport/terminal"
279
+ />
280
+ </div>
281
+ <div>
282
+ <Label>Leg Destination</Label>
283
+ <Input
284
+ value={leg.destination.address ?? ''}
285
+ onChange={(e) =>
286
+ updateLeg(index, { destination: { address: e.target.value } })
287
+ }
288
+ placeholder="Destination port/airport/terminal"
289
+ />
290
+ </div>
291
+ </div>
292
+
293
+ {(leg.mode === 'ROAD' || leg.mode === 'RAIL') && (
294
+ <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
295
+ <div>
296
+ <Label>Distance (km)</Label>
297
+ <Input
298
+ type="number"
299
+ min={0}
300
+ value={leg.distanceKm ?? ''}
301
+ onChange={(e) =>
302
+ updateLeg(index, {
303
+ distanceKm: Number(e.target.value) || undefined,
304
+ })
305
+ }
306
+ />
307
+ </div>
308
+ <div>
309
+ <Label>Weight (kg)</Label>
310
+ <Input
311
+ type="number"
312
+ min={0}
313
+ value={leg.weightKg ?? ''}
314
+ onChange={(e) =>
315
+ updateLeg(index, {
316
+ weightKg: Number(e.target.value) || undefined,
317
+ })
318
+ }
319
+ />
320
+ </div>
321
+ </div>
322
+ )}
323
+
324
+ {leg.mode === 'AIR' && (
325
+ <div className="grid grid-cols-1 gap-4 sm:grid-cols-4">
326
+ <div>
327
+ <Label>Weight (kg)</Label>
328
+ <Input
329
+ type="number"
330
+ min={0}
331
+ value={leg.weightKg ?? ''}
332
+ onChange={(e) =>
333
+ updateLeg(index, {
334
+ weightKg: Number(e.target.value) || undefined,
335
+ })
336
+ }
337
+ />
338
+ </div>
339
+ <div>
340
+ <Label>Length (cm)</Label>
341
+ <Input
342
+ type="number"
343
+ min={0}
344
+ value={leg.lengthCm ?? ''}
345
+ onChange={(e) =>
346
+ updateLeg(index, {
347
+ lengthCm: Number(e.target.value) || undefined,
348
+ })
349
+ }
350
+ />
351
+ </div>
352
+ <div>
353
+ <Label>Width (cm)</Label>
354
+ <Input
355
+ type="number"
356
+ min={0}
357
+ value={leg.widthCm ?? ''}
358
+ onChange={(e) =>
359
+ updateLeg(index, { widthCm: Number(e.target.value) || undefined })
360
+ }
361
+ />
362
+ </div>
363
+ <div>
364
+ <Label>Height (cm)</Label>
365
+ <Input
366
+ type="number"
367
+ min={0}
368
+ value={leg.heightCm ?? ''}
369
+ onChange={(e) =>
370
+ updateLeg(index, {
371
+ heightCm: Number(e.target.value) || undefined,
372
+ })
373
+ }
374
+ />
375
+ </div>
376
+ </div>
377
+ )}
378
+
379
+ {leg.mode === 'OCEAN' && (
380
+ <div className="grid grid-cols-1 gap-4 sm:grid-cols-4">
381
+ <div>
382
+ <Label>Container Type</Label>
383
+ <Select
384
+ value={leg.containerType ?? 'LCL'}
385
+ onValueChange={(value) =>
386
+ updateLeg(index, { containerType: value as OceanContainerType })
387
+ }
388
+ >
389
+ <SelectTrigger>
390
+ <SelectValue />
391
+ </SelectTrigger>
392
+ <SelectContent>
393
+ <SelectItem value="LCL">LCL (shared container)</SelectItem>
394
+ <SelectItem value="FCL_20FT">FCL 20ft</SelectItem>
395
+ <SelectItem value="FCL_40FT">FCL 40ft</SelectItem>
396
+ <SelectItem value="FCL_40FT_HC">FCL 40ft High-Cube</SelectItem>
397
+ </SelectContent>
398
+ </Select>
399
+ </div>
400
+ {(leg.containerType ?? 'LCL') === 'LCL' ? (
401
+ <>
402
+ <div>
403
+ <Label>Weight (kg)</Label>
404
+ <Input
405
+ type="number"
406
+ min={0}
407
+ value={leg.weightKg ?? ''}
408
+ onChange={(e) =>
409
+ updateLeg(index, {
410
+ weightKg: Number(e.target.value) || undefined,
411
+ })
412
+ }
413
+ />
414
+ </div>
415
+ <div>
416
+ <Label>Volume (CBM)</Label>
417
+ <Input
418
+ type="number"
419
+ min={0}
420
+ value={leg.volumeCbm ?? ''}
421
+ onChange={(e) =>
422
+ updateLeg(index, {
423
+ volumeCbm: Number(e.target.value) || undefined,
424
+ })
425
+ }
426
+ />
427
+ </div>
428
+ </>
429
+ ) : (
430
+ <div>
431
+ <Label>Container Count</Label>
432
+ <Input
433
+ type="number"
434
+ min={1}
435
+ value={leg.containerCount ?? 1}
436
+ onChange={(e) =>
437
+ updateLeg(index, {
438
+ containerCount: Number(e.target.value) || 1,
439
+ })
440
+ }
441
+ />
442
+ </div>
443
+ )}
444
+ </div>
445
+ )}
446
+
447
+ <div className="flex items-center gap-3">
448
+ <Button
449
+ type="button"
450
+ variant="secondary"
451
+ size="sm"
452
+ onClick={() => void previewLeg(index)}
453
+ >
454
+ <Calculator size={14} className="mr-1.5" /> Calculate Rate
455
+ </Button>
456
+ {quote && (
457
+ <span className="text-sm font-medium text-text-primary">
458
+ {formatMoney(quote.calculatedCost, quote.currency)}
459
+ {quote.chargeableWeightKg != null && (
460
+ <span className="ml-2 text-xs text-text-secondary">
461
+ ({quote.chargeableWeightKg.toFixed(1)}kg chargeable)
462
+ </span>
463
+ )}
464
+ </span>
465
+ )}
466
+ {quoteError && (
467
+ <span className="text-sm text-status-error-text">{quoteError}</span>
468
+ )}
469
+ </div>
470
+
471
+ {quote && quote.breakdown.length > 0 && (
472
+ <ul className="space-y-1 rounded-md bg-surface-subtle px-3 py-2 text-xs text-text-secondary">
473
+ {quote.breakdown.map((line) => (
474
+ <li key={line.label} className="flex justify-between">
475
+ <span>{line.label}</span>
476
+ <span>{formatMoney(line.amount, quote.currency)}</span>
477
+ </li>
478
+ ))}
479
+ </ul>
480
+ )}
481
+ </CardContent>
482
+ </Card>
483
+ );
484
+ })}
485
+ </div>
486
+
487
+ <div className="flex items-center justify-between">
488
+ <Button type="button" variant="outline" size="sm" onClick={addLeg}>
489
+ <Plus size={14} className="mr-1.5" /> Add Leg
490
+ </Button>
491
+ <div className="flex items-center gap-3">
492
+ {modesUsed.size > 1 && <Badge variant="secondary">Multi-modal</Badge>}
493
+ <span className="text-sm text-text-secondary">
494
+ Estimated total:{' '}
495
+ <strong>{formatMoney(estimatedTotal, quotes[0]?.currency ?? 'USD')}</strong>
496
+ </span>
497
+ </div>
498
+ </div>
499
+
500
+ <Button type="submit" disabled={isSubmitting}>
501
+ {isSubmitting ? 'Planning shipment...' : 'Plan Shipment'}
502
+ </Button>
503
+ </form>
504
+ </CardContent>
505
+ </Card>
506
+ </PageSection>
507
+
508
+ <PageSection title="Planned Multi-Modal Shipments">
509
+ {isLoading ? (
510
+ <LoadingState message="Loading multi-modal shipments..." />
511
+ ) : error ? (
512
+ <FormError message={error.message} />
513
+ ) : shipments.length === 0 ? (
514
+ <EmptyState
515
+ icon={<Ship size={32} />}
516
+ title="No multi-modal shipments yet"
517
+ description="Plan a shipment above to see it here once it spans more than one transport mode."
518
+ />
519
+ ) : (
520
+ <div className="space-y-4">
521
+ {shipments.map((shipment) => (
522
+ <Card key={shipment.id}>
523
+ <CardHeader className="flex flex-row items-center justify-between">
524
+ <CardTitle className="text-base">{shipment.shipmentNumber}</CardTitle>
525
+ <div className="flex items-center gap-2">
526
+ <Badge variant="outline">{shipment.status}</Badge>
527
+ <span className="text-sm font-medium">
528
+ {formatMoney(shipment.totalFreightCost, shipment.freightCurrency)}
529
+ </span>
530
+ </div>
531
+ </CardHeader>
532
+ <CardContent>
533
+ <div className="flex flex-wrap gap-2">
534
+ {shipment.legs.map((leg) => {
535
+ const Icon = MODE_ICON[leg.mode];
536
+ return (
537
+ <Badge key={leg.id} variant="secondary" className="flex items-center gap-1">
538
+ <Icon size={12} /> {MODE_LABEL[leg.mode]}:{' '}
539
+ {formatMoney(leg.calculatedCost, leg.currency)}
540
+ </Badge>
541
+ );
542
+ })}
543
+ </div>
544
+ </CardContent>
545
+ </Card>
546
+ ))}
547
+ </div>
548
+ )}
549
+ </PageSection>
550
+ </PageLayout>
551
+ );
552
+ }