@burdenoff/microfe-movethewheels 2026.830.5 → 2026.920.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 +7 -3
- package/dist/components/AssignmentSuggestions.js +135 -0
- package/dist/components/FleetAlertsPanel.js +114 -0
- package/dist/components/ShippingQuoteResults.js +142 -0
- package/dist/components/VehicleServiceCard.js +176 -0
- package/dist/hooks/useFleet.js +4 -3
- package/dist/hooks/useFleetIntelligence.js +91 -0
- package/dist/hooks/useShippingCarriers.js +29 -0
- package/dist/movethewheels/components/AssignmentSuggestions.d.ts +17 -0
- package/dist/movethewheels/components/FleetAlertsPanel.d.ts +14 -0
- package/dist/movethewheels/components/ShippingQuoteResults.d.ts +8 -0
- package/dist/movethewheels/components/VehicleServiceCard.d.ts +10 -0
- package/dist/movethewheels/hooks/useFleetIntelligence.d.ts +64 -0
- package/dist/movethewheels/hooks/useShippingCarriers.d.ts +43 -0
- package/dist/movethewheels/pages/ShippingRatesPage.d.ts +9 -0
- package/dist/movethewheels/types/index.d.ts +2 -0
- package/dist/node_modules/lucide-react/dist/esm/icons/scale.js +26 -0
- package/dist/node_modules/lucide-react/dist/esm/icons/sparkles.js +26 -0
- package/dist/pages/FleetPage.js +156 -146
- package/dist/pages/RouteDetailsPage.js +202 -184
- package/dist/pages/ShippingRatesPage.js +177 -0
- package/dist/pages/VehicleDetailsPage.js +149 -131
- package/dist/utils/graphqlClient.js +14 -5
- package/package.json +1 -1
- package/src/movethewheels/MoveTheWheelsRoutes.tsx +2 -0
- package/src/movethewheels/components/AssignmentSuggestions.tsx +234 -0
- package/src/movethewheels/components/FleetAlertsPanel.tsx +148 -0
- package/src/movethewheels/components/ShippingQuoteResults.tsx +199 -0
- package/src/movethewheels/components/VehicleServiceCard.tsx +220 -0
- package/src/movethewheels/hooks/useFleet.ts +12 -2
- package/src/movethewheels/hooks/useFleetIntelligence.ts +226 -0
- package/src/movethewheels/hooks/useShippingCarriers.ts +110 -0
- package/src/movethewheels/pages/FleetPage.tsx +21 -0
- package/src/movethewheels/pages/RouteDetailsPage.tsx +33 -1
- package/src/movethewheels/pages/ShippingRatesPage.tsx +216 -0
- package/src/movethewheels/pages/VehicleDetailsPage.tsx +27 -0
- package/src/movethewheels/types/index.ts +2 -0
- package/src/movethewheels/utils/graphqlClient.ts +20 -1
|
@@ -41,6 +41,9 @@ import { RouteMap, type RouteStop as MapRouteStop } from '../components/maps';
|
|
|
41
41
|
import { firstRealLatLng } from '../utils/mapMarkers';
|
|
42
42
|
import { useRoute, useRoutes } from '../hooks/useRoutes';
|
|
43
43
|
import { useFleet } from '../hooks/useFleet';
|
|
44
|
+
import { useAssignmentSuggestions } from '../hooks/useFleetIntelligence';
|
|
45
|
+
import { AssignmentSuggestions } from '../components/AssignmentSuggestions';
|
|
46
|
+
import { useI18n } from '@burdenoff/fe-libs/shared/providers/shell/I18nProvider';
|
|
44
47
|
import { useMoveTheWheels } from '../providers/MoveTheWheelsProvider';
|
|
45
48
|
import { formatDate, formatAddress, formatCurrency } from '../utils/formatters';
|
|
46
49
|
import { joinPath } from '../utils/navigation';
|
|
@@ -374,7 +377,13 @@ export default function RouteDetailsPage() {
|
|
|
374
377
|
const isEditRoute = useLocation().pathname.endsWith('/edit');
|
|
375
378
|
const { basePath, navigate } = useMoveTheWheels();
|
|
376
379
|
const { route, isLoading, error, refetch } = useRoute(routeId);
|
|
377
|
-
const { optimizeRoute } = useRoutes();
|
|
380
|
+
const { optimizeRoute, updateRoute } = useRoutes();
|
|
381
|
+
const suggestions = useAssignmentSuggestions(routeId);
|
|
382
|
+
const { t: translate } = useI18n();
|
|
383
|
+
const t = (key: string, fallback: string): string => {
|
|
384
|
+
const value = translate(key);
|
|
385
|
+
return value === key ? fallback : value;
|
|
386
|
+
};
|
|
378
387
|
const { message: notice, notify } = useNotice();
|
|
379
388
|
const [optimizeError, setOptimizeError] = useState<string | null>(null);
|
|
380
389
|
const [optimizing, setOptimizing] = useState(false);
|
|
@@ -397,6 +406,16 @@ export default function RouteDetailsPage() {
|
|
|
397
406
|
}
|
|
398
407
|
};
|
|
399
408
|
|
|
409
|
+
// A rejected assignment (vehicle in maintenance, licence expired, already on a
|
|
410
|
+
// live route) throws with the server's message, which the suggestions card shows.
|
|
411
|
+
const handleAssign = async (patch: { vehicleId?: string; driverId?: string }) => {
|
|
412
|
+
if (!routeId) return;
|
|
413
|
+
await updateRoute(routeId, patch);
|
|
414
|
+
notify(t('movethewheels.routes.suggest.done', 'Assignment saved.'));
|
|
415
|
+
refetch();
|
|
416
|
+
suggestions.refetch();
|
|
417
|
+
};
|
|
418
|
+
|
|
400
419
|
// Handle new route form
|
|
401
420
|
if (!routeId) {
|
|
402
421
|
return <NewRouteForm />;
|
|
@@ -594,6 +613,19 @@ export default function RouteDetailsPage() {
|
|
|
594
613
|
</div>
|
|
595
614
|
|
|
596
615
|
<div className="space-y-6">
|
|
616
|
+
{(route.status === 'planned' || route.status === 'in_progress') && (
|
|
617
|
+
<AssignmentSuggestions
|
|
618
|
+
suggestions={suggestions.data}
|
|
619
|
+
isLoading={suggestions.isLoading}
|
|
620
|
+
error={suggestions.error}
|
|
621
|
+
onRetry={suggestions.refetch}
|
|
622
|
+
assignedVehicleId={route.vehicleId || undefined}
|
|
623
|
+
assignedDriverId={route.driverId || undefined}
|
|
624
|
+
onAssign={handleAssign}
|
|
625
|
+
t={t}
|
|
626
|
+
/>
|
|
627
|
+
)}
|
|
628
|
+
|
|
597
629
|
{/* Route Info — the page's single emphasis zone */}
|
|
598
630
|
<EmphasisPanel className="space-y-4">
|
|
599
631
|
<h2 className="text-base font-semibold text-text-primary">Route Info</h2>
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shipping Rates Page
|
|
3
|
+
*
|
|
4
|
+
* Compare live courier rates between two pincodes across the platform's
|
|
5
|
+
* carriers (Shiprocket first). Nothing to set up: the carrier accounts belong
|
|
6
|
+
* to the platform. Read-only — comparing never books a shipment or spends
|
|
7
|
+
* carrier credit.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { useState, type FormEvent } from 'react';
|
|
11
|
+
import { Scale } from 'lucide-react';
|
|
12
|
+
|
|
13
|
+
import { FormError } from '../components/FormError';
|
|
14
|
+
import { PageLayout } from '../components/PageLayout';
|
|
15
|
+
import { ShippingQuoteResults } from '../components/ShippingQuoteResults';
|
|
16
|
+
import {
|
|
17
|
+
Button,
|
|
18
|
+
Card,
|
|
19
|
+
CardContent,
|
|
20
|
+
CardHeader,
|
|
21
|
+
CardTitle,
|
|
22
|
+
Input,
|
|
23
|
+
Label,
|
|
24
|
+
Switch,
|
|
25
|
+
} from '../components/ui';
|
|
26
|
+
import { useTr } from '../hooks';
|
|
27
|
+
import {
|
|
28
|
+
useShippingQuotes,
|
|
29
|
+
type ShippingQuoteInput,
|
|
30
|
+
type ShippingQuoteResult,
|
|
31
|
+
} from '../hooks/useShippingCarriers';
|
|
32
|
+
|
|
33
|
+
const POSTCODE = /^[1-9]\d{5}$/;
|
|
34
|
+
|
|
35
|
+
const optionalNumber = (value: string): number | undefined => {
|
|
36
|
+
const parsed = Number(value);
|
|
37
|
+
return value.trim() !== '' && Number.isFinite(parsed) ? parsed : undefined;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export default function ShippingRatesPage() {
|
|
41
|
+
const tr = useTr();
|
|
42
|
+
const fetchQuotes = useShippingQuotes();
|
|
43
|
+
|
|
44
|
+
const [pickup, setPickup] = useState('');
|
|
45
|
+
const [delivery, setDelivery] = useState('');
|
|
46
|
+
const [weight, setWeight] = useState('');
|
|
47
|
+
const [length, setLength] = useState('');
|
|
48
|
+
const [width, setWidth] = useState('');
|
|
49
|
+
const [height, setHeight] = useState('');
|
|
50
|
+
const [cod, setCod] = useState(false);
|
|
51
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
52
|
+
const [error, setError] = useState<string | null>(null);
|
|
53
|
+
const [result, setResult] = useState<ShippingQuoteResult | null>(null);
|
|
54
|
+
|
|
55
|
+
const handleCompare = async (event: FormEvent) => {
|
|
56
|
+
event.preventDefault();
|
|
57
|
+
setError(null);
|
|
58
|
+
|
|
59
|
+
const weightKg = optionalNumber(weight);
|
|
60
|
+
if (!POSTCODE.test(pickup) || !POSTCODE.test(delivery)) {
|
|
61
|
+
setError(
|
|
62
|
+
tr(
|
|
63
|
+
'movethewheels.shipping.form.postcodeError',
|
|
64
|
+
'Enter valid 6-digit pickup and delivery postcodes'
|
|
65
|
+
)
|
|
66
|
+
);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
if (weightKg === undefined || weightKg <= 0) {
|
|
70
|
+
setError(tr('movethewheels.shipping.form.weightError', 'Enter the package weight in kg'));
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
const dims = [optionalNumber(length), optionalNumber(width), optionalNumber(height)];
|
|
74
|
+
const given = dims.filter((d) => d !== undefined).length;
|
|
75
|
+
if (given !== 0 && given !== 3) {
|
|
76
|
+
setError(
|
|
77
|
+
tr(
|
|
78
|
+
'movethewheels.shipping.form.dimensionsError',
|
|
79
|
+
'Enter length, width and height together, or leave all three empty'
|
|
80
|
+
)
|
|
81
|
+
);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const input: ShippingQuoteInput = {
|
|
86
|
+
pickupPostcode: pickup,
|
|
87
|
+
deliveryPostcode: delivery,
|
|
88
|
+
weightKg,
|
|
89
|
+
cod,
|
|
90
|
+
...(given === 3 ? { lengthCm: dims[0], widthCm: dims[1], heightCm: dims[2] } : {}),
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
setIsLoading(true);
|
|
94
|
+
try {
|
|
95
|
+
setResult(await fetchQuotes(input));
|
|
96
|
+
} catch (e: unknown) {
|
|
97
|
+
setResult(null);
|
|
98
|
+
setError(
|
|
99
|
+
e instanceof Error
|
|
100
|
+
? e.message
|
|
101
|
+
: tr('movethewheels.shipping.form.failed', 'Could not fetch rates')
|
|
102
|
+
);
|
|
103
|
+
} finally {
|
|
104
|
+
setIsLoading(false);
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
return (
|
|
109
|
+
<PageLayout
|
|
110
|
+
title={tr('movethewheels.shipping.title', 'Compare Rates')}
|
|
111
|
+
subtitle={tr(
|
|
112
|
+
'movethewheels.shipping.subtitle',
|
|
113
|
+
'Enter a pickup and a delivery pincode to see every courier available for that route, with price and delivery time.'
|
|
114
|
+
)}
|
|
115
|
+
icon={<Scale size={24} />}
|
|
116
|
+
>
|
|
117
|
+
<div className="space-y-6">
|
|
118
|
+
<Card>
|
|
119
|
+
<CardHeader>
|
|
120
|
+
<CardTitle>{tr('movethewheels.shipping.form.title', 'Shipment details')}</CardTitle>
|
|
121
|
+
</CardHeader>
|
|
122
|
+
<CardContent>
|
|
123
|
+
<form onSubmit={(event) => void handleCompare(event)} className="space-y-4">
|
|
124
|
+
<div className="grid gap-3 sm:grid-cols-3">
|
|
125
|
+
<div className="space-y-1">
|
|
126
|
+
<Label htmlFor="rate-pickup">
|
|
127
|
+
{tr('movethewheels.shipping.form.pickup', 'Pickup postcode')}
|
|
128
|
+
</Label>
|
|
129
|
+
<Input
|
|
130
|
+
id="rate-pickup"
|
|
131
|
+
inputMode="numeric"
|
|
132
|
+
maxLength={6}
|
|
133
|
+
value={pickup}
|
|
134
|
+
onChange={(e) => setPickup(e.target.value.trim())}
|
|
135
|
+
/>
|
|
136
|
+
</div>
|
|
137
|
+
<div className="space-y-1">
|
|
138
|
+
<Label htmlFor="rate-delivery">
|
|
139
|
+
{tr('movethewheels.shipping.form.delivery', 'Delivery postcode')}
|
|
140
|
+
</Label>
|
|
141
|
+
<Input
|
|
142
|
+
id="rate-delivery"
|
|
143
|
+
inputMode="numeric"
|
|
144
|
+
maxLength={6}
|
|
145
|
+
value={delivery}
|
|
146
|
+
onChange={(e) => setDelivery(e.target.value.trim())}
|
|
147
|
+
/>
|
|
148
|
+
</div>
|
|
149
|
+
<div className="space-y-1">
|
|
150
|
+
<Label htmlFor="rate-weight">
|
|
151
|
+
{tr('movethewheels.shipping.form.weight', 'Weight (kg)')}
|
|
152
|
+
</Label>
|
|
153
|
+
<Input
|
|
154
|
+
id="rate-weight"
|
|
155
|
+
inputMode="decimal"
|
|
156
|
+
value={weight}
|
|
157
|
+
onChange={(e) => setWeight(e.target.value)}
|
|
158
|
+
/>
|
|
159
|
+
</div>
|
|
160
|
+
</div>
|
|
161
|
+
<div className="grid gap-3 sm:grid-cols-3">
|
|
162
|
+
<div className="space-y-1">
|
|
163
|
+
<Label htmlFor="rate-length">
|
|
164
|
+
{tr('movethewheels.shipping.form.length', 'Length (cm, optional)')}
|
|
165
|
+
</Label>
|
|
166
|
+
<Input
|
|
167
|
+
id="rate-length"
|
|
168
|
+
inputMode="decimal"
|
|
169
|
+
value={length}
|
|
170
|
+
onChange={(e) => setLength(e.target.value)}
|
|
171
|
+
/>
|
|
172
|
+
</div>
|
|
173
|
+
<div className="space-y-1">
|
|
174
|
+
<Label htmlFor="rate-width">
|
|
175
|
+
{tr('movethewheels.shipping.form.width', 'Width (cm, optional)')}
|
|
176
|
+
</Label>
|
|
177
|
+
<Input
|
|
178
|
+
id="rate-width"
|
|
179
|
+
inputMode="decimal"
|
|
180
|
+
value={width}
|
|
181
|
+
onChange={(e) => setWidth(e.target.value)}
|
|
182
|
+
/>
|
|
183
|
+
</div>
|
|
184
|
+
<div className="space-y-1">
|
|
185
|
+
<Label htmlFor="rate-height">
|
|
186
|
+
{tr('movethewheels.shipping.form.height', 'Height (cm, optional)')}
|
|
187
|
+
</Label>
|
|
188
|
+
<Input
|
|
189
|
+
id="rate-height"
|
|
190
|
+
inputMode="decimal"
|
|
191
|
+
value={height}
|
|
192
|
+
onChange={(e) => setHeight(e.target.value)}
|
|
193
|
+
/>
|
|
194
|
+
</div>
|
|
195
|
+
</div>
|
|
196
|
+
<div className="flex items-center gap-2">
|
|
197
|
+
<Switch id="rate-cod" checked={cod} onCheckedChange={setCod} />
|
|
198
|
+
<Label htmlFor="rate-cod">
|
|
199
|
+
{tr('movethewheels.shipping.form.cod', 'Cash on delivery')}
|
|
200
|
+
</Label>
|
|
201
|
+
</div>
|
|
202
|
+
<FormError message={error} />
|
|
203
|
+
<Button type="submit" disabled={isLoading}>
|
|
204
|
+
{isLoading
|
|
205
|
+
? tr('movethewheels.shipping.form.comparing', 'Comparing…')
|
|
206
|
+
: tr('movethewheels.shipping.form.compare', 'Compare rates')}
|
|
207
|
+
</Button>
|
|
208
|
+
</form>
|
|
209
|
+
</CardContent>
|
|
210
|
+
</Card>
|
|
211
|
+
|
|
212
|
+
{result && <ShippingQuoteResults result={result} t={tr} />}
|
|
213
|
+
</div>
|
|
214
|
+
</PageLayout>
|
|
215
|
+
);
|
|
216
|
+
}
|
|
@@ -26,10 +26,14 @@ import {
|
|
|
26
26
|
} from '../components/ui';
|
|
27
27
|
import { EmphasisPanel, NextSteps, PagePurpose } from '@burdenoff/fe-libs/ui';
|
|
28
28
|
import { useVehicle, useVehicleDrivers, useFleet } from '../hooks/useFleet';
|
|
29
|
+
import { useRecordVehicleService } from '../hooks/useFleetIntelligence';
|
|
30
|
+
import { VehicleServiceCard } from '../components/VehicleServiceCard';
|
|
29
31
|
import { useMoveTheWheels } from '../providers/MoveTheWheelsProvider';
|
|
30
32
|
import { formatDate, getInitials } from '../utils/formatters';
|
|
33
|
+
import { useI18n } from '@burdenoff/fe-libs/shared/providers/shell/I18nProvider';
|
|
31
34
|
import { joinPath } from '../utils/navigation';
|
|
32
35
|
import { FormError } from '../components/FormError';
|
|
36
|
+
import { Notice, useNotice } from '../components/Notice';
|
|
33
37
|
import { DocumentsCard } from '../components/DocumentsCard';
|
|
34
38
|
|
|
35
39
|
const VEHICLE_TYPE_OPTIONS = [
|
|
@@ -214,6 +218,13 @@ export default function VehicleDetailsPage() {
|
|
|
214
218
|
const { vehicleId } = useParams<{ vehicleId: string }>();
|
|
215
219
|
const { basePath, navigate } = useMoveTheWheels();
|
|
216
220
|
const { vehicle, isLoading, error, refetch } = useVehicle(vehicleId);
|
|
221
|
+
const recordService = useRecordVehicleService();
|
|
222
|
+
const { message: notice, notify } = useNotice();
|
|
223
|
+
const { t: translate } = useI18n();
|
|
224
|
+
const t = (key: string, fallback: string): string => {
|
|
225
|
+
const value = translate(key);
|
|
226
|
+
return value === key ? fallback : value;
|
|
227
|
+
};
|
|
217
228
|
const isEditRoute = useLocation().pathname.endsWith('/edit');
|
|
218
229
|
// Who is behind the wheel — resolved through DriverFilterInput.vehicleId, the
|
|
219
230
|
// same link `assignDriverVehicle` writes.
|
|
@@ -285,6 +296,12 @@ export default function VehicleDetailsPage() {
|
|
|
285
296
|
]}
|
|
286
297
|
/>
|
|
287
298
|
|
|
299
|
+
{notice && (
|
|
300
|
+
<div className="mb-2">
|
|
301
|
+
<Notice message={notice} />
|
|
302
|
+
</div>
|
|
303
|
+
)}
|
|
304
|
+
|
|
288
305
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
|
289
306
|
<div className="lg:col-span-2 space-y-6">
|
|
290
307
|
{/* Vehicle Information — the page's single emphasis zone */}
|
|
@@ -440,6 +457,16 @@ export default function VehicleDetailsPage() {
|
|
|
440
457
|
</CardContent>
|
|
441
458
|
</Card>
|
|
442
459
|
|
|
460
|
+
<VehicleServiceCard
|
|
461
|
+
vehicle={vehicle}
|
|
462
|
+
t={t}
|
|
463
|
+
onRecord={async (input) => {
|
|
464
|
+
await recordService(vehicle.id, input);
|
|
465
|
+
notify(t('movethewheels.vehicles.service.saved', 'Service recorded.'));
|
|
466
|
+
refetch();
|
|
467
|
+
}}
|
|
468
|
+
/>
|
|
469
|
+
|
|
443
470
|
{vehicle.currentLocation && (
|
|
444
471
|
<Card>
|
|
445
472
|
<CardHeader>
|
|
@@ -341,6 +341,8 @@ export interface Vehicle {
|
|
|
341
341
|
currentLocation?: Location;
|
|
342
342
|
lastMaintenance?: Date;
|
|
343
343
|
nextMaintenance?: Date;
|
|
344
|
+
/** Odometer reading (km) at which the next service is due. */
|
|
345
|
+
nextMaintenanceMileage?: number;
|
|
344
346
|
fuelLevel?: number;
|
|
345
347
|
mileage?: number;
|
|
346
348
|
documents: VehicleDocument[];
|
|
@@ -132,6 +132,19 @@ export async function executeGraphQL<TData>(
|
|
|
132
132
|
}
|
|
133
133
|
}
|
|
134
134
|
|
|
135
|
+
/** Best-effort: the joined `errors[].message` of a non-2xx GraphQL response, or null. */
|
|
136
|
+
async function readGraphqlErrorMessage(response: Response): Promise<string | null> {
|
|
137
|
+
try {
|
|
138
|
+
const payload = (await response.json()) as { errors?: Array<{ message?: string }> };
|
|
139
|
+
const messages = (payload.errors ?? [])
|
|
140
|
+
.map((item) => item.message)
|
|
141
|
+
.filter((message): message is string => typeof message === 'string' && message.length > 0);
|
|
142
|
+
return messages.length > 0 ? messages.join('; ') : null;
|
|
143
|
+
} catch {
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
135
148
|
async function runWithRetries<TData>(
|
|
136
149
|
url: string,
|
|
137
150
|
headers: Record<string, string>,
|
|
@@ -156,7 +169,13 @@ async function runWithRetries<TData>(
|
|
|
156
169
|
});
|
|
157
170
|
|
|
158
171
|
if (!response.ok) {
|
|
159
|
-
|
|
172
|
+
// Transient statuses are retried on the status alone. For anything else the
|
|
173
|
+
// service often answers 4xx with a well-formed GraphQL error (e.g. a business
|
|
174
|
+
// rule rejection) — show that message rather than a bare status code.
|
|
175
|
+
const detail = isTransientStatus(response.status)
|
|
176
|
+
? null
|
|
177
|
+
: await readGraphqlErrorMessage(response);
|
|
178
|
+
lastError = new Error(detail ?? `GraphQL request failed with status ${response.status}`);
|
|
160
179
|
if (canRetry && isTransientStatus(response.status)) continue;
|
|
161
180
|
throw lastError;
|
|
162
181
|
}
|