feat: maintenance + financial reports frontend

MaintenancePage:
- Schedule maintenance (PREVENTIVE/CORRECTIVE/INSPECTION/REPAIR)
- View upcoming by vehicle
- Modal form with date, cost, provider, notes

FinancialReportsPage:
- Aggregate fuel + maintenance costs
- Period selector (3/6/12 months)
- Cost breakdown (percentages, ring progress)
- Operating insights (purchases, efficiency, items, avg cost)
- Cost per month calculation

Routes:
- /dashboard/maintenance
- /dashboard/financial-reports

Sidebar:
- "Maintenance" in Fleet Management
- "Financial Reports" in Fleet Management

QUERY_KEYS:
- FUEL, MAINTENANCE, FINANCIAL_REPORTS cache patterns

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
natib21
2026-06-30 12:41:23 +00:00
parent f436916c42
commit 373c0356f2
4 changed files with 492 additions and 0 deletions

View File

@@ -59,6 +59,8 @@ import FleetResourcePage from "./pages/fleet/FleetResourcePage";
import RoutesPage from "./pages/fleet/RoutesPage";
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 { getCategorySidebarChildren } from "./pages/ruleEngine/config/resources";
import RuleEngineLegacyRedirect from "./pages/ruleEngine/RuleEngineLegacyRedirect";
import RuleEngineResourcePage from "./pages/ruleEngine/RuleEngineResourcePage";
@@ -214,6 +216,18 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
icon: <Truck />,
permission: FREIGHT_PERMS.fleet.view,
},
{
label: "Maintenance",
href: "/dashboard/maintenance",
icon: <Truck />,
permission: FREIGHT_PERMS.fleet.view,
},
{
label: "Financial Reports",
href: "/dashboard/financial-reports",
icon: <Wallet />,
permission: FREIGHT_PERMS.fleet.view,
},
// {
// label: "Containers",
// href: "/dashboard/containers",
@@ -755,6 +769,22 @@ const App = () => {
</RequirePermission>
}
/>
<Route
path="maintenance"
element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
<MaintenancePage />
</RequirePermission>
}
/>
<Route
path="financial-reports"
element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
<FinancialReportsPage />
</RequirePermission>
}
/>
<Route
path="locomotives"
element={

View File

@@ -141,4 +141,24 @@ export const QUERY_KEYS = {
customersTab: (range?: string) => ["overview", "customers", range ?? "30d"] as const,
staffTab: (range?: string) => ["overview", "staff", range ?? "30d"] as const,
},
FUEL: {
ROOT: ["fuel"] as const,
purchases: (vehicleId?: string) => ["fuel", "purchases", vehicleId ?? "all"] as const,
stats: (vehicleId?: string) => ["fuel", "stats", vehicleId ?? "all"] as const,
},
MAINTENANCE: {
ROOT: ["maintenance"] as const,
schedules: (vehicleId?: string) => ["maintenance", "schedules", vehicleId ?? "all"] as const,
upcoming: (vehicleId?: string) => ["maintenance", "upcoming", vehicleId ?? "all"] as const,
history: (vehicleId?: string) => ["maintenance", "history", vehicleId ?? "all"] as const,
stats: (vehicleId?: string) => ["maintenance", "stats", vehicleId ?? "all"] as const,
},
FINANCIAL_REPORTS: {
ROOT: ["financial-reports"] as const,
fleet: (vehicleId?: string, months?: number) =>
["financial-reports", "fleet", vehicleId ?? "all", months ?? 12] as const,
},
} as const;

View File

@@ -0,0 +1,245 @@
import { useMemo, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Card, Button, Stack, Group, Grid, Select, Text, ThemeIcon, RingProgress } from '@mantine/core';
import { QUERY_KEYS } from '@/constants/QUERY_KEYS';
import { api } from '@/services/api';
import { vehiclesService } from '@/services/vehicles.service';
interface FuelStats {
vehicleId: string;
totalPurchases: number;
totalFuel: number;
totalCost: number;
averageCostPerLiter: number;
}
interface MaintenanceStats {
vehicleId: string;
totalCost: number;
numberOfMaintenanceItems: number;
averageCostPerMaintenance: number;
costByType: Record<string, number>;
}
interface CombinedReport {
vehicleId: string;
fuelCost: number;
maintenanceCost: number;
totalOperatingCost: number;
fuelPercentage: number;
maintenancePercentage: number;
}
export function FinancialReportsPage() {
const [selectedVehicle, setSelectedVehicle] = useState<string | null>(null);
const [months, setMonths] = useState('12');
const { data: vehicles } = useQuery({
queryKey: QUERY_KEYS.VEHICLES.list(),
queryFn: () => vehiclesService.getAll({ limit: 1000 }),
});
const { data: fuelStats } = useQuery({
queryKey: QUERY_KEYS.FUEL.stats(selectedVehicle || ''),
queryFn: () => selectedVehicle ? api.get(`/fuel/stats/${selectedVehicle}?months=${months}`) : Promise.resolve(null),
enabled: !!selectedVehicle,
});
const { data: maintenanceStats } = useQuery({
queryKey: QUERY_KEYS.MAINTENANCE.stats(selectedVehicle || ''),
queryFn: () => selectedVehicle ? api.get(`/maintenance/stats/${selectedVehicle}`) : Promise.resolve(null),
enabled: !!selectedVehicle,
});
const vehicleOptions = useMemo(
() => vehicles?.map(v => ({ label: v.registrationNumber || v.id, value: v.id })) || [],
[vehicles]
);
const report = useMemo(() => {
if (!fuelStats || !maintenanceStats) return null;
const fuelCost = Number(fuelStats.totalCost) || 0;
const maintenanceCost = Number(maintenanceStats.totalCost) || 0;
const total = fuelCost + maintenanceCost;
return {
vehicleId: selectedVehicle!,
fuelCost,
maintenanceCost,
totalOperatingCost: total,
fuelPercentage: total > 0 ? Math.round((fuelCost / total) * 100) : 0,
maintenancePercentage: total > 0 ? Math.round((maintenanceCost / total) * 100) : 0,
};
}, [fuelStats, maintenanceStats, selectedVehicle]);
const StatCard = ({ label, value }: { label: string; value: string }) => (
<Card withBorder>
<Card.Section p="md">
<Text size="sm" c="dimmed">
{label}
</Text>
<Text fw={700} size="lg">
{value}
</Text>
</Card.Section>
</Card>
);
return (
<Stack gap="md">
<Card>
<Card.Section p="md" withBorder>
<Text fw={500}>Fleet Financial Analysis</Text>
</Card.Section>
<Card.Section p="md">
<Group>
<Select
label="Vehicle"
placeholder="Select a vehicle"
data={vehicleOptions}
value={selectedVehicle}
onChange={setSelectedVehicle}
style={{ flex: 1 }}
/>
<Select
label="Period"
data={[
{ label: 'Last 3 months', value: '3' },
{ label: 'Last 6 months', value: '6' },
{ label: 'Last 12 months', value: '12' },
]}
value={months}
onChange={v => setMonths(v || '12')}
style={{ flex: 1 }}
/>
</Group>
</Card.Section>
</Card>
{report && (
<>
<Grid>
<Grid.Col span={{ base: 12, sm: 6, md: 3 }}>
<StatCard label="Total Operating Cost" value={`$${report.totalOperatingCost.toFixed(2)}`} />
</Grid.Col>
<Grid.Col span={{ base: 12, sm: 6, md: 3 }}>
<StatCard label="Fuel Cost" value={`$${report.fuelCost.toFixed(2)}`} />
</Grid.Col>
<Grid.Col span={{ base: 12, sm: 6, md: 3 }}>
<StatCard label="Maintenance Cost" value={`$${report.maintenanceCost.toFixed(2)}`} />
</Grid.Col>
<Grid.Col span={{ base: 12, sm: 6, md: 3 }}>
<Card withBorder>
<Card.Section p="md">
<Text size="sm" c="dimmed">
Monthly Avg
</Text>
<Text fw={700} size="lg">
${(report.totalOperatingCost / parseInt(months)).toFixed(2)}
</Text>
</Card.Section>
</Card>
</Grid.Col>
</Grid>
<Grid>
<Grid.Col span={{ base: 12, sm: 6 }}>
<Card>
<Card.Section p="md" withBorder>
<Text fw={500}>Cost Breakdown</Text>
</Card.Section>
<Card.Section p="md">
<Stack gap="lg">
<Group justify="space-between">
<Stack gap={0}>
<Text size="sm" c="dimmed">
Fuel
</Text>
<Text fw={500}>{report.fuelPercentage}%</Text>
</Stack>
<RingProgress
sections={[{ value: report.fuelPercentage, color: 'blue' }]}
label={
<Text size="xs" align="center">
{report.fuelPercentage}%
</Text>
}
size={100}
thickness={4}
/>
</Group>
<Group justify="space-between">
<Stack gap={0}>
<Text size="sm" c="dimmed">
Maintenance
</Text>
<Text fw={500}>{report.maintenancePercentage}%</Text>
</Stack>
<RingProgress
sections={[{ value: report.maintenancePercentage, color: 'orange' }]}
label={
<Text size="xs" align="center">
{report.maintenancePercentage}%
</Text>
}
size={100}
thickness={4}
/>
</Group>
</Stack>
</Card.Section>
</Card>
</Grid.Col>
<Grid.Col span={{ base: 12, sm: 6 }}>
<Card>
<Card.Section p="md" withBorder>
<Text fw={500}>Operational Insights</Text>
</Card.Section>
<Card.Section p="md">
<Stack gap="sm">
<div>
<Text size="sm" c="dimmed">
Fuel Purchases
</Text>
<Text fw={500}>{fuelStats?.totalPurchases || 0} transactions</Text>
</div>
<div>
<Text size="sm" c="dimmed">
Fuel Efficiency
</Text>
<Text fw={500}>
{fuelStats?.fuelEfficiency?.toFixed(2) || 'N/A'} km/L
</Text>
</div>
<div>
<Text size="sm" c="dimmed">
Maintenance Items
</Text>
<Text fw={500}>{maintenanceStats?.numberOfMaintenanceItems || 0} records</Text>
</div>
<div>
<Text size="sm" c="dimmed">
Avg Maintenance Cost
</Text>
<Text fw={500}>${maintenanceStats?.averageCostPerMaintenance?.toFixed(2) || '0.00'}</Text>
</div>
</Stack>
</Card.Section>
</Card>
</Grid.Col>
</Grid>
</>
)}
{!selectedVehicle && (
<Card>
<Card.Section p="md">
<Text c="dimmed">Select a vehicle to view financial reports</Text>
</Card.Section>
</Card>
)}
</Stack>
);
}

View File

@@ -0,0 +1,197 @@
import { useState, useMemo } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Card, Button, Modal, Stack, Group, Grid, Select, TextInput, NumberInput, Table, Badge, Text } from '@mantine/core';
import { DateInput } from '@mantine/dates';
import { QUERY_KEYS } from '@/constants/QUERY_KEYS';
import { api } from '@/services/api';
import { vehiclesService } from '@/services/vehicles.service';
interface MaintenanceSchedule {
id: string;
vehicleId: string;
maintenanceType: string;
description: string;
scheduledDate: string;
completedDate?: string;
status: string;
estimatedCost?: number;
actualCost?: number;
serviceProvider?: string;
}
export function MaintenancePage() {
const [selectedVehicle, setSelectedVehicle] = useState<string | null>(null);
const [openScheduleModal, setOpenScheduleModal] = useState(false);
const [formData, setFormData] = useState({
maintenanceType: 'PREVENTIVE',
description: '',
scheduledDate: new Date(),
estimatedCost: 0,
serviceProvider: '',
notes: '',
});
const queryClient = useQueryClient();
const { data: vehicles } = useQuery({
queryKey: QUERY_KEYS.VEHICLES.list(),
queryFn: () => vehiclesService.getAll({ limit: 1000 }),
});
const { data: upcoming, isLoading } = useQuery({
queryKey: QUERY_KEYS.MAINTENANCE.upcoming(selectedVehicle || ''),
queryFn: () => selectedVehicle ? api.get(`/maintenance/upcoming/${selectedVehicle}`) : Promise.resolve([]),
enabled: !!selectedVehicle,
});
const scheduleMutation = useMutation({
mutationFn: async () => {
if (!selectedVehicle) return;
return api.post('/maintenance/schedules', {
vehicleId: selectedVehicle,
...formData,
});
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.MAINTENANCE.upcoming(selectedVehicle || '') });
setOpenScheduleModal(false);
setFormData({
maintenanceType: 'PREVENTIVE',
description: '',
scheduledDate: new Date(),
estimatedCost: 0,
serviceProvider: '',
notes: '',
});
},
});
const vehicleOptions = useMemo(
() => vehicles?.map(v => ({ label: v.registrationNumber || v.id, value: v.id })) || [],
[vehicles]
);
const statusColor = (status: string) => {
const colors: Record<string, string> = {
SCHEDULED: 'blue',
IN_PROGRESS: 'yellow',
COMPLETED: 'green',
OVERDUE: 'red',
};
return colors[status] || 'gray';
};
return (
<Stack gap="md">
<Card>
<Card.Section p="md" withBorder>
<Group justify="space-between">
<Text fw={500}>Schedule Maintenance</Text>
<Button onClick={() => setOpenScheduleModal(true)}>New Schedule</Button>
</Group>
</Card.Section>
<Card.Section p="md">
<Select
label="Select Vehicle"
placeholder="Pick a vehicle"
data={vehicleOptions}
value={selectedVehicle}
onChange={setSelectedVehicle}
/>
</Card.Section>
</Card>
{selectedVehicle && (
<Card>
<Card.Section p="md" withBorder>
<Text fw={500}>Upcoming Maintenance</Text>
</Card.Section>
<Card.Section p="md">
{isLoading ? (
<Text>Loading...</Text>
) : (upcoming || []).length > 0 ? (
<Table>
<Table.Thead>
<Table.Tr>
<Table.Th>Type</Table.Th>
<Table.Th>Description</Table.Th>
<Table.Th>Scheduled</Table.Th>
<Table.Th>Est. Cost</Table.Th>
<Table.Th>Status</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{(upcoming as MaintenanceSchedule[]).map(m => (
<Table.Tr key={m.id}>
<Table.Td>{m.maintenanceType}</Table.Td>
<Table.Td>{m.description}</Table.Td>
<Table.Td>{new Date(m.scheduledDate).toLocaleDateString()}</Table.Td>
<Table.Td>${m.estimatedCost?.toFixed(2) || '—'}</Table.Td>
<Table.Td>
<Badge color={statusColor(m.status)}>{m.status}</Badge>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
) : (
<Text c="dimmed">No upcoming maintenance</Text>
)}
</Card.Section>
</Card>
)}
<Modal
opened={openScheduleModal}
onClose={() => setOpenScheduleModal(false)}
title="Schedule Maintenance"
size="md"
>
<Stack gap="md">
<Select
label="Type"
data={['PREVENTIVE', 'CORRECTIVE', 'INSPECTION', 'REPAIR']}
value={formData.maintenanceType}
onChange={v => setFormData({ ...formData, maintenanceType: v || 'PREVENTIVE' })}
/>
<TextInput
label="Description"
placeholder="What needs to be done?"
value={formData.description}
onChange={e => setFormData({ ...formData, description: e.currentTarget.value })}
/>
<DateInput
label="Scheduled Date"
value={formData.scheduledDate}
onChange={d => setFormData({ ...formData, scheduledDate: d || new Date() })}
/>
<NumberInput
label="Estimated Cost"
value={formData.estimatedCost}
onChange={v => setFormData({ ...formData, estimatedCost: Number(v) })}
/>
<TextInput
label="Service Provider"
placeholder="e.g., John's Auto Repair"
value={formData.serviceProvider}
onChange={e => setFormData({ ...formData, serviceProvider: e.currentTarget.value })}
/>
<TextInput
label="Notes"
placeholder="Additional notes"
value={formData.notes}
onChange={e => setFormData({ ...formData, notes: e.currentTarget.value })}
/>
<Group justify="flex-end">
<Button variant="light" onClick={() => setOpenScheduleModal(false)}>
Cancel
</Button>
<Button onClick={() => scheduleMutation.mutate()} loading={scheduleMutation.isPending}>
Schedule
</Button>
</Group>
</Stack>
</Modal>
</Stack>
);
}