@burdenoff/microfe-movethewheels 2026.925.2 → 2026.925.3
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/StatusBadge.js +30 -30
- package/dist/constants/index.js +12 -5
- package/dist/hooks/useCustomers.js +0 -3
- package/dist/hooks/useFleet.js +144 -131
- package/dist/hooks/useInventory.js +144 -145
- package/dist/hooks/useRoutes.js +5 -14
- package/dist/movethewheels/constants/index.d.ts +1 -0
- package/dist/movethewheels/hooks/useFleet.d.ts +13 -1
- package/dist/movethewheels/hooks/useInventory.d.ts +10 -0
- package/dist/movethewheels/types/index.d.ts +13 -70
- package/dist/pages/CustomerDetailsPage.js +666 -344
- package/dist/pages/CustomersPage.js +71 -80
- package/dist/pages/FleetPage.js +1 -3
- package/dist/pages/InventoryPage.js +4 -4
- package/dist/pages/RouteDetailsPage.js +273 -248
- package/dist/pages/RoutesPage.js +130 -67
- package/dist/pages/VehicleDetailsPage.js +506 -391
- package/dist/pages/VehiclesPage.js +119 -70
- package/dist/pages/WarehouseDetailsPage.js +410 -370
- package/dist/pages/WarehousesPage.js +131 -82
- package/package.json +1 -1
- package/src/movethewheels/components/StatusBadge.tsx +8 -3
- package/src/movethewheels/constants/index.ts +9 -0
- package/src/movethewheels/hooks/useCustomers.ts +0 -3
- package/src/movethewheels/hooks/useFleet.ts +74 -19
- package/src/movethewheels/hooks/useInventory.ts +76 -32
- package/src/movethewheels/hooks/useRoutes.ts +17 -12
- package/src/movethewheels/pages/CustomerDetailsPage.tsx +376 -109
- package/src/movethewheels/pages/CustomersPage.tsx +6 -15
- package/src/movethewheels/pages/FleetPage.tsx +2 -1
- package/src/movethewheels/pages/InventoryPage.tsx +14 -5
- package/src/movethewheels/pages/RouteDetailsPage.tsx +74 -34
- package/src/movethewheels/pages/RoutesPage.tsx +66 -2
- package/src/movethewheels/pages/VehicleDetailsPage.tsx +151 -11
- package/src/movethewheels/pages/VehiclesPage.tsx +68 -3
- package/src/movethewheels/pages/WarehouseDetailsPage.tsx +213 -135
- package/src/movethewheels/pages/WarehousesPage.tsx +77 -16
- package/src/movethewheels/types/index.ts +14 -70
|
@@ -53,7 +53,7 @@ interface StockRow {
|
|
|
53
53
|
onHand: number;
|
|
54
54
|
allocated: number;
|
|
55
55
|
available: number;
|
|
56
|
-
reorderLevel
|
|
56
|
+
reorderLevel?: number;
|
|
57
57
|
stockState: 'out_of_stock' | 'low' | 'in_stock';
|
|
58
58
|
}
|
|
59
59
|
|
|
@@ -140,10 +140,15 @@ export default function InventoryPage() {
|
|
|
140
140
|
allocated: item.reservedQuantity,
|
|
141
141
|
available: item.availableQuantity,
|
|
142
142
|
reorderLevel: item.reorderLevel,
|
|
143
|
+
// Matches the backend's lowStock rule (inventory.service.ts): only
|
|
144
|
+
// flag "low" when a reorder point is actually configured — items with
|
|
145
|
+
// none set are never considered low stock, even at 0 available,
|
|
146
|
+
// otherwise this table disagreed with the dashboard's low-stock count
|
|
147
|
+
// for the same item (BOFF-7909 follow-up).
|
|
143
148
|
stockState:
|
|
144
149
|
item.quantity === 0
|
|
145
150
|
? 'out_of_stock'
|
|
146
|
-
: item.availableQuantity <= item.reorderLevel
|
|
151
|
+
: item.reorderLevel !== undefined && item.availableQuantity <= item.reorderLevel
|
|
147
152
|
? 'low'
|
|
148
153
|
: 'in_stock',
|
|
149
154
|
}));
|
|
@@ -196,7 +201,7 @@ export default function InventoryPage() {
|
|
|
196
201
|
header: t('movethewheels.inventory.stock.reorderPoint', 'Reorder point'),
|
|
197
202
|
accessorKey: 'reorderLevel',
|
|
198
203
|
sortable: true,
|
|
199
|
-
cell: (row) => (row.reorderLevel
|
|
204
|
+
cell: (row) => (row.reorderLevel !== undefined ? row.reorderLevel.toLocaleString() : '—'),
|
|
200
205
|
},
|
|
201
206
|
{
|
|
202
207
|
id: 'state',
|
|
@@ -501,8 +506,12 @@ export default function InventoryPage() {
|
|
|
501
506
|
</p>
|
|
502
507
|
</div>
|
|
503
508
|
<div className="text-right">
|
|
504
|
-
<Badge
|
|
505
|
-
{
|
|
509
|
+
<Badge
|
|
510
|
+
variant={
|
|
511
|
+
warehouse.status.toUpperCase() === 'ACTIVE' ? 'default' : 'secondary'
|
|
512
|
+
}
|
|
513
|
+
>
|
|
514
|
+
{warehouse.status.toUpperCase() === 'ACTIVE'
|
|
506
515
|
? t('movethewheels.inventory.warehouses.active', 'Active')
|
|
507
516
|
: t('movethewheels.inventory.warehouses.inactive', 'Inactive')}
|
|
508
517
|
</Badge>
|
|
@@ -37,7 +37,17 @@ import {
|
|
|
37
37
|
SelectTrigger,
|
|
38
38
|
SelectValue,
|
|
39
39
|
} from '../components/ui';
|
|
40
|
-
import {
|
|
40
|
+
import {
|
|
41
|
+
EmphasisPanel,
|
|
42
|
+
NextSteps,
|
|
43
|
+
PagePurpose,
|
|
44
|
+
ResponsiveDialog,
|
|
45
|
+
ResponsiveDialogContent,
|
|
46
|
+
ResponsiveDialogHeader,
|
|
47
|
+
ResponsiveDialogFooter,
|
|
48
|
+
ResponsiveDialogTitle,
|
|
49
|
+
ResponsiveDialogDescription,
|
|
50
|
+
} from '@burdenoff/fe-libs/ui';
|
|
41
51
|
import { RouteMap, type RouteStop as MapRouteStop } from '../components/maps';
|
|
42
52
|
import { firstRealLatLng } from '../utils/mapMarkers';
|
|
43
53
|
import { EntityCombobox } from '../components/EntityCombobox';
|
|
@@ -425,7 +435,7 @@ export default function RouteDetailsPage() {
|
|
|
425
435
|
const isEditRoute = useLocation().pathname.endsWith('/edit');
|
|
426
436
|
const { basePath, navigate } = useMoveTheWheels();
|
|
427
437
|
const { route, isLoading, error, refetch } = useRoute(routeId);
|
|
428
|
-
const { optimizeRoute, updateRoute } = useRoutes();
|
|
438
|
+
const { optimizeRoute, updateRoute, deleteRoute } = useRoutes();
|
|
429
439
|
const suggestions = useAssignmentSuggestions(routeId);
|
|
430
440
|
const { t: translate } = useI18n();
|
|
431
441
|
const t = (key: string, fallback: string): string => {
|
|
@@ -435,10 +445,27 @@ export default function RouteDetailsPage() {
|
|
|
435
445
|
const { message: notice, notify } = useNotice();
|
|
436
446
|
const [optimizeError, setOptimizeError] = useState<string | null>(null);
|
|
437
447
|
const [optimizing, setOptimizing] = useState(false);
|
|
448
|
+
const [deleteOpen, setDeleteOpen] = useState(false);
|
|
449
|
+
const [deleting, setDeleting] = useState(false);
|
|
450
|
+
const [deleteError, setDeleteError] = useState<string | null>(null);
|
|
438
451
|
|
|
439
452
|
const goBack = () => navigate?.(joinPath(basePath, 'routes'));
|
|
440
453
|
const goTo = (path: string) => navigate?.(joinPath(basePath, path));
|
|
441
454
|
|
|
455
|
+
const handleDelete = async () => {
|
|
456
|
+
if (!routeId) return;
|
|
457
|
+
setDeleting(true);
|
|
458
|
+
setDeleteError(null);
|
|
459
|
+
try {
|
|
460
|
+
await deleteRoute(routeId);
|
|
461
|
+
setDeleteOpen(false);
|
|
462
|
+
goBack();
|
|
463
|
+
} catch (err) {
|
|
464
|
+
setDeleteError(err instanceof Error ? err.message : 'Failed to delete route.');
|
|
465
|
+
setDeleting(false);
|
|
466
|
+
}
|
|
467
|
+
};
|
|
468
|
+
|
|
442
469
|
const handleOptimize = async () => {
|
|
443
470
|
if (!routeId) return;
|
|
444
471
|
setOptimizing(true);
|
|
@@ -717,18 +744,6 @@ export default function RouteDetailsPage() {
|
|
|
717
744
|
</CardTitle>
|
|
718
745
|
</CardHeader>
|
|
719
746
|
<CardContent className="space-y-3">
|
|
720
|
-
{route.startTime && (
|
|
721
|
-
<div>
|
|
722
|
-
<p className="text-sm text-text-secondary">Started</p>
|
|
723
|
-
<p>{formatDate(route.startTime, 'full')}</p>
|
|
724
|
-
</div>
|
|
725
|
-
)}
|
|
726
|
-
{route.endTime && (
|
|
727
|
-
<div>
|
|
728
|
-
<p className="text-sm text-text-secondary">Completed</p>
|
|
729
|
-
<p>{formatDate(route.endTime, 'full')}</p>
|
|
730
|
-
</div>
|
|
731
|
-
)}
|
|
732
747
|
<div>
|
|
733
748
|
<p className="text-sm text-text-secondary">Created</p>
|
|
734
749
|
<p>{formatDate(route.createdAt, 'full')}</p>
|
|
@@ -736,28 +751,53 @@ export default function RouteDetailsPage() {
|
|
|
736
751
|
</CardContent>
|
|
737
752
|
</Card>
|
|
738
753
|
|
|
739
|
-
|
|
740
|
-
<
|
|
741
|
-
<
|
|
742
|
-
<
|
|
743
|
-
</
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
)}
|
|
754
|
+
<Card className="border-status-error-border">
|
|
755
|
+
<CardHeader>
|
|
756
|
+
<CardTitle className="flex items-center gap-2 text-status-error-text">
|
|
757
|
+
<Trash2 size={18} /> Delete Route
|
|
758
|
+
</CardTitle>
|
|
759
|
+
</CardHeader>
|
|
760
|
+
<CardContent>
|
|
761
|
+
<p className="mb-3 text-sm text-text-secondary">
|
|
762
|
+
Removes this route. It can no longer be dispatched. This cannot be undone from here.
|
|
763
|
+
</p>
|
|
764
|
+
<Button
|
|
765
|
+
variant="outline"
|
|
766
|
+
className="border-status-error-border text-status-error-text hover:bg-status-error-bg-subtle"
|
|
767
|
+
onClick={() => setDeleteOpen(true)}
|
|
768
|
+
>
|
|
769
|
+
<Trash2 size={16} /> Delete this route
|
|
770
|
+
</Button>
|
|
771
|
+
</CardContent>
|
|
772
|
+
</Card>
|
|
759
773
|
</div>
|
|
760
774
|
</div>
|
|
775
|
+
|
|
776
|
+
<ResponsiveDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
|
777
|
+
<ResponsiveDialogContent>
|
|
778
|
+
<ResponsiveDialogHeader>
|
|
779
|
+
<ResponsiveDialogTitle>Delete this route?</ResponsiveDialogTitle>
|
|
780
|
+
<ResponsiveDialogDescription>
|
|
781
|
+
This removes {route.name} and cannot be undone from here.
|
|
782
|
+
</ResponsiveDialogDescription>
|
|
783
|
+
</ResponsiveDialogHeader>
|
|
784
|
+
<div className="px-1">
|
|
785
|
+
<FormError message={deleteError} />
|
|
786
|
+
</div>
|
|
787
|
+
<ResponsiveDialogFooter>
|
|
788
|
+
<Button variant="outline" onClick={() => setDeleteOpen(false)} disabled={deleting}>
|
|
789
|
+
Cancel
|
|
790
|
+
</Button>
|
|
791
|
+
<Button
|
|
792
|
+
className="bg-status-error-text text-text-inverse hover:opacity-90"
|
|
793
|
+
onClick={handleDelete}
|
|
794
|
+
disabled={deleting}
|
|
795
|
+
>
|
|
796
|
+
{deleting ? 'Deleting...' : 'Delete route'}
|
|
797
|
+
</Button>
|
|
798
|
+
</ResponsiveDialogFooter>
|
|
799
|
+
</ResponsiveDialogContent>
|
|
800
|
+
</ResponsiveDialog>
|
|
761
801
|
</PageLayout>
|
|
762
802
|
);
|
|
763
803
|
}
|
|
@@ -3,11 +3,21 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import { useState } from 'react';
|
|
6
|
-
import { Plus, RefreshCw, Navigation } from 'lucide-react';
|
|
6
|
+
import { Plus, RefreshCw, Navigation, Filter } from 'lucide-react';
|
|
7
7
|
import { PageLayout } from '../components/PageLayout';
|
|
8
8
|
import { DataTable, type Column } from '../components/DataTable';
|
|
9
9
|
import { StatusBadge } from '../components/StatusBadge';
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
Badge,
|
|
12
|
+
Button,
|
|
13
|
+
Card,
|
|
14
|
+
CardContent,
|
|
15
|
+
Select,
|
|
16
|
+
SelectContent,
|
|
17
|
+
SelectItem,
|
|
18
|
+
SelectTrigger,
|
|
19
|
+
SelectValue,
|
|
20
|
+
} from '../components/ui';
|
|
11
21
|
import { CTAOverflowMenu, EmphasisPanel, PagePurpose } from '@burdenoff/fe-libs/ui';
|
|
12
22
|
import { useRoutes, useRouteStats } from '../hooks/useRoutes';
|
|
13
23
|
import { useDebouncedValue } from '../hooks/useDebouncedValue';
|
|
@@ -17,17 +27,28 @@ import { joinPath } from '../utils/navigation';
|
|
|
17
27
|
import { ROUTE_STATUS_COLORS } from '../constants';
|
|
18
28
|
import type { Route } from '../types';
|
|
19
29
|
|
|
30
|
+
const STATUS_OPTIONS: Array<{ value: Route['status'] | 'all'; label: string }> = [
|
|
31
|
+
{ value: 'all', label: 'All statuses' },
|
|
32
|
+
{ value: 'planned', label: 'Planned' },
|
|
33
|
+
{ value: 'in_progress', label: 'In Progress' },
|
|
34
|
+
{ value: 'completed', label: 'Completed' },
|
|
35
|
+
{ value: 'cancelled', label: 'Cancelled' },
|
|
36
|
+
];
|
|
37
|
+
|
|
20
38
|
export default function RoutesPage() {
|
|
21
39
|
const { basePath, navigate } = useMoveTheWheels();
|
|
22
40
|
const [page, setPage] = useState(0);
|
|
23
41
|
const [pageSize, setPageSize] = useState(25);
|
|
24
42
|
const [search, setSearch] = useState('');
|
|
43
|
+
const [statusFilter, setStatusFilter] = useState<Route['status'] | 'all'>('all');
|
|
25
44
|
const debouncedSearch = useDebouncedValue(search);
|
|
26
45
|
const { routes, totalRoutes, hasMore, isLoading, error, refetch } = useRoutes({
|
|
27
46
|
limit: pageSize,
|
|
28
47
|
offset: page * pageSize,
|
|
29
48
|
search: debouncedSearch || undefined,
|
|
49
|
+
status: statusFilter === 'all' ? undefined : statusFilter,
|
|
30
50
|
});
|
|
51
|
+
const filtersActive = statusFilter !== 'all' || search.length > 0;
|
|
31
52
|
// Bounded aggregate for the tiles + subtitle — not the truncated page.
|
|
32
53
|
const { stats } = useRouteStats();
|
|
33
54
|
|
|
@@ -126,6 +147,49 @@ export default function RoutesPage() {
|
|
|
126
147
|
</div>
|
|
127
148
|
</EmphasisPanel>
|
|
128
149
|
|
|
150
|
+
{/* Filters */}
|
|
151
|
+
<Card>
|
|
152
|
+
<CardContent className="p-4">
|
|
153
|
+
<div className="flex flex-wrap items-center gap-4">
|
|
154
|
+
<div className="flex items-center gap-2">
|
|
155
|
+
<Filter size={16} className="text-text-secondary" />
|
|
156
|
+
<span className="text-sm font-medium">Filters:</span>
|
|
157
|
+
</div>
|
|
158
|
+
<Select
|
|
159
|
+
value={statusFilter}
|
|
160
|
+
onValueChange={(value) => {
|
|
161
|
+
setStatusFilter(value as Route['status'] | 'all');
|
|
162
|
+
setPage(0);
|
|
163
|
+
}}
|
|
164
|
+
>
|
|
165
|
+
<SelectTrigger className="w-[180px]">
|
|
166
|
+
<SelectValue placeholder="Status" />
|
|
167
|
+
</SelectTrigger>
|
|
168
|
+
<SelectContent>
|
|
169
|
+
{STATUS_OPTIONS.map((option) => (
|
|
170
|
+
<SelectItem key={option.value} value={option.value}>
|
|
171
|
+
{option.label}
|
|
172
|
+
</SelectItem>
|
|
173
|
+
))}
|
|
174
|
+
</SelectContent>
|
|
175
|
+
</Select>
|
|
176
|
+
{filtersActive && (
|
|
177
|
+
<Button
|
|
178
|
+
variant="ghost"
|
|
179
|
+
size="sm"
|
|
180
|
+
onClick={() => {
|
|
181
|
+
setStatusFilter('all');
|
|
182
|
+
setSearch('');
|
|
183
|
+
setPage(0);
|
|
184
|
+
}}
|
|
185
|
+
>
|
|
186
|
+
Clear filters
|
|
187
|
+
</Button>
|
|
188
|
+
)}
|
|
189
|
+
</div>
|
|
190
|
+
</CardContent>
|
|
191
|
+
</Card>
|
|
192
|
+
|
|
129
193
|
<DataTable
|
|
130
194
|
columns={columns}
|
|
131
195
|
data={routes}
|
|
@@ -4,7 +4,18 @@
|
|
|
4
4
|
|
|
5
5
|
import { useEffect, useState } from 'react';
|
|
6
6
|
import { useParams, useLocation } from 'react-router';
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
Truck,
|
|
9
|
+
ArrowLeft,
|
|
10
|
+
MapPin,
|
|
11
|
+
Fuel,
|
|
12
|
+
UserRound,
|
|
13
|
+
Edit,
|
|
14
|
+
Save,
|
|
15
|
+
Mail,
|
|
16
|
+
Phone,
|
|
17
|
+
Trash2,
|
|
18
|
+
} from 'lucide-react';
|
|
8
19
|
import { PageLayout, LoadingState, ErrorState } from '../components/PageLayout';
|
|
9
20
|
import { StatusBadge, VehicleTypeBadge } from '../components/StatusBadge';
|
|
10
21
|
import {
|
|
@@ -24,9 +35,20 @@ import {
|
|
|
24
35
|
Avatar,
|
|
25
36
|
AvatarFallback,
|
|
26
37
|
} from '../components/ui';
|
|
27
|
-
import {
|
|
38
|
+
import {
|
|
39
|
+
EmphasisPanel,
|
|
40
|
+
NextSteps,
|
|
41
|
+
PagePurpose,
|
|
42
|
+
ResponsiveDialog,
|
|
43
|
+
ResponsiveDialogContent,
|
|
44
|
+
ResponsiveDialogHeader,
|
|
45
|
+
ResponsiveDialogFooter,
|
|
46
|
+
ResponsiveDialogTitle,
|
|
47
|
+
ResponsiveDialogDescription,
|
|
48
|
+
} from '@burdenoff/fe-libs/ui';
|
|
28
49
|
import { useVehicle, useVehicleDrivers, useFleet, toApiVehicleStatus } from '../hooks/useFleet';
|
|
29
50
|
import type { VehicleStatus } from '../types';
|
|
51
|
+
import { VEHICLE_STATUS_LABELS } from '../constants';
|
|
30
52
|
import { useRecordVehicleService } from '../hooks/useFleetIntelligence';
|
|
31
53
|
import { VehicleServiceCard } from '../components/VehicleServiceCard';
|
|
32
54
|
import { useMoveTheWheels } from '../providers/MoveTheWheelsProvider';
|
|
@@ -48,12 +70,9 @@ const VEHICLE_TYPE_OPTIONS = [
|
|
|
48
70
|
{ value: 'DRONE', label: 'Drone' },
|
|
49
71
|
];
|
|
50
72
|
|
|
51
|
-
const VEHICLE_STATUS_OPTIONS: Array<{ value: VehicleStatus; label: string }> =
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
{ value: 'maintenance', label: 'Maintenance' },
|
|
55
|
-
{ value: 'out_of_service', label: 'Out of Service' },
|
|
56
|
-
];
|
|
73
|
+
const VEHICLE_STATUS_OPTIONS: Array<{ value: VehicleStatus; label: string }> = (
|
|
74
|
+
Object.entries(VEHICLE_STATUS_LABELS) as Array<[VehicleStatus, string]>
|
|
75
|
+
).map(([value, label]) => ({ value, label }));
|
|
57
76
|
|
|
58
77
|
const OWNERSHIP_TYPE_OPTIONS = [
|
|
59
78
|
{ value: 'owned', label: 'Owned' },
|
|
@@ -63,7 +82,7 @@ const OWNERSHIP_TYPE_OPTIONS = [
|
|
|
63
82
|
|
|
64
83
|
function NewVehicleForm({ editId }: { editId?: string }) {
|
|
65
84
|
const { basePath, navigate } = useMoveTheWheels();
|
|
66
|
-
const { createVehicle, updateVehicle } = useFleet();
|
|
85
|
+
const { createVehicle, updateVehicle, deleteVehicle } = useFleet();
|
|
67
86
|
const { vehicle } = useVehicle(editId);
|
|
68
87
|
const isEditing = !!editId;
|
|
69
88
|
const [form, setForm] = useState({
|
|
@@ -77,9 +96,15 @@ function NewVehicleForm({ editId }: { editId?: string }) {
|
|
|
77
96
|
status: 'available' as VehicleStatus,
|
|
78
97
|
fuelType: '',
|
|
79
98
|
fuelEfficiencyLPer100Km: '',
|
|
99
|
+
capacityWeight: '',
|
|
100
|
+
capacityVolume: '',
|
|
101
|
+
capacityPallets: '',
|
|
80
102
|
});
|
|
81
103
|
const [submitting, setSubmitting] = useState(false);
|
|
82
104
|
const [error, setError] = useState<string | null>(null);
|
|
105
|
+
const [deleteOpen, setDeleteOpen] = useState(false);
|
|
106
|
+
const [deleting, setDeleting] = useState(false);
|
|
107
|
+
const [deleteError, setDeleteError] = useState<string | null>(null);
|
|
83
108
|
|
|
84
109
|
useEffect(() => {
|
|
85
110
|
if (editId && vehicle) {
|
|
@@ -97,6 +122,9 @@ function NewVehicleForm({ editId }: { editId?: string }) {
|
|
|
97
122
|
vehicle.fuelEfficiencyLPer100Km !== undefined
|
|
98
123
|
? String(vehicle.fuelEfficiencyLPer100Km)
|
|
99
124
|
: '',
|
|
125
|
+
capacityWeight: vehicle.capacity.weight ? String(vehicle.capacity.weight) : '',
|
|
126
|
+
capacityVolume: vehicle.capacity.volume ? String(vehicle.capacity.volume) : '',
|
|
127
|
+
capacityPallets: vehicle.capacity.pallets ? String(vehicle.capacity.pallets) : '',
|
|
100
128
|
});
|
|
101
129
|
}
|
|
102
130
|
}, [editId, vehicle]);
|
|
@@ -125,6 +153,14 @@ function NewVehicleForm({ editId }: { editId?: string }) {
|
|
|
125
153
|
fuelEfficiencyLPer100Km: form.fuelEfficiencyLPer100Km
|
|
126
154
|
? Number(form.fuelEfficiencyLPer100Km)
|
|
127
155
|
: undefined,
|
|
156
|
+
capacity:
|
|
157
|
+
form.capacityWeight || form.capacityVolume || form.capacityPallets
|
|
158
|
+
? {
|
|
159
|
+
weight: form.capacityWeight ? Number(form.capacityWeight) : undefined,
|
|
160
|
+
volume: form.capacityVolume ? Number(form.capacityVolume) : undefined,
|
|
161
|
+
pallets: form.capacityPallets ? Number(form.capacityPallets) : undefined,
|
|
162
|
+
}
|
|
163
|
+
: undefined,
|
|
128
164
|
};
|
|
129
165
|
if (editId) {
|
|
130
166
|
const updated = await updateVehicle(editId, payload);
|
|
@@ -140,6 +176,21 @@ function NewVehicleForm({ editId }: { editId?: string }) {
|
|
|
140
176
|
}
|
|
141
177
|
};
|
|
142
178
|
|
|
179
|
+
const handleDelete = async () => {
|
|
180
|
+
if (!editId) return;
|
|
181
|
+
setDeleting(true);
|
|
182
|
+
setDeleteError(null);
|
|
183
|
+
try {
|
|
184
|
+
await deleteVehicle(editId);
|
|
185
|
+
setDeleteOpen(false);
|
|
186
|
+
navigate?.(joinPath(basePath, 'fleet/vehicles'));
|
|
187
|
+
} catch (err) {
|
|
188
|
+
setDeleteError(err instanceof Error ? err.message : 'Failed to delete vehicle.');
|
|
189
|
+
} finally {
|
|
190
|
+
setDeleting(false);
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
|
|
143
194
|
return (
|
|
144
195
|
<PageLayout
|
|
145
196
|
title={isEditing ? 'Edit Vehicle' : 'Add Vehicle'}
|
|
@@ -313,9 +364,98 @@ function NewVehicleForm({ editId }: { editId?: string }) {
|
|
|
313
364
|
/>
|
|
314
365
|
</div>
|
|
315
366
|
</div>
|
|
367
|
+
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
368
|
+
<div className="space-y-2">
|
|
369
|
+
<Label htmlFor="capacityWeight">Capacity — Weight (kg)</Label>
|
|
370
|
+
<Input
|
|
371
|
+
id="capacityWeight"
|
|
372
|
+
type="number"
|
|
373
|
+
min={0}
|
|
374
|
+
value={form.capacityWeight}
|
|
375
|
+
onChange={(e) => setForm((prev) => ({ ...prev, capacityWeight: e.target.value }))}
|
|
376
|
+
placeholder="5000"
|
|
377
|
+
/>
|
|
378
|
+
</div>
|
|
379
|
+
<div className="space-y-2">
|
|
380
|
+
<Label htmlFor="capacityVolume">Capacity — Volume (m³)</Label>
|
|
381
|
+
<Input
|
|
382
|
+
id="capacityVolume"
|
|
383
|
+
type="number"
|
|
384
|
+
min={0}
|
|
385
|
+
step={0.1}
|
|
386
|
+
value={form.capacityVolume}
|
|
387
|
+
onChange={(e) => setForm((prev) => ({ ...prev, capacityVolume: e.target.value }))}
|
|
388
|
+
placeholder="18"
|
|
389
|
+
/>
|
|
390
|
+
</div>
|
|
391
|
+
<div className="space-y-2">
|
|
392
|
+
<Label htmlFor="capacityPallets">Capacity — Pallets</Label>
|
|
393
|
+
<Input
|
|
394
|
+
id="capacityPallets"
|
|
395
|
+
type="number"
|
|
396
|
+
min={0}
|
|
397
|
+
value={form.capacityPallets}
|
|
398
|
+
onChange={(e) =>
|
|
399
|
+
setForm((prev) => ({ ...prev, capacityPallets: e.target.value }))
|
|
400
|
+
}
|
|
401
|
+
placeholder="12"
|
|
402
|
+
/>
|
|
403
|
+
</div>
|
|
404
|
+
</div>
|
|
316
405
|
</div>
|
|
317
406
|
</EmphasisPanel>
|
|
407
|
+
|
|
408
|
+
{isEditing && (
|
|
409
|
+
<Card className="border-status-error-border">
|
|
410
|
+
<CardHeader>
|
|
411
|
+
<CardTitle className="flex items-center gap-2 text-status-error-text">
|
|
412
|
+
<Trash2 size={18} /> Delete Vehicle
|
|
413
|
+
</CardTitle>
|
|
414
|
+
</CardHeader>
|
|
415
|
+
<CardContent>
|
|
416
|
+
<p className="mb-3 text-sm text-text-secondary">
|
|
417
|
+
Removes this vehicle from the active fleet. It can no longer be assigned to drivers,
|
|
418
|
+
orders, or routes. This cannot be undone from here.
|
|
419
|
+
</p>
|
|
420
|
+
<Button
|
|
421
|
+
type="button"
|
|
422
|
+
variant="outline"
|
|
423
|
+
className="border-status-error-border text-status-error-text hover:bg-status-error-bg-subtle"
|
|
424
|
+
onClick={() => setDeleteOpen(true)}
|
|
425
|
+
>
|
|
426
|
+
<Trash2 size={16} /> Delete this vehicle
|
|
427
|
+
</Button>
|
|
428
|
+
</CardContent>
|
|
429
|
+
</Card>
|
|
430
|
+
)}
|
|
318
431
|
</form>
|
|
432
|
+
|
|
433
|
+
<ResponsiveDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
|
434
|
+
<ResponsiveDialogContent>
|
|
435
|
+
<ResponsiveDialogHeader>
|
|
436
|
+
<ResponsiveDialogTitle>Delete this vehicle?</ResponsiveDialogTitle>
|
|
437
|
+
<ResponsiveDialogDescription>
|
|
438
|
+
This removes {form.vehicleNumber || 'this vehicle'} from the active fleet. It cannot
|
|
439
|
+
be undone from here.
|
|
440
|
+
</ResponsiveDialogDescription>
|
|
441
|
+
</ResponsiveDialogHeader>
|
|
442
|
+
<div className="px-1">
|
|
443
|
+
<FormError message={deleteError} />
|
|
444
|
+
</div>
|
|
445
|
+
<ResponsiveDialogFooter>
|
|
446
|
+
<Button variant="outline" onClick={() => setDeleteOpen(false)} disabled={deleting}>
|
|
447
|
+
Cancel
|
|
448
|
+
</Button>
|
|
449
|
+
<Button
|
|
450
|
+
className="bg-status-error-text text-text-inverse hover:opacity-90"
|
|
451
|
+
onClick={handleDelete}
|
|
452
|
+
disabled={deleting}
|
|
453
|
+
>
|
|
454
|
+
{deleting ? 'Deleting...' : 'Delete vehicle'}
|
|
455
|
+
</Button>
|
|
456
|
+
</ResponsiveDialogFooter>
|
|
457
|
+
</ResponsiveDialogContent>
|
|
458
|
+
</ResponsiveDialog>
|
|
319
459
|
</PageLayout>
|
|
320
460
|
);
|
|
321
461
|
}
|
|
@@ -351,7 +491,7 @@ export default function VehicleDetailsPage() {
|
|
|
351
491
|
return (
|
|
352
492
|
<PageLayout
|
|
353
493
|
title={vehicle.vehicleNumber}
|
|
354
|
-
subtitle={`${vehicle.make} ${vehicle.model} (${vehicle.year})`}
|
|
494
|
+
subtitle={`${vehicle.make} ${vehicle.model}${vehicle.year ? ` (${vehicle.year})` : ''}`}
|
|
355
495
|
icon={<Truck size={24} />}
|
|
356
496
|
headerActions={
|
|
357
497
|
<div className="flex gap-2">
|
|
@@ -428,7 +568,7 @@ export default function VehicleDetailsPage() {
|
|
|
428
568
|
</div>
|
|
429
569
|
<div>
|
|
430
570
|
<p className="text-sm text-text-secondary">Year</p>
|
|
431
|
-
<p className="font-medium">{vehicle.year}</p>
|
|
571
|
+
<p className="font-medium">{vehicle.year ?? '—'}</p>
|
|
432
572
|
</div>
|
|
433
573
|
<div>
|
|
434
574
|
<p className="text-sm text-text-secondary">License Plate</p>
|
|
@@ -11,24 +11,44 @@ import {
|
|
|
11
11
|
CircleCheck,
|
|
12
12
|
Route as RouteIcon,
|
|
13
13
|
Wrench,
|
|
14
|
+
Filter,
|
|
14
15
|
} from 'lucide-react';
|
|
15
16
|
import { PageLayout } from '../components/PageLayout';
|
|
16
17
|
import { MetricCard, MetricGrid } from '../components/MetricCard';
|
|
17
18
|
import { DataTable, type Column } from '../components/DataTable';
|
|
18
19
|
import { StatusBadge, VehicleTypeBadge } from '../components/StatusBadge';
|
|
19
|
-
import {
|
|
20
|
+
import {
|
|
21
|
+
Progress,
|
|
22
|
+
Card,
|
|
23
|
+
CardContent,
|
|
24
|
+
Select,
|
|
25
|
+
SelectContent,
|
|
26
|
+
SelectItem,
|
|
27
|
+
SelectTrigger,
|
|
28
|
+
SelectValue,
|
|
29
|
+
Button,
|
|
30
|
+
} from '../components/ui';
|
|
20
31
|
import { CTAOverflowMenu, PagePurpose } from '@burdenoff/fe-libs/ui';
|
|
21
32
|
import { useVehicles, useFleetStats } from '../hooks/useFleet';
|
|
22
33
|
import { useDebouncedValue } from '../hooks/useDebouncedValue';
|
|
23
34
|
import { useMoveTheWheels } from '../providers/MoveTheWheelsProvider';
|
|
24
35
|
import { joinPath } from '../utils/navigation';
|
|
25
|
-
import
|
|
36
|
+
import { VEHICLE_STATUS_LABELS } from '../constants';
|
|
37
|
+
import type { Vehicle, VehicleStatus } from '../types';
|
|
38
|
+
|
|
39
|
+
const STATUS_OPTIONS: Array<{ value: VehicleStatus | 'all'; label: string }> = [
|
|
40
|
+
{ value: 'all', label: 'All statuses' },
|
|
41
|
+
...(Object.entries(VEHICLE_STATUS_LABELS) as Array<[VehicleStatus, string]>).map(
|
|
42
|
+
([value, label]) => ({ value, label })
|
|
43
|
+
),
|
|
44
|
+
];
|
|
26
45
|
|
|
27
46
|
export default function VehiclesPage() {
|
|
28
47
|
const { basePath, navigate } = useMoveTheWheels();
|
|
29
48
|
const [page, setPage] = useState(0);
|
|
30
49
|
const [pageSize, setPageSize] = useState(25);
|
|
31
50
|
const [search, setSearch] = useState('');
|
|
51
|
+
const [statusFilter, setStatusFilter] = useState<VehicleStatus | 'all'>('all');
|
|
32
52
|
const debouncedSearch = useDebouncedValue(search);
|
|
33
53
|
const {
|
|
34
54
|
vehicles,
|
|
@@ -41,7 +61,9 @@ export default function VehiclesPage() {
|
|
|
41
61
|
limit: pageSize,
|
|
42
62
|
offset: page * pageSize,
|
|
43
63
|
search: debouncedSearch || undefined,
|
|
64
|
+
status: statusFilter === 'all' ? undefined : statusFilter,
|
|
44
65
|
});
|
|
66
|
+
const filtersActive = statusFilter !== 'all' || search.length > 0;
|
|
45
67
|
// Fleet totals + tiles from the live aggregate, not the current page.
|
|
46
68
|
const { stats } = useFleetStats();
|
|
47
69
|
const totalVehicles = stats.totalVehicles;
|
|
@@ -74,7 +96,7 @@ export default function VehiclesPage() {
|
|
|
74
96
|
<p className="font-medium">
|
|
75
97
|
{row.make} {row.model}
|
|
76
98
|
</p>
|
|
77
|
-
<p className="text-sm text-text-secondary">{row.year}</p>
|
|
99
|
+
<p className="text-sm text-text-secondary">{row.year ?? '—'}</p>
|
|
78
100
|
</div>
|
|
79
101
|
),
|
|
80
102
|
},
|
|
@@ -178,6 +200,49 @@ export default function VehiclesPage() {
|
|
|
178
200
|
</MetricGrid>
|
|
179
201
|
</div>
|
|
180
202
|
|
|
203
|
+
{/* Filters */}
|
|
204
|
+
<Card>
|
|
205
|
+
<CardContent className="p-4">
|
|
206
|
+
<div className="flex flex-wrap items-center gap-4">
|
|
207
|
+
<div className="flex items-center gap-2">
|
|
208
|
+
<Filter size={16} className="text-text-secondary" />
|
|
209
|
+
<span className="text-sm font-medium">Filters:</span>
|
|
210
|
+
</div>
|
|
211
|
+
<Select
|
|
212
|
+
value={statusFilter}
|
|
213
|
+
onValueChange={(value) => {
|
|
214
|
+
setStatusFilter(value as VehicleStatus | 'all');
|
|
215
|
+
setPage(0);
|
|
216
|
+
}}
|
|
217
|
+
>
|
|
218
|
+
<SelectTrigger className="w-[180px]">
|
|
219
|
+
<SelectValue placeholder="Status" />
|
|
220
|
+
</SelectTrigger>
|
|
221
|
+
<SelectContent>
|
|
222
|
+
{STATUS_OPTIONS.map((option) => (
|
|
223
|
+
<SelectItem key={option.value} value={option.value}>
|
|
224
|
+
{option.label}
|
|
225
|
+
</SelectItem>
|
|
226
|
+
))}
|
|
227
|
+
</SelectContent>
|
|
228
|
+
</Select>
|
|
229
|
+
{filtersActive && (
|
|
230
|
+
<Button
|
|
231
|
+
variant="ghost"
|
|
232
|
+
size="sm"
|
|
233
|
+
onClick={() => {
|
|
234
|
+
setStatusFilter('all');
|
|
235
|
+
setSearch('');
|
|
236
|
+
setPage(0);
|
|
237
|
+
}}
|
|
238
|
+
>
|
|
239
|
+
Clear filters
|
|
240
|
+
</Button>
|
|
241
|
+
)}
|
|
242
|
+
</div>
|
|
243
|
+
</CardContent>
|
|
244
|
+
</Card>
|
|
245
|
+
|
|
181
246
|
<DataTable
|
|
182
247
|
columns={columns}
|
|
183
248
|
data={vehicles}
|