@burdenoff/microfe-movethewheels 2026.830.4 → 2026.918.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.
@@ -0,0 +1,226 @@
1
+ /**
2
+ * Fleet intelligence hooks — compliance/maintenance alerts, dispatch
3
+ * suggestions for a route, and logging a completed vehicle service.
4
+ *
5
+ * All three sit on top of wspace-movethewheels-svc's fleet module; the ranking
6
+ * and alert rules live server-side so every surface (web, CLI, MCP) agrees.
7
+ */
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 FleetAlertType =
14
+ | 'LICENSE_EXPIRED'
15
+ | 'LICENSE_EXPIRING'
16
+ | 'MAINTENANCE_OVERDUE'
17
+ | 'MAINTENANCE_DUE_SOON';
18
+ export type FleetAlertSeverity = 'CRITICAL' | 'WARNING';
19
+ export type FleetAlertSubjectType = 'VEHICLE' | 'DRIVER';
20
+
21
+ export interface FleetAlert {
22
+ id: string;
23
+ type: FleetAlertType;
24
+ severity: FleetAlertSeverity;
25
+ subjectType: FleetAlertSubjectType;
26
+ subjectId: string;
27
+ subjectName: string;
28
+ message: string;
29
+ dueDate: string | null;
30
+ daysUntilDue: number | null;
31
+ kmUntilDue: number | null;
32
+ }
33
+
34
+ export interface VehicleSuggestion {
35
+ score: number;
36
+ reasons: string[];
37
+ vehicle: { id: string; vehicleNumber: string; make: string | null; model: string | null };
38
+ }
39
+
40
+ export interface DriverSuggestion {
41
+ score: number;
42
+ reasons: string[];
43
+ driver: { id: string; firstName: string; lastName: string; rating: number | null };
44
+ }
45
+
46
+ export interface AssignmentSuggestions {
47
+ routeId: string;
48
+ vehicles: VehicleSuggestion[];
49
+ drivers: DriverSuggestion[];
50
+ }
51
+
52
+ export interface RecordVehicleServiceInput {
53
+ /** ISO date-time. Defaults to now on the server. */
54
+ serviceDate?: string;
55
+ mileage?: number;
56
+ intervalDays?: number;
57
+ intervalKm?: number;
58
+ }
59
+
60
+ const FLEET_ALERTS_QUERY = `
61
+ query FleetAlerts {
62
+ fleetAlerts {
63
+ id
64
+ type
65
+ severity
66
+ subjectType
67
+ subjectId
68
+ subjectName
69
+ message
70
+ dueDate
71
+ daysUntilDue
72
+ kmUntilDue
73
+ }
74
+ }
75
+ `;
76
+
77
+ const ASSIGNMENT_SUGGESTIONS_QUERY = `
78
+ query AssignmentSuggestions($routeId: ID!, $limit: Int) {
79
+ assignmentSuggestions(routeId: $routeId, limit: $limit) {
80
+ routeId
81
+ vehicles {
82
+ score
83
+ reasons
84
+ vehicle {
85
+ id
86
+ vehicleNumber
87
+ make
88
+ model
89
+ }
90
+ }
91
+ drivers {
92
+ score
93
+ reasons
94
+ driver {
95
+ id
96
+ firstName
97
+ lastName
98
+ rating
99
+ }
100
+ }
101
+ }
102
+ }
103
+ `;
104
+
105
+ const RECORD_VEHICLE_SERVICE_MUTATION = `
106
+ mutation RecordVehicleService($id: ID!, $input: RecordVehicleServiceInput!) {
107
+ recordVehicleService(id: $id, input: $input) {
108
+ id
109
+ status
110
+ mileage
111
+ lastServiceDate
112
+ nextServiceDueDate
113
+ nextServiceDueMileage
114
+ }
115
+ }
116
+ `;
117
+
118
+ function useGraphqlOptions() {
119
+ const { apiGatewayUrl, authToken, workspaceToken, workspaceId, tenantId, currentUser } =
120
+ useMoveTheWheels();
121
+ return useMemo(
122
+ () => ({
123
+ apiGatewayUrl,
124
+ authToken,
125
+ workspaceToken,
126
+ workspaceId,
127
+ tenantId,
128
+ actorId: currentUser?.id,
129
+ }),
130
+ [apiGatewayUrl, authToken, workspaceToken, workspaceId, tenantId, currentUser?.id]
131
+ );
132
+ }
133
+
134
+ interface AsyncResource<T> {
135
+ data: T;
136
+ isLoading: boolean;
137
+ error: Error | null;
138
+ refetch: () => void;
139
+ }
140
+
141
+ export function useFleetAlerts(): AsyncResource<FleetAlert[]> {
142
+ const graphQLOptions = useGraphqlOptions();
143
+ const [data, setData] = useState<FleetAlert[]>([]);
144
+ const [isLoading, setIsLoading] = useState(true);
145
+ const [error, setError] = useState<Error | null>(null);
146
+ const [nonce, setNonce] = useState(0);
147
+
148
+ useEffect(() => {
149
+ let active = true;
150
+ setIsLoading(true);
151
+ setError(null);
152
+ executeGraphQL<{ fleetAlerts: FleetAlert[] }>(graphQLOptions, FLEET_ALERTS_QUERY)
153
+ .then((result) => {
154
+ if (active) setData(result.fleetAlerts);
155
+ })
156
+ .catch((e: unknown) => {
157
+ if (active) setError(e instanceof Error ? e : new Error('Failed to load fleet alerts'));
158
+ })
159
+ .finally(() => {
160
+ if (active) setIsLoading(false);
161
+ });
162
+ return () => {
163
+ active = false;
164
+ };
165
+ }, [graphQLOptions, nonce]);
166
+
167
+ const refetch = useCallback(() => setNonce((n) => n + 1), []);
168
+ return { data, isLoading, error, refetch };
169
+ }
170
+
171
+ const EMPTY_SUGGESTIONS: AssignmentSuggestions = { routeId: '', vehicles: [], drivers: [] };
172
+
173
+ export function useAssignmentSuggestions(
174
+ routeId: string | undefined,
175
+ limit = 3
176
+ ): AsyncResource<AssignmentSuggestions> {
177
+ const graphQLOptions = useGraphqlOptions();
178
+ const [data, setData] = useState<AssignmentSuggestions>(EMPTY_SUGGESTIONS);
179
+ const [isLoading, setIsLoading] = useState(Boolean(routeId));
180
+ const [error, setError] = useState<Error | null>(null);
181
+ const [nonce, setNonce] = useState(0);
182
+
183
+ useEffect(() => {
184
+ if (!routeId) {
185
+ setData(EMPTY_SUGGESTIONS);
186
+ setIsLoading(false);
187
+ return undefined;
188
+ }
189
+ let active = true;
190
+ setIsLoading(true);
191
+ setError(null);
192
+ executeGraphQL<{ assignmentSuggestions: AssignmentSuggestions }>(
193
+ graphQLOptions,
194
+ ASSIGNMENT_SUGGESTIONS_QUERY,
195
+ { routeId, limit }
196
+ )
197
+ .then((result) => {
198
+ if (active) setData(result.assignmentSuggestions);
199
+ })
200
+ .catch((e: unknown) => {
201
+ if (active) setError(e instanceof Error ? e : new Error('Failed to load suggestions'));
202
+ })
203
+ .finally(() => {
204
+ if (active) setIsLoading(false);
205
+ });
206
+ return () => {
207
+ active = false;
208
+ };
209
+ }, [graphQLOptions, routeId, limit, nonce]);
210
+
211
+ const refetch = useCallback(() => setNonce((n) => n + 1), []);
212
+ return { data, isLoading, error, refetch };
213
+ }
214
+
215
+ export function useRecordVehicleService() {
216
+ const graphQLOptions = useGraphqlOptions();
217
+ return useCallback(
218
+ async (vehicleId: string, input: RecordVehicleServiceInput): Promise<void> => {
219
+ await executeGraphQL(graphQLOptions, RECORD_VEHICLE_SERVICE_MUTATION, {
220
+ id: vehicleId,
221
+ input,
222
+ });
223
+ },
224
+ [graphQLOptions]
225
+ );
226
+ }
@@ -20,6 +20,8 @@ import {
20
20
  } from '../components/ui';
21
21
  import { CTAOverflowMenu, EmphasisPanel, PagePurpose, NextSteps } from '@burdenoff/fe-libs/ui';
22
22
  import { useFleetStats, useVehicles, useDrivers } from '../hooks/useFleet';
23
+ import { useFleetAlerts, type FleetAlert } from '../hooks/useFleetIntelligence';
24
+ import { FleetAlertsPanel } from '../components/FleetAlertsPanel';
23
25
  import { useMoveTheWheels } from '../providers/MoveTheWheelsProvider';
24
26
  import { useI18n } from '@burdenoff/fe-libs/shared/providers/shell/I18nProvider';
25
27
  import { joinPath } from '../utils/navigation';
@@ -50,6 +52,13 @@ export default function FleetPage() {
50
52
  refetch: refetchDrivers,
51
53
  } = useDrivers();
52
54
 
55
+ const {
56
+ data: alerts,
57
+ isLoading: alertsLoading,
58
+ error: alertsError,
59
+ refetch: refetchAlerts,
60
+ } = useFleetAlerts();
61
+
53
62
  const isLoading = statsLoading || vehiclesLoading || driversLoading;
54
63
  const error = statsError ?? vehiclesError ?? driversError;
55
64
  const refetchAll = () => {
@@ -64,6 +73,9 @@ export default function FleetPage() {
64
73
  }
65
74
  };
66
75
 
76
+ const openAlert = (alert: FleetAlert) =>
77
+ goTo(`fleet/${alert.subjectType === 'VEHICLE' ? 'vehicles' : 'drivers'}/${alert.subjectId}`);
78
+
67
79
  const onRowKeyDown = (e: React.KeyboardEvent, action: () => void) => {
68
80
  if (e.key === 'Enter' || e.key === ' ') {
69
81
  e.preventDefault();
@@ -136,6 +148,15 @@ export default function FleetPage() {
136
148
  ]}
137
149
  />
138
150
 
151
+ <FleetAlertsPanel
152
+ alerts={alerts}
153
+ isLoading={alertsLoading}
154
+ error={alertsError}
155
+ onRetry={refetchAlerts}
156
+ onOpen={openAlert}
157
+ t={t}
158
+ />
159
+
139
160
  {/* Key Metrics */}
140
161
  {error && !isLoading ? (
141
162
  <Card className="border-status-error-border">
@@ -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>
@@ -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
  }