@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.
- package/dist/MoveTheWheelsRoutes.js +7 -3
- package/dist/hooks/useMultiModalShipments.js +137 -0
- package/dist/movethewheels/hooks/useMultiModalShipments.d.ts +76 -0
- package/dist/movethewheels/pages/MultiModalShipmentPage.d.ts +8 -0
- package/dist/node_modules/lucide-react/dist/esm/icons/calculator.js +56 -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/pages/DashboardPage.js +156 -151
- package/dist/pages/MultiModalShipmentPage.js +420 -0
- package/package.json +1 -1
- package/src/movethewheels/MoveTheWheelsRoutes.tsx +4 -0
- package/src/movethewheels/hooks/useMultiModalShipments.ts +344 -0
- package/src/movethewheels/pages/DashboardPage.tsx +6 -0
- package/src/movethewheels/pages/MultiModalShipmentPage.tsx +552 -0
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Multi-modal freight execution (BOFF-4398).
|
|
3
|
+
*
|
|
4
|
+
* Wire shape matches wspace-movethewheels-svc: src/modules/shipment/schema/shipment.gql
|
|
5
|
+
* (MtwShipment, MtwShipmentLeg, MtwFreightRateQuote). Field names here are kept in
|
|
6
|
+
* lockstep with that schema — verify against the deployed schema before renaming
|
|
7
|
+
* anything (see BOFF-3922 schema-drift guard / prior MFE-schema-drift incidents).
|
|
8
|
+
*/
|
|
9
|
+
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
10
|
+
import { useMoveTheWheels } from '../providers/MoveTheWheelsProvider';
|
|
11
|
+
import { executeGraphQL } from '../utils/graphqlClient';
|
|
12
|
+
|
|
13
|
+
export type TransportMode = 'ROAD' | 'AIR' | 'OCEAN' | 'RAIL';
|
|
14
|
+
export type ShipmentLegStatus =
|
|
15
|
+
'PLANNED' | 'BOOKED' | 'IN_TRANSIT' | 'ARRIVED' | 'DELIVERED' | 'CANCELLED';
|
|
16
|
+
export type OceanContainerType = 'LCL' | 'FCL_20FT' | 'FCL_40FT' | 'FCL_40FT_HC';
|
|
17
|
+
|
|
18
|
+
export interface FreightRateBreakdownLine {
|
|
19
|
+
label: string;
|
|
20
|
+
amount: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface FreightRateQuote {
|
|
24
|
+
mode: TransportMode;
|
|
25
|
+
chargeableWeightKg?: number | null;
|
|
26
|
+
ratePerUnit: number;
|
|
27
|
+
calculatedCost: number;
|
|
28
|
+
currency: string;
|
|
29
|
+
breakdown: FreightRateBreakdownLine[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface ShipmentLegDraft {
|
|
33
|
+
mode: TransportMode;
|
|
34
|
+
origin: { address?: string };
|
|
35
|
+
destination: { address?: string };
|
|
36
|
+
distanceKm?: number;
|
|
37
|
+
weightKg?: number;
|
|
38
|
+
lengthCm?: number;
|
|
39
|
+
widthCm?: number;
|
|
40
|
+
heightCm?: number;
|
|
41
|
+
volumeCbm?: number;
|
|
42
|
+
containerType?: OceanContainerType;
|
|
43
|
+
containerCount?: number;
|
|
44
|
+
carrierName?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface ShipmentLeg extends ShipmentLegDraft {
|
|
48
|
+
id: string;
|
|
49
|
+
legNumber: number;
|
|
50
|
+
status: ShipmentLegStatus;
|
|
51
|
+
chargeableWeightKg?: number | null;
|
|
52
|
+
calculatedCost?: number | null;
|
|
53
|
+
currency: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface MultiModalShipment {
|
|
57
|
+
id: string;
|
|
58
|
+
shipmentNumber: string;
|
|
59
|
+
status: string;
|
|
60
|
+
primaryMode: TransportMode;
|
|
61
|
+
isMultiModal: boolean;
|
|
62
|
+
totalFreightCost?: number | null;
|
|
63
|
+
freightCurrency?: string | null;
|
|
64
|
+
origin: { address?: string };
|
|
65
|
+
destination: { address?: string };
|
|
66
|
+
createdAt: string;
|
|
67
|
+
legs: ShipmentLeg[];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
interface ApiShipmentLeg {
|
|
71
|
+
id: string;
|
|
72
|
+
legNumber: number;
|
|
73
|
+
mode: TransportMode;
|
|
74
|
+
status: ShipmentLegStatus;
|
|
75
|
+
origin: Record<string, unknown> | null;
|
|
76
|
+
destination: Record<string, unknown> | null;
|
|
77
|
+
distanceKm?: number | null;
|
|
78
|
+
weightKg?: number | null;
|
|
79
|
+
lengthCm?: number | null;
|
|
80
|
+
widthCm?: number | null;
|
|
81
|
+
heightCm?: number | null;
|
|
82
|
+
volumeCbm?: number | null;
|
|
83
|
+
containerType?: OceanContainerType | null;
|
|
84
|
+
containerCount?: number | null;
|
|
85
|
+
carrierName?: string | null;
|
|
86
|
+
chargeableWeightKg?: number | null;
|
|
87
|
+
calculatedCost?: number | null;
|
|
88
|
+
currency: string;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
interface ApiShipment {
|
|
92
|
+
id: string;
|
|
93
|
+
shipmentNumber: string;
|
|
94
|
+
status: string;
|
|
95
|
+
primaryMode: TransportMode;
|
|
96
|
+
isMultiModal: boolean;
|
|
97
|
+
totalFreightCost?: number | null;
|
|
98
|
+
freightCurrency?: string | null;
|
|
99
|
+
origin: Record<string, unknown> | null;
|
|
100
|
+
destination: Record<string, unknown> | null;
|
|
101
|
+
createdAt: string;
|
|
102
|
+
legs: ApiShipmentLeg[];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const LEG_FIELDS = `
|
|
106
|
+
id
|
|
107
|
+
legNumber
|
|
108
|
+
mode
|
|
109
|
+
status
|
|
110
|
+
origin
|
|
111
|
+
destination
|
|
112
|
+
distanceKm
|
|
113
|
+
weightKg
|
|
114
|
+
lengthCm
|
|
115
|
+
widthCm
|
|
116
|
+
heightCm
|
|
117
|
+
volumeCbm
|
|
118
|
+
containerType
|
|
119
|
+
containerCount
|
|
120
|
+
carrierName
|
|
121
|
+
chargeableWeightKg
|
|
122
|
+
calculatedCost
|
|
123
|
+
currency
|
|
124
|
+
`;
|
|
125
|
+
|
|
126
|
+
const SHIPMENT_FIELDS = `
|
|
127
|
+
id
|
|
128
|
+
shipmentNumber
|
|
129
|
+
status
|
|
130
|
+
primaryMode
|
|
131
|
+
isMultiModal
|
|
132
|
+
totalFreightCost
|
|
133
|
+
freightCurrency
|
|
134
|
+
origin
|
|
135
|
+
destination
|
|
136
|
+
createdAt
|
|
137
|
+
legs {
|
|
138
|
+
${LEG_FIELDS}
|
|
139
|
+
}
|
|
140
|
+
`;
|
|
141
|
+
|
|
142
|
+
const MULTI_MODAL_SHIPMENTS_QUERY = `
|
|
143
|
+
query ListMultiModalShipments($pagination: PaginationInput) {
|
|
144
|
+
shipments(filter: { isMultiModal: true }, pagination: $pagination) {
|
|
145
|
+
${SHIPMENT_FIELDS}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
`;
|
|
149
|
+
|
|
150
|
+
const CALCULATE_FREIGHT_RATE_QUERY = `
|
|
151
|
+
query CalculateFreightRate($input: FreightRateInput!) {
|
|
152
|
+
calculateFreightRate(input: $input) {
|
|
153
|
+
mode
|
|
154
|
+
chargeableWeightKg
|
|
155
|
+
ratePerUnit
|
|
156
|
+
calculatedCost
|
|
157
|
+
currency
|
|
158
|
+
breakdown {
|
|
159
|
+
label
|
|
160
|
+
amount
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
`;
|
|
165
|
+
|
|
166
|
+
const PLAN_MULTI_MODAL_SHIPMENT_MUTATION = `
|
|
167
|
+
mutation PlanMultiModalShipment($input: PlanMultiModalShipmentInput!) {
|
|
168
|
+
planMultiModalShipment(input: $input) {
|
|
169
|
+
${SHIPMENT_FIELDS}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
`;
|
|
173
|
+
|
|
174
|
+
function toApiLeg(leg: ShipmentLegDraft) {
|
|
175
|
+
return {
|
|
176
|
+
mode: leg.mode,
|
|
177
|
+
origin: leg.origin,
|
|
178
|
+
destination: leg.destination,
|
|
179
|
+
distanceKm: leg.distanceKm,
|
|
180
|
+
weightKg: leg.weightKg,
|
|
181
|
+
lengthCm: leg.lengthCm,
|
|
182
|
+
widthCm: leg.widthCm,
|
|
183
|
+
heightCm: leg.heightCm,
|
|
184
|
+
volumeCbm: leg.volumeCbm,
|
|
185
|
+
containerType: leg.containerType,
|
|
186
|
+
containerCount: leg.containerCount,
|
|
187
|
+
carrierName: leg.carrierName,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// FreightRateInput (shipment.gql) is intentionally narrower than
|
|
192
|
+
// CreateShipmentLegInput — it has no origin/destination/carrierName, only
|
|
193
|
+
// the fields that actually drive a rate calculation. Sending the full leg
|
|
194
|
+
// shape (as toApiLeg does for planMultiModalShipment/addShipmentLeg) trips
|
|
195
|
+
// GraphQL input-coercion on unknown fields, which the gateway surfaces as a
|
|
196
|
+
// masked "Unexpected error." 500 instead of a validation error — so this
|
|
197
|
+
// mapper must stay in lockstep with FreightRateInput's exact field set.
|
|
198
|
+
function toFreightRateInput(leg: ShipmentLegDraft) {
|
|
199
|
+
return {
|
|
200
|
+
mode: leg.mode,
|
|
201
|
+
distanceKm: leg.distanceKm,
|
|
202
|
+
weightKg: leg.weightKg,
|
|
203
|
+
lengthCm: leg.lengthCm,
|
|
204
|
+
widthCm: leg.widthCm,
|
|
205
|
+
heightCm: leg.heightCm,
|
|
206
|
+
volumeCbm: leg.volumeCbm,
|
|
207
|
+
containerType: leg.containerType,
|
|
208
|
+
containerCount: leg.containerCount,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function mapLeg(leg: ApiShipmentLeg): ShipmentLeg {
|
|
213
|
+
return {
|
|
214
|
+
id: leg.id,
|
|
215
|
+
legNumber: leg.legNumber,
|
|
216
|
+
mode: leg.mode,
|
|
217
|
+
status: leg.status,
|
|
218
|
+
origin: (leg.origin as { address?: string } | null) ?? {},
|
|
219
|
+
destination: (leg.destination as { address?: string } | null) ?? {},
|
|
220
|
+
distanceKm: leg.distanceKm ?? undefined,
|
|
221
|
+
weightKg: leg.weightKg ?? undefined,
|
|
222
|
+
lengthCm: leg.lengthCm ?? undefined,
|
|
223
|
+
widthCm: leg.widthCm ?? undefined,
|
|
224
|
+
heightCm: leg.heightCm ?? undefined,
|
|
225
|
+
volumeCbm: leg.volumeCbm ?? undefined,
|
|
226
|
+
containerType: leg.containerType ?? undefined,
|
|
227
|
+
containerCount: leg.containerCount ?? undefined,
|
|
228
|
+
carrierName: leg.carrierName ?? undefined,
|
|
229
|
+
chargeableWeightKg: leg.chargeableWeightKg,
|
|
230
|
+
calculatedCost: leg.calculatedCost,
|
|
231
|
+
currency: leg.currency,
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function mapShipment(shipment: ApiShipment): MultiModalShipment {
|
|
236
|
+
return {
|
|
237
|
+
id: shipment.id,
|
|
238
|
+
shipmentNumber: shipment.shipmentNumber,
|
|
239
|
+
status: shipment.status,
|
|
240
|
+
primaryMode: shipment.primaryMode,
|
|
241
|
+
isMultiModal: shipment.isMultiModal,
|
|
242
|
+
totalFreightCost: shipment.totalFreightCost,
|
|
243
|
+
freightCurrency: shipment.freightCurrency,
|
|
244
|
+
origin: (shipment.origin as { address?: string } | null) ?? {},
|
|
245
|
+
destination: (shipment.destination as { address?: string } | null) ?? {},
|
|
246
|
+
createdAt: shipment.createdAt,
|
|
247
|
+
legs: (shipment.legs ?? []).map(mapLeg),
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function useGraphqlOptions() {
|
|
252
|
+
const { apiGatewayUrl, authToken, workspaceToken, workspaceId, tenantId, currentUser } =
|
|
253
|
+
useMoveTheWheels();
|
|
254
|
+
return useMemo(
|
|
255
|
+
() => ({
|
|
256
|
+
apiGatewayUrl,
|
|
257
|
+
authToken,
|
|
258
|
+
workspaceToken,
|
|
259
|
+
workspaceId,
|
|
260
|
+
tenantId,
|
|
261
|
+
actorId: currentUser?.id,
|
|
262
|
+
}),
|
|
263
|
+
[apiGatewayUrl, authToken, workspaceToken, workspaceId, tenantId, currentUser?.id]
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
export function useMultiModalShipments() {
|
|
268
|
+
const graphQLOptions = useGraphqlOptions();
|
|
269
|
+
const [shipments, setShipments] = useState<MultiModalShipment[]>([]);
|
|
270
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
271
|
+
const [error, setError] = useState<Error | null>(null);
|
|
272
|
+
|
|
273
|
+
const load = useCallback(async () => {
|
|
274
|
+
setIsLoading(true);
|
|
275
|
+
setError(null);
|
|
276
|
+
try {
|
|
277
|
+
const data = await executeGraphQL<{ shipments: ApiShipment[] }>(
|
|
278
|
+
graphQLOptions,
|
|
279
|
+
MULTI_MODAL_SHIPMENTS_QUERY,
|
|
280
|
+
{ pagination: { limit: 100, offset: 0 } }
|
|
281
|
+
);
|
|
282
|
+
setShipments(data.shipments.map(mapShipment));
|
|
283
|
+
} catch (fetchError) {
|
|
284
|
+
setError(
|
|
285
|
+
fetchError instanceof Error ? fetchError : new Error('Failed to load multi-modal shipments')
|
|
286
|
+
);
|
|
287
|
+
} finally {
|
|
288
|
+
setIsLoading(false);
|
|
289
|
+
}
|
|
290
|
+
}, [graphQLOptions]);
|
|
291
|
+
|
|
292
|
+
useEffect(() => {
|
|
293
|
+
void load();
|
|
294
|
+
}, [load]);
|
|
295
|
+
|
|
296
|
+
const calculateFreightRate = useCallback(
|
|
297
|
+
async (leg: ShipmentLegDraft): Promise<FreightRateQuote> => {
|
|
298
|
+
const data = await executeGraphQL<{ calculateFreightRate: FreightRateQuote }>(
|
|
299
|
+
graphQLOptions,
|
|
300
|
+
CALCULATE_FREIGHT_RATE_QUERY,
|
|
301
|
+
{ input: toFreightRateInput(leg) }
|
|
302
|
+
);
|
|
303
|
+
return data.calculateFreightRate;
|
|
304
|
+
},
|
|
305
|
+
[graphQLOptions]
|
|
306
|
+
);
|
|
307
|
+
|
|
308
|
+
const planMultiModalShipment = useCallback(
|
|
309
|
+
async (input: {
|
|
310
|
+
shipmentNumber: string;
|
|
311
|
+
customerId?: string;
|
|
312
|
+
origin: { address?: string };
|
|
313
|
+
destination: { address?: string };
|
|
314
|
+
legs: ShipmentLegDraft[];
|
|
315
|
+
}): Promise<MultiModalShipment> => {
|
|
316
|
+
const data = await executeGraphQL<{ planMultiModalShipment: ApiShipment }>(
|
|
317
|
+
graphQLOptions,
|
|
318
|
+
PLAN_MULTI_MODAL_SHIPMENT_MUTATION,
|
|
319
|
+
{
|
|
320
|
+
input: {
|
|
321
|
+
shipmentNumber: input.shipmentNumber,
|
|
322
|
+
customerId: input.customerId,
|
|
323
|
+
origin: input.origin,
|
|
324
|
+
destination: input.destination,
|
|
325
|
+
legs: input.legs.map(toApiLeg),
|
|
326
|
+
},
|
|
327
|
+
}
|
|
328
|
+
);
|
|
329
|
+
const created = mapShipment(data.planMultiModalShipment);
|
|
330
|
+
setShipments((previous) => [created, ...previous]);
|
|
331
|
+
return created;
|
|
332
|
+
},
|
|
333
|
+
[graphQLOptions]
|
|
334
|
+
);
|
|
335
|
+
|
|
336
|
+
return {
|
|
337
|
+
shipments,
|
|
338
|
+
isLoading,
|
|
339
|
+
error,
|
|
340
|
+
refetch: () => void load(),
|
|
341
|
+
calculateFreightRate,
|
|
342
|
+
planMultiModalShipment,
|
|
343
|
+
};
|
|
344
|
+
}
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
MapPin,
|
|
17
17
|
ChevronRight,
|
|
18
18
|
Activity,
|
|
19
|
+
Ship,
|
|
19
20
|
} from 'lucide-react';
|
|
20
21
|
import { PageLayout, PageSection } from '../components/PageLayout';
|
|
21
22
|
import { MetricCard, MetricGrid, type MetricDelta } from '../components/MetricCard';
|
|
@@ -158,6 +159,11 @@ export default function DashboardPage() {
|
|
|
158
159
|
icon: <MapPin size={16} />,
|
|
159
160
|
onSelect: () => goTo('tracking/live'),
|
|
160
161
|
},
|
|
162
|
+
{
|
|
163
|
+
label: t('movethewheels.dashboard.actions.multiModal', 'Multi-Modal Freight'),
|
|
164
|
+
icon: <Ship size={16} />,
|
|
165
|
+
onSelect: () => goTo('shipments/multi-modal'),
|
|
166
|
+
},
|
|
161
167
|
]}
|
|
162
168
|
/>
|
|
163
169
|
}
|