mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 06:28:12 +00:00
Backend: - FuelService.getAllFuelPurchases() now loads vehicle relationship - Uses leftJoinAndSelect to eagerly load Vehicle data - Returns complete FuelPurchase with nested vehicle object Frontend: - Table displays vehicle.registrationNumber (primary) - Falls back to vehicle.plateNumber if registration unavailable - Falls back to vehicleId as last resort Better UX: shows human-readable vehicle info instead of UUID. Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
317 lines
10 KiB
TypeScript
317 lines
10 KiB
TypeScript
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";
|
|
import { vehiclesService, type Vehicle as VehicleType } from "@/services/vehicles.service";
|
|
import { freightBrand } from "@/theme/freight-brand";
|
|
|
|
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 = [] } = 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: VehicleType) => ({
|
|
value: v.id,
|
|
label: `${v.plateNumber} - ${v.manufacturer} ${v.model}`,
|
|
})) || [];
|
|
|
|
const totalCost = formData.liters * formData.costPerLiter;
|
|
|
|
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">
|
|
{purchasesData
|
|
.reduce((sum: number, p: FuelPurchase) => sum + Number(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 + Number(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 + Number(p.totalCost), 0) /
|
|
purchasesData.reduce((sum: number, p: FuelPurchase) => sum + Number(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 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>
|
|
</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>
|
|
);
|
|
}
|