@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
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* VehicleServiceCard
|
|
3
|
+
*
|
|
4
|
+
* Shows a vehicle's service schedule (last service, next due date/mileage) and
|
|
5
|
+
* lets an operator log a completed service. Logging a service advances the
|
|
6
|
+
* odometer, sets the next due markers from the intervals given, and returns a
|
|
7
|
+
* vehicle that was in the workshop to service — so the "due" alert clears
|
|
8
|
+
* instead of lingering.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { useState } from 'react';
|
|
12
|
+
import { Wrench } from 'lucide-react';
|
|
13
|
+
|
|
14
|
+
import { FormError } from './FormError';
|
|
15
|
+
import { Button, Card, CardContent, CardHeader, CardTitle, Input, Label } from './ui';
|
|
16
|
+
|
|
17
|
+
import type { RecordVehicleServiceInput } from '../hooks/useFleetIntelligence';
|
|
18
|
+
import type { Vehicle } from '../types';
|
|
19
|
+
|
|
20
|
+
interface VehicleServiceCardProps {
|
|
21
|
+
vehicle: Vehicle;
|
|
22
|
+
onRecord: (input: RecordVehicleServiceInput) => Promise<void>;
|
|
23
|
+
t: (key: string, fallback: string) => string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const DEFAULT_INTERVAL_DAYS = 90;
|
|
27
|
+
const DEFAULT_INTERVAL_KM = 10000;
|
|
28
|
+
|
|
29
|
+
const formatDay = (date: Date | undefined): string | null =>
|
|
30
|
+
date
|
|
31
|
+
? date.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
|
|
32
|
+
: null;
|
|
33
|
+
|
|
34
|
+
const toDateInput = (date: Date): string => date.toISOString().slice(0, 10);
|
|
35
|
+
|
|
36
|
+
/** Empty string -> undefined; otherwise a finite non-negative number, or NaN when invalid. */
|
|
37
|
+
const parseOptionalNumber = (value: string): number | undefined => {
|
|
38
|
+
if (value.trim() === '') return undefined;
|
|
39
|
+
const parsed = Number(value);
|
|
40
|
+
return Number.isFinite(parsed) && parsed >= 0 ? parsed : Number.NaN;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export function VehicleServiceCard({ vehicle, onRecord, t }: VehicleServiceCardProps) {
|
|
44
|
+
const [open, setOpen] = useState(false);
|
|
45
|
+
const [saving, setSaving] = useState(false);
|
|
46
|
+
const [error, setError] = useState<string | null>(null);
|
|
47
|
+
const [form, setForm] = useState({
|
|
48
|
+
serviceDate: toDateInput(new Date()),
|
|
49
|
+
mileage: vehicle.mileage !== undefined ? String(vehicle.mileage) : '',
|
|
50
|
+
intervalDays: String(DEFAULT_INTERVAL_DAYS),
|
|
51
|
+
intervalKm: String(DEFAULT_INTERVAL_KM),
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const lastService = formatDay(vehicle.lastMaintenance);
|
|
55
|
+
const nextDate = formatDay(vehicle.nextMaintenance);
|
|
56
|
+
const nextMileage = vehicle.nextMaintenanceMileage;
|
|
57
|
+
const hasSchedule = nextDate !== null || nextMileage !== undefined;
|
|
58
|
+
|
|
59
|
+
const submit = async (event: React.FormEvent) => {
|
|
60
|
+
event.preventDefault();
|
|
61
|
+
const mileage = parseOptionalNumber(form.mileage);
|
|
62
|
+
const intervalDays = parseOptionalNumber(form.intervalDays);
|
|
63
|
+
const intervalKm = parseOptionalNumber(form.intervalKm);
|
|
64
|
+
if ([mileage, intervalDays, intervalKm].some((n) => n !== undefined && Number.isNaN(n))) {
|
|
65
|
+
setError(t('movethewheels.vehicles.service.invalid', 'Enter valid, non-negative numbers.'));
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
if (intervalKm !== undefined && mileage === undefined && vehicle.mileage === undefined) {
|
|
69
|
+
setError(
|
|
70
|
+
t(
|
|
71
|
+
'movethewheels.vehicles.service.needsMileage',
|
|
72
|
+
'Enter the odometer reading to set a service-by-distance interval.'
|
|
73
|
+
)
|
|
74
|
+
);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
setSaving(true);
|
|
79
|
+
setError(null);
|
|
80
|
+
try {
|
|
81
|
+
await onRecord({
|
|
82
|
+
// Noon UTC keeps the calendar day stable in any timezone.
|
|
83
|
+
serviceDate: `${form.serviceDate}T12:00:00.000Z`,
|
|
84
|
+
...(mileage !== undefined ? { mileage } : {}),
|
|
85
|
+
...(intervalDays !== undefined ? { intervalDays: Math.round(intervalDays) } : {}),
|
|
86
|
+
...(intervalKm !== undefined ? { intervalKm } : {}),
|
|
87
|
+
});
|
|
88
|
+
setOpen(false);
|
|
89
|
+
} catch (e) {
|
|
90
|
+
setError(e instanceof Error ? e.message : 'Could not record the service.');
|
|
91
|
+
} finally {
|
|
92
|
+
setSaving(false);
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
return (
|
|
97
|
+
<Card>
|
|
98
|
+
<CardHeader>
|
|
99
|
+
<CardTitle className="flex items-center gap-2">
|
|
100
|
+
<Wrench size={18} aria-hidden="true" />
|
|
101
|
+
{t('movethewheels.vehicles.service.title', 'Service schedule')}
|
|
102
|
+
</CardTitle>
|
|
103
|
+
</CardHeader>
|
|
104
|
+
<CardContent className="space-y-4">
|
|
105
|
+
<dl className="grid grid-cols-2 gap-4 text-sm">
|
|
106
|
+
<div>
|
|
107
|
+
<dt className="text-text-secondary">
|
|
108
|
+
{t('movethewheels.vehicles.service.last', 'Last service')}
|
|
109
|
+
</dt>
|
|
110
|
+
<dd className="font-medium">
|
|
111
|
+
{lastService ?? t('movethewheels.vehicles.service.none', 'Not recorded')}
|
|
112
|
+
</dd>
|
|
113
|
+
</div>
|
|
114
|
+
<div>
|
|
115
|
+
<dt className="text-text-secondary">
|
|
116
|
+
{t('movethewheels.vehicles.service.next', 'Next service due')}
|
|
117
|
+
</dt>
|
|
118
|
+
<dd className="font-medium">
|
|
119
|
+
{hasSchedule
|
|
120
|
+
? [
|
|
121
|
+
nextDate,
|
|
122
|
+
nextMileage !== undefined ? `${nextMileage.toLocaleString()} km` : null,
|
|
123
|
+
]
|
|
124
|
+
.filter(Boolean)
|
|
125
|
+
.join(' / ')
|
|
126
|
+
: t('movethewheels.vehicles.service.noSchedule', 'No schedule set')}
|
|
127
|
+
</dd>
|
|
128
|
+
</div>
|
|
129
|
+
</dl>
|
|
130
|
+
|
|
131
|
+
{!open ? (
|
|
132
|
+
<Button variant="outline" size="sm" onClick={() => setOpen(true)}>
|
|
133
|
+
{t('movethewheels.vehicles.service.record', 'Record service')}
|
|
134
|
+
</Button>
|
|
135
|
+
) : (
|
|
136
|
+
<form onSubmit={submit} className="space-y-3">
|
|
137
|
+
<FormError message={error} />
|
|
138
|
+
<div className="grid grid-cols-2 gap-3">
|
|
139
|
+
<div className="space-y-1">
|
|
140
|
+
<Label htmlFor="service-date">
|
|
141
|
+
{t('movethewheels.vehicles.service.date', 'Service date')}
|
|
142
|
+
</Label>
|
|
143
|
+
<Input
|
|
144
|
+
id="service-date"
|
|
145
|
+
type="date"
|
|
146
|
+
required
|
|
147
|
+
value={form.serviceDate}
|
|
148
|
+
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
|
149
|
+
setForm((f) => ({ ...f, serviceDate: e.target.value }))
|
|
150
|
+
}
|
|
151
|
+
/>
|
|
152
|
+
</div>
|
|
153
|
+
<div className="space-y-1">
|
|
154
|
+
<Label htmlFor="service-mileage">
|
|
155
|
+
{t('movethewheels.vehicles.service.mileage', 'Odometer (km)')}
|
|
156
|
+
</Label>
|
|
157
|
+
<Input
|
|
158
|
+
id="service-mileage"
|
|
159
|
+
type="number"
|
|
160
|
+
min={0}
|
|
161
|
+
value={form.mileage}
|
|
162
|
+
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
|
163
|
+
setForm((f) => ({ ...f, mileage: e.target.value }))
|
|
164
|
+
}
|
|
165
|
+
/>
|
|
166
|
+
</div>
|
|
167
|
+
<div className="space-y-1">
|
|
168
|
+
<Label htmlFor="service-interval-days">
|
|
169
|
+
{t('movethewheels.vehicles.service.intervalDays', 'Next service in (days)')}
|
|
170
|
+
</Label>
|
|
171
|
+
<Input
|
|
172
|
+
id="service-interval-days"
|
|
173
|
+
type="number"
|
|
174
|
+
min={0}
|
|
175
|
+
value={form.intervalDays}
|
|
176
|
+
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
|
177
|
+
setForm((f) => ({ ...f, intervalDays: e.target.value }))
|
|
178
|
+
}
|
|
179
|
+
/>
|
|
180
|
+
</div>
|
|
181
|
+
<div className="space-y-1">
|
|
182
|
+
<Label htmlFor="service-interval-km">
|
|
183
|
+
{t('movethewheels.vehicles.service.intervalKm', 'Next service in (km)')}
|
|
184
|
+
</Label>
|
|
185
|
+
<Input
|
|
186
|
+
id="service-interval-km"
|
|
187
|
+
type="number"
|
|
188
|
+
min={0}
|
|
189
|
+
value={form.intervalKm}
|
|
190
|
+
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
|
191
|
+
setForm((f) => ({ ...f, intervalKm: e.target.value }))
|
|
192
|
+
}
|
|
193
|
+
/>
|
|
194
|
+
</div>
|
|
195
|
+
</div>
|
|
196
|
+
<div className="flex gap-2">
|
|
197
|
+
<Button type="submit" size="sm" disabled={saving}>
|
|
198
|
+
{saving
|
|
199
|
+
? t('movethewheels.vehicles.service.saving', 'Saving…')
|
|
200
|
+
: t('movethewheels.vehicles.service.save', 'Save service')}
|
|
201
|
+
</Button>
|
|
202
|
+
<Button
|
|
203
|
+
type="button"
|
|
204
|
+
variant="ghost"
|
|
205
|
+
size="sm"
|
|
206
|
+
disabled={saving}
|
|
207
|
+
onClick={() => {
|
|
208
|
+
setOpen(false);
|
|
209
|
+
setError(null);
|
|
210
|
+
}}
|
|
211
|
+
>
|
|
212
|
+
{t('movethewheels.vehicles.service.cancel', 'Cancel')}
|
|
213
|
+
</Button>
|
|
214
|
+
</div>
|
|
215
|
+
</form>
|
|
216
|
+
)}
|
|
217
|
+
</CardContent>
|
|
218
|
+
</Card>
|
|
219
|
+
);
|
|
220
|
+
}
|
|
@@ -53,6 +53,9 @@ interface ApiVehicle {
|
|
|
53
53
|
currentLocation?: Record<string, unknown> | null;
|
|
54
54
|
fuelLevel?: number | null;
|
|
55
55
|
mileage?: number | null;
|
|
56
|
+
lastServiceDate?: string | null;
|
|
57
|
+
nextServiceDueDate?: string | null;
|
|
58
|
+
nextServiceDueMileage?: number | null;
|
|
56
59
|
createdAt: string;
|
|
57
60
|
updatedAt?: string;
|
|
58
61
|
}
|
|
@@ -91,6 +94,9 @@ const LIST_VEHICLES_QUERY = `
|
|
|
91
94
|
currentLocation
|
|
92
95
|
fuelLevel
|
|
93
96
|
mileage
|
|
97
|
+
lastServiceDate
|
|
98
|
+
nextServiceDueDate
|
|
99
|
+
nextServiceDueMileage
|
|
94
100
|
createdAt
|
|
95
101
|
updatedAt
|
|
96
102
|
}
|
|
@@ -135,6 +141,9 @@ const GET_VEHICLE_QUERY = `
|
|
|
135
141
|
currentLocation
|
|
136
142
|
fuelLevel
|
|
137
143
|
mileage
|
|
144
|
+
lastServiceDate
|
|
145
|
+
nextServiceDueDate
|
|
146
|
+
nextServiceDueMileage
|
|
138
147
|
createdAt
|
|
139
148
|
updatedAt
|
|
140
149
|
}
|
|
@@ -424,8 +433,9 @@ function mapVehicle(vehicle: ApiVehicle): Vehicle {
|
|
|
424
433
|
timestamp: new Date(),
|
|
425
434
|
}
|
|
426
435
|
: undefined,
|
|
427
|
-
lastMaintenance: undefined,
|
|
428
|
-
nextMaintenance: undefined,
|
|
436
|
+
lastMaintenance: vehicle.lastServiceDate ? new Date(vehicle.lastServiceDate) : undefined,
|
|
437
|
+
nextMaintenance: vehicle.nextServiceDueDate ? new Date(vehicle.nextServiceDueDate) : undefined,
|
|
438
|
+
nextMaintenanceMileage: vehicle.nextServiceDueMileage ?? undefined,
|
|
429
439
|
fuelLevel: vehicle.fuelLevel ?? undefined,
|
|
430
440
|
mileage: vehicle.mileage ?? undefined,
|
|
431
441
|
documents: [],
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shipping rate comparison — every carrier's price for one pincode pair, on top
|
|
3
|
+
* of wspace-movethewheels-svc's carrier module. The carrier accounts belong to
|
|
4
|
+
* the platform, so there is nothing for a workspace to configure.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { useCallback, useMemo } from 'react';
|
|
8
|
+
import { useMoveTheWheels } from '../providers/MoveTheWheelsProvider';
|
|
9
|
+
import { executeGraphQL } from '../utils/graphqlClient';
|
|
10
|
+
|
|
11
|
+
export type CarrierProvider = 'SHIPROCKET';
|
|
12
|
+
export type CarrierServiceMode = 'SURFACE' | 'AIR';
|
|
13
|
+
|
|
14
|
+
export interface ShippingQuoteInput {
|
|
15
|
+
pickupPostcode: string;
|
|
16
|
+
deliveryPostcode: string;
|
|
17
|
+
weightKg: number;
|
|
18
|
+
lengthCm?: number;
|
|
19
|
+
widthCm?: number;
|
|
20
|
+
heightCm?: number;
|
|
21
|
+
cod?: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface ShippingQuote {
|
|
25
|
+
provider: CarrierProvider;
|
|
26
|
+
courierId: string;
|
|
27
|
+
courierName: string;
|
|
28
|
+
price: number;
|
|
29
|
+
currency: string;
|
|
30
|
+
estimatedDeliveryDays: number | null;
|
|
31
|
+
estimatedDeliveryDate: string | null;
|
|
32
|
+
rating: number | null;
|
|
33
|
+
mode: CarrierServiceMode | null;
|
|
34
|
+
isRecommended: boolean;
|
|
35
|
+
isCheapest: boolean;
|
|
36
|
+
isFastest: boolean;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface CarrierOutcome {
|
|
40
|
+
provider: CarrierProvider;
|
|
41
|
+
ok: boolean;
|
|
42
|
+
quoteCount: number;
|
|
43
|
+
errorMessage: string | null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface ShippingQuoteResult {
|
|
47
|
+
chargeableWeightKg: number;
|
|
48
|
+
quotes: ShippingQuote[];
|
|
49
|
+
providers: CarrierOutcome[];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const SHIPPING_QUOTES_QUERY = `
|
|
53
|
+
query ShippingQuotes($input: ShippingQuoteInput!) {
|
|
54
|
+
shippingQuotes(input: $input) {
|
|
55
|
+
chargeableWeightKg
|
|
56
|
+
quotes {
|
|
57
|
+
provider
|
|
58
|
+
courierId
|
|
59
|
+
courierName
|
|
60
|
+
price
|
|
61
|
+
currency
|
|
62
|
+
estimatedDeliveryDays
|
|
63
|
+
estimatedDeliveryDate
|
|
64
|
+
rating
|
|
65
|
+
mode
|
|
66
|
+
isRecommended
|
|
67
|
+
isCheapest
|
|
68
|
+
isFastest
|
|
69
|
+
}
|
|
70
|
+
providers {
|
|
71
|
+
provider
|
|
72
|
+
ok
|
|
73
|
+
quoteCount
|
|
74
|
+
errorMessage
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
`;
|
|
79
|
+
|
|
80
|
+
function useGraphqlOptions() {
|
|
81
|
+
const { apiGatewayUrl, authToken, workspaceToken, workspaceId, tenantId, currentUser } =
|
|
82
|
+
useMoveTheWheels();
|
|
83
|
+
return useMemo(
|
|
84
|
+
() => ({
|
|
85
|
+
apiGatewayUrl,
|
|
86
|
+
authToken,
|
|
87
|
+
workspaceToken,
|
|
88
|
+
workspaceId,
|
|
89
|
+
tenantId,
|
|
90
|
+
actorId: currentUser?.id,
|
|
91
|
+
}),
|
|
92
|
+
[apiGatewayUrl, authToken, workspaceToken, workspaceId, tenantId, currentUser?.id]
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Imperative: a comparison is something the operator asks for, not page-load data. */
|
|
97
|
+
export function useShippingQuotes() {
|
|
98
|
+
const graphQLOptions = useGraphqlOptions();
|
|
99
|
+
return useCallback(
|
|
100
|
+
async (input: ShippingQuoteInput): Promise<ShippingQuoteResult> => {
|
|
101
|
+
const result = await executeGraphQL<{ shippingQuotes: ShippingQuoteResult }>(
|
|
102
|
+
graphQLOptions,
|
|
103
|
+
SHIPPING_QUOTES_QUERY,
|
|
104
|
+
{ input }
|
|
105
|
+
);
|
|
106
|
+
return result.shippingQuotes;
|
|
107
|
+
},
|
|
108
|
+
[graphQLOptions]
|
|
109
|
+
);
|
|
110
|
+
}
|
|
@@ -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">
|