@burdenoff/microfe-movethewheels 2026.726.1 → 2026.731.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/dist/MoveTheWheelsRoutes.js +11 -3
- package/dist/components/ProofOfDeliveryCard.js +155 -0
- package/dist/components/SignaturePad.js +82 -0
- package/dist/hooks/index.js +1 -0
- package/dist/hooks/useEpod.js +208 -0
- package/dist/hooks/useMultiModalShipments.js +124 -0
- package/dist/index.js +22 -21
- package/dist/movethewheels/components/ProofOfDeliveryCard.d.ts +5 -0
- package/dist/movethewheels/components/SignaturePad.d.ts +11 -0
- package/dist/movethewheels/hooks/index.d.ts +1 -0
- package/dist/movethewheels/hooks/useEpod.d.ts +59 -0
- package/dist/movethewheels/hooks/useMultiModalShipments.d.ts +76 -0
- package/dist/movethewheels/pages/DeliveryCapturePage.d.ts +1 -0
- package/dist/movethewheels/pages/MultiModalShipmentPage.d.ts +8 -0
- package/dist/movethewheels/types/index.d.ts +18 -0
- package/dist/node_modules/lucide-react/dist/esm/icons/calculator.js +56 -0
- package/dist/node_modules/lucide-react/dist/esm/icons/loader-circle.js +8 -0
- package/dist/node_modules/lucide-react/dist/esm/icons/pen-line.js +11 -0
- package/dist/node_modules/lucide-react/dist/esm/icons/plane.js +8 -0
- package/dist/node_modules/lucide-react/dist/esm/icons/ship.js +26 -0
- package/dist/node_modules/lucide-react/dist/esm/icons/train-front.js +30 -0
- package/dist/node_modules/lucide-react/dist/esm/icons/type.js +24 -0
- package/dist/pages/DashboardPage.js +156 -151
- package/dist/pages/DeliveryCapturePage.js +245 -0
- package/dist/pages/MultiModalShipmentPage.js +420 -0
- package/dist/pages/OrderDetailsPage.js +14 -12
- package/dist/pages/TrackingPage.js +156 -154
- package/dist/utils/formatters.js +13 -2
- package/package.json +1 -1
- package/src/movethewheels/MoveTheWheelsRoutes.tsx +8 -0
- package/src/movethewheels/components/ProofOfDeliveryCard.tsx +149 -0
- package/src/movethewheels/components/SignaturePad.tsx +157 -0
- package/src/movethewheels/hooks/index.ts +12 -0
- package/src/movethewheels/hooks/useEpod.ts +410 -0
- package/src/movethewheels/hooks/useMultiModalShipments.ts +323 -0
- package/src/movethewheels/pages/DashboardPage.tsx +6 -0
- package/src/movethewheels/pages/DeliveryCapturePage.tsx +297 -0
- package/src/movethewheels/pages/MultiModalShipmentPage.tsx +552 -0
- package/src/movethewheels/pages/OrderDetailsPage.tsx +4 -0
- package/src/movethewheels/pages/TrackingPage.tsx +4 -0
- package/src/movethewheels/types/index.ts +21 -0
|
@@ -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
|
+
}
|
|
@@ -53,6 +53,7 @@ import { useFleet } from '../hooks/useFleet';
|
|
|
53
53
|
import { FormError } from '../components/FormError';
|
|
54
54
|
import { Notice, useNotice } from '../components/Notice';
|
|
55
55
|
import { DocumentsCard } from '../components/DocumentsCard';
|
|
56
|
+
import { ProofOfDeliveryCard } from '../components/ProofOfDeliveryCard';
|
|
56
57
|
import { useMoveTheWheels } from '../providers/MoveTheWheelsProvider';
|
|
57
58
|
import { formatCurrency, formatDate, formatAddress } from '../utils/formatters';
|
|
58
59
|
import { joinPath } from '../utils/navigation';
|
|
@@ -593,6 +594,9 @@ export default function OrderDetailsPage() {
|
|
|
593
594
|
{/* Documents — real files via wspace-files-svc */}
|
|
594
595
|
<DocumentsCard entityType="order" entityId={order.id} />
|
|
595
596
|
|
|
597
|
+
{/* Proof of delivery — captured by the driver on completion (BOFF-4399) */}
|
|
598
|
+
<ProofOfDeliveryCard deliveryJobId={order.id} />
|
|
599
|
+
|
|
596
600
|
{/* Special Instructions */}
|
|
597
601
|
{order.specialInstructions && (
|
|
598
602
|
<Card>
|
|
@@ -12,6 +12,7 @@ import { StatusBadge } from '../components/StatusBadge';
|
|
|
12
12
|
import { Card, CardContent, CardHeader, CardTitle, Button, Input } from '../components/ui';
|
|
13
13
|
import { EmphasisPanel, PagePurpose } from '@burdenoff/fe-libs/ui';
|
|
14
14
|
import { TrackingMap, type TrackEndpoint, type TrackPoint } from '../components/maps';
|
|
15
|
+
import { ProofOfDeliveryCard } from '../components/ProofOfDeliveryCard';
|
|
15
16
|
import { firstRealLatLng } from '../utils/mapMarkers';
|
|
16
17
|
import { useOrderTracking } from '../hooks/useTracking';
|
|
17
18
|
import { formatDate, formatAddress } from '../utils/formatters';
|
|
@@ -455,6 +456,9 @@ export default function TrackingPage() {
|
|
|
455
456
|
</CardContent>
|
|
456
457
|
</Card>
|
|
457
458
|
|
|
459
|
+
{/* Proof of delivery — captured by the driver on completion (BOFF-4399) */}
|
|
460
|
+
<ProofOfDeliveryCard deliveryJobId={order.id} />
|
|
461
|
+
|
|
458
462
|
{/* Actions */}
|
|
459
463
|
<Card>
|
|
460
464
|
<CardContent className="p-4 space-y-2">
|
|
@@ -288,6 +288,27 @@ export interface Order {
|
|
|
288
288
|
updatedAt: Date;
|
|
289
289
|
}
|
|
290
290
|
|
|
291
|
+
// Electronic proof of delivery (ePOD) — BOFF-4399
|
|
292
|
+
export type SignatureType = 'drawn' | 'typed';
|
|
293
|
+
|
|
294
|
+
export interface ProofOfDelivery {
|
|
295
|
+
id: string;
|
|
296
|
+
deliveryJobId: string;
|
|
297
|
+
shipmentId?: string;
|
|
298
|
+
recipientName: string;
|
|
299
|
+
signatureType: SignatureType;
|
|
300
|
+
/** Drawn: a `data:image/png;base64,...` capture. Typed: the typed full name. */
|
|
301
|
+
signatureData: string;
|
|
302
|
+
/** wspace-files-svc File ids for any attached delivery photos. */
|
|
303
|
+
photoFileIds: string[];
|
|
304
|
+
latitude?: number;
|
|
305
|
+
longitude?: number;
|
|
306
|
+
locationAccuracy?: number;
|
|
307
|
+
deliveredAt: Date;
|
|
308
|
+
capturedByDriverId: string;
|
|
309
|
+
notes?: string;
|
|
310
|
+
}
|
|
311
|
+
|
|
291
312
|
// Vehicle type
|
|
292
313
|
export type VehicleType = 'van' | 'truck' | 'motorcycle' | 'bicycle' | 'car';
|
|
293
314
|
export type VehicleStatus = 'available' | 'assigned' | 'maintenance' | 'out_of_service';
|