mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 09:58:12 +00:00
375 lines
13 KiB
TypeScript
375 lines
13 KiB
TypeScript
import { useMemo } from 'react';
|
|
import { useQuery } from '@tanstack/react-query';
|
|
import { Card, Stack, Group, Grid, Text, ThemeIcon, Progress, Badge, Table, RingProgress, Container, Title, Box, Tabs } from '@mantine/core';
|
|
import { Truck, Fuel, Wrench, AlertCircle, Users, User, MapPin } from 'lucide-react';
|
|
import type { LucideIcon } from 'lucide-react';
|
|
import Breadcrumbs from '@/components/ui/Breadcrumbs';
|
|
import { QUERY_KEYS } from '@/constants/QUERY_KEYS';
|
|
import { api } from '@/auth/http';
|
|
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 Driver {
|
|
id: string;
|
|
firstName: string;
|
|
lastName: string;
|
|
licenseNumber?: string;
|
|
email?: string;
|
|
phone?: string;
|
|
assignedVehicle?: string;
|
|
}
|
|
|
|
interface FleetMetrics {
|
|
totalVehicles: number;
|
|
activeVehicles: number;
|
|
maintenanceOverdue: number;
|
|
totalFuelSpend: number;
|
|
totalMaintenanceSpend: number;
|
|
averageFuelEfficiency: number;
|
|
costPerKm: number;
|
|
totalDrivers: number;
|
|
assignedDrivers: number;
|
|
}
|
|
|
|
/** ETB money, whole-birr for dashboard headlines. */
|
|
const etb = (n: number) => `ETB ${(Number(n) || 0).toLocaleString('en-US', { maximumFractionDigits: 0 })}`;
|
|
/** Safe percentage — 0 when the denominator is 0 (empty fleet). */
|
|
const pct = (n: number, d: number) => (d > 0 ? (n / d) * 100 : 0);
|
|
|
|
interface StatCardProps {
|
|
icon: LucideIcon;
|
|
label: string;
|
|
value: string | number;
|
|
color?: string;
|
|
change?: number;
|
|
}
|
|
|
|
const StatCard = ({ icon: Icon, label, value, color = 'edr-green', change }: StatCardProps) => (
|
|
<Card withBorder p="lg" style={{ borderTop: `3px solid ${freightBrand.primary}` }}>
|
|
<Group justify="space-between" mb="sm">
|
|
<ThemeIcon size="xl" radius="md" color={color} variant="light">
|
|
<Icon size={28} />
|
|
</ThemeIcon>
|
|
</Group>
|
|
<Stack gap="xs">
|
|
<Text size="xs" c="dimmed" fw={500} tt="uppercase">
|
|
{label}
|
|
</Text>
|
|
<Group justify="space-between">
|
|
<Text fw={700} size="xl" c="edr-ink">
|
|
{value}
|
|
</Text>
|
|
{change && <Badge color={change > 0 ? 'edr-green' : 'edr-red'} size="lg">{change > 0 ? '+' : ''}{change}%</Badge>}
|
|
</Group>
|
|
</Stack>
|
|
</Card>
|
|
);
|
|
|
|
export function FleetDashboard() {
|
|
const { data: vehicles = [] } = useQuery({
|
|
queryKey: QUERY_KEYS.VEHICLES.list(),
|
|
queryFn: async () => {
|
|
const res = await vehiclesService.getAll({ limit: 1000 });
|
|
return res.data || [];
|
|
},
|
|
});
|
|
|
|
const { data: drivers = [] } = useQuery({
|
|
queryKey: ['drivers'],
|
|
queryFn: async () => {
|
|
try {
|
|
const res = await api.get('/drivers');
|
|
return res.data || [];
|
|
} catch {
|
|
return [];
|
|
}
|
|
},
|
|
});
|
|
|
|
const { data: fuelStats } = useQuery({
|
|
queryKey: ['fleet-fuel-stats'],
|
|
queryFn: async () => {
|
|
try {
|
|
const res = await api.get('/fuel/stats');
|
|
return res.data || {};
|
|
} catch {
|
|
return {};
|
|
}
|
|
},
|
|
});
|
|
|
|
const { data: maintenanceStats } = useQuery({
|
|
queryKey: ['fleet-maintenance-stats'],
|
|
queryFn: async () => {
|
|
try {
|
|
const res = await api.get('/maintenance/stats');
|
|
return res.data || {};
|
|
} catch {
|
|
return {};
|
|
}
|
|
},
|
|
});
|
|
|
|
const metrics = useMemo((): FleetMetrics => {
|
|
const totalVehicles = (vehicles as Vehicle[]).length;
|
|
const activeVehicles = (vehicles as Vehicle[]).filter(v => v.status === 'ACTIVE').length;
|
|
const totalDrivers = (drivers as Driver[]).length;
|
|
const assignedDrivers = (drivers as Driver[]).filter(d => d.assignedVehicle).length;
|
|
|
|
const fuelTotal = Number(fuelStats?.totalCost) || 0;
|
|
const maintenanceTotal = Number(maintenanceStats?.totalCost) || 0;
|
|
|
|
return {
|
|
totalVehicles,
|
|
activeVehicles,
|
|
maintenanceOverdue: 0, // TODO: fetch from API
|
|
totalFuelSpend: fuelTotal,
|
|
totalMaintenanceSpend: maintenanceTotal,
|
|
averageFuelEfficiency: fuelStats?.averageEfficiency || 0,
|
|
costPerKm: (fuelTotal + maintenanceTotal) / 100000, // Placeholder
|
|
totalDrivers,
|
|
assignedDrivers,
|
|
};
|
|
}, [vehicles, drivers, fuelStats, maintenanceStats]);
|
|
|
|
const operatingCost = metrics.totalFuelSpend + metrics.totalMaintenanceSpend;
|
|
const fuelPercent = operatingCost > 0 ? Math.round((metrics.totalFuelSpend / operatingCost) * 100) : 0;
|
|
|
|
return (
|
|
<Container size="xl" py="xl" px="lg">
|
|
<Breadcrumbs items={[{ label: 'Fleet' }, { label: 'Dashboard' }]} />
|
|
|
|
<Box mb="xl">
|
|
<Title order={1} mb="xs">
|
|
Fleet Management Dashboard
|
|
</Title>
|
|
<Text c="dimmed" size="sm">
|
|
Real-time fleet overview, vehicle & driver management
|
|
</Text>
|
|
</Box>
|
|
|
|
{/* Primary Metrics */}
|
|
<Grid mb="xl">
|
|
<Grid.Col span={{ base: 12, sm: 6, md: 3 }}>
|
|
<StatCard icon={Truck} label="Total Vehicles" value={metrics.totalVehicles} color="edr-green" />
|
|
</Grid.Col>
|
|
<Grid.Col span={{ base: 12, sm: 6, md: 3 }}>
|
|
<StatCard icon={Users} label="Total Drivers" value={metrics.totalDrivers} color="edr-blue" />
|
|
</Grid.Col>
|
|
<Grid.Col span={{ base: 12, sm: 6, md: 3 }}>
|
|
<StatCard icon={Fuel} label="Fuel Spend" value={etb(metrics.totalFuelSpend)} color="edr-accent" />
|
|
</Grid.Col>
|
|
<Grid.Col span={{ base: 12, sm: 6, md: 3 }}>
|
|
<StatCard icon={Wrench} label="Maintenance" value={etb(metrics.totalMaintenanceSpend)} color="edr-red" />
|
|
</Grid.Col>
|
|
</Grid>
|
|
|
|
{/* Fleet Status */}
|
|
<Grid mb="lg">
|
|
<Grid.Col span={{ base: 12, md: 6 }}>
|
|
<Card withBorder>
|
|
<Card.Section p="md" withBorder>
|
|
<Text fw={500}>Fleet Status</Text>
|
|
</Card.Section>
|
|
<Card.Section p="md">
|
|
<Stack gap="lg">
|
|
<div>
|
|
<Group justify="space-between" mb="xs">
|
|
<Text size="sm">Active Vehicles</Text>
|
|
<Text fw={700}>{metrics.activeVehicles} / {metrics.totalVehicles}</Text>
|
|
</Group>
|
|
<Progress value={pct(metrics.activeVehicles, metrics.totalVehicles)} color="edr-green" />
|
|
</div>
|
|
|
|
<div>
|
|
<Group justify="space-between" mb="xs">
|
|
<Text size="sm">Maintenance Overdue</Text>
|
|
<Badge color="edr-red">{metrics.maintenanceOverdue}</Badge>
|
|
</Group>
|
|
<Progress value={0} color="edr-red" />
|
|
</div>
|
|
|
|
<div>
|
|
<Group justify="space-between" mb="xs">
|
|
<Text size="sm">Idle / Under Maintenance</Text>
|
|
<Text fw={700}>{metrics.totalVehicles - metrics.activeVehicles}</Text>
|
|
</Group>
|
|
<Progress value={pct(metrics.totalVehicles - metrics.activeVehicles, metrics.totalVehicles)} color="edr-amber-soft" />
|
|
</div>
|
|
</Stack>
|
|
</Card.Section>
|
|
</Card>
|
|
</Grid.Col>
|
|
|
|
<Grid.Col span={{ base: 12, md: 6 }}>
|
|
<Card withBorder>
|
|
<Card.Section p="md" withBorder>
|
|
<Text fw={500}>Operating Cost Breakdown</Text>
|
|
</Card.Section>
|
|
<Card.Section p="md">
|
|
<Stack gap="lg">
|
|
<Group justify="center">
|
|
<RingProgress
|
|
sections={[
|
|
{ value: fuelPercent, color: 'edr-accent' },
|
|
{ value: 100 - fuelPercent, color: 'edr-red' },
|
|
]}
|
|
label={
|
|
<div style={{ textAlign: 'center' }}>
|
|
<Text fw={700} size="sm">
|
|
{etb(operatingCost)}
|
|
</Text>
|
|
<Text size="xs" c="dimmed">
|
|
Total Cost
|
|
</Text>
|
|
</div>
|
|
}
|
|
size={120}
|
|
thickness={4}
|
|
/>
|
|
</Group>
|
|
|
|
<div>
|
|
<Group justify="space-between">
|
|
<Group gap="xs">
|
|
<ThemeIcon size="sm" color="edr-accent" variant="light">
|
|
<Fuel size={14} />
|
|
</ThemeIcon>
|
|
<Text size="sm">Fuel</Text>
|
|
</Group>
|
|
<Text fw={700}>{fuelPercent}%</Text>
|
|
</Group>
|
|
</div>
|
|
|
|
<div>
|
|
<Group justify="space-between">
|
|
<Group gap="xs">
|
|
<ThemeIcon size="sm" color="edr-red" variant="light">
|
|
<Wrench size={14} />
|
|
</ThemeIcon>
|
|
<Text size="sm">Maintenance</Text>
|
|
</Group>
|
|
<Text fw={700}>{100 - fuelPercent}%</Text>
|
|
</Group>
|
|
</div>
|
|
</Stack>
|
|
</Card.Section>
|
|
</Card>
|
|
</Grid.Col>
|
|
</Grid>
|
|
|
|
{/* Vehicles & Drivers Tabs */}
|
|
<Card withBorder>
|
|
<Tabs defaultValue="vehicles" p="md">
|
|
<Tabs.List>
|
|
<Tabs.Tab value="vehicles" leftSection={<Truck size={16} />}>
|
|
Vehicles ({(vehicles as Vehicle[]).length})
|
|
</Tabs.Tab>
|
|
<Tabs.Tab value="drivers" leftSection={<Users size={16} />}>
|
|
Drivers ({(drivers as Driver[]).length})
|
|
</Tabs.Tab>
|
|
</Tabs.List>
|
|
|
|
<Tabs.Panel value="vehicles" pt="md">
|
|
{(vehicles as Vehicle[]).length > 0 ? (
|
|
<Table striped highlightOnHover>
|
|
<Table.Thead>
|
|
<Table.Tr>
|
|
<Table.Th>Registration</Table.Th>
|
|
<Table.Th>Plate</Table.Th>
|
|
<Table.Th>Model</Table.Th>
|
|
<Table.Th>Status</Table.Th>
|
|
</Table.Tr>
|
|
</Table.Thead>
|
|
<Table.Tbody>
|
|
{(vehicles as Vehicle[]).slice(0, 15).map(v => (
|
|
<Table.Tr key={v.id}>
|
|
<Table.Td fw={500}>{v.registrationNumber}</Table.Td>
|
|
<Table.Td>{v.plateNumber}</Table.Td>
|
|
<Table.Td>
|
|
{v.manufacturer} {v.model}
|
|
</Table.Td>
|
|
<Table.Td>
|
|
<Badge color={v.status === 'ACTIVE' ? 'edr-green' : v.status === 'MAINTENANCE' ? 'edr-amber-soft' : 'gray'}>
|
|
{v.status || 'UNKNOWN'}
|
|
</Badge>
|
|
</Table.Td>
|
|
</Table.Tr>
|
|
))}
|
|
</Table.Tbody>
|
|
</Table>
|
|
) : (
|
|
<Group gap="xs" p="md">
|
|
<AlertCircle size={20} />
|
|
<Text c="dimmed">No vehicles in fleet</Text>
|
|
</Group>
|
|
)}
|
|
</Tabs.Panel>
|
|
|
|
<Tabs.Panel value="drivers" pt="md">
|
|
{(drivers as Driver[]).length > 0 ? (
|
|
<Table striped highlightOnHover>
|
|
<Table.Thead>
|
|
<Table.Tr>
|
|
<Table.Th>Name</Table.Th>
|
|
<Table.Th>License</Table.Th>
|
|
<Table.Th>Contact</Table.Th>
|
|
<Table.Th>Assigned Vehicle</Table.Th>
|
|
</Table.Tr>
|
|
</Table.Thead>
|
|
<Table.Tbody>
|
|
{(drivers as Driver[]).slice(0, 15).map(d => (
|
|
<Table.Tr key={d.id}>
|
|
<Table.Td fw={500}>
|
|
<Group gap="xs">
|
|
<ThemeIcon size="sm" radius="xl" variant="light" color="blue">
|
|
<User size={14} />
|
|
</ThemeIcon>
|
|
{d.firstName} {d.lastName}
|
|
</Group>
|
|
</Table.Td>
|
|
<Table.Td>{d.licenseNumber || 'N/A'}</Table.Td>
|
|
<Table.Td>
|
|
<Stack gap={0}>
|
|
{d.phone && (
|
|
<Group gap={4}>
|
|
<MapPin size={12} />
|
|
<Text size="xs">{d.phone}</Text>
|
|
</Group>
|
|
)}
|
|
{d.email && <Text size="xs">{d.email}</Text>}
|
|
</Stack>
|
|
</Table.Td>
|
|
<Table.Td>
|
|
{d.assignedVehicle ? (
|
|
<Badge color="edr-green">Assigned</Badge>
|
|
) : (
|
|
<Badge color="edr-slate">Unassigned</Badge>
|
|
)}
|
|
</Table.Td>
|
|
</Table.Tr>
|
|
))}
|
|
</Table.Tbody>
|
|
</Table>
|
|
) : (
|
|
<Group gap="xs" p="md">
|
|
<AlertCircle size={20} />
|
|
<Text c="dimmed">No drivers in system</Text>
|
|
</Group>
|
|
)}
|
|
</Tabs.Panel>
|
|
</Tabs>
|
|
</Card>
|
|
</Container>
|
|
);
|
|
}
|