From df6e417a56405221da559af0d34821a7bee12a43 Mon Sep 17 00:00:00 2001 From: natib21 Date: Tue, 30 Jun 2026 10:07:13 +0000 Subject: [PATCH 1/3] feat: add fuel management system backend - FuelPurchase entity: record fuel purchases with cost tracking - FuelConsumption entity: monthly aggregation of fuel metrics - FuelService: record purchases, calculate stats, efficiency - FuelController: REST API for fuel operations - FuelModule: integrated into app - Migration: create fuel_purchases and fuel_consumption tables Co-Authored-By: Claude Haiku 4.5 --- apps/edr-freight-api/src/app.module.ts | 2 + .../1840000000000-CreateFuelTables.ts | 79 +++++++++++++++++ .../fuel/dto/create-fuel-purchase.dto.ts | 40 +++++++++ .../fuel/entities/fuel-consumption.entity.ts | 35 ++++++++ .../fuel/entities/fuel-purchase.entity.ts | 51 +++++++++++ .../src/modules/fuel/fuel.controller.ts | 48 ++++++++++ .../src/modules/fuel/fuel.module.ts | 15 ++++ .../src/modules/fuel/fuel.repository.ts | 68 +++++++++++++++ .../src/modules/fuel/fuel.service.ts | 87 +++++++++++++++++++ 9 files changed, 425 insertions(+) create mode 100644 apps/edr-freight-api/src/migrations/1840000000000-CreateFuelTables.ts create mode 100644 apps/edr-freight-api/src/modules/fuel/dto/create-fuel-purchase.dto.ts create mode 100644 apps/edr-freight-api/src/modules/fuel/entities/fuel-consumption.entity.ts create mode 100644 apps/edr-freight-api/src/modules/fuel/entities/fuel-purchase.entity.ts create mode 100644 apps/edr-freight-api/src/modules/fuel/fuel.controller.ts create mode 100644 apps/edr-freight-api/src/modules/fuel/fuel.module.ts create mode 100644 apps/edr-freight-api/src/modules/fuel/fuel.repository.ts create mode 100644 apps/edr-freight-api/src/modules/fuel/fuel.service.ts diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index db05ae26f..cbc8ce22a 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -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, diff --git a/apps/edr-freight-api/src/migrations/1840000000000-CreateFuelTables.ts b/apps/edr-freight-api/src/migrations/1840000000000-CreateFuelTables.ts new file mode 100644 index 000000000..a74a58f8e --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1840000000000-CreateFuelTables.ts @@ -0,0 +1,79 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class CreateFuelTables1840000000000 implements MigrationInterface { + name = "CreateFuelTables1840000000000"; + + public async up(queryRunner: QueryRunner): Promise { + 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 { + await queryRunner.query(`DROP TABLE IF EXISTS freight.fuel_consumption;`); + await queryRunner.query(`DROP TABLE IF EXISTS freight.fuel_purchases;`); + } +} diff --git a/apps/edr-freight-api/src/modules/fuel/dto/create-fuel-purchase.dto.ts b/apps/edr-freight-api/src/modules/fuel/dto/create-fuel-purchase.dto.ts new file mode 100644 index 000000000..254af7104 --- /dev/null +++ b/apps/edr-freight-api/src/modules/fuel/dto/create-fuel-purchase.dto.ts @@ -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; +} diff --git a/apps/edr-freight-api/src/modules/fuel/entities/fuel-consumption.entity.ts b/apps/edr-freight-api/src/modules/fuel/entities/fuel-consumption.entity.ts new file mode 100644 index 000000000..002eca861 --- /dev/null +++ b/apps/edr-freight-api/src/modules/fuel/entities/fuel-consumption.entity.ts @@ -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; +} diff --git a/apps/edr-freight-api/src/modules/fuel/entities/fuel-purchase.entity.ts b/apps/edr-freight-api/src/modules/fuel/entities/fuel-purchase.entity.ts new file mode 100644 index 000000000..163618d0b --- /dev/null +++ b/apps/edr-freight-api/src/modules/fuel/entities/fuel-purchase.entity.ts @@ -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; +} diff --git a/apps/edr-freight-api/src/modules/fuel/fuel.controller.ts b/apps/edr-freight-api/src/modules/fuel/fuel.controller.ts new file mode 100644 index 000000000..b9d19626a --- /dev/null +++ b/apps/edr-freight-api/src/modules/fuel/fuel.controller.ts @@ -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); + } +} diff --git a/apps/edr-freight-api/src/modules/fuel/fuel.module.ts b/apps/edr-freight-api/src/modules/fuel/fuel.module.ts new file mode 100644 index 000000000..258350f1c --- /dev/null +++ b/apps/edr-freight-api/src/modules/fuel/fuel.module.ts @@ -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 {} diff --git a/apps/edr-freight-api/src/modules/fuel/fuel.repository.ts b/apps/edr-freight-api/src/modules/fuel/fuel.repository.ts new file mode 100644 index 000000000..6e7c7901e --- /dev/null +++ b/apps/edr-freight-api/src/modules/fuel/fuel.repository.ts @@ -0,0 +1,68 @@ +import { Injectable } from '@nestjs/common'; +import { BaseRepository } from '@edr/api-common'; +import { DataSource } from 'typeorm'; +import { FuelPurchase } from './entities/fuel-purchase.entity'; +import { FuelConsumption } from './entities/fuel-consumption.entity'; + +@Injectable() +export class FuelRepository extends BaseRepository { + constructor(dataSource: DataSource) { + super(FuelPurchase, dataSource.createEntityManager()); + } + + async findByVehicleAndDateRange( + vehicleId: string, + startDate: Date, + endDate: Date, + ): Promise { + return this.find({ + where: { + vehicleId, + purchaseDate: { + $gte: startDate, + $lte: endDate, + }, + }, + order: { purchaseDate: 'DESC' }, + }); + } + + async getMonthlyConsumption( + vehicleId: string, + month: Date, + ): Promise { + const consumptionRepository = this.manager.getRepository(FuelConsumption); + return consumptionRepository.findOne({ + where: { + vehicleId, + month, + }, + }); + } + + async updateMonthlyConsumption( + vehicleId: string, + month: Date, + data: Partial, + ): Promise { + const consumptionRepository = this.manager.getRepository(FuelConsumption); + let consumption = await consumptionRepository.findOne({ + where: { + vehicleId, + month, + }, + }); + + if (!consumption) { + consumption = consumptionRepository.create({ + vehicleId, + month, + ...data, + }); + } else { + Object.assign(consumption, data); + } + + return consumptionRepository.save(consumption); + } +} diff --git a/apps/edr-freight-api/src/modules/fuel/fuel.service.ts b/apps/edr-freight-api/src/modules/fuel/fuel.service.ts new file mode 100644 index 000000000..e8d0f6d04 --- /dev/null +++ b/apps/edr-freight-api/src/modules/fuel/fuel.service.ts @@ -0,0 +1,87 @@ +import { Injectable } from '@nestjs/common'; +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) {} + + async recordFuelPurchase(dto: CreateFuelPurchaseDto): Promise { + const totalCost = dto.liters * dto.costPerLiter; + + const purchase = this.fuelRepository.create({ + ...dto, + totalCost, + }); + + const saved = await this.fuelRepository.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 { + return this.fuelRepository.findByVehicleAndDateRange(vehicleId, startDate, endDate); + } + + async getMonthlyConsumption( + vehicleId: string, + month: Date, + ): Promise { + 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, p) => sum + Number(p.liters), 0); + const totalCost = purchases.reduce((sum, p) => 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 { + const monthStart = new Date(date.getFullYear(), date.getMonth(), 1); + + const purchases = await this.fuelRepository.find({ + where: { + vehicleId, + purchaseDate: { + $gte: monthStart, + $lt: new Date(monthStart.getFullYear(), monthStart.getMonth() + 1, 1), + }, + }, + }); + + const totalLiters = purchases.reduce((sum, p) => sum + Number(p.liters), 0); + const totalCost = purchases.reduce((sum, p) => 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, + } as Partial); + } +} From 7b9cd8871298b29805062113e72dc5d1a0f4dab5 Mon Sep 17 00:00:00 2001 From: natib21 Date: Tue, 30 Jun 2026 10:08:56 +0000 Subject: [PATCH 2/3] fix: correct fuel module TypeScript errors - Use @InjectRepository decorators for proper dependency injection - Fix BaseRepository initialization with Repository instance - Remove unnecessary DataSource references - Add proper type annotations to reduce handlers Co-Authored-By: Claude Haiku 4.5 --- .../src/modules/fuel/fuel.repository.ts | 36 +++++++++++------- .../src/modules/fuel/fuel.service.ts | 37 ++++++++++--------- 2 files changed, 42 insertions(+), 31 deletions(-) diff --git a/apps/edr-freight-api/src/modules/fuel/fuel.repository.ts b/apps/edr-freight-api/src/modules/fuel/fuel.repository.ts index 6e7c7901e..d062c38e1 100644 --- a/apps/edr-freight-api/src/modules/fuel/fuel.repository.ts +++ b/apps/edr-freight-api/src/modules/fuel/fuel.repository.ts @@ -1,13 +1,19 @@ import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; import { BaseRepository } from '@edr/api-common'; -import { DataSource } from 'typeorm'; +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 { - constructor(dataSource: DataSource) { - super(FuelPurchase, dataSource.createEntityManager()); + constructor( + @InjectRepository(FuelPurchase) + private readonly purchaseRepository: Repository, + @InjectRepository(FuelConsumption) + private readonly consumptionRepository: Repository, + ) { + super(purchaseRepository); } async findByVehicleAndDateRange( @@ -15,13 +21,10 @@ export class FuelRepository extends BaseRepository { startDate: Date, endDate: Date, ): Promise { - return this.find({ + return this.purchaseRepository.find({ where: { vehicleId, - purchaseDate: { - $gte: startDate, - $lte: endDate, - }, + purchaseDate: Between(startDate, endDate), }, order: { purchaseDate: 'DESC' }, }); @@ -31,8 +34,7 @@ export class FuelRepository extends BaseRepository { vehicleId: string, month: Date, ): Promise { - const consumptionRepository = this.manager.getRepository(FuelConsumption); - return consumptionRepository.findOne({ + return this.consumptionRepository.findOne({ where: { vehicleId, month, @@ -45,8 +47,7 @@ export class FuelRepository extends BaseRepository { month: Date, data: Partial, ): Promise { - const consumptionRepository = this.manager.getRepository(FuelConsumption); - let consumption = await consumptionRepository.findOne({ + let consumption = await this.consumptionRepository.findOne({ where: { vehicleId, month, @@ -54,7 +55,7 @@ export class FuelRepository extends BaseRepository { }); if (!consumption) { - consumption = consumptionRepository.create({ + consumption = this.consumptionRepository.create({ vehicleId, month, ...data, @@ -63,6 +64,13 @@ export class FuelRepository extends BaseRepository { Object.assign(consumption, data); } - return consumptionRepository.save(consumption); + return this.consumptionRepository.save(consumption); + } + + async findPurchasesByVehicle(vehicleId: string): Promise { + return this.purchaseRepository.find({ + where: { vehicleId }, + order: { purchaseDate: 'DESC' }, + }); } } diff --git a/apps/edr-freight-api/src/modules/fuel/fuel.service.ts b/apps/edr-freight-api/src/modules/fuel/fuel.service.ts index e8d0f6d04..9241f9975 100644 --- a/apps/edr-freight-api/src/modules/fuel/fuel.service.ts +++ b/apps/edr-freight-api/src/modules/fuel/fuel.service.ts @@ -1,4 +1,6 @@ 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'; @@ -6,17 +8,21 @@ import { CreateFuelPurchaseDto } from './dto/create-fuel-purchase.dto'; @Injectable() export class FuelService { - constructor(private readonly fuelRepository: FuelRepository) {} + constructor( + private readonly fuelRepository: FuelRepository, + @InjectRepository(FuelPurchase) + private readonly purchaseRepository: Repository, + ) {} async recordFuelPurchase(dto: CreateFuelPurchaseDto): Promise { const totalCost = dto.liters * dto.costPerLiter; - const purchase = this.fuelRepository.create({ + const purchase = this.purchaseRepository.create({ ...dto, totalCost, }); - const saved = await this.fuelRepository.save(purchase); + const saved = await this.purchaseRepository.save(purchase); // Update monthly consumption await this.updateMonthlyConsumption(dto.vehicleId, new Date(dto.purchaseDate)); @@ -45,8 +51,8 @@ export class FuelService { const purchases = await this.getFuelPurchases(vehicleId, startDate, endDate); - const totalLiters = purchases.reduce((sum, p) => sum + Number(p.liters), 0); - const totalCost = purchases.reduce((sum, p) => sum + Number(p.totalCost), 0); + 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 { @@ -61,19 +67,16 @@ export class FuelService { private async updateMonthlyConsumption(vehicleId: string, date: Date): Promise { const monthStart = new Date(date.getFullYear(), date.getMonth(), 1); + const monthEnd = new Date(monthStart.getFullYear(), monthStart.getMonth() + 1, 1); - const purchases = await this.fuelRepository.find({ - where: { - vehicleId, - purchaseDate: { - $gte: monthStart, - $lt: new Date(monthStart.getFullYear(), monthStart.getMonth() + 1, 1), - }, - }, - }); + const purchases = await this.fuelRepository.findByVehicleAndDateRange( + vehicleId, + monthStart, + monthEnd, + ); - const totalLiters = purchases.reduce((sum, p) => sum + Number(p.liters), 0); - const totalCost = purchases.reduce((sum, p) => sum + Number(p.totalCost), 0); + 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; @@ -82,6 +85,6 @@ export class FuelService { totalCost, numberOfPurchases, averageCostPerLiter, - } as Partial); + }); } } From 3f81bb77adfcdd8de95e99ad351f5d469f918708 Mon Sep 17 00:00:00 2001 From: natib21 Date: Tue, 30 Jun 2026 10:11:13 +0000 Subject: [PATCH 3/3] 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 --- apps/edr-freight-web/backoffice/src/App.tsx | 30 ++ .../src/pages/fleet/FuelPurchasePage.tsx | 320 ++++++++++++++++++ .../src/pages/fleet/FuelStatsPage.tsx | 207 +++++++++++ 3 files changed, 557 insertions(+) create mode 100644 apps/edr-freight-web/backoffice/src/pages/fleet/FuelPurchasePage.tsx create mode 100644 apps/edr-freight-web/backoffice/src/pages/fleet/FuelStatsPage.tsx diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 542a848a4..8d5b568dc 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -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: , permission: FREIGHT_PERMS.fleet.view, }, + { + label: "Fuel Purchases", + href: "/dashboard/fuel-purchases", + icon: , + permission: FREIGHT_PERMS.fleet.view, + }, + { + label: "Fuel Analytics", + href: "/dashboard/fuel-stats", + icon: , + permission: FREIGHT_PERMS.fleet.view, + }, // { // label: "Containers", // href: "/dashboard/containers", @@ -725,6 +739,22 @@ const App = () => { } /> + + + + } + /> + + + + } + /> { + 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 ( + + + + + Fuel Purchases + + + + {/* Stats Cards */} + + + + + Total Purchases + + + {purchasesData.length} + + + + + + + Total Liters + + + {purchasesData + .reduce((sum: number, p: FuelPurchase) => sum + p.liters, 0) + .toFixed(2)}{" "} + L + + + + + + + Total Cost + + + ETB {purchasesData + .reduce((sum: number, p: FuelPurchase) => sum + p.totalCost, 0) + .toLocaleString("en-US", { maximumFractionDigits: 2 })} + + + + + + + Avg Price/L + + + ETB{" "} + {( + purchasesData.reduce((sum: number, p: FuelPurchase) => sum + p.totalCost, 0) / + purchasesData.reduce((sum: number, p: FuelPurchase) => sum + p.liters, 0) || 0 + ).toFixed(2)} + + + + + + {/* Purchases Table */} + + + + + Vehicle + Date + Liters + Cost/L + Total + Station + Payment + + + + {(purchasesData as FuelPurchase[])?.map((purchase) => ( + + {purchase.vehicleName || purchase.vehicleId} + {new Date(purchase.purchaseDate).toLocaleDateString()} + {purchase.liters.toFixed(2)} + ETB {purchase.costPerLiter.toFixed(2)} + ETB {purchase.totalCost.toLocaleString("en-US", { maximumFractionDigits: 2 })} + {purchase.fuelStation || "—"} + + {purchase.paymentMethod} + + + ))} + +
+
+ + {/* Modal */} + setModalOpen(false)} title="Record Fuel Purchase" size="lg"> + + setFormData({ ...formData, paymentMethod: val || "CASH" })} + /> + + setFormData({ ...formData, odometerReading: val as number | undefined })} + decimalScale={0} + min={0} + /> + + setFormData({ ...formData, receiptNumber: e.currentTarget.value })} + /> + + setFormData({ ...formData, notes: e.currentTarget.value })} + /> + + + + + + + +
+ ); +} diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/FuelStatsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/FuelStatsPage.tsx new file mode 100644 index 000000000..b2efb85be --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/FuelStatsPage.tsx @@ -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(""); + const [monthsBack, setMonthsBack] = useState("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 ( + + + + + Fuel Consumption Analysis + + + {/* Filters */} + + + + setMonthsBack(val || "12")} + /> + + + + + {selectedVehicleId && statsData ? ( + <> + {/* Stats Cards */} + + + + + Total Purchases + + + {statsData.totalPurchases} + + + + + + + Total Fuel + + + {statsData.totalLiters.toFixed(2)} L + + + + + + + Total Cost + + + ETB {statsData.totalCost.toLocaleString("en-US", { maximumFractionDigits: 2 })} + + + + + + + Avg Price/L + + + ETB {statsData.averagePricePerLiter.toFixed(2)} + + + + + + {/* Efficiency Metrics */} + + + + + Fuel Efficiency + + + {efficiency} km/L + + + + + + + Cost per KM + + + ETB {costPerKm} + + + + + + {/* Summary */} + + +
+ + Summary + + + {selectedVehicle?.plateNumber} consumed{" "} + + {statsData.totalLiters.toFixed(2)} liters + {" "} + over the last {monthsBack} months, costing{" "} + + ETB {statsData.totalCost.toLocaleString("en-US", { maximumFractionDigits: 2 })} + + . Average fuel price was{" "} + + ETB {statsData.averagePricePerLiter.toFixed(2)} per liter + + . + +
+
+
+ + ) : ( + + + Select a vehicle to view fuel consumption statistics + + + )} +
+ ); +}