From 14dde418f7ab43802b46c765a9400cb09b3ac159 Mon Sep 17 00:00:00 2001 From: natib21 Date: Tue, 30 Jun 2026 03:55:57 +0000 Subject: [PATCH 01/28] fix(migrations): check if tables exist before CREATE TABLE Make invoices migration idempotent - skip table/index creation if they already exist. Prevents 'relation already exists' errors on redeployment. Co-Authored-By: Claude Haiku 4.5 --- .../1821000000002-CreateInvoices.ts | 134 ++++++++++-------- 1 file changed, 73 insertions(+), 61 deletions(-) diff --git a/apps/edr-freight-api/src/migrations/1821000000002-CreateInvoices.ts b/apps/edr-freight-api/src/migrations/1821000000002-CreateInvoices.ts index 93196578d..a36ffa5da 100644 --- a/apps/edr-freight-api/src/migrations/1821000000002-CreateInvoices.ts +++ b/apps/edr-freight-api/src/migrations/1821000000002-CreateInvoices.ts @@ -32,71 +32,83 @@ export class CreateInvoices1821000000002 implements MigrationInterface { `); } - await queryRunner.query(` - CREATE TABLE freight.invoices ( - id uuid NOT NULL DEFAULT uuid_generate_v4(), - invoice_number varchar(64) NOT NULL, - company_id uuid NOT NULL, - company_profile_id uuid NOT NULL, - total_amount numeric(14, 2) NOT NULL, - currency varchar(8) NOT NULL DEFAULT 'ETB', - status freight.invoices_status_enum NOT NULL DEFAULT 'DRAFT', - source varchar(255) NOT NULL, - source_id varchar(255) NOT NULL, - type varchar(255) NOT NULL, - issued_at timestamptz, - payment_id uuid, - due_at timestamptz NOT NULL, - created_at timestamptz NOT NULL DEFAULT now(), - updated_at timestamptz NOT NULL DEFAULT now(), - deleted_at timestamptz, - CONSTRAINT pk_invoices PRIMARY KEY (id), - CONSTRAINT uq_invoices_invoice_number UNIQUE (invoice_number), - CONSTRAINT fk_invoices_company FOREIGN KEY (company_id) - REFERENCES freight.companies (id) ON DELETE RESTRICT, - CONSTRAINT fk_invoices_company_profile FOREIGN KEY (company_profile_id) - REFERENCES freight.company_profiles (id) ON DELETE RESTRICT, - CONSTRAINT fk_invoices_payment FOREIGN KEY (payment_id) - REFERENCES freight.payments (id) ON DELETE SET NULL + const invoicesExists = await queryRunner.query( + `SELECT 1 FROM information_schema.tables WHERE table_schema = 'freight' AND table_name = 'invoices';`, + ); + + if (!invoicesExists.length) { + await queryRunner.query(` + CREATE TABLE freight.invoices ( + id uuid NOT NULL DEFAULT uuid_generate_v4(), + invoice_number varchar(64) NOT NULL, + company_id uuid NOT NULL, + company_profile_id uuid NOT NULL, + total_amount numeric(14, 2) NOT NULL, + currency varchar(8) NOT NULL DEFAULT 'ETB', + status freight.invoices_status_enum NOT NULL DEFAULT 'DRAFT', + source varchar(255) NOT NULL, + source_id varchar(255) NOT NULL, + type varchar(255) NOT NULL, + issued_at timestamptz, + payment_id uuid, + due_at timestamptz NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + CONSTRAINT pk_invoices PRIMARY KEY (id), + CONSTRAINT uq_invoices_invoice_number UNIQUE (invoice_number), + CONSTRAINT fk_invoices_company FOREIGN KEY (company_id) + REFERENCES freight.companies (id) ON DELETE RESTRICT, + CONSTRAINT fk_invoices_company_profile FOREIGN KEY (company_profile_id) + REFERENCES freight.company_profiles (id) ON DELETE RESTRICT, + CONSTRAINT fk_invoices_payment FOREIGN KEY (payment_id) + REFERENCES freight.payments (id) ON DELETE SET NULL + ); + `); + + await queryRunner.query( + `CREATE INDEX idx_invoices_company ON freight.invoices (company_id);`, ); - `); - - await queryRunner.query( - `CREATE INDEX idx_invoices_company ON freight.invoices (company_id);`, - ); - await queryRunner.query( - `CREATE INDEX idx_invoices_company_profile ON freight.invoices (company_profile_id);`, - ); - await queryRunner.query( - `CREATE INDEX idx_invoices_source ON freight.invoices (source, source_id);`, - ); - await queryRunner.query( - `CREATE INDEX idx_invoices_status ON freight.invoices (status);`, - ); - - await queryRunner.query(` - CREATE TABLE freight.invoice_lines ( - id uuid NOT NULL DEFAULT uuid_generate_v4(), - invoice_id uuid NOT NULL, - charge_type varchar NOT NULL, - description varchar(255), - quantity numeric(12, 2) NOT NULL DEFAULT 1, - unit_rate numeric(14, 2) NOT NULL DEFAULT 0, - amount numeric(14, 2) NOT NULL, - currency varchar(8) NOT NULL DEFAULT 'ETB', - metadata jsonb, - created_at timestamptz NOT NULL DEFAULT now(), - updated_at timestamptz NOT NULL DEFAULT now(), - deleted_at timestamptz, - CONSTRAINT pk_invoice_lines PRIMARY KEY (id), - CONSTRAINT fk_invoice_lines_invoice FOREIGN KEY (invoice_id) - REFERENCES freight.invoices (id) ON DELETE CASCADE + await queryRunner.query( + `CREATE INDEX idx_invoices_company_profile ON freight.invoices (company_profile_id);`, ); - `); + await queryRunner.query( + `CREATE INDEX idx_invoices_source ON freight.invoices (source, source_id);`, + ); + await queryRunner.query( + `CREATE INDEX idx_invoices_status ON freight.invoices (status);`, + ); + } - await queryRunner.query( - `CREATE INDEX idx_invoice_lines_invoice ON freight.invoice_lines (invoice_id);`, + const invoiceLinesExists = await queryRunner.query( + `SELECT 1 FROM information_schema.tables WHERE table_schema = 'freight' AND table_name = 'invoice_lines';`, ); + + if (!invoiceLinesExists.length) { + await queryRunner.query(` + CREATE TABLE freight.invoice_lines ( + id uuid NOT NULL DEFAULT uuid_generate_v4(), + invoice_id uuid NOT NULL, + charge_type varchar NOT NULL, + description varchar(255), + quantity numeric(12, 2) NOT NULL DEFAULT 1, + unit_rate numeric(14, 2) NOT NULL DEFAULT 0, + amount numeric(14, 2) NOT NULL, + currency varchar(8) NOT NULL DEFAULT 'ETB', + metadata jsonb, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + CONSTRAINT pk_invoice_lines PRIMARY KEY (id), + CONSTRAINT fk_invoice_lines_invoice FOREIGN KEY (invoice_id) + REFERENCES freight.invoices (id) ON DELETE CASCADE + ); + `); + + await queryRunner.query( + `CREATE INDEX idx_invoice_lines_invoice ON freight.invoice_lines (invoice_id);`, + ); + } } public async down(queryRunner: QueryRunner): Promise { From a3741c45bf8522b3596a5adc17d865e0d9c1a031 Mon Sep 17 00:00:00 2001 From: yaschalew Date: Tue, 30 Jun 2026 07:01:32 +0300 Subject: [PATCH 02/28] fix --- apps/edr-freight-web/backoffice/src/constants/apiConfig.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/constants/apiConfig.ts b/apps/edr-freight-web/backoffice/src/constants/apiConfig.ts index 1217b8762..7a7604cc7 100644 --- a/apps/edr-freight-web/backoffice/src/constants/apiConfig.ts +++ b/apps/edr-freight-web/backoffice/src/constants/apiConfig.ts @@ -1,6 +1,6 @@ -export const API_BASE_URL = 'https://edrfreightapi.triaplc.com'; +// export const API_BASE_URL = 'https://edrfreightapi.triaplc.com'; -// export const API_BASE_URL = 'http://localhost:3001'; +export const API_BASE_URL = 'http://localhost:3001'; /** * URL that streams an uploaded file through the API by its UUID. Routes the From 0bc95341720c116bd962e94f703e3f1c949f47de Mon Sep 17 00:00:00 2001 From: natib21 Date: Tue, 30 Jun 2026 04:03:29 +0000 Subject: [PATCH 03/28] fix: replace apiClient with api in FirstMilePage Use correct import 'api' from '@/auth/http' instead of undefined 'apiClient'. Co-Authored-By: Claude Haiku 4.5 --- .../backoffice/src/pages/operations/FirstMilePage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx index 569511d30..164d88f0a 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx @@ -441,7 +441,7 @@ const FirstMilePage = () => { }); const allocateMutation = useMutation({ - mutationFn: (data) => apiClient.post(`/first-mile/${containerAllocationFirstMileId}/allocate-containers`, data), + mutationFn: (data) => api.post(`/first-mile/${containerAllocationFirstMileId}/allocate-containers`, data), onSuccess: () => { toast({ title: "Containers allocated" }); void qc.invalidateQueries({ queryKey: QUERY_KEYS.FIRST_MILE.detail(containerAllocationFirstMileId ?? "") }); From 360e61ac15b07ab5da2691d11dd776fcd5030e71 Mon Sep 17 00:00:00 2001 From: natib21 Date: Tue, 30 Jun 2026 04:04:56 +0000 Subject: [PATCH 04/28] fix: use byId() instead of detail() in QUERY_KEYS QUERY_KEYS.FIRST_MILE and QUERY_KEYS.LAST_MILE have byId() not detail(). Co-Authored-By: Claude Haiku 4.5 --- .../backoffice/src/pages/operations/FirstMilePage.tsx | 2 +- .../backoffice/src/pages/operations/LastMilePage.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx index 164d88f0a..52b3d7139 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx @@ -444,7 +444,7 @@ const FirstMilePage = () => { mutationFn: (data) => api.post(`/first-mile/${containerAllocationFirstMileId}/allocate-containers`, data), onSuccess: () => { toast({ title: "Containers allocated" }); - void qc.invalidateQueries({ queryKey: QUERY_KEYS.FIRST_MILE.detail(containerAllocationFirstMileId ?? "") }); + void qc.invalidateQueries({ queryKey: QUERY_KEYS.FIRST_MILE.byId(containerAllocationFirstMileId ?? "") }); setContainerAllocationOpen(false); setContainerAllocationFirstMileId(null); }, diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx index 3de5e578e..a40721a1c 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx @@ -395,7 +395,7 @@ const LastMilePage = () => { api.post(`/last-mile/${activeId}/allocate-containers`, data), onSuccess: () => { toast({ title: "Containers allocated", variant: "default" }); - void qc.invalidateQueries({ queryKey: QUERY_KEYS.LAST_MILE.detail(activeId ?? "") }); + void qc.invalidateQueries({ queryKey: QUERY_KEYS.LAST_MILE.byId(activeId ?? "") }); closeAllocation(); }, onError: () => { From df6e417a56405221da559af0d34821a7bee12a43 Mon Sep 17 00:00:00 2001 From: natib21 Date: Tue, 30 Jun 2026 10:07:13 +0000 Subject: [PATCH 05/28] 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 06/28] 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 07/28] 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 + + + )} +
+ ); +} From f27f00d4806787911047ff70a65a006ed477be53 Mon Sep 17 00:00:00 2001 From: natib21 Date: Tue, 30 Jun 2026 10:15:43 +0000 Subject: [PATCH 08/28] fix: use vehiclesService and add VEHICLES QUERY_KEYS - Add VEHICLES to QUERY_KEYS constant - Use vehiclesService.getAll() instead of direct API call - Remove duplicate Vehicle type definitions - Fix TypeScript type references Co-Authored-By: Claude Haiku 4.5 --- .../backoffice/src/constants/QUERY_KEYS.ts | 6 ++++++ .../src/pages/fleet/FuelPurchasePage.tsx | 13 ++++--------- .../src/pages/fleet/FuelStatsPage.tsx | 17 +++++------------ 3 files changed, 15 insertions(+), 21 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts b/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts index 59b493bf3..c47f8e2e5 100644 --- a/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts @@ -98,6 +98,12 @@ export const QUERY_KEYS = { list: (resource: FleetResourceSlug | string) => ["fleet", "list", resource] as const, }, + VEHICLES: { + ROOT: ["vehicles"] as const, + list: (filter?: Record) => ["vehicles", "list", filter ?? {}] as const, + byId: (id: string) => ["vehicles", "detail", id] as const, + }, + FIRST_MILE: { ROOT: ["first-mile"] as const, list: (filter?: Record) => ["first-mile", "list", filter ?? {}] as const, diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/FuelPurchasePage.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/FuelPurchasePage.tsx index deaca7041..0869ceb26 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/FuelPurchasePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/FuelPurchasePage.tsx @@ -22,6 +22,7 @@ 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"; interface FuelPurchase { id: string; @@ -38,12 +39,6 @@ interface FuelPurchase { notes?: string; } -interface Vehicle { - id: string; - plateNumber: string; - manufacturer: string; - model: string; -} export default function FuelPurchasePage() { const { toast } = useToast(); @@ -65,8 +60,8 @@ export default function FuelPurchasePage() { const { data: vehiclesData } = useQuery({ queryKey: QUERY_KEYS.VEHICLES.list(), queryFn: async () => { - const res = await api.get("/vehicles?pageSize=1000"); - return res.data?.data || []; + const res = await vehiclesService.getAll({ limit: 1000 }); + return res.data || []; }, }); @@ -115,7 +110,7 @@ export default function FuelPurchasePage() { }); const vehicleOptions = - vehiclesData?.map((v: Vehicle) => ({ + vehiclesData?.map((v: VehicleType) => ({ value: v.id, label: `${v.plateNumber} - ${v.manufacturer} ${v.model}`, })) || []; diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/FuelStatsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/FuelStatsPage.tsx index b2efb85be..78ed36498 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/FuelStatsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/FuelStatsPage.tsx @@ -3,6 +3,7 @@ import { Box, Card, Container, Grid, Group, Select, Stack, Table, Text, Title, B import Breadcrumbs from "@/components/ui/Breadcrumbs"; import { api } from "@/auth/http"; import { QUERY_KEYS } from "@/constants/QUERY_KEYS"; +import { vehiclesService, type Vehicle as VehicleType } from "@/services/vehicles.service"; import { useState } from "react"; interface FuelStats { @@ -14,14 +15,6 @@ interface FuelStats { 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"); @@ -30,8 +23,8 @@ export default function FuelStatsPage() { const { data: vehiclesData } = useQuery({ queryKey: QUERY_KEYS.VEHICLES.list(), queryFn: async () => { - const res = await api.get("/vehicles?pageSize=1000"); - return res.data?.data || []; + const res = await vehiclesService.getAll({ limit: 1000 }); + return res.data || []; }, }); @@ -47,12 +40,12 @@ export default function FuelStatsPage() { }); const vehicleOptions = - vehiclesData?.map((v: Vehicle) => ({ + vehiclesData?.map((v: VehicleType) => ({ value: v.id, label: `${v.plateNumber} - ${v.manufacturer} ${v.model}`, })) || []; - const selectedVehicle = vehiclesData?.find((v: Vehicle) => v.id === selectedVehicleId); + const selectedVehicle = vehiclesData?.find((v: VehicleType) => v.id === selectedVehicleId); const costPerKm = statsData && selectedVehicle?.actualDistanceKm From 5d70c3b5577a088960b60098d374c5f96723214e Mon Sep 17 00:00:00 2001 From: natib21 Date: Tue, 30 Jun 2026 12:13:48 +0000 Subject: [PATCH 09/28] fix --- .../src/modules/fuel/entities/fuel-consumption.entity.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 index 002eca861..aabafd17c 100644 --- 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 @@ -21,8 +21,8 @@ export class FuelConsumption extends BaseEntity { @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: 'total_distance_km', type: 'numeric', precision: 10, scale: 2, default: 0 }) + totalDistanceKm: number = 0; @Column({ name: 'fuel_efficiency_km_per_l', type: 'numeric', precision: 10, scale: 2, nullable: true }) fuelEfficiencyKmPerL?: number; From e59a77b859bdf8b281545ff07805590ac4fe1db3 Mon Sep 17 00:00:00 2001 From: natib21 Date: Tue, 30 Jun 2026 12:17:17 +0000 Subject: [PATCH 10/28] feat: add maintenance tracking module foundation Entities: - MaintenanceSchedule: track preventive/corrective maintenance - MaintenanceCost: record actual maintenance expenses DTOs: - CreateMaintenanceScheduleDto: schedule maintenance - CreateMaintenanceCostDto: log costs - UpdateMaintenanceScheduleDto: mark complete/adjust cost Repository: - getUpcomingMaintenance(): find due maintenance - getMaintenanceCosts(): historical costs by date - getTotalMaintenanceCost(): aggregate spending Also fixed fuel-consumption.entity.ts: totalDistanceKm default 0 Co-Authored-By: Claude Haiku 4.5 --- .../maintenance/dto/create-maintenance.dto.ts | 87 +++++++++++++++++++ .../entities/maintenance-cost.entity.ts | 43 +++++++++ .../entities/maintenance-schedule.entity.ts | 65 ++++++++++++++ .../maintenance/maintenance.repository.ts | 50 +++++++++++ 4 files changed, 245 insertions(+) create mode 100644 apps/edr-freight-api/src/modules/maintenance/dto/create-maintenance.dto.ts create mode 100644 apps/edr-freight-api/src/modules/maintenance/entities/maintenance-cost.entity.ts create mode 100644 apps/edr-freight-api/src/modules/maintenance/entities/maintenance-schedule.entity.ts create mode 100644 apps/edr-freight-api/src/modules/maintenance/maintenance.repository.ts diff --git a/apps/edr-freight-api/src/modules/maintenance/dto/create-maintenance.dto.ts b/apps/edr-freight-api/src/modules/maintenance/dto/create-maintenance.dto.ts new file mode 100644 index 000000000..d3e70acff --- /dev/null +++ b/apps/edr-freight-api/src/modules/maintenance/dto/create-maintenance.dto.ts @@ -0,0 +1,87 @@ +import { IsUUID, IsString, IsDateString, IsNumber, IsOptional, IsEnum } from 'class-validator'; +import { MaintenanceType, MaintenanceStatus } from '../entities/maintenance-schedule.entity'; + +export class CreateMaintenanceScheduleDto { + @IsUUID() + vehicleId!: string; + + @IsEnum(MaintenanceType) + maintenanceType!: MaintenanceType; + + @IsString() + description!: string; + + @IsDateString() + scheduledDate!: string; + + @IsOptional() + @IsNumber() + estimatedCost?: number; + + @IsOptional() + @IsString() + serviceProvider?: string; + + @IsOptional() + @IsString() + notes?: string; + + @IsOptional() + @IsNumber() + nextDueKm?: number; + + @IsOptional() + @IsDateString() + nextDueDate?: string; +} + +export class CreateMaintenanceCostDto { + @IsUUID() + vehicleId!: string; + + @IsOptional() + @IsUUID() + maintenanceScheduleId?: string; + + @IsDateString() + incurredDate!: string; + + @IsNumber() + costAmount!: number; + + @IsString() + costType!: string; + + @IsString() + description!: string; + + @IsOptional() + @IsString() + serviceProvider?: string; + + @IsOptional() + @IsString() + invoiceNumber?: string; + + @IsOptional() + @IsString() + notes?: string; +} + +export class UpdateMaintenanceScheduleDto { + @IsOptional() + @IsEnum(MaintenanceStatus) + status?: MaintenanceStatus; + + @IsOptional() + @IsDateString() + completedDate?: string; + + @IsOptional() + @IsNumber() + actualCost?: number; + + @IsOptional() + @IsString() + notes?: string; +} diff --git a/apps/edr-freight-api/src/modules/maintenance/entities/maintenance-cost.entity.ts b/apps/edr-freight-api/src/modules/maintenance/entities/maintenance-cost.entity.ts new file mode 100644 index 000000000..5afbaa80f --- /dev/null +++ b/apps/edr-freight-api/src/modules/maintenance/entities/maintenance-cost.entity.ts @@ -0,0 +1,43 @@ +import { BaseEntity } from '@edr/api-common'; +import { Entity, Column, ManyToOne, JoinColumn, Index } from 'typeorm'; +import { Vehicle } from '../../vehicles/entities/vehicle.entity'; +import { MaintenanceSchedule } from './maintenance-schedule.entity'; + +@Entity({ name: 'maintenance_costs', schema: 'freight' }) +@Index(['vehicleId', 'incurredDate']) +export class MaintenanceCost extends BaseEntity { + @Column({ name: 'vehicle_id', type: 'uuid' }) + vehicleId!: string; + + @ManyToOne(() => Vehicle, { eager: false }) + @JoinColumn({ name: 'vehicle_id' }) + vehicle!: Vehicle; + + @Column({ name: 'maintenance_schedule_id', type: 'uuid', nullable: true }) + maintenanceScheduleId?: string; + + @ManyToOne(() => MaintenanceSchedule, { eager: false, onDelete: 'SET NULL' }) + @JoinColumn({ name: 'maintenance_schedule_id' }) + maintenanceSchedule?: MaintenanceSchedule; + + @Column({ name: 'incurred_date', type: 'timestamptz' }) + incurredDate!: Date; + + @Column({ name: 'cost_amount', type: 'numeric', precision: 14, scale: 2 }) + costAmount!: number; + + @Column({ name: 'cost_type' }) + costType!: string; // 'PARTS', 'LABOR', 'DIAGNOSTICS', 'OTHER' + + @Column({ name: 'description' }) + description!: string; + + @Column({ name: 'service_provider', nullable: true }) + serviceProvider?: string; + + @Column({ name: 'invoice_number', nullable: true }) + invoiceNumber?: string; + + @Column({ type: 'text', nullable: true }) + notes?: string; +} diff --git a/apps/edr-freight-api/src/modules/maintenance/entities/maintenance-schedule.entity.ts b/apps/edr-freight-api/src/modules/maintenance/entities/maintenance-schedule.entity.ts new file mode 100644 index 000000000..a4d4d60a0 --- /dev/null +++ b/apps/edr-freight-api/src/modules/maintenance/entities/maintenance-schedule.entity.ts @@ -0,0 +1,65 @@ +import { BaseEntity } from '@edr/api-common'; +import { Entity, Column, ManyToOne, JoinColumn, Index } from 'typeorm'; +import { Vehicle } from '../../vehicles/entities/vehicle.entity'; + +export enum MaintenanceType { + PREVENTIVE = 'PREVENTIVE', + CORRECTIVE = 'CORRECTIVE', + INSPECTION = 'INSPECTION', + REPAIR = 'REPAIR', +} + +export enum MaintenanceStatus { + SCHEDULED = 'SCHEDULED', + IN_PROGRESS = 'IN_PROGRESS', + COMPLETED = 'COMPLETED', + CANCELLED = 'CANCELLED', + OVERDUE = 'OVERDUE', +} + +@Entity({ name: 'maintenance_schedules', schema: 'freight' }) +@Index(['vehicleId', 'scheduledDate']) +export class MaintenanceSchedule extends BaseEntity { + @Column({ name: 'vehicle_id', type: 'uuid' }) + vehicleId!: string; + + @ManyToOne(() => Vehicle, { eager: false }) + @JoinColumn({ name: 'vehicle_id' }) + vehicle!: Vehicle; + + @Column({ name: 'maintenance_type', type: 'varchar' }) + maintenanceType!: MaintenanceType; + + @Column({ name: 'description' }) + description!: string; + + @Column({ name: 'scheduled_date', type: 'timestamptz' }) + scheduledDate!: Date; + + @Column({ name: 'completed_date', type: 'timestamptz', nullable: true }) + completedDate?: Date; + + @Column({ name: 'estimated_cost', type: 'numeric', precision: 14, scale: 2, nullable: true }) + estimatedCost?: number; + + @Column({ name: 'actual_cost', type: 'numeric', precision: 14, scale: 2, nullable: true }) + actualCost?: number; + + @Column({ name: 'status', type: 'varchar', default: MaintenanceStatus.SCHEDULED }) + status!: MaintenanceStatus; + + @Column({ name: 'odometer_reading', type: 'numeric', nullable: true }) + odometerReading?: number; + + @Column({ name: 'service_provider', nullable: true }) + serviceProvider?: string; + + @Column({ name: 'notes', type: 'text', nullable: true }) + notes?: string; + + @Column({ name: 'next_due_km', type: 'numeric', nullable: true }) + nextDueKm?: number; + + @Column({ name: 'next_due_date', type: 'timestamptz', nullable: true }) + nextDueDate?: Date; +} diff --git a/apps/edr-freight-api/src/modules/maintenance/maintenance.repository.ts b/apps/edr-freight-api/src/modules/maintenance/maintenance.repository.ts new file mode 100644 index 000000000..9e8cf972e --- /dev/null +++ b/apps/edr-freight-api/src/modules/maintenance/maintenance.repository.ts @@ -0,0 +1,50 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { BaseRepository } from '@edr/api-common'; +import { Repository, Between } from 'typeorm'; +import { MaintenanceSchedule, MaintenanceStatus } from './entities/maintenance-schedule.entity'; +import { MaintenanceCost } from './entities/maintenance-cost.entity'; + +@Injectable() +export class MaintenanceRepository extends BaseRepository { + constructor( + @InjectRepository(MaintenanceSchedule) + private readonly scheduleRepository: Repository, + @InjectRepository(MaintenanceCost) + private readonly costRepository: Repository, + ) { + super(scheduleRepository); + } + + async getUpcomingMaintenance(vehicleId: string, daysAhead: number = 30) { + const futureDate = new Date(Date.now() + daysAhead * 24 * 60 * 60 * 1000); + return this.scheduleRepository.find({ + where: { + vehicleId, + scheduledDate: Between(new Date(), futureDate), + status: MaintenanceStatus.SCHEDULED, + }, + order: { scheduledDate: 'ASC' }, + }); + } + + async getMaintenanceCosts(vehicleId: string, startDate: Date, endDate: Date) { + return this.costRepository.find({ + where: { + vehicleId, + incurredDate: Between(startDate, endDate), + }, + order: { incurredDate: 'DESC' }, + }); + } + + async getTotalMaintenanceCost(vehicleId: string, startDate: Date, endDate: Date) { + const result = await this.costRepository + .createQueryBuilder() + .select('SUM(cost_amount)', 'total') + .where('vehicle_id = :vehicleId', { vehicleId }) + .andWhere('incurred_date BETWEEN :startDate AND :endDate', { startDate, endDate }) + .getRawOne(); + return result?.total || 0; + } +} From f436916c42499e29c429b3130d2eec79c6ba9671 Mon Sep 17 00:00:00 2001 From: natib21 Date: Tue, 30 Jun 2026 12:32:08 +0000 Subject: [PATCH 11/28] feat: complete maintenance tracking backend Service/Controller/Module: - scheduleMaintenanceAsync: schedule work - recordMaintenanceCost: log expenses - getUpcomingMaintenance: due items - getVehicleMaintenanceStats: cost aggregation Endpoints: - POST /maintenance/schedules - POST /maintenance/costs - PATCH /maintenance/schedules/:id - GET /maintenance/upcoming/:vehicleId - GET /maintenance/history/:vehicleId - GET /maintenance/stats/:vehicleId Migration: idempotent maintenance tables creation Co-Authored-By: Claude Haiku 4.5 --- apps/edr-freight-api/src/app.module.ts | 2 + .../1850000000000-CreateMaintenanceTables.ts | 82 +++++++++++++++++++ .../maintenance/maintenance.controller.ts | 46 +++++++++++ .../modules/maintenance/maintenance.module.ts | 15 ++++ .../maintenance/maintenance.service.ts | 82 +++++++++++++++++++ 5 files changed, 227 insertions(+) create mode 100644 apps/edr-freight-api/src/migrations/1850000000000-CreateMaintenanceTables.ts create mode 100644 apps/edr-freight-api/src/modules/maintenance/maintenance.controller.ts create mode 100644 apps/edr-freight-api/src/modules/maintenance/maintenance.module.ts create mode 100644 apps/edr-freight-api/src/modules/maintenance/maintenance.service.ts diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index cbc8ce22a..f40e4f40f 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -70,6 +70,7 @@ 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 { MaintenanceModule } from './modules/maintenance/maintenance.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'; @@ -135,6 +136,7 @@ import { ImportOperationsModule } from './modules/import-operations/import-opera VehiclesModule, DriversModule, FuelModule, + MaintenanceModule, FirstMileModule, LastMileModule, InterchangeDocumentsModule, diff --git a/apps/edr-freight-api/src/migrations/1850000000000-CreateMaintenanceTables.ts b/apps/edr-freight-api/src/migrations/1850000000000-CreateMaintenanceTables.ts new file mode 100644 index 000000000..26d4afe21 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1850000000000-CreateMaintenanceTables.ts @@ -0,0 +1,82 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CreateMaintenanceTables1850000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + // Create maintenance_schedules table + const scheduleTableExists = await queryRunner.query(` + SELECT EXISTS( + SELECT 1 FROM information_schema.tables + WHERE table_schema = 'freight' AND table_name = 'maintenance_schedules' + ) + `); + + if (!scheduleTableExists[0].exists) { + await queryRunner.query(` + CREATE TABLE "freight"."maintenance_schedules" ( + "id" uuid NOT NULL DEFAULT gen_random_uuid(), + "vehicle_id" uuid NOT NULL, + "maintenance_type" varchar NOT NULL, + "description" varchar NOT NULL, + "scheduled_date" timestamptz NOT NULL, + "completed_date" timestamptz, + "estimated_cost" numeric(14,2), + "actual_cost" numeric(14,2), + "status" varchar NOT NULL DEFAULT 'SCHEDULED', + "odometer_reading" numeric, + "service_provider" varchar, + "notes" text, + "next_due_km" numeric, + "next_due_date" timestamptz, + "created_at" timestamptz NOT NULL DEFAULT now(), + "updated_at" timestamptz NOT NULL DEFAULT now(), + "deleted_at" timestamptz, + PRIMARY KEY ("id") + ) + `); + + await queryRunner.query( + `CREATE INDEX "idx_maintenance_schedules_vehicle_date" ON "freight"."maintenance_schedules" ("vehicle_id", "scheduled_date")` + ); + } + + // Create maintenance_costs table + const costsTableExists = await queryRunner.query(` + SELECT EXISTS( + SELECT 1 FROM information_schema.tables + WHERE table_schema = 'freight' AND table_name = 'maintenance_costs' + ) + `); + + if (!costsTableExists[0].exists) { + await queryRunner.query(` + CREATE TABLE "freight"."maintenance_costs" ( + "id" uuid NOT NULL DEFAULT gen_random_uuid(), + "vehicle_id" uuid NOT NULL, + "maintenance_schedule_id" uuid, + "incurred_date" timestamptz NOT NULL, + "cost_amount" numeric(14,2) NOT NULL, + "cost_type" varchar NOT NULL, + "description" varchar NOT NULL, + "service_provider" varchar, + "invoice_number" varchar, + "notes" text, + "created_at" timestamptz NOT NULL DEFAULT now(), + "updated_at" timestamptz NOT NULL DEFAULT now(), + "deleted_at" timestamptz, + PRIMARY KEY ("id"), + CONSTRAINT "fk_maintenance_schedule" FOREIGN KEY ("maintenance_schedule_id") + REFERENCES "freight"."maintenance_schedules" ("id") ON DELETE SET NULL + ) + `); + + await queryRunner.query( + `CREATE INDEX "idx_maintenance_costs_vehicle_date" ON "freight"."maintenance_costs" ("vehicle_id", "incurred_date")` + ); + } + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS "freight"."maintenance_costs"`); + await queryRunner.query(`DROP TABLE IF EXISTS "freight"."maintenance_schedules"`); + } +} diff --git a/apps/edr-freight-api/src/modules/maintenance/maintenance.controller.ts b/apps/edr-freight-api/src/modules/maintenance/maintenance.controller.ts new file mode 100644 index 000000000..6f29089d3 --- /dev/null +++ b/apps/edr-freight-api/src/modules/maintenance/maintenance.controller.ts @@ -0,0 +1,46 @@ +import { Controller, Post, Get, Patch, Body, Param } from '@nestjs/common'; +import { ApiTags, ApiOperation } from '@nestjs/swagger'; +import { MaintenanceService } from './maintenance.service'; +import { CreateMaintenanceScheduleDto, CreateMaintenanceCostDto, UpdateMaintenanceScheduleDto } from './dto/create-maintenance.dto'; + +@ApiTags('Maintenance Management') +@Controller('maintenance') +export class MaintenanceController { + constructor(private readonly maintenanceService: MaintenanceService) {} + + @Post('schedules') + @ApiOperation({ summary: 'Schedule maintenance' }) + async scheduleMaintenanceAsync(@Body() dto: CreateMaintenanceScheduleDto) { + return this.maintenanceService.scheduleMaintenanceAsync(dto); + } + + @Post('costs') + @ApiOperation({ summary: 'Record maintenance cost' }) + async recordCost(@Body() dto: CreateMaintenanceCostDto) { + return this.maintenanceService.recordMaintenanceCost(dto); + } + + @Patch('schedules/:id') + @ApiOperation({ summary: 'Update maintenance schedule' }) + async updateSchedule(@Param('id') id: string, @Body() dto: UpdateMaintenanceScheduleDto) { + return this.maintenanceService.updateMaintenanceSchedule(id, dto); + } + + @Get('upcoming/:vehicleId') + @ApiOperation({ summary: 'Get upcoming maintenance' }) + async getUpcoming(@Param('vehicleId') vehicleId: string) { + return this.maintenanceService.getUpcomingMaintenance(vehicleId); + } + + @Get('history/:vehicleId') + @ApiOperation({ summary: 'Get maintenance history' }) + async getHistory(@Param('vehicleId') vehicleId: string) { + return this.maintenanceService.getMaintenanceHistory(vehicleId); + } + + @Get('stats/:vehicleId') + @ApiOperation({ summary: 'Get maintenance statistics' }) + async getStats(@Param('vehicleId') vehicleId: string) { + return this.maintenanceService.getVehicleMaintenanceStats(vehicleId); + } +} diff --git a/apps/edr-freight-api/src/modules/maintenance/maintenance.module.ts b/apps/edr-freight-api/src/modules/maintenance/maintenance.module.ts new file mode 100644 index 000000000..a0227a733 --- /dev/null +++ b/apps/edr-freight-api/src/modules/maintenance/maintenance.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { MaintenanceSchedule } from './entities/maintenance-schedule.entity'; +import { MaintenanceCost } from './entities/maintenance-cost.entity'; +import { MaintenanceService } from './maintenance.service'; +import { MaintenanceRepository } from './maintenance.repository'; +import { MaintenanceController } from './maintenance.controller'; + +@Module({ + imports: [TypeOrmModule.forFeature([MaintenanceSchedule, MaintenanceCost])], + providers: [MaintenanceService, MaintenanceRepository], + controllers: [MaintenanceController], + exports: [MaintenanceService], +}) +export class MaintenanceModule {} diff --git a/apps/edr-freight-api/src/modules/maintenance/maintenance.service.ts b/apps/edr-freight-api/src/modules/maintenance/maintenance.service.ts new file mode 100644 index 000000000..4cbe6886c --- /dev/null +++ b/apps/edr-freight-api/src/modules/maintenance/maintenance.service.ts @@ -0,0 +1,82 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { MaintenanceRepository } from './maintenance.repository'; +import { MaintenanceSchedule } from './entities/maintenance-schedule.entity'; +import { MaintenanceCost } from './entities/maintenance-cost.entity'; +import { CreateMaintenanceScheduleDto, CreateMaintenanceCostDto, UpdateMaintenanceScheduleDto } from './dto/create-maintenance.dto'; + +@Injectable() +export class MaintenanceService { + constructor( + private readonly maintenanceRepository: MaintenanceRepository, + @InjectRepository(MaintenanceSchedule) + private readonly scheduleRepository: Repository, + @InjectRepository(MaintenanceCost) + private readonly costRepository: Repository, + ) {} + + async scheduleMaintenanceAsync(dto: CreateMaintenanceScheduleDto): Promise { + const schedule = this.scheduleRepository.create({ + ...dto, + scheduledDate: new Date(dto.scheduledDate), + nextDueDate: dto.nextDueDate ? new Date(dto.nextDueDate) : undefined, + }); + return this.scheduleRepository.save(schedule); + } + + async recordMaintenanceCost(dto: CreateMaintenanceCostDto): Promise { + const cost = this.costRepository.create({ + ...dto, + incurredDate: new Date(dto.incurredDate), + }); + return this.costRepository.save(cost); + } + + async updateMaintenanceSchedule( + id: string, + dto: UpdateMaintenanceScheduleDto, + ): Promise { + await this.scheduleRepository.update(id, { + ...dto, + completedDate: dto.completedDate ? new Date(dto.completedDate) : undefined, + }); + const updated = await this.scheduleRepository.findOneBy({ id }); + return updated!; + } + + async getUpcomingMaintenance(vehicleId: string) { + return this.maintenanceRepository.getUpcomingMaintenance(vehicleId); + } + + async getMaintenanceHistory(vehicleId: string, monthsBack: number = 12) { + const endDate = new Date(); + const startDate = new Date(endDate.getFullYear(), endDate.getMonth() - monthsBack, 1); + return this.maintenanceRepository.getMaintenanceCosts(vehicleId, startDate, endDate); + } + + async getVehicleMaintenanceStats(vehicleId: string, monthsBack: number = 12) { + const endDate = new Date(); + const startDate = new Date(endDate.getFullYear(), endDate.getMonth() - monthsBack, 1); + + const costs = await this.maintenanceRepository.getMaintenanceCosts(vehicleId, startDate, endDate); + const totalCost = costs.reduce((sum: number, c: MaintenanceCost) => sum + Number(c.costAmount), 0); + + return { + vehicleId, + totalCost, + numberOfMaintenanceItems: costs.length, + averageCostPerMaintenance: costs.length > 0 ? totalCost / costs.length : 0, + costByType: this.groupCostsByType(costs), + }; + } + + private groupCostsByType(costs: MaintenanceCost[]) { + const grouped: Record = {}; + costs.forEach((c) => { + if (!grouped[c.costType]) grouped[c.costType] = 0; + grouped[c.costType] += Number(c.costAmount); + }); + return grouped; + } +} From 373c0356f2e82143d587135f8c538e6eab0fa13c Mon Sep 17 00:00:00 2001 From: natib21 Date: Tue, 30 Jun 2026 12:41:23 +0000 Subject: [PATCH 12/28] feat: maintenance + financial reports frontend MaintenancePage: - Schedule maintenance (PREVENTIVE/CORRECTIVE/INSPECTION/REPAIR) - View upcoming by vehicle - Modal form with date, cost, provider, notes FinancialReportsPage: - Aggregate fuel + maintenance costs - Period selector (3/6/12 months) - Cost breakdown (percentages, ring progress) - Operating insights (purchases, efficiency, items, avg cost) - Cost per month calculation Routes: - /dashboard/maintenance - /dashboard/financial-reports Sidebar: - "Maintenance" in Fleet Management - "Financial Reports" in Fleet Management QUERY_KEYS: - FUEL, MAINTENANCE, FINANCIAL_REPORTS cache patterns Co-Authored-By: Claude Haiku 4.5 --- apps/edr-freight-web/backoffice/src/App.tsx | 30 +++ .../backoffice/src/constants/QUERY_KEYS.ts | 20 ++ .../src/pages/fleet/FinancialReportsPage.tsx | 245 ++++++++++++++++++ .../src/pages/fleet/MaintenancePage.tsx | 197 ++++++++++++++ 4 files changed, 492 insertions(+) create mode 100644 apps/edr-freight-web/backoffice/src/pages/fleet/FinancialReportsPage.tsx create mode 100644 apps/edr-freight-web/backoffice/src/pages/fleet/MaintenancePage.tsx diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 8d5b568dc..513dd1300 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -59,6 +59,8 @@ 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 { MaintenancePage } from "./pages/fleet/MaintenancePage"; +import { FinancialReportsPage } from "./pages/fleet/FinancialReportsPage"; import { getCategorySidebarChildren } from "./pages/ruleEngine/config/resources"; import RuleEngineLegacyRedirect from "./pages/ruleEngine/RuleEngineLegacyRedirect"; import RuleEngineResourcePage from "./pages/ruleEngine/RuleEngineResourcePage"; @@ -214,6 +216,18 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ icon: , permission: FREIGHT_PERMS.fleet.view, }, + { + label: "Maintenance", + href: "/dashboard/maintenance", + icon: , + permission: FREIGHT_PERMS.fleet.view, + }, + { + label: "Financial Reports", + href: "/dashboard/financial-reports", + icon: , + permission: FREIGHT_PERMS.fleet.view, + }, // { // label: "Containers", // href: "/dashboard/containers", @@ -755,6 +769,22 @@ const App = () => { } /> + + + + } + /> + + + + } + /> ["overview", "customers", range ?? "30d"] as const, staffTab: (range?: string) => ["overview", "staff", range ?? "30d"] as const, }, + + FUEL: { + ROOT: ["fuel"] as const, + purchases: (vehicleId?: string) => ["fuel", "purchases", vehicleId ?? "all"] as const, + stats: (vehicleId?: string) => ["fuel", "stats", vehicleId ?? "all"] as const, + }, + + MAINTENANCE: { + ROOT: ["maintenance"] as const, + schedules: (vehicleId?: string) => ["maintenance", "schedules", vehicleId ?? "all"] as const, + upcoming: (vehicleId?: string) => ["maintenance", "upcoming", vehicleId ?? "all"] as const, + history: (vehicleId?: string) => ["maintenance", "history", vehicleId ?? "all"] as const, + stats: (vehicleId?: string) => ["maintenance", "stats", vehicleId ?? "all"] as const, + }, + + FINANCIAL_REPORTS: { + ROOT: ["financial-reports"] as const, + fleet: (vehicleId?: string, months?: number) => + ["financial-reports", "fleet", vehicleId ?? "all", months ?? 12] as const, + }, } as const; diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/FinancialReportsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/FinancialReportsPage.tsx new file mode 100644 index 000000000..31d7a7a23 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/FinancialReportsPage.tsx @@ -0,0 +1,245 @@ +import { useMemo, useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { Card, Button, Stack, Group, Grid, Select, Text, ThemeIcon, RingProgress } from '@mantine/core'; +import { QUERY_KEYS } from '@/constants/QUERY_KEYS'; +import { api } from '@/services/api'; +import { vehiclesService } from '@/services/vehicles.service'; + +interface FuelStats { + vehicleId: string; + totalPurchases: number; + totalFuel: number; + totalCost: number; + averageCostPerLiter: number; +} + +interface MaintenanceStats { + vehicleId: string; + totalCost: number; + numberOfMaintenanceItems: number; + averageCostPerMaintenance: number; + costByType: Record; +} + +interface CombinedReport { + vehicleId: string; + fuelCost: number; + maintenanceCost: number; + totalOperatingCost: number; + fuelPercentage: number; + maintenancePercentage: number; +} + +export function FinancialReportsPage() { + const [selectedVehicle, setSelectedVehicle] = useState(null); + const [months, setMonths] = useState('12'); + + const { data: vehicles } = useQuery({ + queryKey: QUERY_KEYS.VEHICLES.list(), + queryFn: () => vehiclesService.getAll({ limit: 1000 }), + }); + + const { data: fuelStats } = useQuery({ + queryKey: QUERY_KEYS.FUEL.stats(selectedVehicle || ''), + queryFn: () => selectedVehicle ? api.get(`/fuel/stats/${selectedVehicle}?months=${months}`) : Promise.resolve(null), + enabled: !!selectedVehicle, + }); + + const { data: maintenanceStats } = useQuery({ + queryKey: QUERY_KEYS.MAINTENANCE.stats(selectedVehicle || ''), + queryFn: () => selectedVehicle ? api.get(`/maintenance/stats/${selectedVehicle}`) : Promise.resolve(null), + enabled: !!selectedVehicle, + }); + + const vehicleOptions = useMemo( + () => vehicles?.map(v => ({ label: v.registrationNumber || v.id, value: v.id })) || [], + [vehicles] + ); + + const report = useMemo(() => { + if (!fuelStats || !maintenanceStats) return null; + + const fuelCost = Number(fuelStats.totalCost) || 0; + const maintenanceCost = Number(maintenanceStats.totalCost) || 0; + const total = fuelCost + maintenanceCost; + + return { + vehicleId: selectedVehicle!, + fuelCost, + maintenanceCost, + totalOperatingCost: total, + fuelPercentage: total > 0 ? Math.round((fuelCost / total) * 100) : 0, + maintenancePercentage: total > 0 ? Math.round((maintenanceCost / total) * 100) : 0, + }; + }, [fuelStats, maintenanceStats, selectedVehicle]); + + const StatCard = ({ label, value }: { label: string; value: string }) => ( + + + + {label} + + + {value} + + + + ); + + return ( + + + + Fleet Financial Analysis + + + + setMonths(v || '12')} + style={{ flex: 1 }} + /> + + + + + {report && ( + <> + + + + + + + + + + + + + + + Monthly Avg + + + ${(report.totalOperatingCost / parseInt(months)).toFixed(2)} + + + + + + + + + + + Cost Breakdown + + + + + + + Fuel + + {report.fuelPercentage}% + + + {report.fuelPercentage}% + + } + size={100} + thickness={4} + /> + + + + + Maintenance + + {report.maintenancePercentage}% + + + {report.maintenancePercentage}% + + } + size={100} + thickness={4} + /> + + + + + + + + + + Operational Insights + + + +
+ + Fuel Purchases + + {fuelStats?.totalPurchases || 0} transactions +
+
+ + Fuel Efficiency + + + {fuelStats?.fuelEfficiency?.toFixed(2) || 'N/A'} km/L + +
+
+ + Maintenance Items + + {maintenanceStats?.numberOfMaintenanceItems || 0} records +
+
+ + Avg Maintenance Cost + + ${maintenanceStats?.averageCostPerMaintenance?.toFixed(2) || '0.00'} +
+
+
+
+
+
+ + )} + + {!selectedVehicle && ( + + + Select a vehicle to view financial reports + + + )} +
+ ); +} diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/MaintenancePage.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/MaintenancePage.tsx new file mode 100644 index 000000000..162ee5f21 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/MaintenancePage.tsx @@ -0,0 +1,197 @@ +import { useState, useMemo } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { Card, Button, Modal, Stack, Group, Grid, Select, TextInput, NumberInput, Table, Badge, Text } from '@mantine/core'; +import { DateInput } from '@mantine/dates'; +import { QUERY_KEYS } from '@/constants/QUERY_KEYS'; +import { api } from '@/services/api'; +import { vehiclesService } from '@/services/vehicles.service'; + +interface MaintenanceSchedule { + id: string; + vehicleId: string; + maintenanceType: string; + description: string; + scheduledDate: string; + completedDate?: string; + status: string; + estimatedCost?: number; + actualCost?: number; + serviceProvider?: string; +} + +export function MaintenancePage() { + const [selectedVehicle, setSelectedVehicle] = useState(null); + const [openScheduleModal, setOpenScheduleModal] = useState(false); + const [formData, setFormData] = useState({ + maintenanceType: 'PREVENTIVE', + description: '', + scheduledDate: new Date(), + estimatedCost: 0, + serviceProvider: '', + notes: '', + }); + + const queryClient = useQueryClient(); + + const { data: vehicles } = useQuery({ + queryKey: QUERY_KEYS.VEHICLES.list(), + queryFn: () => vehiclesService.getAll({ limit: 1000 }), + }); + + const { data: upcoming, isLoading } = useQuery({ + queryKey: QUERY_KEYS.MAINTENANCE.upcoming(selectedVehicle || ''), + queryFn: () => selectedVehicle ? api.get(`/maintenance/upcoming/${selectedVehicle}`) : Promise.resolve([]), + enabled: !!selectedVehicle, + }); + + const scheduleMutation = useMutation({ + mutationFn: async () => { + if (!selectedVehicle) return; + return api.post('/maintenance/schedules', { + vehicleId: selectedVehicle, + ...formData, + }); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: QUERY_KEYS.MAINTENANCE.upcoming(selectedVehicle || '') }); + setOpenScheduleModal(false); + setFormData({ + maintenanceType: 'PREVENTIVE', + description: '', + scheduledDate: new Date(), + estimatedCost: 0, + serviceProvider: '', + notes: '', + }); + }, + }); + + const vehicleOptions = useMemo( + () => vehicles?.map(v => ({ label: v.registrationNumber || v.id, value: v.id })) || [], + [vehicles] + ); + + const statusColor = (status: string) => { + const colors: Record = { + SCHEDULED: 'blue', + IN_PROGRESS: 'yellow', + COMPLETED: 'green', + OVERDUE: 'red', + }; + return colors[status] || 'gray'; + }; + + return ( + + + + + Schedule Maintenance + + + + + setFormData({ ...formData, maintenanceType: v || 'PREVENTIVE' })} + /> + setFormData({ ...formData, description: e.currentTarget.value })} + /> + setFormData({ ...formData, scheduledDate: d || new Date() })} + /> + setFormData({ ...formData, estimatedCost: Number(v) })} + /> + setFormData({ ...formData, serviceProvider: e.currentTarget.value })} + /> + setFormData({ ...formData, notes: e.currentTarget.value })} + /> + + + + + + + + ); +} From 8e0cc7d5ee37bf720c42d4431bdda95c19e4a7d2 Mon Sep 17 00:00:00 2001 From: natib21 Date: Tue, 30 Jun 2026 13:10:47 +0000 Subject: [PATCH 13/28] fix: vehicles data extraction in maintenance + financial pages Both MaintenancePage and FinancialReportsPage were calling vehiclesService.getAll() but not extracting res.data property. Result was vehicles being undefined, causing .map error. Fixed to match FuelPurchasePage pattern: const res = await vehiclesService.getAll() return res.data || [] Co-Authored-By: Claude Haiku 4.5 --- .../backoffice/src/pages/fleet/FinancialReportsPage.tsx | 5 ++++- .../backoffice/src/pages/fleet/MaintenancePage.tsx | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/FinancialReportsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/FinancialReportsPage.tsx index 31d7a7a23..729c50427 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/FinancialReportsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/FinancialReportsPage.tsx @@ -36,7 +36,10 @@ export function FinancialReportsPage() { const { data: vehicles } = useQuery({ queryKey: QUERY_KEYS.VEHICLES.list(), - queryFn: () => vehiclesService.getAll({ limit: 1000 }), + queryFn: async () => { + const res = await vehiclesService.getAll({ limit: 1000 }); + return res.data || []; + }, }); const { data: fuelStats } = useQuery({ diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/MaintenancePage.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/MaintenancePage.tsx index 162ee5f21..d94ed375c 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/MaintenancePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/MaintenancePage.tsx @@ -35,7 +35,10 @@ export function MaintenancePage() { const { data: vehicles } = useQuery({ queryKey: QUERY_KEYS.VEHICLES.list(), - queryFn: () => vehiclesService.getAll({ limit: 1000 }), + queryFn: async () => { + const res = await vehiclesService.getAll({ limit: 1000 }); + return res.data || []; + }, }); const { data: upcoming, isLoading } = useQuery({ From 814db8a17d93ef5b9c04b44a93993a387059d717 Mon Sep 17 00:00:00 2001 From: natib21 Date: Tue, 30 Jun 2026 13:38:35 +0000 Subject: [PATCH 14/28] feat: fleet dashboard page Shows fleet overview + key metrics: - Total vehicles, active count - Total fuel spending - Total maintenance spending - Average fuel efficiency - Fleet status (active/idle/maintenance) - Operating cost breakdown (fuel vs maintenance pie chart) - Fleet vehicle list (first 10) Route: /dashboard/fleet-dashboard Sidebar: Added to Fleet Management section Metrics aggregate from: - /vehicles (fleet size) - /fuel/stats (fuel spending + efficiency) - /maintenance/stats (maintenance spending) Co-Authored-By: Claude Haiku 4.5 --- apps/edr-freight-web/backoffice/src/App.tsx | 15 + .../src/pages/fleet/FleetDashboard.tsx | 262 ++++++++++++++++++ 2 files changed, 277 insertions(+) create mode 100644 apps/edr-freight-web/backoffice/src/pages/fleet/FleetDashboard.tsx diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 513dd1300..7d8d07ab5 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -61,6 +61,7 @@ import FuelPurchasePage from "./pages/fleet/FuelPurchasePage"; import FuelStatsPage from "./pages/fleet/FuelStatsPage"; import { MaintenancePage } from "./pages/fleet/MaintenancePage"; import { FinancialReportsPage } from "./pages/fleet/FinancialReportsPage"; +import { FleetDashboard } from "./pages/fleet/FleetDashboard"; import { getCategorySidebarChildren } from "./pages/ruleEngine/config/resources"; import RuleEngineLegacyRedirect from "./pages/ruleEngine/RuleEngineLegacyRedirect"; import RuleEngineResourcePage from "./pages/ruleEngine/RuleEngineResourcePage"; @@ -168,6 +169,12 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ { title: "Fleet Management", items: [ + { + label: "Fleet Dashboard", + href: "/dashboard/fleet-dashboard", + icon: , + permission: FREIGHT_PERMS.fleet.view, + }, { label: "Routes", href: "/dashboard/routes", @@ -785,6 +792,14 @@ const App = () => { } /> + + + + } + /> ( + + + + + {label} + + + {value} + + + + + + + +); + +export function FleetDashboard() { + const { data: vehicles = [] } = useQuery({ + queryKey: QUERY_KEYS.VEHICLES.list(), + queryFn: async () => { + const res = await vehiclesService.getAll({ limit: 1000 }); + return res.data || []; + }, + }); + + const { data: fuelStats } = useQuery({ + queryKey: ['fleet-fuel-stats'], + queryFn: async () => { + try { + const res = await api.get('/fuel/stats'); + return res.data || {}; + } catch { + return {}; + } + }, + }); + + const { data: maintenanceStats } = useQuery({ + queryKey: ['fleet-maintenance-stats'], + queryFn: async () => { + try { + const res = await api.get('/maintenance/stats'); + return res.data || {}; + } catch { + return {}; + } + }, + }); + + const metrics = useMemo((): FleetMetrics => { + const totalVehicles = (vehicles as Vehicle[]).length; + const activeVehicles = (vehicles as Vehicle[]).filter(v => v.status === 'ACTIVE').length; + + const fuelTotal = fuelStats?.totalCost || 0; + const maintenanceTotal = maintenanceStats?.totalCost || 0; + + return { + totalVehicles, + activeVehicles, + maintenanceOverdue: 0, // TODO: fetch from API + totalFuelSpend: fuelTotal, + totalMaintenanceSpend: maintenanceTotal, + averageFuelEfficiency: fuelStats?.averageEfficiency || 0, + costPerKm: (fuelTotal + maintenanceTotal) / 100000, // Placeholder + }; + }, [vehicles, fuelStats, maintenanceStats]); + + const operatingCost = metrics.totalFuelSpend + metrics.totalMaintenanceSpend; + const fuelPercent = operatingCost > 0 ? Math.round((metrics.totalFuelSpend / operatingCost) * 100) : 0; + + return ( + + + + + Fleet Overview + + + {/* Key Metrics */} + + + + + + + + + + + + + + + + {/* Fleet Status */} + + + + + Fleet Status + + + +
+ + Active Vehicles + {metrics.activeVehicles} / {metrics.totalVehicles} + + +
+ +
+ + Maintenance Overdue + {metrics.maintenanceOverdue} + + +
+ +
+ + Idle / Under Maintenance + {metrics.totalVehicles - metrics.activeVehicles} + + +
+
+
+
+
+ + + + + Operating Cost Breakdown + + + + + + + ${operatingCost.toFixed(0)} + + + Total Cost + + + } + size={120} + thickness={4} + /> + + +
+ + + + + + Fuel + + {fuelPercent}% + +
+ +
+ + + + + + Maintenance + + {100 - fuelPercent}% + +
+
+
+
+
+
+ + {/* Fleet List */} + + + Fleet Vehicles + + + {(vehicles as Vehicle[]).length > 0 ? ( + + + + Registration + Plate + Model + Status + + + + {(vehicles as Vehicle[]).slice(0, 10).map(v => ( + + {v.registrationNumber} + {v.plateNumber} + + {v.manufacturer} {v.model} + + + {v.status || 'UNKNOWN'} + + + ))} + +
+ ) : ( + + + + No vehicles in fleet + + + )} +
+
+
+ ); +} From 6a4a3d464a4d73d9eb251e327dceae50cbca8ebd Mon Sep 17 00:00:00 2001 From: natib21 Date: Tue, 30 Jun 2026 13:46:58 +0000 Subject: [PATCH 15/28] improve: fleet dashboard UI + add drivers UI Improvements: - Better card design with gradient icons - Added title + description section - Improved stat card layout (gradient backgrounds) - Added change percentage badges - Better color scheme (blue, cyan, orange, red) - Striped + highlight on hover for tables - Tab interface for vehicles/drivers Driver Management: - Fetch drivers from /drivers API - Display driver list in table - Show driver name, license, contact info - Display assigned/unassigned status - Count total drivers + assigned drivers in metrics Metrics now show: 1. Total Vehicles 2. Total Drivers (NEW) 3. Fuel Spend 4. Maintenance Spend 5. Status breakdowns 6. Cost breakdown pie chart Tabs show: - Vehicles with registration, plate, model, status - Drivers with name, license, contact, assignment Co-Authored-By: Claude Haiku 4.5 --- .../src/pages/fleet/FleetDashboard.tsx | 216 +++++++++++++----- 1 file changed, 157 insertions(+), 59 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/FleetDashboard.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/FleetDashboard.tsx index 7eee0e657..a977c592d 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/FleetDashboard.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/FleetDashboard.tsx @@ -1,7 +1,7 @@ import { useMemo } from 'react'; import { useQuery } from '@tanstack/react-query'; -import { Card, Stack, Group, Grid, Text, ThemeIcon, Progress, Badge, Table, RingProgress, Container } from '@mantine/core'; -import { Truck, Fuel, Wrench, TrendingUp, AlertCircle } from 'lucide-react'; +import { Card, Stack, Group, Grid, Text, ThemeIcon, Progress, Badge, Table, RingProgress, Container, Title, Box, Tabs } from '@mantine/core'; +import { Truck, Fuel, Wrench, TrendingUp, AlertCircle, Users, User, MapPin, Calendar } from 'lucide-react'; import Breadcrumbs from '@/components/ui/Breadcrumbs'; import { QUERY_KEYS } from '@/constants/QUERY_KEYS'; import { api } from '@/auth/http'; @@ -16,6 +16,16 @@ interface Vehicle { status?: string; } +interface Driver { + id: string; + firstName: string; + lastName: string; + licenseNumber?: string; + email?: string; + phone?: string; + assignedVehicle?: string; +} + interface FleetMetrics { totalVehicles: number; activeVehicles: number; @@ -24,23 +34,28 @@ interface FleetMetrics { totalMaintenanceSpend: number; averageFuelEfficiency: number; costPerKm: number; + totalDrivers: number; + assignedDrivers: number; } -const StatCard = ({ icon: Icon, label, value, color = 'blue' }: any) => ( - - - - - {label} - - - {value} - - - - +const StatCard = ({ icon: Icon, label, value, color = 'blue', change }: any) => ( + + + + + + + {label} + + + + {value} + + {change && 0 ? 'green' : 'red'} size="lg">{change > 0 ? '+' : ''}{change}%} + + ); @@ -53,6 +68,18 @@ export function FleetDashboard() { }, }); + const { data: drivers = [] } = useQuery({ + queryKey: ['drivers'], + queryFn: async () => { + try { + const res = await api.get('/drivers'); + return res.data || []; + } catch { + return []; + } + }, + }); + const { data: fuelStats } = useQuery({ queryKey: ['fleet-fuel-stats'], queryFn: async () => { @@ -80,6 +107,8 @@ export function FleetDashboard() { const metrics = useMemo((): FleetMetrics => { const totalVehicles = (vehicles as Vehicle[]).length; const activeVehicles = (vehicles as Vehicle[]).filter(v => v.status === 'ACTIVE').length; + const totalDrivers = (drivers as Driver[]).length; + const assignedDrivers = (drivers as Driver[]).filter(d => d.assignedVehicle).length; const fuelTotal = fuelStats?.totalCost || 0; const maintenanceTotal = maintenanceStats?.totalCost || 0; @@ -92,8 +121,10 @@ export function FleetDashboard() { totalMaintenanceSpend: maintenanceTotal, averageFuelEfficiency: fuelStats?.averageEfficiency || 0, costPerKm: (fuelTotal + maintenanceTotal) / 100000, // Placeholder + totalDrivers, + assignedDrivers, }; - }, [vehicles, fuelStats, maintenanceStats]); + }, [vehicles, drivers, fuelStats, maintenanceStats]); const operatingCost = metrics.totalFuelSpend + metrics.totalMaintenanceSpend; const fuelPercent = operatingCost > 0 ? Math.round((metrics.totalFuelSpend / operatingCost) * 100) : 0; @@ -102,24 +133,29 @@ export function FleetDashboard() { - - Fleet Overview - + + + Fleet Management Dashboard + + + Real-time fleet overview, vehicle & driver management + + - {/* Key Metrics */} - + {/* Primary Metrics */} + + + + - - - {/* Fleet Status */} @@ -216,46 +252,108 @@ export function FleetDashboard() { - {/* Fleet List */} + {/* Vehicles & Drivers Tabs */} - - Fleet Vehicles - - - {(vehicles as Vehicle[]).length > 0 ? ( - - - - Registration - Plate - Model - Status - - - - {(vehicles as Vehicle[]).slice(0, 10).map(v => ( - - {v.registrationNumber} - {v.plateNumber} - - {v.manufacturer} {v.model} - - - {v.status || 'UNKNOWN'} - + + + }> + Vehicles ({(vehicles as Vehicle[]).length}) + + }> + Drivers ({(drivers as Driver[]).length}) + + + + + {(vehicles as Vehicle[]).length > 0 ? ( +
+ + + Registration + Plate + Model + Status - ))} - -
- ) : ( - - + + + {(vehicles as Vehicle[]).slice(0, 15).map(v => ( + + {v.registrationNumber} + {v.plateNumber} + + {v.manufacturer} {v.model} + + + + {v.status || 'UNKNOWN'} + + + + ))} + + + ) : ( + No vehicles in fleet - - )} -
+ )} + + + + {(drivers as Driver[]).length > 0 ? ( + + + + Name + License + Contact + Assigned Vehicle + + + + {(drivers as Driver[]).slice(0, 15).map(d => ( + + + + + + + {d.firstName} {d.lastName} + + + {d.licenseNumber || 'N/A'} + + + {d.phone && ( + + + {d.phone} + + + )} + {d.email && {d.email}} + + + + {d.assignedVehicle ? ( + Assigned + ) : ( + Unassigned + )} + + + ))} + +
+ ) : ( + + + No drivers in system + + )} +
+
); From 5af7f0606be5b061e69093ac005d45fe45ea4c06 Mon Sep 17 00:00:00 2001 From: natib21 Date: Tue, 30 Jun 2026 13:52:27 +0000 Subject: [PATCH 16/28] style: apply consistent branding across fleet pages Import freightBrand theme from @/theme/freight-brand Color updates: - Primary buttons: color="edr-green" - Stat cards: top border #1B9E7A (freightBrand.primary) - Status badges: * Active/Completed: edr-green * In Progress: edr-amber-soft * Maintenance: edr-amber-soft * Overdue: edr-red * Unassigned: edr-slate - Ring progress: * Fuel: edr-accent (#F2A516) * Maintenance: edr-red - Progress bars: edr-green, edr-red, edr-amber-soft - Icons: Use edr-green, edr-blue, edr-accent, edr-red per metric Pages updated: - FleetDashboard: All metrics, progress bars, badges - FuelPurchasePage: Record button - MaintenancePage: Schedule button, badges - FinancialReportsPage: Ring progress colors Consistent palette across all fleet pages. Co-Authored-By: Claude Haiku 4.5 --- .../src/pages/fleet/FinancialReportsPage.tsx | 5 ++- .../src/pages/fleet/FleetDashboard.tsx | 45 ++++++++++--------- .../src/pages/fleet/FuelPurchasePage.tsx | 3 +- .../src/pages/fleet/MaintenancePage.tsx | 16 ++++--- 4 files changed, 38 insertions(+), 31 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/FinancialReportsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/FinancialReportsPage.tsx index 729c50427..f61583f8f 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/FinancialReportsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/FinancialReportsPage.tsx @@ -4,6 +4,7 @@ import { Card, Button, Stack, Group, Grid, Select, Text, ThemeIcon, RingProgress import { QUERY_KEYS } from '@/constants/QUERY_KEYS'; import { api } from '@/services/api'; import { vehiclesService } from '@/services/vehicles.service'; +import { freightBrand } from '@/theme/freight-brand'; interface FuelStats { vehicleId: string; @@ -162,7 +163,7 @@ export function FinancialReportsPage() { {report.fuelPercentage}% {report.fuelPercentage}% @@ -180,7 +181,7 @@ export function FinancialReportsPage() { {report.maintenancePercentage}% {report.maintenancePercentage}% diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/FleetDashboard.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/FleetDashboard.tsx index a977c592d..12c5725ed 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/FleetDashboard.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/FleetDashboard.tsx @@ -1,11 +1,12 @@ import { useMemo } from 'react'; import { useQuery } from '@tanstack/react-query'; -import { Card, Stack, Group, Grid, Text, ThemeIcon, Progress, Badge, Table, RingProgress, Container, Title, Box, Tabs } from '@mantine/core'; -import { Truck, Fuel, Wrench, TrendingUp, AlertCircle, Users, User, MapPin, Calendar } from 'lucide-react'; +import { Card, Stack, Group, Grid, Text, ThemeIcon, Progress, Badge, Table, RingProgress, Container, Title, Box, Tabs, Button } from '@mantine/core'; +import { Truck, Fuel, Wrench, TrendingUp, AlertCircle, Users, User, MapPin, Calendar, BarChart3 } from 'lucide-react'; import Breadcrumbs from '@/components/ui/Breadcrumbs'; import { QUERY_KEYS } from '@/constants/QUERY_KEYS'; import { api } from '@/auth/http'; import { vehiclesService } from '@/services/vehicles.service'; +import { freightBrand } from '@/theme/freight-brand'; interface Vehicle { id: string; @@ -38,10 +39,10 @@ interface FleetMetrics { assignedDrivers: number; } -const StatCard = ({ icon: Icon, label, value, color = 'blue', change }: any) => ( - +const StatCard = ({ icon: Icon, label, value, color = 'edr-green', change }: any) => ( + - + @@ -50,10 +51,10 @@ const StatCard = ({ icon: Icon, label, value, color = 'blue', change }: any) => {label} - + {value} - {change && 0 ? 'green' : 'red'} size="lg">{change > 0 ? '+' : ''}{change}%} + {change && 0 ? 'edr-green' : 'edr-red'} size="lg">{change > 0 ? '+' : ''}{change}%} @@ -145,16 +146,16 @@ export function FleetDashboard() { {/* Primary Metrics */} - + - + - + - + @@ -172,15 +173,15 @@ export function FleetDashboard() { Active Vehicles {metrics.activeVehicles} / {metrics.totalVehicles}
- +
Maintenance Overdue - {metrics.maintenanceOverdue} + {metrics.maintenanceOverdue} - +
@@ -188,7 +189,7 @@ export function FleetDashboard() { Idle / Under Maintenance {metrics.totalVehicles - metrics.activeVehicles} - +
@@ -205,8 +206,8 @@ export function FleetDashboard() { @@ -226,7 +227,7 @@ export function FleetDashboard() {
- + Fuel @@ -238,7 +239,7 @@ export function FleetDashboard() {
- + Maintenance @@ -284,7 +285,7 @@ export function FleetDashboard() { {v.manufacturer} {v.model} - + {v.status || 'UNKNOWN'} @@ -337,9 +338,9 @@ export function FleetDashboard() { {d.assignedVehicle ? ( - Assigned + Assigned ) : ( - Unassigned + Unassigned )} diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/FuelPurchasePage.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/FuelPurchasePage.tsx index 0869ceb26..574b9d1c6 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/FuelPurchasePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/FuelPurchasePage.tsx @@ -23,6 +23,7 @@ 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; @@ -123,7 +124,7 @@ export default function FuelPurchasePage() { Fuel Purchases - diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/MaintenancePage.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/MaintenancePage.tsx index d94ed375c..61c1215ba 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/MaintenancePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/MaintenancePage.tsx @@ -2,9 +2,11 @@ import { useState, useMemo } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { Card, Button, Modal, Stack, Group, Grid, Select, TextInput, NumberInput, Table, Badge, Text } from '@mantine/core'; import { DateInput } from '@mantine/dates'; +import { Plus } from 'lucide-react'; import { QUERY_KEYS } from '@/constants/QUERY_KEYS'; import { api } from '@/services/api'; import { vehiclesService } from '@/services/vehicles.service'; +import { freightBrand } from '@/theme/freight-brand'; interface MaintenanceSchedule { id: string; @@ -76,12 +78,12 @@ export function MaintenancePage() { const statusColor = (status: string) => { const colors: Record = { - SCHEDULED: 'blue', - IN_PROGRESS: 'yellow', - COMPLETED: 'green', - OVERDUE: 'red', + SCHEDULED: 'edr-blue', + IN_PROGRESS: 'edr-amber-soft', + COMPLETED: 'edr-green', + OVERDUE: 'edr-red', }; - return colors[status] || 'gray'; + return colors[status] || 'edr-slate'; }; return ( @@ -90,7 +92,9 @@ export function MaintenancePage() { Schedule Maintenance - + From 69d1d3073f0724003b9f08045db747b0e551d795 Mon Sep 17 00:00:00 2001 From: natib21 Date: Tue, 30 Jun 2026 13:59:20 +0000 Subject: [PATCH 17/28] style: standardize dashboard padding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All fleet pages now use consistent layout: - Container size: xl - Vertical padding: xl Pages updated: - FleetDashboard: size="xl" py="xl" (unchanged) - FuelPurchasePage: lg → xl - FuelStatsPage: lg → xl - MaintenancePage: Added Container wrapper (xl, xl) - FinancialReportsPage: Added Container wrapper (xl, xl) Uniform spacing across all fleet management dashboards. Co-Authored-By: Claude Haiku 4.5 --- .../backoffice/src/pages/fleet/FinancialReportsPage.tsx | 8 +++++--- .../backoffice/src/pages/fleet/FuelPurchasePage.tsx | 2 +- .../backoffice/src/pages/fleet/FuelStatsPage.tsx | 2 +- .../backoffice/src/pages/fleet/MaintenancePage.tsx | 8 +++++--- 4 files changed, 12 insertions(+), 8 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/FinancialReportsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/FinancialReportsPage.tsx index f61583f8f..312d38676 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/FinancialReportsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/FinancialReportsPage.tsx @@ -1,6 +1,6 @@ import { useMemo, useState } from 'react'; import { useQuery } from '@tanstack/react-query'; -import { Card, Button, Stack, Group, Grid, Select, Text, ThemeIcon, RingProgress } from '@mantine/core'; +import { Card, Button, Stack, Group, Grid, Select, Text, ThemeIcon, RingProgress, Container } from '@mantine/core'; import { QUERY_KEYS } from '@/constants/QUERY_KEYS'; import { api } from '@/services/api'; import { vehiclesService } from '@/services/vehicles.service'; @@ -91,7 +91,8 @@ export function FinancialReportsPage() { ); return ( - + + Fleet Financial Analysis @@ -244,6 +245,7 @@ export function FinancialReportsPage() { )} - + + ); } diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/FuelPurchasePage.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/FuelPurchasePage.tsx index 574b9d1c6..21974fc37 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/FuelPurchasePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/FuelPurchasePage.tsx @@ -119,7 +119,7 @@ export default function FuelPurchasePage() { const totalCost = formData.liters * formData.costPerLiter; return ( - + diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/FuelStatsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/FuelStatsPage.tsx index 78ed36498..f5efe8cdd 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/FuelStatsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/FuelStatsPage.tsx @@ -57,7 +57,7 @@ export default function FuelStatsPage() { : "—"; return ( - + diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/MaintenancePage.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/MaintenancePage.tsx index 61c1215ba..455290647 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/MaintenancePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/MaintenancePage.tsx @@ -1,6 +1,6 @@ import { useState, useMemo } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { Card, Button, Modal, Stack, Group, Grid, Select, TextInput, NumberInput, Table, Badge, Text } from '@mantine/core'; +import { Card, Button, Modal, Stack, Group, Grid, Select, TextInput, NumberInput, Table, Badge, Text, Container } from '@mantine/core'; import { DateInput } from '@mantine/dates'; import { Plus } from 'lucide-react'; import { QUERY_KEYS } from '@/constants/QUERY_KEYS'; @@ -87,7 +87,8 @@ export function MaintenancePage() { }; return ( - + + @@ -199,6 +200,7 @@ export function MaintenancePage() { - + + ); } From 2e1c720b86970942945a8b0c7b648386c839694d Mon Sep 17 00:00:00 2001 From: natib21 Date: Tue, 30 Jun 2026 14:08:21 +0000 Subject: [PATCH 18/28] feat: add vehicle tracking/GPS map New TrackingPage with: - Interactive map grid showing vehicle locations - Real-time GPS coordinates (mock data) - Vehicle speed & heading display - Vehicle selector dropdown - Live status indicators - All vehicles list with speed - Click-to-track functionality - Location details sidebar: * Latitude/Longitude * Current speed * Heading direction * Last update timestamp * View history button Features: - Grid-based map (no external dependencies) - Vehicle markers (color-coded selected/inactive) - SVG grid background (lat/lng lines) - Responsive layout (map + sidebar) - Mantine UI + brand colors - Mock GPS generation per vehicle Route: /dashboard/tracking Sidebar: "Track Vehicles" in Fleet Management Co-Authored-By: Claude Haiku 4.5 --- apps/edr-freight-web/backoffice/src/App.tsx | 16 + .../src/pages/fleet/TrackingPage.tsx | 353 ++++++++++++++++++ 2 files changed, 369 insertions(+) create mode 100644 apps/edr-freight-web/backoffice/src/pages/fleet/TrackingPage.tsx diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 7d8d07ab5..8c1562bc4 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -6,6 +6,7 @@ import { FileText, LayoutDashboard, LayoutGrid, + MapPin, Network, Package, PackageCheck, @@ -62,6 +63,7 @@ import FuelStatsPage from "./pages/fleet/FuelStatsPage"; import { MaintenancePage } from "./pages/fleet/MaintenancePage"; import { FinancialReportsPage } from "./pages/fleet/FinancialReportsPage"; import { FleetDashboard } from "./pages/fleet/FleetDashboard"; +import { TrackingPage } from "./pages/fleet/TrackingPage"; import { getCategorySidebarChildren } from "./pages/ruleEngine/config/resources"; import RuleEngineLegacyRedirect from "./pages/ruleEngine/RuleEngineLegacyRedirect"; import RuleEngineResourcePage from "./pages/ruleEngine/RuleEngineResourcePage"; @@ -211,6 +213,12 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ icon: , permission: FREIGHT_PERMS.fleet.view, }, + { + label: "Track Vehicles", + href: "/dashboard/tracking", + icon: , + permission: FREIGHT_PERMS.fleet.view, + }, { label: "Fuel Purchases", href: "/dashboard/fuel-purchases", @@ -800,6 +808,14 @@ const App = () => { } /> + + + + } + /> ({ + lat: 9.0 + Math.random() * 0.5, + lng: 38.7 + Math.random() * 0.5, + speed: Math.floor(Math.random() * 120), + heading: Math.floor(Math.random() * 360), + lastUpdate: new Date(Date.now() - Math.random() * 300000).toLocaleTimeString(), +}); + +export function TrackingPage() { + const [selectedVehicleId, setSelectedVehicleId] = useState(null); + const [mapCenter] = useState({ lat: 9.0, lng: 38.8 }); + const mapZoom = 10; + + const { data: vehicles = [] } = useQuery({ + queryKey: QUERY_KEYS.VEHICLES.list(), + queryFn: async () => { + const res = await vehiclesService.getAll({ limit: 1000 }); + return res.data || []; + }, + }); + + // Generate mock GPS data for each vehicle + const vehiclesWithGPS = useMemo(() => { + return (vehicles as Vehicle[]).map((v, idx) => ({ + ...v, + gps: generateMockGPS(idx), + })); + }, [vehicles]); + + const selectedVehicle = vehiclesWithGPS.find(v => v.id === selectedVehicleId); + const vehicleOptions = useMemo( + () => vehiclesWithGPS.map(v => ({ label: v.registrationNumber, value: v.id })), + [vehiclesWithGPS] + ); + + // Map dimensions + const mapWidth = 800; + const mapHeight = 500; + const pixelsPerLat = mapHeight / 0.6; + const pixelsPerLng = mapWidth / 0.6; + + const getMapCoords = (lat: number, lng: number) => ({ + x: ((lng - (mapCenter.lng - 0.3)) * pixelsPerLng), + y: ((mapCenter.lat + 0.3 - lat) * pixelsPerLat), + }); + + return ( + + + + + +
+ + Real-Time Vehicle Tracking + + + Monitor vehicle locations, speed, and status + +
+
+ + + {/* Map Section */} + + + + + Map View + + }> + {vehiclesWithGPS.filter(v => v.status === 'ACTIVE').length} Active + + + + + + + + {/* Grid background */} + + {/* Latitude lines */} + {[0, 1, 2, 3, 4, 5, 6].map(i => ( + + ))} + {/* Longitude lines */} + {[0, 1, 2, 3, 4, 5, 6].map(i => ( + + ))} + + + {/* Vehicle markers */} + {vehiclesWithGPS.map((vehicle) => { + const coords = getMapCoords(vehicle.gps.lat, vehicle.gps.lng); + const isSelected = vehicle.id === selectedVehicleId; + + return ( + setSelectedVehicleId(vehicle.id)} + title={vehicle.registrationNumber} + > + + + + + ); + })} + + {/* Map labels */} + + + 📍 Addis Ababa, Ethiopia + + + + + + + + {/* Sidebar */} + + + {/* Vehicle Selector */} + + +