diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index db05ae26f..f40e4f40f 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -69,6 +69,8 @@ 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 { 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'; @@ -133,6 +135,8 @@ import { ImportOperationsModule } from './modules/import-operations/import-opera OverviewModule, VehiclesModule, DriversModule, + FuelModule, + MaintenanceModule, 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/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/migrations/1860000000000-AddPaidToFirstAndLastMile.ts b/apps/edr-freight-api/src/migrations/1860000000000-AddPaidToFirstAndLastMile.ts new file mode 100644 index 000000000..261e16099 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1860000000000-AddPaidToFirstAndLastMile.ts @@ -0,0 +1,34 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Add paid column to first_mile and last_mile tables to track invoice payment status. + */ +export class AddPaidToFirstAndLastMile1860000000000 + implements MigrationInterface +{ + name = "AddPaidToFirstAndLastMile1860000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.first_mile + ADD COLUMN IF NOT EXISTS paid boolean NOT NULL DEFAULT false; + `); + + await queryRunner.query(` + ALTER TABLE freight.last_mile + ADD COLUMN IF NOT EXISTS paid boolean NOT NULL DEFAULT false; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.first_mile + DROP COLUMN IF EXISTS paid; + `); + + await queryRunner.query(` + ALTER TABLE freight.last_mile + DROP COLUMN IF EXISTS paid; + `); + } +} diff --git a/apps/edr-freight-api/src/modules/billing/billing.controller.ts b/apps/edr-freight-api/src/modules/billing/billing.controller.ts index 9a441ab65..7da5e2d66 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.controller.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.controller.ts @@ -1,5 +1,5 @@ import { Controller, Get, Param, ParseUUIDPipe, Res } from "@nestjs/common"; -import { ApiOperation, ApiTags } from "@nestjs/swagger"; +import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger"; import type { Response } from "express"; import { FreightAdmin } from "../../common/booking-guards"; @@ -8,6 +8,7 @@ import { BillingService } from "./billing.service"; @ApiTags("billing") @Controller("billing") @FreightAdmin() +@ApiBearerAuth() export class BillingController { constructor(private readonly billingService: BillingService) { } diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.ts b/apps/edr-freight-api/src/modules/billing/billing.service.ts index 443f511ee..3566bf4e5 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -344,6 +344,7 @@ export class BillingService { input: GenerateInvoiceInput, manager?: EntityManager, ): Promise { + console.log("oooooooooo", input); const run = (mg: EntityManager) => this.createInvoice(input, mg); return manager ? run(manager) : this.dataSource.transaction(run); } diff --git a/apps/edr-freight-api/src/modules/first-mile/dto/create-first-mile.dto.ts b/apps/edr-freight-api/src/modules/first-mile/dto/create-first-mile.dto.ts index e2535083e..45e9f5b1a 100644 --- a/apps/edr-freight-api/src/modules/first-mile/dto/create-first-mile.dto.ts +++ b/apps/edr-freight-api/src/modules/first-mile/dto/create-first-mile.dto.ts @@ -1,6 +1,6 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Transform } from 'class-transformer'; -import { IsIn, IsNumber, IsOptional, IsUUID, Min } from 'class-validator'; +import { IsBoolean, IsIn, IsNumber, IsOptional, IsUUID, Min } from 'class-validator'; import { FIRST_MILE_STATUSES, FirstMileStatus } from '../entities/first-mile.entity'; @@ -58,4 +58,9 @@ export class CreateFirstMileDto { @Transform(({ value }) => (value === '' ? undefined : value)) @IsUUID() vehicleId?: string | null; + + @ApiPropertyOptional({ description: 'Invoice payment status', default: false }) + @IsOptional() + @IsBoolean() + paid?: boolean; } diff --git a/apps/edr-freight-api/src/modules/first-mile/entities/first-mile.entity.ts b/apps/edr-freight-api/src/modules/first-mile/entities/first-mile.entity.ts index 253d2d4c8..27dcfef87 100644 --- a/apps/edr-freight-api/src/modules/first-mile/entities/first-mile.entity.ts +++ b/apps/edr-freight-api/src/modules/first-mile/entities/first-mile.entity.ts @@ -35,6 +35,9 @@ export class FirstMile extends BaseEntity { @Column({ name: 'remaining_payment', type: 'numeric', precision: 14, scale: 2, default: 0 }) remainingPayment!: number; + @Column({ name: 'paid', type: 'boolean', default: false }) + paid!: boolean; + // TODO: uncomment after migration creates column // @Column({ type: 'boolean', default: false }) // isPostPaymentCompleted!: boolean; diff --git a/apps/edr-freight-api/src/modules/first-mile/first-mile.controller.ts b/apps/edr-freight-api/src/modules/first-mile/first-mile.controller.ts index 6bb307a4b..680bb5d09 100644 --- a/apps/edr-freight-api/src/modules/first-mile/first-mile.controller.ts +++ b/apps/edr-freight-api/src/modules/first-mile/first-mile.controller.ts @@ -21,6 +21,9 @@ import { AllocateFirstMileContainersDto } from './dto/allocate-containers.dto'; import { FirstMileStatus } from './entities/first-mile.entity'; import { FirstMileService } from './first-mile.service'; import { FirstMileInvoiceService } from './first-mile-invoice.service'; +import { BillingService } from '../billing/billing.service'; +import { BookingsService } from '../bookings/bookings.service'; +import { Freight } from '@edr/types'; @ApiTags('first-mile') @ApiBearerAuth() @@ -30,7 +33,9 @@ export class FirstMileController { constructor( private readonly firstMileService: FirstMileService, private readonly firstMileInvoiceService: FirstMileInvoiceService, - ) {} + private readonly billingService: BillingService, + private readonly bookingsService: BookingsService + ) { } @Get() @ApiOperation({ summary: 'List first-mile legs' }) @@ -80,7 +85,34 @@ export class FirstMileController { async update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateFirstMileDto) { const record = await this.firstMileService.update(id, dto); // Auto-generate invoice if distance or payment was updated - if (dto.exactKm !== undefined || dto.remainingPayment !== undefined) { + const booking = await this.bookingsService.findById(record.bookingId); + if (dto.exactKm !== undefined || dto.exactKm != record.exactKm || dto.remainingPayment !== undefined || dto.remainingPayment !== record.remainingPayment) { + await this.billingService.generateInvoice({ + source: Freight.InvoiceSource.FirstMile, + sourceId: record.id, + type: "FIRST_MILE", + companyId: booking.companyId, + companyProfileId: booking.companyProfileId, + currency: "ETB", + + lines: [ + { + chargeType: "FIRST_MILE", + description: "First Mile Transportation Service", + quantity: 1, + unitRate: record.remainingPayment, + amount: record.remainingPayment, + currency: "ETB", + }, + ], + + subtotalAmount: record.remainingPayment, + taxAmount: 0, // Replace if VAT/tax applies + totalAmount: record.remainingPayment, + + dueInDays: 7, + status: Freight.InvoiceStatus.Pending, + }); await this.firstMileInvoiceService.ensureInvoiceFor(record); } return record; diff --git a/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts b/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts index 08cd9ab10..ae0ada831 100644 --- a/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts +++ b/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts @@ -12,6 +12,8 @@ import { UpdateFirstMileDto } from './dto/update-first-mile.dto'; import { FirstMile, FirstMileStatus } from './entities/first-mile.entity'; import { FirstMileContainerAllocation } from './entities/first-mile-container-allocation.entity'; import { FirstMileRepository } from './first-mile.repository'; +import { OnEvent } from '@nestjs/event-emitter'; +import { InvoiceEventPayload } from '../billing/billing.service'; type FirstMileListFilter = { status?: FirstMileStatus; @@ -146,6 +148,18 @@ export class FirstMileService { }; } + @OnEvent("firstmile.invoice.paid") + async onBookingInvoicePaid(payload: InvoiceEventPayload): Promise { + try { + await this.firstMileRepository.update(payload.sourceId, { paid: true } as any); + this.logger.log(`Marked first-mile record ${payload.sourceId} as paid (invoice ${payload.invoiceId})`); + } catch (err) { + this.logger.error( + `Failed to update first-mile payment status for record ${payload.sourceId}: ${String(err)}`, + ); + } + } + async findById(id: string): Promise { const record = await this.firstMileRepository.findById(id, { relations: { @@ -175,6 +189,7 @@ export class FirstMileService { estimatedKm: dto.estimatedKm ?? null, exactKm: dto.exactKm ?? null, vehicleId: dto.vehicleId ?? null, + paid: (dto as any).paid ?? false, }); } @@ -207,6 +222,7 @@ export class FirstMileService { async update(id: string, dto: UpdateFirstMileDto): Promise { const existing = await this.findById(id); + const dtoAny = dto as any; const updated = await this.firstMileRepository.update(id, { ...(dto.bookingId !== undefined ? { bookingId: dto.bookingId } : {}), ...(dto.status !== undefined ? { status: dto.status } : {}), @@ -215,7 +231,8 @@ export class FirstMileService { ...(dto.estimatedKm !== undefined ? { estimatedKm: dto.estimatedKm } : {}), ...(dto.exactKm !== undefined ? { exactKm: dto.exactKm } : {}), ...(dto.vehicleId !== undefined ? { vehicleId: dto.vehicleId } : {}), - }); + ...(dtoAny.paid !== undefined ? { paid: dtoAny.paid } : {}), + } as any); if (!updated) { throw new NotFoundException(`First-mile record ${id} not found`); 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..aabafd17c --- /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, default: 0 }) + totalDistanceKm: number = 0; + + @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..62207bfa1 --- /dev/null +++ b/apps/edr-freight-api/src/modules/fuel/fuel.controller.ts @@ -0,0 +1,60 @@ +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') + @ApiOperation({ summary: 'Get all fuel purchases' }) + async getAllFuelPurchases() { + return this.fuelService.getAllFuelPurchases(); + } + + @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') + @ApiOperation({ summary: 'Get fleet-wide fuel statistics' }) + async getFleetFuelStats(@Query('months') months: number = 12) { + return this.fuelService.getFleetFuelStats(months); + } + + @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..d062c38e1 --- /dev/null +++ b/apps/edr-freight-api/src/modules/fuel/fuel.repository.ts @@ -0,0 +1,76 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { BaseRepository } from '@edr/api-common'; +import { Repository, Between } from 'typeorm'; +import { FuelPurchase } from './entities/fuel-purchase.entity'; +import { FuelConsumption } from './entities/fuel-consumption.entity'; + +@Injectable() +export class FuelRepository extends BaseRepository { + constructor( + @InjectRepository(FuelPurchase) + private readonly purchaseRepository: Repository, + @InjectRepository(FuelConsumption) + private readonly consumptionRepository: Repository, + ) { + super(purchaseRepository); + } + + async findByVehicleAndDateRange( + vehicleId: string, + startDate: Date, + endDate: Date, + ): Promise { + return this.purchaseRepository.find({ + where: { + vehicleId, + purchaseDate: Between(startDate, endDate), + }, + order: { purchaseDate: 'DESC' }, + }); + } + + async getMonthlyConsumption( + vehicleId: string, + month: Date, + ): Promise { + return this.consumptionRepository.findOne({ + where: { + vehicleId, + month, + }, + }); + } + + async updateMonthlyConsumption( + vehicleId: string, + month: Date, + data: Partial, + ): Promise { + let consumption = await this.consumptionRepository.findOne({ + where: { + vehicleId, + month, + }, + }); + + if (!consumption) { + consumption = this.consumptionRepository.create({ + vehicleId, + month, + ...data, + }); + } else { + Object.assign(consumption, data); + } + + return this.consumptionRepository.save(consumption); + } + + async findPurchasesByVehicle(vehicleId: string): Promise { + 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 new file mode 100644 index 000000000..54c157c2d --- /dev/null +++ b/apps/edr-freight-api/src/modules/fuel/fuel.service.ts @@ -0,0 +1,121 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { FuelRepository } from './fuel.repository'; +import { FuelPurchase } from './entities/fuel-purchase.entity'; +import { FuelConsumption } from './entities/fuel-consumption.entity'; +import { CreateFuelPurchaseDto } from './dto/create-fuel-purchase.dto'; + +@Injectable() +export class FuelService { + constructor( + private readonly fuelRepository: FuelRepository, + @InjectRepository(FuelPurchase) + private readonly purchaseRepository: Repository, + ) {} + + async recordFuelPurchase(dto: CreateFuelPurchaseDto): Promise { + const totalCost = dto.liters * dto.costPerLiter; + + const purchase = this.purchaseRepository.create({ + ...dto, + totalCost, + }); + + const saved = await this.purchaseRepository.save(purchase); + + // Update monthly consumption + await this.updateMonthlyConsumption(dto.vehicleId, new Date(dto.purchaseDate)); + + return saved; + } + + async getAllFuelPurchases(): Promise { + return this.purchaseRepository + .createQueryBuilder('purchase') + .leftJoinAndSelect('purchase.vehicle', 'vehicle') + .orderBy('purchase.purchaseDate', 'DESC') + .getMany(); + } + + 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 getFleetFuelStats(monthsBack: number = 12) { + const endDate = new Date(); + const startDate = new Date(endDate.getFullYear(), endDate.getMonth() - monthsBack, 1); + + const purchases = await this.purchaseRepository + .createQueryBuilder('purchase') + .where('purchase.purchaseDate BETWEEN :startDate AND :endDate', { startDate, endDate }) + .getMany(); + + 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 { + totalPurchases: purchases.length, + totalLiters, + totalCost, + averagePricePerLiter: averagePrice, + averageEfficiency: 0, // Placeholder - would need distance data + dateRange: { startDate, endDate }, + }; + } + + async getVehicleFuelStats(vehicleId: string, monthsBack: number = 12) { + const endDate = new Date(); + const startDate = new Date(endDate.getFullYear(), endDate.getMonth() - monthsBack, 1); + + const purchases = await this.getFuelPurchases(vehicleId, startDate, endDate); + + const totalLiters = purchases.reduce((sum: number, p: FuelPurchase) => sum + Number(p.liters), 0); + const totalCost = purchases.reduce((sum: number, p: FuelPurchase) => sum + Number(p.totalCost), 0); + const averagePrice = totalLiters > 0 ? totalCost / totalLiters : 0; + + return { + vehicleId, + totalPurchases: purchases.length, + totalLiters, + totalCost, + averagePricePerLiter: averagePrice, + dateRange: { startDate, endDate }, + }; + } + + private async updateMonthlyConsumption(vehicleId: string, date: Date): Promise { + const monthStart = new Date(date.getFullYear(), date.getMonth(), 1); + const monthEnd = new Date(monthStart.getFullYear(), monthStart.getMonth() + 1, 1); + + const purchases = await this.fuelRepository.findByVehicleAndDateRange( + vehicleId, + monthStart, + monthEnd, + ); + + const totalLiters = purchases.reduce((sum: number, p: FuelPurchase) => sum + Number(p.liters), 0); + const totalCost = purchases.reduce((sum: number, p: FuelPurchase) => sum + Number(p.totalCost), 0); + const numberOfPurchases = purchases.length; + const averageCostPerLiter = totalLiters > 0 ? totalCost / totalLiters : 0; + + await this.fuelRepository.updateMonthlyConsumption(vehicleId, monthStart, { + totalLiters, + totalCost, + numberOfPurchases, + averageCostPerLiter, + }); + } +} diff --git a/apps/edr-freight-api/src/modules/last-mile/dto/create-last-mile.dto.ts b/apps/edr-freight-api/src/modules/last-mile/dto/create-last-mile.dto.ts index 4f6f5fc8f..08de632f1 100644 --- a/apps/edr-freight-api/src/modules/last-mile/dto/create-last-mile.dto.ts +++ b/apps/edr-freight-api/src/modules/last-mile/dto/create-last-mile.dto.ts @@ -1,6 +1,6 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Transform } from 'class-transformer'; -import { IsIn, IsNumber, IsOptional, IsUUID, Min } from 'class-validator'; +import { IsBoolean, IsIn, IsNumber, IsOptional, IsUUID, Min } from 'class-validator'; import { LAST_MILE_STATUSES, LastMileStatus } from '../entities/last-mile.entity'; @@ -58,4 +58,9 @@ export class CreateLastMileDto { @Transform(({ value }) => (value === '' ? undefined : value)) @IsUUID() vehicleId?: string | null; + + @ApiPropertyOptional({ description: 'Invoice payment status', default: false }) + @IsOptional() + @IsBoolean() + paid?: boolean; } diff --git a/apps/edr-freight-api/src/modules/last-mile/entities/last-mile.entity.ts b/apps/edr-freight-api/src/modules/last-mile/entities/last-mile.entity.ts index 1747e308c..85d01b7f0 100644 --- a/apps/edr-freight-api/src/modules/last-mile/entities/last-mile.entity.ts +++ b/apps/edr-freight-api/src/modules/last-mile/entities/last-mile.entity.ts @@ -35,6 +35,9 @@ export class LastMile extends BaseEntity { @Column({ name: 'remaining_payment', type: 'numeric', precision: 14, scale: 2, default: 0 }) remainingPayment!: number; + @Column({ name: 'paid', type: 'boolean', default: false }) + paid!: boolean; + // TODO: uncomment after migration creates column // @Column({ type: 'boolean', default: false }) // isPostPaymentCompleted!: boolean; diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts index 929d97a3e..88a96e6c2 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts @@ -21,6 +21,9 @@ import { AllocateLastMileContainersDto } from './dto/allocate-containers.dto'; import { LastMileStatus } from './entities/last-mile.entity'; import { LastMileService } from './last-mile.service'; import { LastMileInvoiceService } from './last-mile-invoice.service'; +import { Freight } from '@edr/types'; +import { BillingService } from '../billing/billing.service'; +import { BookingsService } from '../bookings/bookings.service'; @ApiTags('last-mile') @ApiBearerAuth() @@ -30,6 +33,8 @@ export class LastMileController { constructor( private readonly lastMileService: LastMileService, private readonly lastMileInvoiceService: LastMileInvoiceService, + private readonly billingService: BillingService, + private readonly bookingsService: BookingsService ) {} @Get() @@ -80,9 +85,36 @@ export class LastMileController { async update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateLastMileDto) { const record = await this.lastMileService.update(id, dto); // Auto-generate invoice if distance or payment was updated - if (dto.exactKm !== undefined || dto.remainingPayment !== undefined) { - await this.lastMileInvoiceService.ensureInvoiceFor(record); - } + const booking = await this.bookingsService.findById(record.bookingId); + if (dto.exactKm !== undefined || dto.exactKm != record.exactKm || dto.remainingPayment !== undefined || dto.remainingPayment !== record.remainingPayment) { + await this.billingService.generateInvoice({ + source: Freight.InvoiceSource.LastMile, + sourceId: record.id, + type: "LAST_MILE", + companyId: booking.companyId, + companyProfileId: booking.companyProfileId, + currency: "ETB", + + lines: [ + { + chargeType: "LAST_MILE", + description: "Last Mile Transportation Service", + quantity: 1, + unitRate: record.remainingPayment, + amount: record.remainingPayment, + currency: "ETB", + }, + ], + + subtotalAmount: record.remainingPayment, + taxAmount: 0, // Replace if VAT/tax applies + totalAmount: record.remainingPayment, + + dueInDays: 7, + status: Freight.InvoiceStatus.Pending, + }); + await this.lastMileInvoiceService.ensureInvoiceFor(record); + } return record; } diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts index 5faad49b9..732b1a618 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts @@ -10,6 +10,8 @@ import { UpdateLastMileDto } from './dto/update-last-mile.dto'; import { LastMile, LastMileStatus } from './entities/last-mile.entity'; import { LastMileContainerAllocation } from './entities/last-mile-container-allocation.entity'; import { LastMileRepository } from './last-mile.repository'; +import { InvoiceEventPayload } from '../billing/billing.service'; +import { OnEvent } from '@nestjs/event-emitter'; type LastMileListFilter = { status?: LastMileStatus; @@ -137,12 +139,26 @@ export class LastMileService { estimatedKm: dto.estimatedKm ?? null, exactKm: dto.exactKm ?? null, vehicleId: dto.vehicleId ?? null, + paid: (dto as any).paid ?? false, }); } + @OnEvent("lastmile.invoice.paid") + async onBookingInvoicePaid(payload: InvoiceEventPayload): Promise { + try { + await this.lastMileRepository.update(payload.sourceId, { paid: true } as any); + this.logger.log(`Marked last-mile record ${payload.sourceId} as paid (invoice ${payload.invoiceId})`); + } catch (err) { + this.logger.error( + `Failed to update last-mile payment status for record ${payload.sourceId}: ${String(err)}`, + ); + } + } + async update(id: string, dto: UpdateLastMileDto): Promise { const existing = await this.findById(id); + const dtoAny = dto as any; const updated = await this.lastMileRepository.update(id, { ...(dto.bookingId !== undefined ? { bookingId: dto.bookingId } : {}), ...(dto.status !== undefined ? { status: dto.status } : {}), @@ -151,7 +167,8 @@ export class LastMileService { ...(dto.estimatedKm !== undefined ? { estimatedKm: dto.estimatedKm } : {}), ...(dto.exactKm !== undefined ? { exactKm: dto.exactKm } : {}), ...(dto.vehicleId !== undefined ? { vehicleId: dto.vehicleId } : {}), - }); + ...(dtoAny.paid !== undefined ? { paid: dtoAny.paid } : {}), + } as any); if (!updated) { throw new NotFoundException(`Last-mile record ${id} not found`); 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.controller.ts b/apps/edr-freight-api/src/modules/maintenance/maintenance.controller.ts new file mode 100644 index 000000000..de5ff08f2 --- /dev/null +++ b/apps/edr-freight-api/src/modules/maintenance/maintenance.controller.ts @@ -0,0 +1,52 @@ +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') + @ApiOperation({ summary: 'Get fleet-wide maintenance statistics' }) + async getFleetStats() { + return this.maintenanceService.getFleetMaintenanceStats(); + } + + @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.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; + } +} 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..e804243a0 --- /dev/null +++ b/apps/edr-freight-api/src/modules/maintenance/maintenance.service.ts @@ -0,0 +1,101 @@ +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 getFleetMaintenanceStats(monthsBack: number = 12) { + const endDate = new Date(); + const startDate = new Date(endDate.getFullYear(), endDate.getMonth() - monthsBack, 1); + + const costs = await this.costRepository + .createQueryBuilder('cost') + .where('cost.incurredDate BETWEEN :startDate AND :endDate', { startDate, endDate }) + .getMany(); + + const totalCost = costs.reduce((sum: number, c: MaintenanceCost) => sum + Number(c.costAmount), 0); + + return { + totalCost, + numberOfMaintenanceItems: costs.length, + averageCostPerMaintenance: costs.length > 0 ? totalCost / costs.length : 0, + costByType: this.groupCostsByType(costs), + }; + } + + 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; + } +} diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 542a848a4..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, @@ -57,6 +58,12 @@ 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 { 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"; @@ -164,6 +171,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", @@ -200,6 +213,36 @@ 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", + icon: , + permission: FREIGHT_PERMS.fleet.view, + }, + { + label: "Fuel Analytics", + href: "/dashboard/fuel-stats", + 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", @@ -725,6 +768,54 @@ const App = () => { } /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> ["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, @@ -135,4 +141,24 @@ export const QUERY_KEYS = { customersTab: (range?: string) => ["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..73a7456af --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/FinancialReportsPage.tsx @@ -0,0 +1,251 @@ +import { useMemo, useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +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'; +import { freightBrand } from '@/theme/freight-brand'; + +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: async () => { + const res = await vehiclesService.getAll({ limit: 1000 }); + return res.data || []; + }, + }); + + 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/FleetDashboard.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/FleetDashboard.tsx new file mode 100644 index 000000000..a311a16eb --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/FleetDashboard.tsx @@ -0,0 +1,361 @@ +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, 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; + registrationNumber: string; + plateNumber: string; + manufacturer: string; + model: string; + status?: string; +} + +interface Driver { + id: string; + firstName: string; + lastName: string; + licenseNumber?: string; + email?: string; + phone?: string; + assignedVehicle?: string; +} + +interface FleetMetrics { + totalVehicles: number; + activeVehicles: number; + maintenanceOverdue: number; + totalFuelSpend: number; + totalMaintenanceSpend: number; + averageFuelEfficiency: number; + costPerKm: number; + totalDrivers: number; + assignedDrivers: number; +} + +const StatCard = ({ icon: Icon, label, value, color = 'edr-green', change }: any) => ( + + + + + + + + + {label} + + + + {value} + + {change && 0 ? 'edr-green' : 'edr-red'} size="lg">{change > 0 ? '+' : ''}{change}%} + + + +); + +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: 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 () => { + 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 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; + + return { + totalVehicles, + activeVehicles, + maintenanceOverdue: 0, // TODO: fetch from API + totalFuelSpend: fuelTotal, + totalMaintenanceSpend: maintenanceTotal, + averageFuelEfficiency: fuelStats?.averageEfficiency || 0, + costPerKm: (fuelTotal + maintenanceTotal) / 100000, // Placeholder + totalDrivers, + assignedDrivers, + }; + }, [vehicles, drivers, fuelStats, maintenanceStats]); + + const operatingCost = metrics.totalFuelSpend + metrics.totalMaintenanceSpend; + const fuelPercent = operatingCost > 0 ? Math.round((metrics.totalFuelSpend / operatingCost) * 100) : 0; + + return ( + + + + + + Fleet Management Dashboard + + + Real-time fleet overview, vehicle & driver management + + + + {/* Primary 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}% + +
+
+
+
+
+
+ + {/* Vehicles & Drivers Tabs */} + + + + }> + 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 + + )} +
+
+
+
+ ); +} diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/FuelPurchasePage.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/FuelPurchasePage.tsx new file mode 100644 index 000000000..b3c168df3 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/FuelPurchasePage.tsx @@ -0,0 +1,316 @@ +import { useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + Box, + Button, + Card, + Container, + Group, + Modal, + NumberInput, + Select, + Stack, + Table, + Text, + TextInput, + Title, + Badge, + Grid, +} from "@mantine/core"; +import { Plus, Trash2 } from "lucide-react"; +import Breadcrumbs from "@/components/ui/Breadcrumbs"; +import { useToast } from "@/hooks/use-toast"; +import { api } from "@/auth/http"; +import { QUERY_KEYS } from "@/constants/QUERY_KEYS"; +import { vehiclesService, type Vehicle as VehicleType } from "@/services/vehicles.service"; +import { freightBrand } from "@/theme/freight-brand"; + +interface FuelPurchase { + id: string; + vehicleId: string; + vehicleName?: string; + purchaseDate: string; + liters: number; + costPerLiter: number; + totalCost: number; + fuelStation?: string; + paymentMethod: string; + odometerReading?: number; + receiptNumber?: string; + notes?: string; +} + + +export default function FuelPurchasePage() { + const { toast } = useToast(); + const qc = useQueryClient(); + const [modalOpen, setModalOpen] = useState(false); + const [formData, setFormData] = useState({ + vehicleId: "", + purchaseDate: new Date().toISOString().split("T")[0], + liters: 0, + costPerLiter: 0, + fuelStation: "", + paymentMethod: "CASH", + odometerReading: undefined as number | undefined, + receiptNumber: "", + notes: "", + }); + + // Fetch vehicles + const { data: vehiclesData } = useQuery({ + queryKey: QUERY_KEYS.VEHICLES.list(), + queryFn: async () => { + const res = await vehiclesService.getAll({ limit: 1000 }); + return res.data || []; + }, + }); + + // Fetch fuel purchases + const { data: purchasesData = [] } = useQuery({ + queryKey: ["fuel-purchases"], + queryFn: async () => { + const res = await api.get("/fuel/purchases"); + return res.data || []; + }, + }); + + // Record purchase mutation + const recordMutation = useMutation({ + mutationFn: async (data: typeof formData) => { + const res = await api.post("/fuel/purchases", { + ...data, + liters: parseFloat(data.liters.toString()), + costPerLiter: parseFloat(data.costPerLiter.toString()), + }); + return res.data; + }, + onSuccess: () => { + toast({ title: "Fuel purchase recorded" }); + setModalOpen(false); + setFormData({ + vehicleId: "", + purchaseDate: new Date().toISOString().split("T")[0], + liters: 0, + costPerLiter: 0, + fuelStation: "", + paymentMethod: "CASH", + odometerReading: undefined, + receiptNumber: "", + notes: "", + }); + qc.invalidateQueries({ queryKey: ["fuel-purchases"] }); + }, + onError: (error: any) => { + toast({ + title: "Error recording purchase", + message: error?.response?.data?.message || "Failed to record fuel purchase", + color: "red", + }); + }, + }); + + const vehicleOptions = + vehiclesData?.map((v: VehicleType) => ({ + value: v.id, + label: `${v.plateNumber} - ${v.manufacturer} ${v.model}`, + })) || []; + + const totalCost = formData.liters * formData.costPerLiter; + + return ( + + + + + Fuel Purchases + + + + {/* Stats Cards */} + + + + + Total Purchases + + + {purchasesData.length} + + + + + + + Total Liters + + + {purchasesData + .reduce((sum: number, p: FuelPurchase) => sum + Number(p.liters), 0) + .toFixed(2)}{" "} + L + + + + + + + Total Cost + + + ETB {purchasesData + .reduce((sum: number, p: FuelPurchase) => sum + Number(p.totalCost), 0) + .toLocaleString("en-US", { maximumFractionDigits: 2 })} + + + + + + + Avg Price/L + + + ETB{" "} + {( + purchasesData.reduce((sum: number, p: FuelPurchase) => sum + Number(p.totalCost), 0) / + purchasesData.reduce((sum: number, p: FuelPurchase) => sum + Number(p.liters), 0) || 0 + ).toFixed(2)} + + + + + + {/* Purchases Table */} + + + + + Vehicle + Date + Liters + Cost/L + Total + Station + Payment + + + + {(purchasesData as FuelPurchase[])?.map((purchase) => ( + + {(purchase as any).vehicle?.registrationNumber || (purchase as any).vehicle?.plateNumber || purchase.vehicleId} + {new Date(purchase.purchaseDate).toLocaleDateString()} + {Number(purchase.liters).toFixed(2)} + ETB {Number(purchase.costPerLiter).toFixed(2)} + ETB {Number(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..04c872966 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/FuelStatsPage.tsx @@ -0,0 +1,200 @@ +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 { vehiclesService, type Vehicle as VehicleType } from "@/services/vehicles.service"; +import { useState } from "react"; + +interface FuelStats { + vehicleId: string; + totalPurchases: number; + totalLiters: number; + totalCost: number; + averagePricePerLiter: number; + dateRange: { startDate: string; endDate: string }; +} + +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 vehiclesService.getAll({ limit: 1000 }); + return res.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: VehicleType) => ({ + value: v.id, + label: `${v.plateNumber} - ${v.manufacturer} ${v.model}`, + })) || []; + + const selectedVehicle = vehiclesData?.find((v: VehicleType) => 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 + + + )} +
+ ); +} 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..b2bd155d3 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/MaintenancePage.tsx @@ -0,0 +1,206 @@ +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, Container } 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; + 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: async () => { + const res = await vehiclesService.getAll({ limit: 1000 }); + return res.data || []; + }, + }); + + 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: 'edr-blue', + IN_PROGRESS: 'edr-amber-soft', + COMPLETED: 'edr-green', + OVERDUE: 'edr-red', + }; + return colors[status] || 'edr-slate'; + }; + + 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 })} + /> + + + + + + + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/TrackingPage.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/TrackingPage.tsx new file mode 100644 index 000000000..8230a7446 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/TrackingPage.tsx @@ -0,0 +1,359 @@ +import { useState, useMemo } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { Container, Grid, Card, Stack, Group, Select, Text, Badge, Button, Box, Table, ThemeIcon, SimpleGrid } from '@mantine/core'; +import { MapPin, Navigation, Radio, Activity } from 'lucide-react'; +import Breadcrumbs from '@/components/ui/Breadcrumbs'; +import { QUERY_KEYS } from '@/constants/QUERY_KEYS'; +import { vehiclesService } from '@/services/vehicles.service'; +import { freightBrand } from '@/theme/freight-brand'; + +interface Vehicle { + id: string; + registrationNumber: string; + plateNumber: string; + manufacturer: string; + model: string; + status?: string; +} + +interface GPSLocation { + lat: number; + lng: number; + speed?: number; + heading?: number; + lastUpdate?: string; +} + +// Mock GPS data for demo +const generateMockGPS = (index: number): GPSLocation => ({ + 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]); + + // For demo: show all vehicles as trackable (or filter by ACTIVE if status data available) + const trackableVehicles = useMemo( + () => vehiclesWithGPS.slice(0, 10), // Limit to first 10 for demo + [vehiclesWithGPS] + ); + + const selectedVehicle = trackableVehicles.find(v => v.id === selectedVehicleId); + const vehicleOptions = useMemo( + () => trackableVehicles.map(v => ({ label: v.registrationNumber, value: v.id })), + [trackableVehicles] + ); + + // 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 + + }> + {trackableVehicles.length} Tracked + + + + + + + + {/* 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 */} + {trackableVehicles.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 */} + + +