@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.
Files changed (38) hide show
  1. package/dist/MoveTheWheelsRoutes.js +7 -3
  2. package/dist/components/AssignmentSuggestions.js +135 -0
  3. package/dist/components/FleetAlertsPanel.js +114 -0
  4. package/dist/components/ShippingQuoteResults.js +142 -0
  5. package/dist/components/VehicleServiceCard.js +176 -0
  6. package/dist/hooks/useFleet.js +4 -3
  7. package/dist/hooks/useFleetIntelligence.js +91 -0
  8. package/dist/hooks/useShippingCarriers.js +29 -0
  9. package/dist/movethewheels/components/AssignmentSuggestions.d.ts +17 -0
  10. package/dist/movethewheels/components/FleetAlertsPanel.d.ts +14 -0
  11. package/dist/movethewheels/components/ShippingQuoteResults.d.ts +8 -0
  12. package/dist/movethewheels/components/VehicleServiceCard.d.ts +10 -0
  13. package/dist/movethewheels/hooks/useFleetIntelligence.d.ts +64 -0
  14. package/dist/movethewheels/hooks/useShippingCarriers.d.ts +43 -0
  15. package/dist/movethewheels/pages/ShippingRatesPage.d.ts +9 -0
  16. package/dist/movethewheels/types/index.d.ts +2 -0
  17. package/dist/node_modules/lucide-react/dist/esm/icons/scale.js +26 -0
  18. package/dist/node_modules/lucide-react/dist/esm/icons/sparkles.js +26 -0
  19. package/dist/pages/FleetPage.js +156 -146
  20. package/dist/pages/RouteDetailsPage.js +202 -184
  21. package/dist/pages/ShippingRatesPage.js +177 -0
  22. package/dist/pages/VehicleDetailsPage.js +149 -131
  23. package/dist/utils/graphqlClient.js +14 -5
  24. package/package.json +1 -1
  25. package/src/movethewheels/MoveTheWheelsRoutes.tsx +2 -0
  26. package/src/movethewheels/components/AssignmentSuggestions.tsx +234 -0
  27. package/src/movethewheels/components/FleetAlertsPanel.tsx +148 -0
  28. package/src/movethewheels/components/ShippingQuoteResults.tsx +199 -0
  29. package/src/movethewheels/components/VehicleServiceCard.tsx +220 -0
  30. package/src/movethewheels/hooks/useFleet.ts +12 -2
  31. package/src/movethewheels/hooks/useFleetIntelligence.ts +226 -0
  32. package/src/movethewheels/hooks/useShippingCarriers.ts +110 -0
  33. package/src/movethewheels/pages/FleetPage.tsx +21 -0
  34. package/src/movethewheels/pages/RouteDetailsPage.tsx +33 -1
  35. package/src/movethewheels/pages/ShippingRatesPage.tsx +216 -0
  36. package/src/movethewheels/pages/VehicleDetailsPage.tsx +27 -0
  37. package/src/movethewheels/types/index.ts +2 -0
  38. 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
- lastError = new Error(`GraphQL request failed with status ${response.status}`);
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
  }