feat: add fuel management frontend pages

- FuelPurchasePage: Record fuel purchases, calculate total costs
- FuelStatsPage: View fuel consumption stats, efficiency metrics
- Routes: /dashboard/fuel-purchases and /dashboard/fuel-stats
- Sidebar menu items in Fleet Management section

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
natib21
2026-06-30 10:11:13 +00:00
parent 7b9cd88712
commit 3f81bb77ad
3 changed files with 557 additions and 0 deletions

View File

@@ -57,6 +57,8 @@ import FileUploadSettingsPage from "./pages/documents/FileUploadSettingsPage";
import DropdownSettingsPage from "./pages/dropdown_settings/DropdownSettingsPage";
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 { getCategorySidebarChildren } from "./pages/ruleEngine/config/resources";
import RuleEngineLegacyRedirect from "./pages/ruleEngine/RuleEngineLegacyRedirect";
import RuleEngineResourcePage from "./pages/ruleEngine/RuleEngineResourcePage";
@@ -200,6 +202,18 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
icon: <Users />,
permission: FREIGHT_PERMS.fleet.view,
},
{
label: "Fuel Purchases",
href: "/dashboard/fuel-purchases",
icon: <Truck />,
permission: FREIGHT_PERMS.fleet.view,
},
{
label: "Fuel Analytics",
href: "/dashboard/fuel-stats",
icon: <Truck />,
permission: FREIGHT_PERMS.fleet.view,
},
// {
// label: "Containers",
// href: "/dashboard/containers",
@@ -725,6 +739,22 @@ const App = () => {
</RequirePermission>
}
/>
<Route
path="fuel-purchases"
element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
<FuelPurchasePage />
</RequirePermission>
}
/>
<Route
path="fuel-stats"
element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
<FuelStatsPage />
</RequirePermission>
}
/>
<Route
path="locomotives"
element={

View File

@@ -0,0 +1,320 @@
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Box,
Button,
Card,
Container,
Group,
Modal,
NumberInput,
Select,
Stack,
Table,
Text,
TextInput,
Title,
Badge,
Grid,
} from "@mantine/core";
import { Plus, Trash2 } from "lucide-react";
import Breadcrumbs from "@/components/ui/Breadcrumbs";
import { useToast } from "@/hooks/use-toast";
import { api } from "@/auth/http";
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
interface FuelPurchase {
id: string;
vehicleId: string;
vehicleName?: string;
purchaseDate: string;
liters: number;
costPerLiter: number;
totalCost: number;
fuelStation?: string;
paymentMethod: string;
odometerReading?: number;
receiptNumber?: string;
notes?: string;
}
interface Vehicle {
id: string;
plateNumber: string;
manufacturer: string;
model: string;
}
export default function FuelPurchasePage() {
const { toast } = useToast();
const qc = useQueryClient();
const [modalOpen, setModalOpen] = useState(false);
const [formData, setFormData] = useState({
vehicleId: "",
purchaseDate: new Date().toISOString().split("T")[0],
liters: 0,
costPerLiter: 0,
fuelStation: "",
paymentMethod: "CASH",
odometerReading: undefined as number | undefined,
receiptNumber: "",
notes: "",
});
// Fetch vehicles
const { data: vehiclesData } = useQuery({
queryKey: QUERY_KEYS.VEHICLES.list(),
queryFn: async () => {
const res = await api.get("/vehicles?pageSize=1000");
return res.data?.data || [];
},
});
// Fetch fuel purchases
const { data: purchasesData = [] } = useQuery({
queryKey: ["fuel-purchases"],
queryFn: async () => {
const res = await api.get("/fuel/purchases");
return res.data || [];
},
});
// Record purchase mutation
const recordMutation = useMutation({
mutationFn: async (data: typeof formData) => {
const res = await api.post("/fuel/purchases", {
...data,
liters: parseFloat(data.liters.toString()),
costPerLiter: parseFloat(data.costPerLiter.toString()),
});
return res.data;
},
onSuccess: () => {
toast({ title: "Fuel purchase recorded" });
setModalOpen(false);
setFormData({
vehicleId: "",
purchaseDate: new Date().toISOString().split("T")[0],
liters: 0,
costPerLiter: 0,
fuelStation: "",
paymentMethod: "CASH",
odometerReading: undefined,
receiptNumber: "",
notes: "",
});
qc.invalidateQueries({ queryKey: ["fuel-purchases"] });
},
onError: (error: any) => {
toast({
title: "Error recording purchase",
message: error?.response?.data?.message || "Failed to record fuel purchase",
color: "red",
});
},
});
const vehicleOptions =
vehiclesData?.map((v: Vehicle) => ({
value: v.id,
label: `${v.plateNumber} - ${v.manufacturer} ${v.model}`,
})) || [];
const totalCost = formData.liters * formData.costPerLiter;
return (
<Container size="lg" py="xl">
<Breadcrumbs items={[{ label: "Fleet" }, { label: "Fuel Management" }, { label: "Record Purchase" }]} />
<Group justify="space-between" mb="lg">
<Title order={1}>Fuel Purchases</Title>
<Button leftSection={<Plus size={16} />} onClick={() => setModalOpen(true)}>
Record Purchase
</Button>
</Group>
{/* Stats Cards */}
<Grid mb="lg">
<Grid.Col span={{ base: 12, sm: 6, md: 3 }}>
<Card withBorder padding="lg">
<Text size="sm" c="dimmed" fw={500}>
Total Purchases
</Text>
<Text fw={700} size="lg">
{purchasesData.length}
</Text>
</Card>
</Grid.Col>
<Grid.Col span={{ base: 12, sm: 6, md: 3 }}>
<Card withBorder padding="lg">
<Text size="sm" c="dimmed" fw={500}>
Total Liters
</Text>
<Text fw={700} size="lg">
{purchasesData
.reduce((sum: number, p: FuelPurchase) => sum + p.liters, 0)
.toFixed(2)}{" "}
L
</Text>
</Card>
</Grid.Col>
<Grid.Col span={{ base: 12, sm: 6, md: 3 }}>
<Card withBorder padding="lg">
<Text size="sm" c="dimmed" fw={500}>
Total Cost
</Text>
<Text fw={700} size="lg">
ETB {purchasesData
.reduce((sum: number, p: FuelPurchase) => sum + p.totalCost, 0)
.toLocaleString("en-US", { maximumFractionDigits: 2 })}
</Text>
</Card>
</Grid.Col>
<Grid.Col span={{ base: 12, sm: 6, md: 3 }}>
<Card withBorder padding="lg">
<Text size="sm" c="dimmed" fw={500}>
Avg Price/L
</Text>
<Text fw={700} size="lg">
ETB{" "}
{(
purchasesData.reduce((sum: number, p: FuelPurchase) => sum + p.totalCost, 0) /
purchasesData.reduce((sum: number, p: FuelPurchase) => sum + p.liters, 0) || 0
).toFixed(2)}
</Text>
</Card>
</Grid.Col>
</Grid>
{/* Purchases Table */}
<Card withBorder>
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Vehicle</Table.Th>
<Table.Th>Date</Table.Th>
<Table.Th align="right">Liters</Table.Th>
<Table.Th align="right">Cost/L</Table.Th>
<Table.Th align="right">Total</Table.Th>
<Table.Th>Station</Table.Th>
<Table.Th>Payment</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{(purchasesData as FuelPurchase[])?.map((purchase) => (
<Table.Tr key={purchase.id}>
<Table.Td>{purchase.vehicleName || purchase.vehicleId}</Table.Td>
<Table.Td>{new Date(purchase.purchaseDate).toLocaleDateString()}</Table.Td>
<Table.Td align="right">{purchase.liters.toFixed(2)}</Table.Td>
<Table.Td align="right">ETB {purchase.costPerLiter.toFixed(2)}</Table.Td>
<Table.Td align="right">ETB {purchase.totalCost.toLocaleString("en-US", { maximumFractionDigits: 2 })}</Table.Td>
<Table.Td>{purchase.fuelStation || "—"}</Table.Td>
<Table.Td>
<Badge size="sm">{purchase.paymentMethod}</Badge>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Card>
{/* Modal */}
<Modal opened={modalOpen} onClose={() => setModalOpen(false)} title="Record Fuel Purchase" size="lg">
<Stack gap="md">
<Select
label="Vehicle"
placeholder="Select vehicle"
data={vehicleOptions}
value={formData.vehicleId}
onChange={(val) => setFormData({ ...formData, vehicleId: val || "" })}
required
/>
<TextInput
label="Purchase Date"
type="date"
value={formData.purchaseDate}
onChange={(e) => setFormData({ ...formData, purchaseDate: e.currentTarget.value })}
required
/>
<NumberInput
label="Liters"
placeholder="0.00"
value={formData.liters}
onChange={(val) => setFormData({ ...formData, liters: val as number })}
decimalScale={2}
min={0}
required
/>
<NumberInput
label="Cost per Liter"
placeholder="0.00"
value={formData.costPerLiter}
onChange={(val) => setFormData({ ...formData, costPerLiter: val as number })}
decimalScale={2}
min={0}
required
/>
<Card withBorder bg="gray.0" padding="md">
<Text fw={600} size="lg">
Total Cost: ETB {totalCost.toFixed(2)}
</Text>
</Card>
<TextInput
label="Fuel Station"
placeholder="Station name"
value={formData.fuelStation}
onChange={(e) => setFormData({ ...formData, fuelStation: e.currentTarget.value })}
/>
<Select
label="Payment Method"
data={["CASH", "CARD", "FUEL_CARD", "TRANSFER", "CHEQUE"]}
value={formData.paymentMethod}
onChange={(val) => setFormData({ ...formData, paymentMethod: val || "CASH" })}
/>
<NumberInput
label="Odometer Reading (KM)"
placeholder="Optional"
value={formData.odometerReading}
onChange={(val) => setFormData({ ...formData, odometerReading: val as number | undefined })}
decimalScale={0}
min={0}
/>
<TextInput
label="Receipt Number"
placeholder="Optional"
value={formData.receiptNumber}
onChange={(e) => setFormData({ ...formData, receiptNumber: e.currentTarget.value })}
/>
<TextInput
label="Notes"
placeholder="Optional notes"
value={formData.notes}
onChange={(e) => setFormData({ ...formData, notes: e.currentTarget.value })}
/>
<Group justify="flex-end">
<Button variant="light" onClick={() => setModalOpen(false)}>
Cancel
</Button>
<Button
onClick={() => recordMutation.mutate(formData)}
loading={recordMutation.isPending}
disabled={!formData.vehicleId || formData.liters <= 0 || formData.costPerLiter <= 0}
>
Record Purchase
</Button>
</Group>
</Stack>
</Modal>
</Container>
);
}

View File

@@ -0,0 +1,207 @@
import { useQuery } from "@tanstack/react-query";
import { Box, Card, Container, Grid, Group, Select, Stack, Table, Text, Title, Badge } from "@mantine/core";
import Breadcrumbs from "@/components/ui/Breadcrumbs";
import { api } from "@/auth/http";
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
import { useState } from "react";
interface FuelStats {
vehicleId: string;
totalPurchases: number;
totalLiters: number;
totalCost: number;
averagePricePerLiter: number;
dateRange: { startDate: string; endDate: string };
}
interface Vehicle {
id: string;
plateNumber: string;
manufacturer: string;
model: string;
actualDistanceKm?: number;
}
export default function FuelStatsPage() {
const [selectedVehicleId, setSelectedVehicleId] = useState<string>("");
const [monthsBack, setMonthsBack] = useState<string>("12");
// Fetch vehicles
const { data: vehiclesData } = useQuery({
queryKey: QUERY_KEYS.VEHICLES.list(),
queryFn: async () => {
const res = await api.get("/vehicles?pageSize=1000");
return res.data?.data || [];
},
});
// Fetch fuel stats
const { data: statsData } = useQuery({
queryKey: ["fuel-stats", selectedVehicleId, monthsBack],
queryFn: async () => {
if (!selectedVehicleId) return null;
const res = await api.get(`/fuel/stats/${selectedVehicleId}?months=${monthsBack}`);
return res.data;
},
enabled: !!selectedVehicleId,
});
const vehicleOptions =
vehiclesData?.map((v: Vehicle) => ({
value: v.id,
label: `${v.plateNumber} - ${v.manufacturer} ${v.model}`,
})) || [];
const selectedVehicle = vehiclesData?.find((v: Vehicle) => v.id === selectedVehicleId);
const costPerKm =
statsData && selectedVehicle?.actualDistanceKm
? (statsData.totalCost / selectedVehicle.actualDistanceKm).toFixed(2)
: "—";
const efficiency = statsData
? (statsData.totalLiters > 0 ? (selectedVehicle?.actualDistanceKm || 0) / statsData.totalLiters : 0).toFixed(2)
: "—";
return (
<Container size="lg" py="xl">
<Breadcrumbs items={[{ label: "Fleet" }, { label: "Fuel Management" }, { label: "Statistics" }]} />
<Group justify="space-between" mb="lg">
<Title order={1}>Fuel Consumption Analysis</Title>
</Group>
{/* Filters */}
<Card withBorder mb="lg" padding="md">
<Grid>
<Grid.Col span={{ base: 12, sm: 6 }}>
<Select
label="Vehicle"
placeholder="Select vehicle to analyze"
data={vehicleOptions}
value={selectedVehicleId}
onChange={(val) => setSelectedVehicleId(val || "")}
searchable
/>
</Grid.Col>
<Grid.Col span={{ base: 12, sm: 6 }}>
<Select
label="Period"
data={[
{ value: "3", label: "Last 3 months" },
{ value: "6", label: "Last 6 months" },
{ value: "12", label: "Last 12 months" },
]}
value={monthsBack}
onChange={(val) => setMonthsBack(val || "12")}
/>
</Grid.Col>
</Grid>
</Card>
{selectedVehicleId && statsData ? (
<>
{/* Stats Cards */}
<Grid mb="lg">
<Grid.Col span={{ base: 12, sm: 6, md: 3 }}>
<Card withBorder padding="lg">
<Text size="sm" c="dimmed" fw={500}>
Total Purchases
</Text>
<Text fw={700} size="lg">
{statsData.totalPurchases}
</Text>
</Card>
</Grid.Col>
<Grid.Col span={{ base: 12, sm: 6, md: 3 }}>
<Card withBorder padding="lg">
<Text size="sm" c="dimmed" fw={500}>
Total Fuel
</Text>
<Text fw={700} size="lg">
{statsData.totalLiters.toFixed(2)} L
</Text>
</Card>
</Grid.Col>
<Grid.Col span={{ base: 12, sm: 6, md: 3 }}>
<Card withBorder padding="lg">
<Text size="sm" c="dimmed" fw={500}>
Total Cost
</Text>
<Text fw={700} size="lg">
ETB {statsData.totalCost.toLocaleString("en-US", { maximumFractionDigits: 2 })}
</Text>
</Card>
</Grid.Col>
<Grid.Col span={{ base: 12, sm: 6, md: 3 }}>
<Card withBorder padding="lg">
<Text size="sm" c="dimmed" fw={500}>
Avg Price/L
</Text>
<Text fw={700} size="lg">
ETB {statsData.averagePricePerLiter.toFixed(2)}
</Text>
</Card>
</Grid.Col>
</Grid>
{/* Efficiency Metrics */}
<Grid mb="lg">
<Grid.Col span={{ base: 12, sm: 6 }}>
<Card withBorder padding="lg">
<Text size="sm" c="dimmed" fw={500}>
Fuel Efficiency
</Text>
<Text fw={700} size="lg">
{efficiency} km/L
</Text>
</Card>
</Grid.Col>
<Grid.Col span={{ base: 12, sm: 6 }}>
<Card withBorder padding="lg">
<Text size="sm" c="dimmed" fw={500}>
Cost per KM
</Text>
<Text fw={700} size="lg">
ETB {costPerKm}
</Text>
</Card>
</Grid.Col>
</Grid>
{/* Summary */}
<Card withBorder padding="lg">
<Stack gap="md">
<div>
<Text fw={600} mb="xs">
Summary
</Text>
<Text size="sm">
{selectedVehicle?.plateNumber} consumed{" "}
<Text fw={700} span>
{statsData.totalLiters.toFixed(2)} liters
</Text>{" "}
over the last {monthsBack} months, costing{" "}
<Text fw={700} span>
ETB {statsData.totalCost.toLocaleString("en-US", { maximumFractionDigits: 2 })}
</Text>
. Average fuel price was{" "}
<Text fw={700} span>
ETB {statsData.averagePricePerLiter.toFixed(2)} per liter
</Text>
.
</Text>
</div>
</Stack>
</Card>
</>
) : (
<Card withBorder padding="lg">
<Text c="dimmed" ta="center">
Select a vehicle to view fuel consumption statistics
</Text>
</Card>
)}
</Container>
);
}