mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 21:08:12 +00:00
364 lines
12 KiB
TypeScript
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>
|
|
);
|
|
}
|