Files
edr-platform/apps/edr-freight-web/backoffice/src/pages/fleet/FuelPurchasePage.tsx

364 lines
12 KiB
TypeScript

import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Button,
Card,
Container,
Group,
Loader,
Modal,
NumberInput,
Select,
Stack,
Table,
Text,
TextInput,
Title,
Badge,
Grid,
} from "@mantine/core";
import { Plus } from "lucide-react";
import Breadcrumbs from "@/components/ui/Breadcrumbs";
import ListControls from "@/components/common/ListControls";
// Generic list footer — already shared by the fleet and train-scheduling lists
// despite the ruleEngine path.
import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter";
import { useListControls } from "@/hooks/useListControls";
import { useToast } from "@/hooks/use-toast";
import { api } from "@/auth/http";
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
import { vehiclesService, type Vehicle as VehicleType } from "@/services/vehicles.service";
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;
}
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 vehiclesService.getAll({ limit: 1000 });
return res.data || [];
},
});
// Fetch fuel purchases
const { data: purchasesData = [], isLoading: isLoadingPurchases } = 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",
description: error?.response?.data?.message || "Failed to record fuel purchase",
variant: "destructive",
});
},
});
const vehicleOptions =
vehiclesData?.map((v: VehicleType) => ({
value: v.id,
label: `${v.plateNumber} - ${v.manufacturer} ${v.model}`,
})) || [];
const totalCost = formData.liters * formData.costPerLiter;
// Aggregate stats (guarded against divide-by-zero when there are no purchases)
const controls = useListControls(purchasesData as FuelPurchase[], {
searchKeys: ["fuelStation", "paymentMethod"],
dateKey: "purchaseDate",
});
const totalLiters = (purchasesData as FuelPurchase[]).reduce(
(sum, p) => sum + Number(p.liters),
0
);
const totalPurchaseCost = (purchasesData as FuelPurchase[]).reduce(
(sum, p) => sum + Number(p.totalCost),
0
);
const avgPricePerLiter = totalLiters > 0 ? totalPurchaseCost / totalLiters : 0;
return (
<Container size="xl" py="xl" px="lg">
<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)} color="edr-green">
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">
{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 {totalPurchaseCost.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 {avgPricePerLiter.toFixed(2)}
</Text>
</Card>
</Grid.Col>
</Grid>
{/* Purchases Table */}
<Card withBorder>
<ListControls
search={controls.search}
onSearchChange={controls.setSearch}
searchPlaceholder="Search station or payment method…"
dateFrom={controls.dateFrom}
onDateFromChange={controls.setDateFrom}
dateTo={controls.dateTo}
onDateToChange={controls.setDateTo}
dateLabel="Purchased"
hasFilters={controls.hasFilters}
onReset={controls.reset}
/>
<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>
{isLoadingPurchases ? (
<Table.Tr>
<Table.Td colSpan={7}>
<Group justify="center" py="md">
<Loader size="sm" />
</Group>
</Table.Td>
</Table.Tr>
) : purchasesData.length === 0 ? (
<Table.Tr>
<Table.Td colSpan={7}>
<Text c="dimmed" ta="center" py="md">
No fuel purchases recorded yet.
</Text>
</Table.Td>
</Table.Tr>
) : null}
{controls.pagedRows.map((purchase) => (
<Table.Tr key={purchase.id}>
<Table.Td>{(purchase as any).vehicle?.registrationNumber || (purchase as any).vehicle?.plateNumber || purchase.vehicleId}</Table.Td>
<Table.Td>{new Date(purchase.purchaseDate).toLocaleDateString()}</Table.Td>
<Table.Td align="right">{Number(purchase.liters).toFixed(2)}</Table.Td>
<Table.Td align="right">ETB {Number(purchase.costPerLiter).toFixed(2)}</Table.Td>
<Table.Td align="right">ETB {Number(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>
<RuleEngineListFooter
pagination={controls.pagination}
pageCount={controls.pageCount}
totalCount={controls.totalCount}
itemLabel="purchases"
onPaginationChange={controls.setPagination}
/>
</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>
);
}