@burdenoff/microfe-movethewheels 2026.924.2 → 2026.925.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/components/OrderItemRow.js +195 -109
- package/dist/components/StatusBadge.js +31 -31
- package/dist/constants/index.js +11 -3
- package/dist/generated/wspace-operations.d.ts +7 -0
- package/dist/hooks/useFleet.js +85 -76
- package/dist/hooks/useOrders.js +8 -4
- package/dist/hooks/useTracking.js +127 -66
- package/dist/movethewheels/components/OrderItemRow.d.ts +1 -2
- package/dist/movethewheels/components/StatusBadge.d.ts +3 -1
- package/dist/movethewheels/constants/index.d.ts +1 -0
- package/dist/movethewheels/hooks/useFleet.d.ts +41 -1
- package/dist/movethewheels/hooks/useOrders.d.ts +1 -0
- package/dist/movethewheels/hooks/useTracking.d.ts +2 -2
- package/dist/movethewheels/types/index.d.ts +17 -39
- package/dist/node_modules/lucide-react/dist/esm/icons/sticky-note.js +11 -0
- package/dist/node_modules/lucide-react/dist/esm/icons/user-x.js +30 -0
- package/dist/pages/CreateOrderPage.js +597 -420
- package/dist/pages/DriverDetailsPage.js +702 -421
- package/dist/pages/DriversPage.js +106 -117
- package/dist/pages/FleetPage.js +4 -10
- package/dist/pages/OrderDetailsPage.js +290 -262
- package/dist/pages/OrdersListPage.js +4 -0
- package/dist/pages/RouteDetailsPage.js +264 -252
- package/dist/pages/TrackingPage.js +205 -195
- package/dist/pages/VehicleDetailsPage.js +403 -243
- package/dist/utils/mapMarkers.js +2 -1
- package/package.json +1 -1
- package/src/generated/wspace-operations.ts +10 -3
- package/src/movethewheels/components/OrderItemRow.tsx +162 -99
- package/src/movethewheels/components/StatusBadge.tsx +11 -2
- package/src/movethewheels/constants/index.ts +13 -0
- package/src/movethewheels/hooks/useFleet.ts +163 -44
- package/src/movethewheels/hooks/useOrders.ts +17 -3
- package/src/movethewheels/hooks/useTracking.ts +208 -27
- package/src/movethewheels/pages/CreateOrderPage.tsx +372 -263
- package/src/movethewheels/pages/DriverDetailsPage.tsx +516 -156
- package/src/movethewheels/pages/DriversPage.tsx +28 -11
- package/src/movethewheels/pages/FleetPage.tsx +7 -10
- package/src/movethewheels/pages/OrderDetailsPage.tsx +67 -30
- package/src/movethewheels/pages/OrdersListPage.tsx +1 -0
- package/src/movethewheels/pages/RouteDetailsPage.tsx +15 -7
- package/src/movethewheels/pages/TrackingPage.tsx +44 -14
- package/src/movethewheels/pages/VehicleDetailsPage.tsx +137 -15
- package/src/movethewheels/types/index.ts +17 -38
- package/src/movethewheels/utils/mapMarkers.ts +1 -0
- package/src/operations/wspace/movethewheels/queries/dashboard.graphql +5 -0
- package/src/operations/wspace/movethewheels/queries/logistics.graphql +2 -2
|
@@ -37,14 +37,17 @@ import { useDebouncedValue } from '../hooks/useDebouncedValue';
|
|
|
37
37
|
import { useMoveTheWheels } from '../providers/MoveTheWheelsProvider';
|
|
38
38
|
import { formatDate } from '../utils/formatters';
|
|
39
39
|
import { joinPath } from '../utils/navigation';
|
|
40
|
+
import { DRIVER_STATUS_LABELS } from '../constants';
|
|
40
41
|
import type { Driver, DriverStatus } from '../types';
|
|
41
42
|
|
|
43
|
+
// Sourced from the same DRIVER_STATUS_LABELS map the roster table's
|
|
44
|
+
// StatusBadge renders from, so this list can never drift from the table
|
|
45
|
+
// again (BOFF-7909).
|
|
42
46
|
const STATUS_OPTIONS: Array<{ value: DriverStatus | 'all'; label: string }> = [
|
|
43
47
|
{ value: 'all', label: 'All statuses' },
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
{ value: 'offline', label: 'Off duty' },
|
|
48
|
+
...(Object.entries(DRIVER_STATUS_LABELS) as Array<[DriverStatus, string]>).map(
|
|
49
|
+
([value, label]) => ({ value, label })
|
|
50
|
+
),
|
|
48
51
|
];
|
|
49
52
|
|
|
50
53
|
export default function DriversPage() {
|
|
@@ -74,8 +77,17 @@ export default function DriversPage() {
|
|
|
74
77
|
// Exact ON_BREAK / OFF_DUTY / INACTIVE counts — fleetStats cannot split these.
|
|
75
78
|
const { counts } = useDriverStatusCounts();
|
|
76
79
|
// Drivers carry only `vehicleId`; the roster shows the fleet number, so the
|
|
77
|
-
//
|
|
78
|
-
|
|
80
|
+
// exact vehicles referenced on this page are looked up by id — this stays
|
|
81
|
+
// correct regardless of total fleet size (a flat top-N page would silently
|
|
82
|
+
// miss drivers assigned to a vehicle outside the first N, BOFF-7909).
|
|
83
|
+
const assignedVehicleIds = useMemo(
|
|
84
|
+
() =>
|
|
85
|
+
Array.from(
|
|
86
|
+
new Set(drivers.map((driver) => driver.assignedVehicle).filter(Boolean))
|
|
87
|
+
) as string[],
|
|
88
|
+
[drivers]
|
|
89
|
+
);
|
|
90
|
+
const { vehicles } = useVehicles({ ids: assignedVehicleIds });
|
|
79
91
|
const vehicleNumberById = useMemo(
|
|
80
92
|
() => new Map(vehicles.map((vehicle) => [vehicle.id, vehicle.vehicleNumber])),
|
|
81
93
|
[vehicles]
|
|
@@ -154,10 +166,10 @@ export default function DriversPage() {
|
|
|
154
166
|
id: 'rating',
|
|
155
167
|
header: 'Rating',
|
|
156
168
|
cell: (row) =>
|
|
157
|
-
row.
|
|
169
|
+
row.rating > 0 ? (
|
|
158
170
|
<div className="flex items-center gap-1">
|
|
159
171
|
<Star size={14} className="text-status-warning-text fill-current" />
|
|
160
|
-
<span className="font-medium">{row.
|
|
172
|
+
<span className="font-medium">{row.rating.toFixed(1)}</span>
|
|
161
173
|
</div>
|
|
162
174
|
) : (
|
|
163
175
|
<span className="text-sm text-text-secondary">Not rated</span>
|
|
@@ -181,10 +193,15 @@ export default function DriversPage() {
|
|
|
181
193
|
},
|
|
182
194
|
{
|
|
183
195
|
id: 'joined',
|
|
184
|
-
header: '
|
|
185
|
-
accessorKey: '
|
|
196
|
+
header: 'Hire Date',
|
|
197
|
+
accessorKey: 'hireDate',
|
|
186
198
|
sortable: true,
|
|
187
|
-
cell: (row) =>
|
|
199
|
+
cell: (row) =>
|
|
200
|
+
row.hireDate ? (
|
|
201
|
+
formatDate(row.hireDate, 'short')
|
|
202
|
+
) : (
|
|
203
|
+
<span className="text-text-secondary">Not recorded</span>
|
|
204
|
+
),
|
|
188
205
|
},
|
|
189
206
|
];
|
|
190
207
|
|
|
@@ -302,20 +302,17 @@ export default function FleetPage() {
|
|
|
302
302
|
</Avatar>
|
|
303
303
|
<div>
|
|
304
304
|
<p className="font-medium">{driver.fullName}</p>
|
|
305
|
-
<p className="text-sm text-text-secondary">
|
|
306
|
-
{driver.employeeId} - {driver.completedOrders}{' '}
|
|
307
|
-
{t('movethewheels.fleet.drivers.deliveries', 'deliveries')}
|
|
308
|
-
</p>
|
|
305
|
+
<p className="text-sm text-text-secondary">{driver.employeeId}</p>
|
|
309
306
|
</div>
|
|
310
307
|
</div>
|
|
311
308
|
<div className="text-right">
|
|
312
309
|
<StatusBadge status={driver.status} type="driver" size="sm" />
|
|
313
|
-
|
|
314
|
-
<
|
|
315
|
-
|
|
316
|
-
{driver.
|
|
317
|
-
</
|
|
318
|
-
|
|
310
|
+
{driver.rating > 0 && (
|
|
311
|
+
<div className="mt-1 flex items-center gap-1 justify-end">
|
|
312
|
+
<Star size={12} className="text-status-warning-text fill-current" />
|
|
313
|
+
<span className="text-sm font-medium">{driver.rating.toFixed(1)}</span>
|
|
314
|
+
</div>
|
|
315
|
+
)}
|
|
319
316
|
</div>
|
|
320
317
|
</div>
|
|
321
318
|
</div>
|
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
ArrowRight,
|
|
24
24
|
XCircle,
|
|
25
25
|
UserCheck,
|
|
26
|
+
Search,
|
|
26
27
|
} from 'lucide-react';
|
|
27
28
|
import { PageLayout, LoadingState, ErrorState } from '../components/PageLayout';
|
|
28
29
|
import { StatusBadge, PriorityBadge, PaymentStatusBadge } from '../components/StatusBadge';
|
|
@@ -79,10 +80,13 @@ const STATUS_LABELS: Record<OrderStatus, string> = {
|
|
|
79
80
|
in_transit: 'In Transit',
|
|
80
81
|
out_for_delivery: 'Out for Delivery',
|
|
81
82
|
delivered: 'Delivered',
|
|
83
|
+
returned: 'Returned',
|
|
82
84
|
cancelled: 'Cancelled',
|
|
83
85
|
failed: 'Failed',
|
|
84
86
|
};
|
|
85
87
|
|
|
88
|
+
const TERMINAL_STATUSES = new Set<OrderStatus>(['delivered', 'returned', 'cancelled', 'failed']);
|
|
89
|
+
|
|
86
90
|
function nextStatus(current: OrderStatus): OrderStatus | null {
|
|
87
91
|
const index = STATUS_FLOW.indexOf(current);
|
|
88
92
|
if (index === -1 || index >= STATUS_FLOW.length - 1) return null;
|
|
@@ -149,18 +153,31 @@ export default function OrderDetailsPage() {
|
|
|
149
153
|
}, [selectedVehicle, vehicles, vehiclesLoading]);
|
|
150
154
|
|
|
151
155
|
const handleAssign = async () => {
|
|
152
|
-
if (!orderId ||
|
|
153
|
-
setActionError('
|
|
156
|
+
if (!orderId || !order) {
|
|
157
|
+
setActionError('This order is no longer available. Refresh and try again.');
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const vehicleId = selectedVehicle || order.vehicleId;
|
|
162
|
+
const driverId = selectedDriver || order.driverId;
|
|
163
|
+
if (!vehicleId || !driverId) {
|
|
164
|
+
setActionError('Select both a compatible vehicle and driver before dispatching.');
|
|
154
165
|
return;
|
|
155
166
|
}
|
|
167
|
+
|
|
168
|
+
const wasFullyAssigned = Boolean(order.vehicleId && order.driverId);
|
|
156
169
|
setAssigning(true);
|
|
157
170
|
setActionError(null);
|
|
158
171
|
try {
|
|
159
|
-
await updateOrder(orderId, {
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
}
|
|
163
|
-
notify(
|
|
172
|
+
await updateOrder(orderId, { vehicleId, driverId });
|
|
173
|
+
if (!wasFullyAssigned && order.status === 'confirmed') {
|
|
174
|
+
await updateOrderStatus(orderId, 'assigned');
|
|
175
|
+
}
|
|
176
|
+
notify(
|
|
177
|
+
!wasFullyAssigned && order.status === 'confirmed'
|
|
178
|
+
? 'Fleet assigned and order advanced to Assigned.'
|
|
179
|
+
: 'Assignment updated.'
|
|
180
|
+
);
|
|
164
181
|
setSelectedVehicle('');
|
|
165
182
|
setSelectedDriver('');
|
|
166
183
|
refetch();
|
|
@@ -172,7 +189,13 @@ export default function OrderDetailsPage() {
|
|
|
172
189
|
};
|
|
173
190
|
|
|
174
191
|
const handleStatusChange = async (status: OrderStatus, successMessage: string) => {
|
|
175
|
-
if (!orderId) return;
|
|
192
|
+
if (!orderId || !order) return;
|
|
193
|
+
if (status === 'assigned' && (!order.vehicleId || !order.driverId)) {
|
|
194
|
+
setActionError('Assign both a compatible vehicle and driver before advancing to Assigned.');
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
if (TERMINAL_STATUSES.has(order.status)) return;
|
|
198
|
+
|
|
176
199
|
setAdvancing(true);
|
|
177
200
|
setActionError(null);
|
|
178
201
|
try {
|
|
@@ -555,13 +578,17 @@ export default function OrderDetailsPage() {
|
|
|
555
578
|
|
|
556
579
|
<div className="space-y-3">
|
|
557
580
|
<div className="space-y-1.5">
|
|
558
|
-
<Label htmlFor="assign-fleet-search">Search
|
|
559
|
-
<
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
581
|
+
<Label htmlFor="assign-fleet-search">Search vehicles & drivers</Label>
|
|
582
|
+
<div className="relative">
|
|
583
|
+
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-text-secondary" />
|
|
584
|
+
<Input
|
|
585
|
+
id="assign-fleet-search"
|
|
586
|
+
value={fleetSearch}
|
|
587
|
+
onChange={(event) => setFleetSearch(event.target.value)}
|
|
588
|
+
placeholder="Vehicle number, driver name or employee ID"
|
|
589
|
+
className="pl-9"
|
|
590
|
+
/>
|
|
591
|
+
</div>
|
|
565
592
|
</div>
|
|
566
593
|
<div className="space-y-1.5">
|
|
567
594
|
<Label htmlFor="assign-vehicle">Vehicle</Label>
|
|
@@ -635,24 +662,34 @@ export default function OrderDetailsPage() {
|
|
|
635
662
|
{(() => {
|
|
636
663
|
const upcoming = nextStatus(order.status);
|
|
637
664
|
if (!upcoming) return null;
|
|
665
|
+
const missingDispatchPair =
|
|
666
|
+
upcoming === 'assigned' && (!order.vehicleId || !order.driverId);
|
|
638
667
|
return (
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
668
|
+
<>
|
|
669
|
+
<Button
|
|
670
|
+
variant="outline"
|
|
671
|
+
className="w-full"
|
|
672
|
+
onClick={() =>
|
|
673
|
+
handleStatusChange(
|
|
674
|
+
upcoming,
|
|
675
|
+
`Order advanced to ${STATUS_LABELS[upcoming]}.`
|
|
676
|
+
)
|
|
677
|
+
}
|
|
678
|
+
disabled={advancing || missingDispatchPair}
|
|
679
|
+
>
|
|
680
|
+
<ArrowRight size={16} />
|
|
681
|
+
{advancing ? 'Updating…' : `Advance to ${STATUS_LABELS[upcoming]}`}
|
|
682
|
+
</Button>
|
|
683
|
+
{missingDispatchPair && (
|
|
684
|
+
<p className="text-xs text-text-secondary">
|
|
685
|
+
Assign both a compatible vehicle and driver before dispatching this
|
|
686
|
+
order.
|
|
687
|
+
</p>
|
|
688
|
+
)}
|
|
689
|
+
</>
|
|
653
690
|
);
|
|
654
691
|
})()}
|
|
655
|
-
{order.status !== 'cancelled' && order.status
|
|
692
|
+
{order.status !== 'cancelled' && !TERMINAL_STATUSES.has(order.status) && (
|
|
656
693
|
<Button
|
|
657
694
|
variant="outline"
|
|
658
695
|
className="w-full text-status-error-text"
|
|
@@ -195,6 +195,7 @@ export default function OrdersListPage() {
|
|
|
195
195
|
label: t('movethewheels.orders.status.outForDelivery', 'Out for Delivery'),
|
|
196
196
|
},
|
|
197
197
|
{ value: 'delivered', label: t('movethewheels.orders.status.delivered', 'Delivered') },
|
|
198
|
+
{ value: 'returned', label: t('movethewheels.orders.status.returned', 'Returned') },
|
|
198
199
|
{ value: 'cancelled', label: t('movethewheels.orders.status.cancelled', 'Cancelled') },
|
|
199
200
|
{ value: 'failed', label: t('movethewheels.orders.status.failed', 'Failed') },
|
|
200
201
|
];
|
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
ArrowUp,
|
|
19
19
|
ArrowDown,
|
|
20
20
|
Fuel,
|
|
21
|
+
Search,
|
|
21
22
|
} from 'lucide-react';
|
|
22
23
|
import { PageLayout, LoadingState, ErrorState } from '../components/PageLayout';
|
|
23
24
|
import { StatusBadge } from '../components/StatusBadge';
|
|
@@ -276,13 +277,20 @@ function NewRouteForm({ editId }: { editId?: string }) {
|
|
|
276
277
|
</div>
|
|
277
278
|
</div>
|
|
278
279
|
<div className="space-y-2">
|
|
279
|
-
<Label htmlFor="fleet-search">Search
|
|
280
|
-
<
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
280
|
+
<Label htmlFor="fleet-search">Search vehicles & drivers</Label>
|
|
281
|
+
<div className="relative">
|
|
282
|
+
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-text-secondary" />
|
|
283
|
+
<Input
|
|
284
|
+
id="fleet-search"
|
|
285
|
+
value={fleetSearch}
|
|
286
|
+
onChange={(e) => setFleetSearch(e.target.value)}
|
|
287
|
+
placeholder="Vehicle number, driver name or employee ID"
|
|
288
|
+
className="pl-9"
|
|
289
|
+
/>
|
|
290
|
+
</div>
|
|
291
|
+
<p className="text-xs text-text-secondary">
|
|
292
|
+
Narrows both pickers below as you type — up to 20 matches at a time.
|
|
293
|
+
</p>
|
|
286
294
|
</div>
|
|
287
295
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
288
296
|
<div className="space-y-2">
|
|
@@ -60,7 +60,20 @@ export default function TrackingPage() {
|
|
|
60
60
|
? { ...destCoord, label: `Delivery · ${order.deliveryAddress.city || 'Destination'}` }
|
|
61
61
|
: null;
|
|
62
62
|
|
|
63
|
-
const
|
|
63
|
+
const trackingTrail: TrackPoint[] = (tracking?.locationHistory ?? [])
|
|
64
|
+
.map((location): TrackPoint | null => {
|
|
65
|
+
const coord = firstRealLatLng(location);
|
|
66
|
+
return coord
|
|
67
|
+
? {
|
|
68
|
+
lat: coord.lat,
|
|
69
|
+
lng: coord.lng,
|
|
70
|
+
timestamp: location.timestamp?.toISOString() ?? new Date().toISOString(),
|
|
71
|
+
}
|
|
72
|
+
: null;
|
|
73
|
+
})
|
|
74
|
+
.filter((point): point is TrackPoint => point !== null);
|
|
75
|
+
|
|
76
|
+
const statusTrail: TrackPoint[] = order.timeline
|
|
64
77
|
.map((event): TrackPoint | null => {
|
|
65
78
|
const coord = event.location ? firstRealLatLng(event.location) : null;
|
|
66
79
|
return coord
|
|
@@ -69,6 +82,7 @@ export default function TrackingPage() {
|
|
|
69
82
|
})
|
|
70
83
|
.filter((point): point is TrackPoint => point !== null);
|
|
71
84
|
|
|
85
|
+
const trail = trackingTrail.length > 0 ? trackingTrail : statusTrail;
|
|
72
86
|
const currentCoord = tracking ? firstRealLatLng(tracking.currentLocation) : null;
|
|
73
87
|
const current: TrackEndpoint | null = currentCoord
|
|
74
88
|
? {
|
|
@@ -163,7 +177,7 @@ export default function TrackingPage() {
|
|
|
163
177
|
)}
|
|
164
178
|
|
|
165
179
|
{/* Tracking Results */}
|
|
166
|
-
{order &&
|
|
180
|
+
{order && (
|
|
167
181
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
|
168
182
|
{/* Main Tracking Info */}
|
|
169
183
|
<div className="lg:col-span-2 space-y-6">
|
|
@@ -181,14 +195,14 @@ export default function TrackingPage() {
|
|
|
181
195
|
<p className="text-sm text-text-secondary">
|
|
182
196
|
{t('movethewheels.tracking.details.trackingNumber', 'Tracking Number')}
|
|
183
197
|
</p>
|
|
184
|
-
<p className="font-mono font-medium">{order.trackingNumber}</p>
|
|
198
|
+
<p className="font-mono font-medium">{order.trackingNumber || '—'}</p>
|
|
185
199
|
</div>
|
|
186
200
|
<div>
|
|
187
201
|
<p className="text-sm text-text-secondary">
|
|
188
202
|
{t('movethewheels.tracking.details.estimatedDelivery', 'Estimated Delivery')}
|
|
189
203
|
</p>
|
|
190
204
|
<p className="font-medium">
|
|
191
|
-
{tracking
|
|
205
|
+
{tracking?.estimatedArrival
|
|
192
206
|
? formatDate(tracking.estimatedArrival, 'full')
|
|
193
207
|
: order.scheduledDelivery
|
|
194
208
|
? formatDate(order.scheduledDelivery, 'full')
|
|
@@ -207,9 +221,21 @@ export default function TrackingPage() {
|
|
|
207
221
|
<p className="text-sm text-text-secondary">
|
|
208
222
|
{t('movethewheels.tracking.details.lastUpdated', 'Last Updated')}
|
|
209
223
|
</p>
|
|
210
|
-
<p className="font-medium">
|
|
224
|
+
<p className="font-medium">
|
|
225
|
+
{tracking
|
|
226
|
+
? formatDate(tracking.lastUpdated, 'relative')
|
|
227
|
+
: t('movethewheels.tracking.details.noUpdates', 'No tracking updates yet')}
|
|
228
|
+
</p>
|
|
211
229
|
</div>
|
|
212
230
|
</div>
|
|
231
|
+
{!tracking && (
|
|
232
|
+
<p className="mt-4 rounded-card border border-border-subtle bg-bg-sunken p-3 text-sm text-text-secondary">
|
|
233
|
+
{t(
|
|
234
|
+
'movethewheels.tracking.details.noUpdatesDescription',
|
|
235
|
+
'The delivery was found, but no tracking updates have been recorded yet.'
|
|
236
|
+
)}
|
|
237
|
+
</p>
|
|
238
|
+
)}
|
|
213
239
|
</EmphasisPanel>
|
|
214
240
|
|
|
215
241
|
{/* Route Map — real origin/destination + tracking trail + live position */}
|
|
@@ -356,7 +382,7 @@ export default function TrackingPage() {
|
|
|
356
382
|
</Card>
|
|
357
383
|
|
|
358
384
|
{/* Alerts */}
|
|
359
|
-
{tracking
|
|
385
|
+
{tracking?.alerts && tracking.alerts.length > 0 && (
|
|
360
386
|
<Card>
|
|
361
387
|
<CardHeader>
|
|
362
388
|
<CardTitle className="flex items-center gap-2">
|
|
@@ -430,14 +456,18 @@ export default function TrackingPage() {
|
|
|
430
456
|
</p>
|
|
431
457
|
</div>
|
|
432
458
|
)}
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
459
|
+
{tracking && (
|
|
460
|
+
<>
|
|
461
|
+
<p className="text-sm">
|
|
462
|
+
{tracking.currentLocation.address ||
|
|
463
|
+
t('movethewheels.tracking.location.updating', 'Location updating...')}
|
|
464
|
+
</p>
|
|
465
|
+
<p className="text-xs text-text-secondary mt-1">
|
|
466
|
+
{t('movethewheels.tracking.location.updated', 'Updated')}{' '}
|
|
467
|
+
{formatDate(tracking.currentLocation.timestamp || new Date(), 'relative')}
|
|
468
|
+
</p>
|
|
469
|
+
</>
|
|
470
|
+
)}
|
|
441
471
|
</CardContent>
|
|
442
472
|
</Card>
|
|
443
473
|
|
|
@@ -25,7 +25,8 @@ import {
|
|
|
25
25
|
AvatarFallback,
|
|
26
26
|
} from '../components/ui';
|
|
27
27
|
import { EmphasisPanel, NextSteps, PagePurpose } from '@burdenoff/fe-libs/ui';
|
|
28
|
-
import { useVehicle, useVehicleDrivers, useFleet } from '../hooks/useFleet';
|
|
28
|
+
import { useVehicle, useVehicleDrivers, useFleet, toApiVehicleStatus } from '../hooks/useFleet';
|
|
29
|
+
import type { VehicleStatus } from '../types';
|
|
29
30
|
import { useRecordVehicleService } from '../hooks/useFleetIntelligence';
|
|
30
31
|
import { VehicleServiceCard } from '../components/VehicleServiceCard';
|
|
31
32
|
import { useMoveTheWheels } from '../providers/MoveTheWheelsProvider';
|
|
@@ -47,6 +48,19 @@ const VEHICLE_TYPE_OPTIONS = [
|
|
|
47
48
|
{ value: 'DRONE', label: 'Drone' },
|
|
48
49
|
];
|
|
49
50
|
|
|
51
|
+
const VEHICLE_STATUS_OPTIONS: Array<{ value: VehicleStatus; label: string }> = [
|
|
52
|
+
{ value: 'available', label: 'Available' },
|
|
53
|
+
{ value: 'assigned', label: 'In Transit' },
|
|
54
|
+
{ value: 'maintenance', label: 'Maintenance' },
|
|
55
|
+
{ value: 'out_of_service', label: 'Out of Service' },
|
|
56
|
+
];
|
|
57
|
+
|
|
58
|
+
const OWNERSHIP_TYPE_OPTIONS = [
|
|
59
|
+
{ value: 'owned', label: 'Owned' },
|
|
60
|
+
{ value: 'leased', label: 'Leased' },
|
|
61
|
+
{ value: 'rented', label: 'Rented' },
|
|
62
|
+
];
|
|
63
|
+
|
|
50
64
|
function NewVehicleForm({ editId }: { editId?: string }) {
|
|
51
65
|
const { basePath, navigate } = useMoveTheWheels();
|
|
52
66
|
const { createVehicle, updateVehicle } = useFleet();
|
|
@@ -58,6 +72,11 @@ function NewVehicleForm({ editId }: { editId?: string }) {
|
|
|
58
72
|
make: '',
|
|
59
73
|
model: '',
|
|
60
74
|
licensePlate: '',
|
|
75
|
+
ownershipType: '',
|
|
76
|
+
year: '',
|
|
77
|
+
status: 'available' as VehicleStatus,
|
|
78
|
+
fuelType: '',
|
|
79
|
+
fuelEfficiencyLPer100Km: '',
|
|
61
80
|
});
|
|
62
81
|
const [submitting, setSubmitting] = useState(false);
|
|
63
82
|
const [error, setError] = useState<string | null>(null);
|
|
@@ -70,6 +89,14 @@ function NewVehicleForm({ editId }: { editId?: string }) {
|
|
|
70
89
|
make: vehicle.make ?? '',
|
|
71
90
|
model: vehicle.model ?? '',
|
|
72
91
|
licensePlate: vehicle.licensePlate ?? '',
|
|
92
|
+
ownershipType: vehicle.ownershipType ?? '',
|
|
93
|
+
year: vehicle.year ? String(vehicle.year) : '',
|
|
94
|
+
status: vehicle.status,
|
|
95
|
+
fuelType: vehicle.fuelType ?? '',
|
|
96
|
+
fuelEfficiencyLPer100Km:
|
|
97
|
+
vehicle.fuelEfficiencyLPer100Km !== undefined
|
|
98
|
+
? String(vehicle.fuelEfficiencyLPer100Km)
|
|
99
|
+
: '',
|
|
73
100
|
});
|
|
74
101
|
}
|
|
75
102
|
}, [editId, vehicle]);
|
|
@@ -85,23 +112,25 @@ function NewVehicleForm({ editId }: { editId?: string }) {
|
|
|
85
112
|
setSubmitting(true);
|
|
86
113
|
setError(null);
|
|
87
114
|
try {
|
|
115
|
+
const payload = {
|
|
116
|
+
vehicleNumber: form.vehicleNumber.trim(),
|
|
117
|
+
type: form.type,
|
|
118
|
+
make: form.make.trim() || undefined,
|
|
119
|
+
model: form.model.trim() || undefined,
|
|
120
|
+
licensePlate: form.licensePlate.trim() || undefined,
|
|
121
|
+
ownershipType: form.ownershipType || undefined,
|
|
122
|
+
year: form.year ? Number(form.year) : undefined,
|
|
123
|
+
status: toApiVehicleStatus(form.status),
|
|
124
|
+
fuelType: form.fuelType || undefined,
|
|
125
|
+
fuelEfficiencyLPer100Km: form.fuelEfficiencyLPer100Km
|
|
126
|
+
? Number(form.fuelEfficiencyLPer100Km)
|
|
127
|
+
: undefined,
|
|
128
|
+
};
|
|
88
129
|
if (editId) {
|
|
89
|
-
const updated = await updateVehicle(editId,
|
|
90
|
-
vehicleNumber: form.vehicleNumber.trim(),
|
|
91
|
-
type: form.type,
|
|
92
|
-
make: form.make.trim() || undefined,
|
|
93
|
-
model: form.model.trim() || undefined,
|
|
94
|
-
licensePlate: form.licensePlate.trim() || undefined,
|
|
95
|
-
});
|
|
130
|
+
const updated = await updateVehicle(editId, payload);
|
|
96
131
|
navigate?.(joinPath(basePath, `fleet/vehicles/${updated.id}`));
|
|
97
132
|
} else {
|
|
98
|
-
const created = await createVehicle(
|
|
99
|
-
vehicleNumber: form.vehicleNumber.trim(),
|
|
100
|
-
type: form.type,
|
|
101
|
-
make: form.make.trim() || undefined,
|
|
102
|
-
model: form.model.trim() || undefined,
|
|
103
|
-
licensePlate: form.licensePlate.trim() || undefined,
|
|
104
|
-
});
|
|
133
|
+
const created = await createVehicle(payload);
|
|
105
134
|
navigate?.(joinPath(basePath, `fleet/vehicles/${created.id}`));
|
|
106
135
|
}
|
|
107
136
|
} catch (err) {
|
|
@@ -207,6 +236,83 @@ function NewVehicleForm({ editId }: { editId?: string }) {
|
|
|
207
236
|
/>
|
|
208
237
|
</div>
|
|
209
238
|
</div>
|
|
239
|
+
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
240
|
+
<div className="space-y-2">
|
|
241
|
+
<Label htmlFor="ownershipType">Ownership</Label>
|
|
242
|
+
<Select
|
|
243
|
+
value={form.ownershipType || undefined}
|
|
244
|
+
onValueChange={(value) => setForm((prev) => ({ ...prev, ownershipType: value }))}
|
|
245
|
+
>
|
|
246
|
+
<SelectTrigger id="ownershipType">
|
|
247
|
+
<SelectValue placeholder="Select ownership" />
|
|
248
|
+
</SelectTrigger>
|
|
249
|
+
<SelectContent>
|
|
250
|
+
{OWNERSHIP_TYPE_OPTIONS.map((option) => (
|
|
251
|
+
<SelectItem key={option.value} value={option.value}>
|
|
252
|
+
{option.label}
|
|
253
|
+
</SelectItem>
|
|
254
|
+
))}
|
|
255
|
+
</SelectContent>
|
|
256
|
+
</Select>
|
|
257
|
+
</div>
|
|
258
|
+
<div className="space-y-2">
|
|
259
|
+
<Label htmlFor="year">Year</Label>
|
|
260
|
+
<Input
|
|
261
|
+
id="year"
|
|
262
|
+
type="number"
|
|
263
|
+
min={1980}
|
|
264
|
+
max={new Date().getFullYear() + 1}
|
|
265
|
+
value={form.year}
|
|
266
|
+
onChange={(e) => setForm((prev) => ({ ...prev, year: e.target.value }))}
|
|
267
|
+
placeholder={String(new Date().getFullYear())}
|
|
268
|
+
/>
|
|
269
|
+
</div>
|
|
270
|
+
<div className="space-y-2">
|
|
271
|
+
<Label htmlFor="vehicleStatus">Status</Label>
|
|
272
|
+
<Select
|
|
273
|
+
value={form.status}
|
|
274
|
+
onValueChange={(value) =>
|
|
275
|
+
setForm((prev) => ({ ...prev, status: value as VehicleStatus }))
|
|
276
|
+
}
|
|
277
|
+
>
|
|
278
|
+
<SelectTrigger id="vehicleStatus">
|
|
279
|
+
<SelectValue />
|
|
280
|
+
</SelectTrigger>
|
|
281
|
+
<SelectContent>
|
|
282
|
+
{VEHICLE_STATUS_OPTIONS.map((option) => (
|
|
283
|
+
<SelectItem key={option.value} value={option.value}>
|
|
284
|
+
{option.label}
|
|
285
|
+
</SelectItem>
|
|
286
|
+
))}
|
|
287
|
+
</SelectContent>
|
|
288
|
+
</Select>
|
|
289
|
+
</div>
|
|
290
|
+
</div>
|
|
291
|
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
292
|
+
<div className="space-y-2">
|
|
293
|
+
<Label htmlFor="fuelType">Fuel Type</Label>
|
|
294
|
+
<Input
|
|
295
|
+
id="fuelType"
|
|
296
|
+
value={form.fuelType}
|
|
297
|
+
onChange={(e) => setForm((prev) => ({ ...prev, fuelType: e.target.value }))}
|
|
298
|
+
placeholder="Diesel"
|
|
299
|
+
/>
|
|
300
|
+
</div>
|
|
301
|
+
<div className="space-y-2">
|
|
302
|
+
<Label htmlFor="fuelEfficiency">Fuel Efficiency (L/100km)</Label>
|
|
303
|
+
<Input
|
|
304
|
+
id="fuelEfficiency"
|
|
305
|
+
type="number"
|
|
306
|
+
min={0}
|
|
307
|
+
step={0.1}
|
|
308
|
+
value={form.fuelEfficiencyLPer100Km}
|
|
309
|
+
onChange={(e) =>
|
|
310
|
+
setForm((prev) => ({ ...prev, fuelEfficiencyLPer100Km: e.target.value }))
|
|
311
|
+
}
|
|
312
|
+
placeholder="12.5"
|
|
313
|
+
/>
|
|
314
|
+
</div>
|
|
315
|
+
</div>
|
|
210
316
|
</div>
|
|
211
317
|
</EmphasisPanel>
|
|
212
318
|
</form>
|
|
@@ -394,6 +500,22 @@ export default function VehicleDetailsPage() {
|
|
|
394
500
|
</p>
|
|
395
501
|
</div>
|
|
396
502
|
)}
|
|
503
|
+
{(vehicle.fuelType || vehicle.fuelEfficiencyLPer100Km !== undefined) && (
|
|
504
|
+
<div className="grid grid-cols-2 gap-4">
|
|
505
|
+
{vehicle.fuelType && (
|
|
506
|
+
<div>
|
|
507
|
+
<p className="text-sm text-text-secondary">Fuel Type</p>
|
|
508
|
+
<p className="font-medium">{vehicle.fuelType}</p>
|
|
509
|
+
</div>
|
|
510
|
+
)}
|
|
511
|
+
{vehicle.fuelEfficiencyLPer100Km !== undefined && (
|
|
512
|
+
<div>
|
|
513
|
+
<p className="text-sm text-text-secondary">Efficiency</p>
|
|
514
|
+
<p className="font-medium">{vehicle.fuelEfficiencyLPer100Km} L/100km</p>
|
|
515
|
+
</div>
|
|
516
|
+
)}
|
|
517
|
+
</div>
|
|
518
|
+
)}
|
|
397
519
|
</CardContent>
|
|
398
520
|
</Card>
|
|
399
521
|
|