mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 08:32:54 +00:00
Merge branch 'freight/hot_fix' of github.com:Tria-plc/edr-platform into freight/hot_fix
This commit is contained in:
@@ -69,6 +69,7 @@ import { WarehousesModule } from './modules/warehouses/warehouses.module';
|
||||
import { OverviewModule } from './modules/overview/overview.module';
|
||||
import { VehiclesModule } from './modules/vehicles/vehicles.module';
|
||||
import { DriversModule } from './modules/drivers/drivers.module';
|
||||
import { FuelModule } from './modules/fuel/fuel.module';
|
||||
import { FirstMileModule } from './modules/first-mile/first-mile.module';
|
||||
import { LastMileModule } from './modules/last-mile/last-mile.module';
|
||||
import { InterchangeDocumentsModule } from './modules/interchange-documents/interchange-documents.module';
|
||||
@@ -133,6 +134,7 @@ import { ImportOperationsModule } from './modules/import-operations/import-opera
|
||||
OverviewModule,
|
||||
VehiclesModule,
|
||||
DriversModule,
|
||||
FuelModule,
|
||||
FirstMileModule,
|
||||
LastMileModule,
|
||||
InterchangeDocumentsModule,
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
export class CreateFuelTables1840000000000 implements MigrationInterface {
|
||||
name = "CreateFuelTables1840000000000";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
const fuelPurchasesExists = await queryRunner.query(
|
||||
`SELECT 1 FROM information_schema.tables WHERE table_schema = 'freight' AND table_name = 'fuel_purchases';`,
|
||||
);
|
||||
|
||||
if (!fuelPurchasesExists.length) {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE freight.fuel_purchases (
|
||||
id uuid NOT NULL DEFAULT gen_random_uuid(),
|
||||
vehicle_id uuid NOT NULL,
|
||||
purchase_date timestamptz NOT NULL,
|
||||
liters numeric(10, 2) NOT NULL,
|
||||
cost_per_liter numeric(10, 2) NOT NULL,
|
||||
total_cost numeric(14, 2) NOT NULL,
|
||||
fuel_station varchar(255) NULL,
|
||||
payment_method varchar(50) DEFAULT 'CASH',
|
||||
odometer_reading numeric(10, 2) NULL,
|
||||
driver_id uuid NULL,
|
||||
receipt_number varchar(255) NULL,
|
||||
notes text NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
deleted_at timestamptz NULL,
|
||||
CONSTRAINT pk_fuel_purchases PRIMARY KEY (id),
|
||||
CONSTRAINT fk_fuel_purchases_vehicle FOREIGN KEY (vehicle_id)
|
||||
REFERENCES freight.vehicles (id) ON DELETE CASCADE
|
||||
);
|
||||
`);
|
||||
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX idx_fuel_purchases_vehicle ON freight.fuel_purchases (vehicle_id);`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX idx_fuel_purchases_date ON freight.fuel_purchases (purchase_date);`,
|
||||
);
|
||||
}
|
||||
|
||||
const fuelConsumptionExists = await queryRunner.query(
|
||||
`SELECT 1 FROM information_schema.tables WHERE table_schema = 'freight' AND table_name = 'fuel_consumption';`,
|
||||
);
|
||||
|
||||
if (!fuelConsumptionExists.length) {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE freight.fuel_consumption (
|
||||
id uuid NOT NULL DEFAULT gen_random_uuid(),
|
||||
vehicle_id uuid NOT NULL,
|
||||
month date NOT NULL,
|
||||
total_liters numeric(10, 2) NOT NULL,
|
||||
total_cost numeric(14, 2) NOT NULL,
|
||||
total_distance_km numeric(10, 2) NOT NULL,
|
||||
fuel_efficiency_km_per_l numeric(10, 2) NULL,
|
||||
number_of_purchases integer DEFAULT 0,
|
||||
average_cost_per_liter numeric(10, 2) NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
deleted_at timestamptz NULL,
|
||||
CONSTRAINT pk_fuel_consumption PRIMARY KEY (id),
|
||||
CONSTRAINT fk_fuel_consumption_vehicle FOREIGN KEY (vehicle_id)
|
||||
REFERENCES freight.vehicles (id) ON DELETE CASCADE,
|
||||
CONSTRAINT uq_fuel_consumption_vehicle_month UNIQUE (vehicle_id, month)
|
||||
);
|
||||
`);
|
||||
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX idx_fuel_consumption_vehicle_month ON freight.fuel_consumption (vehicle_id, month);`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.fuel_consumption;`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.fuel_purchases;`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { IsUUID, IsNumber, IsDateString, IsString, IsOptional, IsEnum } from 'class-validator';
|
||||
import { PaymentMethod } from '../entities/fuel-purchase.entity';
|
||||
|
||||
export class CreateFuelPurchaseDto {
|
||||
@IsUUID()
|
||||
vehicleId!: string;
|
||||
|
||||
@IsDateString()
|
||||
purchaseDate!: string;
|
||||
|
||||
@IsNumber()
|
||||
liters!: number;
|
||||
|
||||
@IsNumber()
|
||||
costPerLiter!: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
fuelStation?: string;
|
||||
|
||||
@IsEnum(PaymentMethod)
|
||||
@IsOptional()
|
||||
paymentMethod?: PaymentMethod;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
odometerReading?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
driverId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
receiptNumber?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
notes?: string;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Entity, Column, ManyToOne, JoinColumn, Index } from 'typeorm';
|
||||
import { Vehicle } from '../../vehicles/entities/vehicle.entity';
|
||||
|
||||
@Entity({ name: 'fuel_consumption', schema: 'freight' })
|
||||
@Index(['vehicleId', 'month'])
|
||||
export class FuelConsumption extends BaseEntity {
|
||||
@Column({ name: 'vehicle_id', type: 'uuid' })
|
||||
vehicleId!: string;
|
||||
|
||||
@ManyToOne(() => Vehicle, { eager: false })
|
||||
@JoinColumn({ name: 'vehicle_id' })
|
||||
vehicle!: Vehicle;
|
||||
|
||||
@Column({ name: 'month', type: 'date' })
|
||||
month!: Date;
|
||||
|
||||
@Column({ name: 'total_liters', type: 'numeric', precision: 10, scale: 2 })
|
||||
totalLiters!: number;
|
||||
|
||||
@Column({ name: 'total_cost', type: 'numeric', precision: 14, scale: 2 })
|
||||
totalCost!: number;
|
||||
|
||||
@Column({ name: 'total_distance_km', type: 'numeric', precision: 10, scale: 2 })
|
||||
totalDistanceKm!: number;
|
||||
|
||||
@Column({ name: 'fuel_efficiency_km_per_l', type: 'numeric', precision: 10, scale: 2, nullable: true })
|
||||
fuelEfficiencyKmPerL?: number;
|
||||
|
||||
@Column({ name: 'number_of_purchases', type: 'integer', default: 0 })
|
||||
numberOfPurchases!: number;
|
||||
|
||||
@Column({ name: 'average_cost_per_liter', type: 'numeric', precision: 10, scale: 2, nullable: true })
|
||||
averageCostPerLiter?: number;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Entity, Column, ManyToOne, JoinColumn } from 'typeorm';
|
||||
import { Vehicle } from '../../vehicles/entities/vehicle.entity';
|
||||
|
||||
export enum PaymentMethod {
|
||||
CASH = 'CASH',
|
||||
CARD = 'CARD',
|
||||
FUEL_CARD = 'FUEL_CARD',
|
||||
TRANSFER = 'TRANSFER',
|
||||
CHEQUE = 'CHEQUE',
|
||||
}
|
||||
|
||||
@Entity({ name: 'fuel_purchases', schema: 'freight' })
|
||||
export class FuelPurchase extends BaseEntity {
|
||||
@Column({ name: 'vehicle_id', type: 'uuid' })
|
||||
vehicleId!: string;
|
||||
|
||||
@ManyToOne(() => Vehicle, { eager: false })
|
||||
@JoinColumn({ name: 'vehicle_id' })
|
||||
vehicle!: Vehicle;
|
||||
|
||||
@Column({ name: 'purchase_date', type: 'timestamptz' })
|
||||
purchaseDate!: Date;
|
||||
|
||||
@Column({ name: 'liters', type: 'numeric', precision: 10, scale: 2 })
|
||||
liters!: number;
|
||||
|
||||
@Column({ name: 'cost_per_liter', type: 'numeric', precision: 10, scale: 2 })
|
||||
costPerLiter!: number;
|
||||
|
||||
@Column({ name: 'total_cost', type: 'numeric', precision: 14, scale: 2 })
|
||||
totalCost!: number;
|
||||
|
||||
@Column({ name: 'fuel_station', nullable: true })
|
||||
fuelStation?: string;
|
||||
|
||||
@Column({ name: 'payment_method', type: 'varchar', default: PaymentMethod.CASH })
|
||||
paymentMethod!: PaymentMethod;
|
||||
|
||||
@Column({ name: 'odometer_reading', type: 'numeric', nullable: true })
|
||||
odometerReading?: number;
|
||||
|
||||
@Column({ name: 'driver_id', type: 'uuid', nullable: true })
|
||||
driverId?: string;
|
||||
|
||||
@Column({ name: 'receipt_number', nullable: true })
|
||||
receiptNumber?: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
notes?: string;
|
||||
}
|
||||
48
apps/edr-freight-api/src/modules/fuel/fuel.controller.ts
Normal file
48
apps/edr-freight-api/src/modules/fuel/fuel.controller.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { Controller, Post, Get, Body, Param, Query } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation } from '@nestjs/swagger';
|
||||
import { FuelService } from './fuel.service';
|
||||
import { CreateFuelPurchaseDto } from './dto/create-fuel-purchase.dto';
|
||||
|
||||
@ApiTags('Fuel Management')
|
||||
@Controller('fuel')
|
||||
export class FuelController {
|
||||
constructor(private readonly fuelService: FuelService) {}
|
||||
|
||||
@Post('purchases')
|
||||
@ApiOperation({ summary: 'Record fuel purchase' })
|
||||
async recordFuelPurchase(@Body() dto: CreateFuelPurchaseDto) {
|
||||
return this.fuelService.recordFuelPurchase(dto);
|
||||
}
|
||||
|
||||
@Get('purchases/:vehicleId')
|
||||
@ApiOperation({ summary: 'Get fuel purchases for vehicle' })
|
||||
async getFuelPurchases(
|
||||
@Param('vehicleId') vehicleId: string,
|
||||
@Query('startDate') startDate: string,
|
||||
@Query('endDate') endDate: string,
|
||||
) {
|
||||
return this.fuelService.getFuelPurchases(
|
||||
vehicleId,
|
||||
new Date(startDate),
|
||||
new Date(endDate),
|
||||
);
|
||||
}
|
||||
|
||||
@Get('consumption/:vehicleId/:month')
|
||||
@ApiOperation({ summary: 'Get monthly fuel consumption' })
|
||||
async getMonthlyConsumption(
|
||||
@Param('vehicleId') vehicleId: string,
|
||||
@Param('month') month: string,
|
||||
) {
|
||||
return this.fuelService.getMonthlyConsumption(vehicleId, new Date(month));
|
||||
}
|
||||
|
||||
@Get('stats/:vehicleId')
|
||||
@ApiOperation({ summary: 'Get fuel statistics for vehicle' })
|
||||
async getVehicleFuelStats(
|
||||
@Param('vehicleId') vehicleId: string,
|
||||
@Query('months') months: number = 12,
|
||||
) {
|
||||
return this.fuelService.getVehicleFuelStats(vehicleId, months);
|
||||
}
|
||||
}
|
||||
15
apps/edr-freight-api/src/modules/fuel/fuel.module.ts
Normal file
15
apps/edr-freight-api/src/modules/fuel/fuel.module.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { FuelController } from './fuel.controller';
|
||||
import { FuelService } from './fuel.service';
|
||||
import { FuelRepository } from './fuel.repository';
|
||||
import { FuelPurchase } from './entities/fuel-purchase.entity';
|
||||
import { FuelConsumption } from './entities/fuel-consumption.entity';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([FuelPurchase, FuelConsumption])],
|
||||
controllers: [FuelController],
|
||||
providers: [FuelService, FuelRepository],
|
||||
exports: [FuelService],
|
||||
})
|
||||
export class FuelModule {}
|
||||
76
apps/edr-freight-api/src/modules/fuel/fuel.repository.ts
Normal file
76
apps/edr-freight-api/src/modules/fuel/fuel.repository.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { BaseRepository } from '@edr/api-common';
|
||||
import { Repository, Between } from 'typeorm';
|
||||
import { FuelPurchase } from './entities/fuel-purchase.entity';
|
||||
import { FuelConsumption } from './entities/fuel-consumption.entity';
|
||||
|
||||
@Injectable()
|
||||
export class FuelRepository extends BaseRepository<FuelPurchase> {
|
||||
constructor(
|
||||
@InjectRepository(FuelPurchase)
|
||||
private readonly purchaseRepository: Repository<FuelPurchase>,
|
||||
@InjectRepository(FuelConsumption)
|
||||
private readonly consumptionRepository: Repository<FuelConsumption>,
|
||||
) {
|
||||
super(purchaseRepository);
|
||||
}
|
||||
|
||||
async findByVehicleAndDateRange(
|
||||
vehicleId: string,
|
||||
startDate: Date,
|
||||
endDate: Date,
|
||||
): Promise<FuelPurchase[]> {
|
||||
return this.purchaseRepository.find({
|
||||
where: {
|
||||
vehicleId,
|
||||
purchaseDate: Between(startDate, endDate),
|
||||
},
|
||||
order: { purchaseDate: 'DESC' },
|
||||
});
|
||||
}
|
||||
|
||||
async getMonthlyConsumption(
|
||||
vehicleId: string,
|
||||
month: Date,
|
||||
): Promise<FuelConsumption | null> {
|
||||
return this.consumptionRepository.findOne({
|
||||
where: {
|
||||
vehicleId,
|
||||
month,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async updateMonthlyConsumption(
|
||||
vehicleId: string,
|
||||
month: Date,
|
||||
data: Partial<FuelConsumption>,
|
||||
): Promise<FuelConsumption> {
|
||||
let consumption = await this.consumptionRepository.findOne({
|
||||
where: {
|
||||
vehicleId,
|
||||
month,
|
||||
},
|
||||
});
|
||||
|
||||
if (!consumption) {
|
||||
consumption = this.consumptionRepository.create({
|
||||
vehicleId,
|
||||
month,
|
||||
...data,
|
||||
});
|
||||
} else {
|
||||
Object.assign(consumption, data);
|
||||
}
|
||||
|
||||
return this.consumptionRepository.save(consumption);
|
||||
}
|
||||
|
||||
async findPurchasesByVehicle(vehicleId: string): Promise<FuelPurchase[]> {
|
||||
return this.purchaseRepository.find({
|
||||
where: { vehicleId },
|
||||
order: { purchaseDate: 'DESC' },
|
||||
});
|
||||
}
|
||||
}
|
||||
90
apps/edr-freight-api/src/modules/fuel/fuel.service.ts
Normal file
90
apps/edr-freight-api/src/modules/fuel/fuel.service.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { FuelRepository } from './fuel.repository';
|
||||
import { FuelPurchase } from './entities/fuel-purchase.entity';
|
||||
import { FuelConsumption } from './entities/fuel-consumption.entity';
|
||||
import { CreateFuelPurchaseDto } from './dto/create-fuel-purchase.dto';
|
||||
|
||||
@Injectable()
|
||||
export class FuelService {
|
||||
constructor(
|
||||
private readonly fuelRepository: FuelRepository,
|
||||
@InjectRepository(FuelPurchase)
|
||||
private readonly purchaseRepository: Repository<FuelPurchase>,
|
||||
) {}
|
||||
|
||||
async recordFuelPurchase(dto: CreateFuelPurchaseDto): Promise<FuelPurchase> {
|
||||
const totalCost = dto.liters * dto.costPerLiter;
|
||||
|
||||
const purchase = this.purchaseRepository.create({
|
||||
...dto,
|
||||
totalCost,
|
||||
});
|
||||
|
||||
const saved = await this.purchaseRepository.save(purchase);
|
||||
|
||||
// Update monthly consumption
|
||||
await this.updateMonthlyConsumption(dto.vehicleId, new Date(dto.purchaseDate));
|
||||
|
||||
return saved;
|
||||
}
|
||||
|
||||
async getFuelPurchases(
|
||||
vehicleId: string,
|
||||
startDate: Date,
|
||||
endDate: Date,
|
||||
): Promise<FuelPurchase[]> {
|
||||
return this.fuelRepository.findByVehicleAndDateRange(vehicleId, startDate, endDate);
|
||||
}
|
||||
|
||||
async getMonthlyConsumption(
|
||||
vehicleId: string,
|
||||
month: Date,
|
||||
): Promise<FuelConsumption | null> {
|
||||
return this.fuelRepository.getMonthlyConsumption(vehicleId, month);
|
||||
}
|
||||
|
||||
async getVehicleFuelStats(vehicleId: string, monthsBack: number = 12) {
|
||||
const endDate = new Date();
|
||||
const startDate = new Date(endDate.getFullYear(), endDate.getMonth() - monthsBack, 1);
|
||||
|
||||
const purchases = await this.getFuelPurchases(vehicleId, startDate, endDate);
|
||||
|
||||
const totalLiters = purchases.reduce((sum: number, p: FuelPurchase) => sum + Number(p.liters), 0);
|
||||
const totalCost = purchases.reduce((sum: number, p: FuelPurchase) => sum + Number(p.totalCost), 0);
|
||||
const averagePrice = totalLiters > 0 ? totalCost / totalLiters : 0;
|
||||
|
||||
return {
|
||||
vehicleId,
|
||||
totalPurchases: purchases.length,
|
||||
totalLiters,
|
||||
totalCost,
|
||||
averagePricePerLiter: averagePrice,
|
||||
dateRange: { startDate, endDate },
|
||||
};
|
||||
}
|
||||
|
||||
private async updateMonthlyConsumption(vehicleId: string, date: Date): Promise<void> {
|
||||
const monthStart = new Date(date.getFullYear(), date.getMonth(), 1);
|
||||
const monthEnd = new Date(monthStart.getFullYear(), monthStart.getMonth() + 1, 1);
|
||||
|
||||
const purchases = await this.fuelRepository.findByVehicleAndDateRange(
|
||||
vehicleId,
|
||||
monthStart,
|
||||
monthEnd,
|
||||
);
|
||||
|
||||
const totalLiters = purchases.reduce((sum: number, p: FuelPurchase) => sum + Number(p.liters), 0);
|
||||
const totalCost = purchases.reduce((sum: number, p: FuelPurchase) => sum + Number(p.totalCost), 0);
|
||||
const numberOfPurchases = purchases.length;
|
||||
const averageCostPerLiter = totalLiters > 0 ? totalCost / totalLiters : 0;
|
||||
|
||||
await this.fuelRepository.updateMonthlyConsumption(vehicleId, monthStart, {
|
||||
totalLiters,
|
||||
totalCost,
|
||||
numberOfPurchases,
|
||||
averageCostPerLiter,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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={
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user