mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 03:10:54 +00:00
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:
@@ -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={
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user