feat: fleet dashboard page

Shows fleet overview + key metrics:
- Total vehicles, active count
- Total fuel spending
- Total maintenance spending
- Average fuel efficiency
- Fleet status (active/idle/maintenance)
- Operating cost breakdown (fuel vs maintenance pie chart)
- Fleet vehicle list (first 10)

Route: /dashboard/fleet-dashboard
Sidebar: Added to Fleet Management section

Metrics aggregate from:
- /vehicles (fleet size)
- /fuel/stats (fuel spending + efficiency)
- /maintenance/stats (maintenance spending)

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
natib21
2026-06-30 13:38:35 +00:00
parent 8e0cc7d5ee
commit 814db8a17d
2 changed files with 277 additions and 0 deletions

View File

@@ -61,6 +61,7 @@ import FuelPurchasePage from "./pages/fleet/FuelPurchasePage";
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 { getCategorySidebarChildren } from "./pages/ruleEngine/config/resources";
import RuleEngineLegacyRedirect from "./pages/ruleEngine/RuleEngineLegacyRedirect";
import RuleEngineResourcePage from "./pages/ruleEngine/RuleEngineResourcePage";
@@ -168,6 +169,12 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
{
title: "Fleet Management",
items: [
{
label: "Fleet Dashboard",
href: "/dashboard/fleet-dashboard",
icon: <LayoutDashboard />,
permission: FREIGHT_PERMS.fleet.view,
},
{
label: "Routes",
href: "/dashboard/routes",
@@ -785,6 +792,14 @@ const App = () => {
</RequirePermission>
}
/>
<Route
path="fleet-dashboard"
element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
<FleetDashboard />
</RequirePermission>
}
/>
<Route
path="locomotives"
element={

View File

@@ -0,0 +1,262 @@
import { useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Card, Stack, Group, Grid, Text, ThemeIcon, Progress, Badge, Table, RingProgress, Container } from '@mantine/core';
import { Truck, Fuel, Wrench, TrendingUp, AlertCircle } 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';
interface Vehicle {
id: string;
registrationNumber: string;
plateNumber: string;
manufacturer: string;
model: string;
status?: string;
}
interface FleetMetrics {
totalVehicles: number;
activeVehicles: number;
maintenanceOverdue: number;
totalFuelSpend: number;
totalMaintenanceSpend: number;
averageFuelEfficiency: number;
costPerKm: number;
}
const StatCard = ({ icon: Icon, label, value, color = 'blue' }: any) => (
<Card withBorder>
<Group justify="space-between">
<Stack gap={0}>
<Text size="sm" c="dimmed">
{label}
</Text>
<Text fw={700} size="lg">
{value}
</Text>
</Stack>
<ThemeIcon size="lg" radius="md" variant="light" color={color}>
<Icon size={24} />
</ThemeIcon>
</Group>
</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: 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 fuelTotal = fuelStats?.totalCost || 0;
const maintenanceTotal = 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
};
}, [vehicles, 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">
<Breadcrumbs items={[{ label: 'Fleet' }, { label: 'Dashboard' }]} />
<Text fw={700} size="xl" mb="lg">
Fleet Overview
</Text>
{/* Key Metrics */}
<Grid mb="lg">
<Grid.Col span={{ base: 12, sm: 6, md: 3 }}>
<StatCard icon={Truck} label="Total Vehicles" value={metrics.totalVehicles} color="blue" />
</Grid.Col>
<Grid.Col span={{ base: 12, sm: 6, md: 3 }}>
<StatCard icon={Fuel} label="Fuel Spend" value={`$${metrics.totalFuelSpend.toFixed(0)}`} color="orange" />
</Grid.Col>
<Grid.Col span={{ base: 12, sm: 6, md: 3 }}>
<StatCard icon={Wrench} label="Maintenance" value={`$${metrics.totalMaintenanceSpend.toFixed(0)}`} color="red" />
</Grid.Col>
<Grid.Col span={{ base: 12, sm: 6, md: 3 }}>
<StatCard icon={TrendingUp} label="Efficiency" value={`${metrics.averageFuelEfficiency.toFixed(1)} km/L`} color="green" />
</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={(metrics.activeVehicles / metrics.totalVehicles) * 100} color="green" />
</div>
<div>
<Group justify="space-between" mb="xs">
<Text size="sm">Maintenance Overdue</Text>
<Badge color="red">{metrics.maintenanceOverdue}</Badge>
</Group>
<Progress value={0} color="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={((metrics.totalVehicles - metrics.activeVehicles) / metrics.totalVehicles) * 100} color="yellow" />
</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: 'orange' },
{ value: 100 - fuelPercent, color: 'red' },
]}
label={
<div style={{ textAlign: 'center' }}>
<Text fw={700} size="sm">
${operatingCost.toFixed(0)}
</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="orange" 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="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>
{/* Fleet List */}
<Card withBorder>
<Card.Section p="md" withBorder>
<Text fw={500}>Fleet Vehicles</Text>
</Card.Section>
<Card.Section>
{(vehicles as Vehicle[]).length > 0 ? (
<Table>
<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, 10).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' ? 'green' : 'gray'}>{v.status || 'UNKNOWN'}</Badge>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
) : (
<Card.Section p="md">
<Group gap="xs">
<AlertCircle size={20} />
<Text c="dimmed">No vehicles in fleet</Text>
</Group>
</Card.Section>
)}
</Card.Section>
</Card>
</Container>
);
}