Merge branch 'freight/hot_fix' of github.com:Tria-plc/edr-platform into freight/hot_fix

This commit is contained in:
yaschalew
2026-06-30 17:09:19 +03:00
2 changed files with 369 additions and 0 deletions

View File

@@ -6,6 +6,7 @@ import {
FileText,
LayoutDashboard,
LayoutGrid,
MapPin,
Network,
Package,
PackageCheck,
@@ -62,6 +63,7 @@ import FuelStatsPage from "./pages/fleet/FuelStatsPage";
import { MaintenancePage } from "./pages/fleet/MaintenancePage";
import { FinancialReportsPage } from "./pages/fleet/FinancialReportsPage";
import { FleetDashboard } from "./pages/fleet/FleetDashboard";
import { TrackingPage } from "./pages/fleet/TrackingPage";
import { getCategorySidebarChildren } from "./pages/ruleEngine/config/resources";
import RuleEngineLegacyRedirect from "./pages/ruleEngine/RuleEngineLegacyRedirect";
import RuleEngineResourcePage from "./pages/ruleEngine/RuleEngineResourcePage";
@@ -211,6 +213,12 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
icon: <Users />,
permission: FREIGHT_PERMS.fleet.view,
},
{
label: "Track Vehicles",
href: "/dashboard/tracking",
icon: <MapPin />,
permission: FREIGHT_PERMS.fleet.view,
},
{
label: "Fuel Purchases",
href: "/dashboard/fuel-purchases",
@@ -800,6 +808,14 @@ const App = () => {
</RequirePermission>
}
/>
<Route
path="tracking"
element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
<TrackingPage />
</RequirePermission>
}
/>
<Route
path="locomotives"
element={

View File

@@ -0,0 +1,353 @@
import { useState, useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Container, Grid, Card, Stack, Group, Select, Text, Badge, Button, Box, Table, ThemeIcon, SimpleGrid } from '@mantine/core';
import { MapPin, Navigation, Radio, Activity } from 'lucide-react';
import Breadcrumbs from '@/components/ui/Breadcrumbs';
import { QUERY_KEYS } from '@/constants/QUERY_KEYS';
import { vehiclesService } from '@/services/vehicles.service';
import { freightBrand } from '@/theme/freight-brand';
interface Vehicle {
id: string;
registrationNumber: string;
plateNumber: string;
manufacturer: string;
model: string;
status?: string;
}
interface GPSLocation {
lat: number;
lng: number;
speed?: number;
heading?: number;
lastUpdate?: string;
}
// Mock GPS data for demo
const generateMockGPS = (index: number): GPSLocation => ({
lat: 9.0 + Math.random() * 0.5,
lng: 38.7 + Math.random() * 0.5,
speed: Math.floor(Math.random() * 120),
heading: Math.floor(Math.random() * 360),
lastUpdate: new Date(Date.now() - Math.random() * 300000).toLocaleTimeString(),
});
export function TrackingPage() {
const [selectedVehicleId, setSelectedVehicleId] = useState<string | null>(null);
const [mapCenter] = useState({ lat: 9.0, lng: 38.8 });
const mapZoom = 10;
const { data: vehicles = [] } = useQuery({
queryKey: QUERY_KEYS.VEHICLES.list(),
queryFn: async () => {
const res = await vehiclesService.getAll({ limit: 1000 });
return res.data || [];
},
});
// Generate mock GPS data for each vehicle
const vehiclesWithGPS = useMemo(() => {
return (vehicles as Vehicle[]).map((v, idx) => ({
...v,
gps: generateMockGPS(idx),
}));
}, [vehicles]);
const selectedVehicle = vehiclesWithGPS.find(v => v.id === selectedVehicleId);
const vehicleOptions = useMemo(
() => vehiclesWithGPS.map(v => ({ label: v.registrationNumber, value: v.id })),
[vehiclesWithGPS]
);
// Map dimensions
const mapWidth = 800;
const mapHeight = 500;
const pixelsPerLat = mapHeight / 0.6;
const pixelsPerLng = mapWidth / 0.6;
const getMapCoords = (lat: number, lng: number) => ({
x: ((lng - (mapCenter.lng - 0.3)) * pixelsPerLng),
y: ((mapCenter.lat + 0.3 - lat) * pixelsPerLat),
});
return (
<Container size="xl" py="xl">
<Breadcrumbs items={[{ label: 'Fleet' }, { label: 'Vehicle Tracking' }]} />
<Stack gap="xl">
<Group justify="space-between">
<div>
<Text fw={700} size="xl">
Real-Time Vehicle Tracking
</Text>
<Text c="dimmed" size="sm">
Monitor vehicle locations, speed, and status
</Text>
</div>
</Group>
<Grid>
{/* Map Section */}
<Grid.Col span={{ base: 12, lg: 8 }}>
<Card withBorder p="lg">
<Card.Section p="md" withBorder>
<Group justify="space-between">
<Text fw={500}>Map View</Text>
<Group gap="xs">
<Badge color="edr-green" leftSection={<Radio size={12} />}>
{vehiclesWithGPS.filter(v => v.status === 'ACTIVE').length} Active
</Badge>
</Group>
</Group>
</Card.Section>
<Card.Section p="md">
<Box
pos="relative"
style={{
width: mapWidth,
height: mapHeight,
backgroundColor: '#f0f8f7',
border: `2px solid ${freightBrand.primary}`,
borderRadius: '8px',
overflow: 'hidden',
}}
>
{/* Grid background */}
<svg
width={mapWidth}
height={mapHeight}
style={{ position: 'absolute', top: 0, left: 0 }}
>
{/* Latitude lines */}
{[0, 1, 2, 3, 4, 5, 6].map(i => (
<line
key={`lat-${i}`}
x1={0}
y1={(i / 6) * mapHeight}
x2={mapWidth}
y2={(i / 6) * mapHeight}
stroke="#e0e0e0"
strokeWidth={1}
/>
))}
{/* Longitude lines */}
{[0, 1, 2, 3, 4, 5, 6].map(i => (
<line
key={`lng-${i}`}
x1={(i / 6) * mapWidth}
y1={0}
x2={(i / 6) * mapWidth}
y2={mapHeight}
stroke="#e0e0e0"
strokeWidth={1}
/>
))}
</svg>
{/* Vehicle markers */}
{vehiclesWithGPS.map((vehicle) => {
const coords = getMapCoords(vehicle.gps.lat, vehicle.gps.lng);
const isSelected = vehicle.id === selectedVehicleId;
return (
<Box
key={vehicle.id}
pos="absolute"
style={{
left: coords.x - 15,
top: coords.y - 15,
width: 30,
height: 30,
cursor: 'pointer',
zIndex: isSelected ? 100 : 10,
}}
onClick={() => setSelectedVehicleId(vehicle.id)}
title={vehicle.registrationNumber}
>
<Box
pos="absolute"
inset={0}
style={{
backgroundColor: isSelected ? freightBrand.primary : '#3498db',
borderRadius: '50%',
border: isSelected ? `3px solid ${freightBrand.primaryDark}` : 'none',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'white',
fontSize: '16px',
boxShadow: isSelected ? `0 0 0 8px ${freightBrand.ring}` : 'none',
}}
>
<Navigation size={16} />
</Box>
</Box>
);
})}
{/* Map labels */}
<Box pos="absolute" bottom={8} left={8} style={{ zIndex: 50 }}>
<Text size="xs" c="dimmed">
📍 Addis Ababa, Ethiopia
</Text>
</Box>
</Box>
</Card.Section>
</Card>
</Grid.Col>
{/* Sidebar */}
<Grid.Col span={{ base: 12, lg: 4 }}>
<Stack gap="md">
{/* Vehicle Selector */}
<Card withBorder p="lg">
<Stack gap="md">
<Select
label="Track Vehicle"
placeholder="Select a vehicle to track"
data={vehicleOptions}
value={selectedVehicleId}
onChange={setSelectedVehicleId}
searchable
/>
{selectedVehicle && (
<Box p="md" style={{ backgroundColor: freightBrand.mutedBg, borderRadius: '8px' }}>
<Stack gap="sm">
<div>
<Text size="sm" c="dimmed">
Registration
</Text>
<Text fw={600}>{selectedVehicle.registrationNumber}</Text>
</div>
<div>
<Text size="sm" c="dimmed">
Vehicle
</Text>
<Text fw={600}>
{selectedVehicle.manufacturer} {selectedVehicle.model}
</Text>
</div>
<div>
<Text size="sm" c="dimmed">
Status
</Text>
<Badge color={selectedVehicle.status === 'ACTIVE' ? 'edr-green' : 'gray'}>
{selectedVehicle.status || 'Unknown'}
</Badge>
</div>
</Stack>
</Box>
)}
</Stack>
</Card>
{/* GPS Details */}
{selectedVehicle && (
<Card withBorder p="lg">
<Stack gap="md">
<Group justify="space-between">
<Text fw={500}>GPS Location</Text>
<Badge color="edr-green" leftSection={<Activity size={12} />}>
Live
</Badge>
</Group>
<SimpleGrid cols={2} spacing="sm">
<Box p="sm" style={{ backgroundColor: '#f8f9fa', borderRadius: '8px' }}>
<Text size="xs" c="dimmed">
Latitude
</Text>
<Text fw={600} size="sm">
{selectedVehicle.gps.lat.toFixed(4)}°
</Text>
</Box>
<Box p="sm" style={{ backgroundColor: '#f8f9fa', borderRadius: '8px' }}>
<Text size="xs" c="dimmed">
Longitude
</Text>
<Text fw={600} size="sm">
{selectedVehicle.gps.lng.toFixed(4)}°
</Text>
</Box>
<Box p="sm" style={{ backgroundColor: '#f8f9fa', borderRadius: '8px' }}>
<Text size="xs" c="dimmed">
Speed
</Text>
<Text fw={600} size="sm">
{selectedVehicle.gps.speed} km/h
</Text>
</Box>
<Box p="sm" style={{ backgroundColor: '#f8f9fa', borderRadius: '8px' }}>
<Text size="xs" c="dimmed">
Heading
</Text>
<Text fw={600} size="sm">
{selectedVehicle.gps.heading}°
</Text>
</Box>
</SimpleGrid>
<div>
<Text size="xs" c="dimmed">
Last Update
</Text>
<Text fw={500}>{selectedVehicle.gps.lastUpdate}</Text>
</div>
<Button color="edr-green" fullWidth leftSection={<MapPin size={16} />}>
View Full History
</Button>
</Stack>
</Card>
)}
{/* All Vehicles List */}
<Card withBorder p="lg">
<Stack gap="md">
<Text fw={500}>All Vehicles</Text>
<div style={{ maxHeight: '300px', overflowY: 'auto' }}>
<Table size="sm">
<Table.Tbody>
{vehiclesWithGPS.slice(0, 10).map(v => (
<Table.Tr
key={v.id}
style={{
cursor: 'pointer',
backgroundColor: v.id === selectedVehicleId ? freightBrand.mutedBg : 'transparent',
}}
onClick={() => setSelectedVehicleId(v.id)}
>
<Table.Td>
<Stack gap={0}>
<Text size="sm" fw={600}>
{v.registrationNumber}
</Text>
<Text size="xs" c="dimmed">
{v.gps.speed} km/h
</Text>
</Stack>
</Table.Td>
<Table.Td align="right">
<Badge
color={v.status === 'ACTIVE' ? 'edr-green' : 'gray'}
size="sm"
>
{v.status || 'N/A'}
</Badge>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</div>
</Stack>
</Card>
</Stack>
</Grid.Col>
</Grid>
</Stack>
</Container>
);
}