mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 21:50:57 +00:00
Changed from filtering by ACTIVE status to showing first 10 vehicles. Ensures at least 2+ car icons visible on map for testing. Changes: - trackableVehicles: first 10 vehicles instead of ACTIVE filter - Map markers: shows all trackable vehicles - Badge: "Tracked" count instead of "Active" - Vehicle list: "Tracked Vehicles (count)" instead of "Active Vehicles" Enables demo of map functionality with actual fleet data. Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
360 lines
13 KiB
TypeScript
360 lines
13 KiB
TypeScript
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]);
|
|
|
|
// For demo: show all vehicles as trackable (or filter by ACTIVE if status data available)
|
|
const trackableVehicles = useMemo(
|
|
() => vehiclesWithGPS.slice(0, 10), // Limit to first 10 for demo
|
|
[vehiclesWithGPS]
|
|
);
|
|
|
|
const selectedVehicle = trackableVehicles.find(v => v.id === selectedVehicleId);
|
|
const vehicleOptions = useMemo(
|
|
() => trackableVehicles.map(v => ({ label: v.registrationNumber, value: v.id })),
|
|
[trackableVehicles]
|
|
);
|
|
|
|
// 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" px="lg">
|
|
<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} />}>
|
|
{trackableVehicles.length} Tracked
|
|
</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 */}
|
|
{trackableVehicles.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>
|
|
)}
|
|
|
|
{/* Tracked Vehicles List */}
|
|
<Card withBorder p="lg">
|
|
<Stack gap="md">
|
|
<Text fw={500}>Tracked Vehicles ({trackableVehicles.length})</Text>
|
|
<div style={{ maxHeight: '300px', overflowY: 'auto' }}>
|
|
<Table size="sm">
|
|
<Table.Tbody>
|
|
{trackableVehicles.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>
|
|
);
|
|
}
|