mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 00:45:41 +00:00
Merge pull request #396 from Tria-plc/freight/feature/first_mile_invoice
Freight/feature/first mile invoice
This commit is contained in:
@@ -69,6 +69,8 @@ import { WarehousesModule } from './modules/warehouses/warehouses.module';
|
|||||||
import { OverviewModule } from './modules/overview/overview.module';
|
import { OverviewModule } from './modules/overview/overview.module';
|
||||||
import { VehiclesModule } from './modules/vehicles/vehicles.module';
|
import { VehiclesModule } from './modules/vehicles/vehicles.module';
|
||||||
import { DriversModule } from './modules/drivers/drivers.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 { FirstMileModule } from './modules/first-mile/first-mile.module';
|
||||||
import { LastMileModule } from './modules/last-mile/last-mile.module';
|
import { LastMileModule } from './modules/last-mile/last-mile.module';
|
||||||
import { InterchangeDocumentsModule } from './modules/interchange-documents/interchange-documents.module';
|
import { InterchangeDocumentsModule } from './modules/interchange-documents/interchange-documents.module';
|
||||||
@@ -133,6 +135,8 @@ import { ImportOperationsModule } from './modules/import-operations/import-opera
|
|||||||
OverviewModule,
|
OverviewModule,
|
||||||
VehiclesModule,
|
VehiclesModule,
|
||||||
DriversModule,
|
DriversModule,
|
||||||
|
FuelModule,
|
||||||
|
MaintenanceModule,
|
||||||
FirstMileModule,
|
FirstMileModule,
|
||||||
LastMileModule,
|
LastMileModule,
|
||||||
InterchangeDocumentsModule,
|
InterchangeDocumentsModule,
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||||
|
|
||||||
|
export class CreateFuelTables1840000000000 implements MigrationInterface {
|
||||||
|
name = "CreateFuelTables1840000000000";
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
const fuelPurchasesExists = await queryRunner.query(
|
||||||
|
`SELECT 1 FROM information_schema.tables WHERE table_schema = 'freight' AND table_name = 'fuel_purchases';`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!fuelPurchasesExists.length) {
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE freight.fuel_purchases (
|
||||||
|
id uuid NOT NULL DEFAULT gen_random_uuid(),
|
||||||
|
vehicle_id uuid NOT NULL,
|
||||||
|
purchase_date timestamptz NOT NULL,
|
||||||
|
liters numeric(10, 2) NOT NULL,
|
||||||
|
cost_per_liter numeric(10, 2) NOT NULL,
|
||||||
|
total_cost numeric(14, 2) NOT NULL,
|
||||||
|
fuel_station varchar(255) NULL,
|
||||||
|
payment_method varchar(50) DEFAULT 'CASH',
|
||||||
|
odometer_reading numeric(10, 2) NULL,
|
||||||
|
driver_id uuid NULL,
|
||||||
|
receipt_number varchar(255) NULL,
|
||||||
|
notes text NULL,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
deleted_at timestamptz NULL,
|
||||||
|
CONSTRAINT pk_fuel_purchases PRIMARY KEY (id),
|
||||||
|
CONSTRAINT fk_fuel_purchases_vehicle FOREIGN KEY (vehicle_id)
|
||||||
|
REFERENCES freight.vehicles (id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE INDEX idx_fuel_purchases_vehicle ON freight.fuel_purchases (vehicle_id);`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE INDEX idx_fuel_purchases_date ON freight.fuel_purchases (purchase_date);`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fuelConsumptionExists = await queryRunner.query(
|
||||||
|
`SELECT 1 FROM information_schema.tables WHERE table_schema = 'freight' AND table_name = 'fuel_consumption';`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!fuelConsumptionExists.length) {
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE freight.fuel_consumption (
|
||||||
|
id uuid NOT NULL DEFAULT gen_random_uuid(),
|
||||||
|
vehicle_id uuid NOT NULL,
|
||||||
|
month date NOT NULL,
|
||||||
|
total_liters numeric(10, 2) NOT NULL,
|
||||||
|
total_cost numeric(14, 2) NOT NULL,
|
||||||
|
total_distance_km numeric(10, 2) NOT NULL,
|
||||||
|
fuel_efficiency_km_per_l numeric(10, 2) NULL,
|
||||||
|
number_of_purchases integer DEFAULT 0,
|
||||||
|
average_cost_per_liter numeric(10, 2) NULL,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
deleted_at timestamptz NULL,
|
||||||
|
CONSTRAINT pk_fuel_consumption PRIMARY KEY (id),
|
||||||
|
CONSTRAINT fk_fuel_consumption_vehicle FOREIGN KEY (vehicle_id)
|
||||||
|
REFERENCES freight.vehicles (id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT uq_fuel_consumption_vehicle_month UNIQUE (vehicle_id, month)
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE INDEX idx_fuel_consumption_vehicle_month ON freight.fuel_consumption (vehicle_id, month);`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`DROP TABLE IF EXISTS freight.fuel_consumption;`);
|
||||||
|
await queryRunner.query(`DROP TABLE IF EXISTS freight.fuel_purchases;`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class CreateMaintenanceTables1850000000000 implements MigrationInterface {
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
// 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<void> {
|
||||||
|
await queryRunner.query(`DROP TABLE IF EXISTS "freight"."maintenance_costs"`);
|
||||||
|
await queryRunner.query(`DROP TABLE IF EXISTS "freight"."maintenance_schedules"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<void> {
|
||||||
|
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<void> {
|
||||||
|
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;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Controller, Get, Param, ParseUUIDPipe, Res } from "@nestjs/common";
|
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 type { Response } from "express";
|
||||||
|
|
||||||
import { FreightAdmin } from "../../common/booking-guards";
|
import { FreightAdmin } from "../../common/booking-guards";
|
||||||
@@ -8,6 +8,7 @@ import { BillingService } from "./billing.service";
|
|||||||
@ApiTags("billing")
|
@ApiTags("billing")
|
||||||
@Controller("billing")
|
@Controller("billing")
|
||||||
@FreightAdmin()
|
@FreightAdmin()
|
||||||
|
@ApiBearerAuth()
|
||||||
export class BillingController {
|
export class BillingController {
|
||||||
constructor(private readonly billingService: BillingService) { }
|
constructor(private readonly billingService: BillingService) { }
|
||||||
|
|
||||||
|
|||||||
@@ -344,6 +344,7 @@ export class BillingService {
|
|||||||
input: GenerateInvoiceInput,
|
input: GenerateInvoiceInput,
|
||||||
manager?: EntityManager,
|
manager?: EntityManager,
|
||||||
): Promise<Invoice & { lines: InvoiceLine[] }> {
|
): Promise<Invoice & { lines: InvoiceLine[] }> {
|
||||||
|
console.log("oooooooooo", input);
|
||||||
const run = (mg: EntityManager) => this.createInvoice(input, mg);
|
const run = (mg: EntityManager) => this.createInvoice(input, mg);
|
||||||
return manager ? run(manager) : this.dataSource.transaction(run);
|
return manager ? run(manager) : this.dataSource.transaction(run);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
import { Transform } from 'class-transformer';
|
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';
|
import { FIRST_MILE_STATUSES, FirstMileStatus } from '../entities/first-mile.entity';
|
||||||
|
|
||||||
@@ -58,4 +58,9 @@ export class CreateFirstMileDto {
|
|||||||
@Transform(({ value }) => (value === '' ? undefined : value))
|
@Transform(({ value }) => (value === '' ? undefined : value))
|
||||||
@IsUUID()
|
@IsUUID()
|
||||||
vehicleId?: string | null;
|
vehicleId?: string | null;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Invoice payment status', default: false })
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
paid?: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,9 @@ export class FirstMile extends BaseEntity {
|
|||||||
@Column({ name: 'remaining_payment', type: 'numeric', precision: 14, scale: 2, default: 0 })
|
@Column({ name: 'remaining_payment', type: 'numeric', precision: 14, scale: 2, default: 0 })
|
||||||
remainingPayment!: number;
|
remainingPayment!: number;
|
||||||
|
|
||||||
|
@Column({ name: 'paid', type: 'boolean', default: false })
|
||||||
|
paid!: boolean;
|
||||||
|
|
||||||
// TODO: uncomment after migration creates column
|
// TODO: uncomment after migration creates column
|
||||||
// @Column({ type: 'boolean', default: false })
|
// @Column({ type: 'boolean', default: false })
|
||||||
// isPostPaymentCompleted!: boolean;
|
// isPostPaymentCompleted!: boolean;
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ import { AllocateFirstMileContainersDto } from './dto/allocate-containers.dto';
|
|||||||
import { FirstMileStatus } from './entities/first-mile.entity';
|
import { FirstMileStatus } from './entities/first-mile.entity';
|
||||||
import { FirstMileService } from './first-mile.service';
|
import { FirstMileService } from './first-mile.service';
|
||||||
import { FirstMileInvoiceService } from './first-mile-invoice.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')
|
@ApiTags('first-mile')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@@ -30,7 +33,9 @@ export class FirstMileController {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly firstMileService: FirstMileService,
|
private readonly firstMileService: FirstMileService,
|
||||||
private readonly firstMileInvoiceService: FirstMileInvoiceService,
|
private readonly firstMileInvoiceService: FirstMileInvoiceService,
|
||||||
) {}
|
private readonly billingService: BillingService,
|
||||||
|
private readonly bookingsService: BookingsService
|
||||||
|
) { }
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@ApiOperation({ summary: 'List first-mile legs' })
|
@ApiOperation({ summary: 'List first-mile legs' })
|
||||||
@@ -80,7 +85,34 @@ export class FirstMileController {
|
|||||||
async update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateFirstMileDto) {
|
async update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateFirstMileDto) {
|
||||||
const record = await this.firstMileService.update(id, dto);
|
const record = await this.firstMileService.update(id, dto);
|
||||||
// Auto-generate invoice if distance or payment was updated
|
// 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);
|
await this.firstMileInvoiceService.ensureInvoiceFor(record);
|
||||||
}
|
}
|
||||||
return record;
|
return record;
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import { UpdateFirstMileDto } from './dto/update-first-mile.dto';
|
|||||||
import { FirstMile, FirstMileStatus } from './entities/first-mile.entity';
|
import { FirstMile, FirstMileStatus } from './entities/first-mile.entity';
|
||||||
import { FirstMileContainerAllocation } from './entities/first-mile-container-allocation.entity';
|
import { FirstMileContainerAllocation } from './entities/first-mile-container-allocation.entity';
|
||||||
import { FirstMileRepository } from './first-mile.repository';
|
import { FirstMileRepository } from './first-mile.repository';
|
||||||
|
import { OnEvent } from '@nestjs/event-emitter';
|
||||||
|
import { InvoiceEventPayload } from '../billing/billing.service';
|
||||||
|
|
||||||
type FirstMileListFilter = {
|
type FirstMileListFilter = {
|
||||||
status?: FirstMileStatus;
|
status?: FirstMileStatus;
|
||||||
@@ -146,6 +148,18 @@ export class FirstMileService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OnEvent("firstmile.invoice.paid")
|
||||||
|
async onBookingInvoicePaid(payload: InvoiceEventPayload): Promise<void> {
|
||||||
|
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<FirstMile> {
|
async findById(id: string): Promise<FirstMile> {
|
||||||
const record = await this.firstMileRepository.findById(id, {
|
const record = await this.firstMileRepository.findById(id, {
|
||||||
relations: {
|
relations: {
|
||||||
@@ -175,6 +189,7 @@ export class FirstMileService {
|
|||||||
estimatedKm: dto.estimatedKm ?? null,
|
estimatedKm: dto.estimatedKm ?? null,
|
||||||
exactKm: dto.exactKm ?? null,
|
exactKm: dto.exactKm ?? null,
|
||||||
vehicleId: dto.vehicleId ?? 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<FirstMile> {
|
async update(id: string, dto: UpdateFirstMileDto): Promise<FirstMile> {
|
||||||
const existing = await this.findById(id);
|
const existing = await this.findById(id);
|
||||||
|
|
||||||
|
const dtoAny = dto as any;
|
||||||
const updated = await this.firstMileRepository.update(id, {
|
const updated = await this.firstMileRepository.update(id, {
|
||||||
...(dto.bookingId !== undefined ? { bookingId: dto.bookingId } : {}),
|
...(dto.bookingId !== undefined ? { bookingId: dto.bookingId } : {}),
|
||||||
...(dto.status !== undefined ? { status: dto.status } : {}),
|
...(dto.status !== undefined ? { status: dto.status } : {}),
|
||||||
@@ -215,7 +231,8 @@ export class FirstMileService {
|
|||||||
...(dto.estimatedKm !== undefined ? { estimatedKm: dto.estimatedKm } : {}),
|
...(dto.estimatedKm !== undefined ? { estimatedKm: dto.estimatedKm } : {}),
|
||||||
...(dto.exactKm !== undefined ? { exactKm: dto.exactKm } : {}),
|
...(dto.exactKm !== undefined ? { exactKm: dto.exactKm } : {}),
|
||||||
...(dto.vehicleId !== undefined ? { vehicleId: dto.vehicleId } : {}),
|
...(dto.vehicleId !== undefined ? { vehicleId: dto.vehicleId } : {}),
|
||||||
});
|
...(dtoAny.paid !== undefined ? { paid: dtoAny.paid } : {}),
|
||||||
|
} as any);
|
||||||
|
|
||||||
if (!updated) {
|
if (!updated) {
|
||||||
throw new NotFoundException(`First-mile record ${id} not found`);
|
throw new NotFoundException(`First-mile record ${id} not found`);
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { IsUUID, IsNumber, IsDateString, IsString, IsOptional, IsEnum } from 'class-validator';
|
||||||
|
import { PaymentMethod } from '../entities/fuel-purchase.entity';
|
||||||
|
|
||||||
|
export class CreateFuelPurchaseDto {
|
||||||
|
@IsUUID()
|
||||||
|
vehicleId!: string;
|
||||||
|
|
||||||
|
@IsDateString()
|
||||||
|
purchaseDate!: string;
|
||||||
|
|
||||||
|
@IsNumber()
|
||||||
|
liters!: number;
|
||||||
|
|
||||||
|
@IsNumber()
|
||||||
|
costPerLiter!: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
fuelStation?: string;
|
||||||
|
|
||||||
|
@IsEnum(PaymentMethod)
|
||||||
|
@IsOptional()
|
||||||
|
paymentMethod?: PaymentMethod;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
odometerReading?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
driverId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
receiptNumber?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { BaseEntity } from '@edr/api-common';
|
||||||
|
import { Entity, Column, ManyToOne, JoinColumn, Index } from 'typeorm';
|
||||||
|
import { Vehicle } from '../../vehicles/entities/vehicle.entity';
|
||||||
|
|
||||||
|
@Entity({ name: 'fuel_consumption', schema: 'freight' })
|
||||||
|
@Index(['vehicleId', 'month'])
|
||||||
|
export class FuelConsumption extends BaseEntity {
|
||||||
|
@Column({ name: 'vehicle_id', type: 'uuid' })
|
||||||
|
vehicleId!: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => Vehicle, { eager: false })
|
||||||
|
@JoinColumn({ name: 'vehicle_id' })
|
||||||
|
vehicle!: Vehicle;
|
||||||
|
|
||||||
|
@Column({ name: 'month', type: 'date' })
|
||||||
|
month!: Date;
|
||||||
|
|
||||||
|
@Column({ name: 'total_liters', type: 'numeric', precision: 10, scale: 2 })
|
||||||
|
totalLiters!: number;
|
||||||
|
|
||||||
|
@Column({ name: 'total_cost', type: 'numeric', precision: 14, scale: 2 })
|
||||||
|
totalCost!: number;
|
||||||
|
|
||||||
|
@Column({ name: 'total_distance_km', type: 'numeric', precision: 10, scale: 2, 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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
60
apps/edr-freight-api/src/modules/fuel/fuel.controller.ts
Normal file
60
apps/edr-freight-api/src/modules/fuel/fuel.controller.ts
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
15
apps/edr-freight-api/src/modules/fuel/fuel.module.ts
Normal file
15
apps/edr-freight-api/src/modules/fuel/fuel.module.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { FuelController } from './fuel.controller';
|
||||||
|
import { FuelService } from './fuel.service';
|
||||||
|
import { FuelRepository } from './fuel.repository';
|
||||||
|
import { FuelPurchase } from './entities/fuel-purchase.entity';
|
||||||
|
import { FuelConsumption } from './entities/fuel-consumption.entity';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [TypeOrmModule.forFeature([FuelPurchase, FuelConsumption])],
|
||||||
|
controllers: [FuelController],
|
||||||
|
providers: [FuelService, FuelRepository],
|
||||||
|
exports: [FuelService],
|
||||||
|
})
|
||||||
|
export class FuelModule {}
|
||||||
76
apps/edr-freight-api/src/modules/fuel/fuel.repository.ts
Normal file
76
apps/edr-freight-api/src/modules/fuel/fuel.repository.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { BaseRepository } from '@edr/api-common';
|
||||||
|
import { Repository, Between } from 'typeorm';
|
||||||
|
import { FuelPurchase } from './entities/fuel-purchase.entity';
|
||||||
|
import { FuelConsumption } from './entities/fuel-consumption.entity';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class FuelRepository extends BaseRepository<FuelPurchase> {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(FuelPurchase)
|
||||||
|
private readonly purchaseRepository: Repository<FuelPurchase>,
|
||||||
|
@InjectRepository(FuelConsumption)
|
||||||
|
private readonly consumptionRepository: Repository<FuelConsumption>,
|
||||||
|
) {
|
||||||
|
super(purchaseRepository);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByVehicleAndDateRange(
|
||||||
|
vehicleId: string,
|
||||||
|
startDate: Date,
|
||||||
|
endDate: Date,
|
||||||
|
): Promise<FuelPurchase[]> {
|
||||||
|
return this.purchaseRepository.find({
|
||||||
|
where: {
|
||||||
|
vehicleId,
|
||||||
|
purchaseDate: Between(startDate, endDate),
|
||||||
|
},
|
||||||
|
order: { purchaseDate: 'DESC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getMonthlyConsumption(
|
||||||
|
vehicleId: string,
|
||||||
|
month: Date,
|
||||||
|
): Promise<FuelConsumption | null> {
|
||||||
|
return this.consumptionRepository.findOne({
|
||||||
|
where: {
|
||||||
|
vehicleId,
|
||||||
|
month,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateMonthlyConsumption(
|
||||||
|
vehicleId: string,
|
||||||
|
month: Date,
|
||||||
|
data: Partial<FuelConsumption>,
|
||||||
|
): Promise<FuelConsumption> {
|
||||||
|
let consumption = await this.consumptionRepository.findOne({
|
||||||
|
where: {
|
||||||
|
vehicleId,
|
||||||
|
month,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!consumption) {
|
||||||
|
consumption = this.consumptionRepository.create({
|
||||||
|
vehicleId,
|
||||||
|
month,
|
||||||
|
...data,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
Object.assign(consumption, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.consumptionRepository.save(consumption);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findPurchasesByVehicle(vehicleId: string): Promise<FuelPurchase[]> {
|
||||||
|
return this.purchaseRepository.find({
|
||||||
|
where: { vehicleId },
|
||||||
|
order: { purchaseDate: 'DESC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
121
apps/edr-freight-api/src/modules/fuel/fuel.service.ts
Normal file
121
apps/edr-freight-api/src/modules/fuel/fuel.service.ts
Normal file
@@ -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<FuelPurchase>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async recordFuelPurchase(dto: CreateFuelPurchaseDto): Promise<FuelPurchase> {
|
||||||
|
const totalCost = dto.liters * dto.costPerLiter;
|
||||||
|
|
||||||
|
const purchase = this.purchaseRepository.create({
|
||||||
|
...dto,
|
||||||
|
totalCost,
|
||||||
|
});
|
||||||
|
|
||||||
|
const saved = await this.purchaseRepository.save(purchase);
|
||||||
|
|
||||||
|
// Update monthly consumption
|
||||||
|
await this.updateMonthlyConsumption(dto.vehicleId, new Date(dto.purchaseDate));
|
||||||
|
|
||||||
|
return saved;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAllFuelPurchases(): Promise<FuelPurchase[]> {
|
||||||
|
return this.purchaseRepository
|
||||||
|
.createQueryBuilder('purchase')
|
||||||
|
.leftJoinAndSelect('purchase.vehicle', 'vehicle')
|
||||||
|
.orderBy('purchase.purchaseDate', 'DESC')
|
||||||
|
.getMany();
|
||||||
|
}
|
||||||
|
|
||||||
|
async getFuelPurchases(
|
||||||
|
vehicleId: string,
|
||||||
|
startDate: Date,
|
||||||
|
endDate: Date,
|
||||||
|
): Promise<FuelPurchase[]> {
|
||||||
|
return this.fuelRepository.findByVehicleAndDateRange(vehicleId, startDate, endDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getMonthlyConsumption(
|
||||||
|
vehicleId: string,
|
||||||
|
month: Date,
|
||||||
|
): Promise<FuelConsumption | null> {
|
||||||
|
return this.fuelRepository.getMonthlyConsumption(vehicleId, month);
|
||||||
|
}
|
||||||
|
|
||||||
|
async 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<void> {
|
||||||
|
const monthStart = new Date(date.getFullYear(), date.getMonth(), 1);
|
||||||
|
const monthEnd = new Date(monthStart.getFullYear(), monthStart.getMonth() + 1, 1);
|
||||||
|
|
||||||
|
const purchases = await this.fuelRepository.findByVehicleAndDateRange(
|
||||||
|
vehicleId,
|
||||||
|
monthStart,
|
||||||
|
monthEnd,
|
||||||
|
);
|
||||||
|
|
||||||
|
const totalLiters = purchases.reduce((sum: number, p: FuelPurchase) => sum + Number(p.liters), 0);
|
||||||
|
const totalCost = purchases.reduce((sum: number, p: FuelPurchase) => sum + Number(p.totalCost), 0);
|
||||||
|
const numberOfPurchases = purchases.length;
|
||||||
|
const averageCostPerLiter = totalLiters > 0 ? totalCost / totalLiters : 0;
|
||||||
|
|
||||||
|
await this.fuelRepository.updateMonthlyConsumption(vehicleId, monthStart, {
|
||||||
|
totalLiters,
|
||||||
|
totalCost,
|
||||||
|
numberOfPurchases,
|
||||||
|
averageCostPerLiter,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
import { Transform } from 'class-transformer';
|
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';
|
import { LAST_MILE_STATUSES, LastMileStatus } from '../entities/last-mile.entity';
|
||||||
|
|
||||||
@@ -58,4 +58,9 @@ export class CreateLastMileDto {
|
|||||||
@Transform(({ value }) => (value === '' ? undefined : value))
|
@Transform(({ value }) => (value === '' ? undefined : value))
|
||||||
@IsUUID()
|
@IsUUID()
|
||||||
vehicleId?: string | null;
|
vehicleId?: string | null;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Invoice payment status', default: false })
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
paid?: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,9 @@ export class LastMile extends BaseEntity {
|
|||||||
@Column({ name: 'remaining_payment', type: 'numeric', precision: 14, scale: 2, default: 0 })
|
@Column({ name: 'remaining_payment', type: 'numeric', precision: 14, scale: 2, default: 0 })
|
||||||
remainingPayment!: number;
|
remainingPayment!: number;
|
||||||
|
|
||||||
|
@Column({ name: 'paid', type: 'boolean', default: false })
|
||||||
|
paid!: boolean;
|
||||||
|
|
||||||
// TODO: uncomment after migration creates column
|
// TODO: uncomment after migration creates column
|
||||||
// @Column({ type: 'boolean', default: false })
|
// @Column({ type: 'boolean', default: false })
|
||||||
// isPostPaymentCompleted!: boolean;
|
// isPostPaymentCompleted!: boolean;
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ import { AllocateLastMileContainersDto } from './dto/allocate-containers.dto';
|
|||||||
import { LastMileStatus } from './entities/last-mile.entity';
|
import { LastMileStatus } from './entities/last-mile.entity';
|
||||||
import { LastMileService } from './last-mile.service';
|
import { LastMileService } from './last-mile.service';
|
||||||
import { LastMileInvoiceService } from './last-mile-invoice.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')
|
@ApiTags('last-mile')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@@ -30,6 +33,8 @@ export class LastMileController {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly lastMileService: LastMileService,
|
private readonly lastMileService: LastMileService,
|
||||||
private readonly lastMileInvoiceService: LastMileInvoiceService,
|
private readonly lastMileInvoiceService: LastMileInvoiceService,
|
||||||
|
private readonly billingService: BillingService,
|
||||||
|
private readonly bookingsService: BookingsService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@@ -80,9 +85,36 @@ export class LastMileController {
|
|||||||
async update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateLastMileDto) {
|
async update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateLastMileDto) {
|
||||||
const record = await this.lastMileService.update(id, dto);
|
const record = await this.lastMileService.update(id, dto);
|
||||||
// Auto-generate invoice if distance or payment was updated
|
// Auto-generate invoice if distance or payment was updated
|
||||||
if (dto.exactKm !== undefined || dto.remainingPayment !== undefined) {
|
const booking = await this.bookingsService.findById(record.bookingId);
|
||||||
await this.lastMileInvoiceService.ensureInvoiceFor(record);
|
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;
|
return record;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import { UpdateLastMileDto } from './dto/update-last-mile.dto';
|
|||||||
import { LastMile, LastMileStatus } from './entities/last-mile.entity';
|
import { LastMile, LastMileStatus } from './entities/last-mile.entity';
|
||||||
import { LastMileContainerAllocation } from './entities/last-mile-container-allocation.entity';
|
import { LastMileContainerAllocation } from './entities/last-mile-container-allocation.entity';
|
||||||
import { LastMileRepository } from './last-mile.repository';
|
import { LastMileRepository } from './last-mile.repository';
|
||||||
|
import { InvoiceEventPayload } from '../billing/billing.service';
|
||||||
|
import { OnEvent } from '@nestjs/event-emitter';
|
||||||
|
|
||||||
type LastMileListFilter = {
|
type LastMileListFilter = {
|
||||||
status?: LastMileStatus;
|
status?: LastMileStatus;
|
||||||
@@ -137,12 +139,26 @@ export class LastMileService {
|
|||||||
estimatedKm: dto.estimatedKm ?? null,
|
estimatedKm: dto.estimatedKm ?? null,
|
||||||
exactKm: dto.exactKm ?? null,
|
exactKm: dto.exactKm ?? null,
|
||||||
vehicleId: dto.vehicleId ?? null,
|
vehicleId: dto.vehicleId ?? null,
|
||||||
|
paid: (dto as any).paid ?? false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OnEvent("lastmile.invoice.paid")
|
||||||
|
async onBookingInvoicePaid(payload: InvoiceEventPayload): Promise<void> {
|
||||||
|
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<LastMile> {
|
async update(id: string, dto: UpdateLastMileDto): Promise<LastMile> {
|
||||||
const existing = await this.findById(id);
|
const existing = await this.findById(id);
|
||||||
|
|
||||||
|
const dtoAny = dto as any;
|
||||||
const updated = await this.lastMileRepository.update(id, {
|
const updated = await this.lastMileRepository.update(id, {
|
||||||
...(dto.bookingId !== undefined ? { bookingId: dto.bookingId } : {}),
|
...(dto.bookingId !== undefined ? { bookingId: dto.bookingId } : {}),
|
||||||
...(dto.status !== undefined ? { status: dto.status } : {}),
|
...(dto.status !== undefined ? { status: dto.status } : {}),
|
||||||
@@ -151,7 +167,8 @@ export class LastMileService {
|
|||||||
...(dto.estimatedKm !== undefined ? { estimatedKm: dto.estimatedKm } : {}),
|
...(dto.estimatedKm !== undefined ? { estimatedKm: dto.estimatedKm } : {}),
|
||||||
...(dto.exactKm !== undefined ? { exactKm: dto.exactKm } : {}),
|
...(dto.exactKm !== undefined ? { exactKm: dto.exactKm } : {}),
|
||||||
...(dto.vehicleId !== undefined ? { vehicleId: dto.vehicleId } : {}),
|
...(dto.vehicleId !== undefined ? { vehicleId: dto.vehicleId } : {}),
|
||||||
});
|
...(dtoAny.paid !== undefined ? { paid: dtoAny.paid } : {}),
|
||||||
|
} as any);
|
||||||
|
|
||||||
if (!updated) {
|
if (!updated) {
|
||||||
throw new NotFoundException(`Last-mile record ${id} not found`);
|
throw new NotFoundException(`Last-mile record ${id} not found`);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -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<MaintenanceSchedule> {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(MaintenanceSchedule)
|
||||||
|
private readonly scheduleRepository: Repository<MaintenanceSchedule>,
|
||||||
|
@InjectRepository(MaintenanceCost)
|
||||||
|
private readonly costRepository: Repository<MaintenanceCost>,
|
||||||
|
) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<MaintenanceSchedule>,
|
||||||
|
@InjectRepository(MaintenanceCost)
|
||||||
|
private readonly costRepository: Repository<MaintenanceCost>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async scheduleMaintenanceAsync(dto: CreateMaintenanceScheduleDto): Promise<MaintenanceSchedule> {
|
||||||
|
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<MaintenanceCost> {
|
||||||
|
const cost = this.costRepository.create({
|
||||||
|
...dto,
|
||||||
|
incurredDate: new Date(dto.incurredDate),
|
||||||
|
});
|
||||||
|
return this.costRepository.save(cost);
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateMaintenanceSchedule(
|
||||||
|
id: string,
|
||||||
|
dto: UpdateMaintenanceScheduleDto,
|
||||||
|
): Promise<MaintenanceSchedule> {
|
||||||
|
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<string, number> = {};
|
||||||
|
costs.forEach((c) => {
|
||||||
|
if (!grouped[c.costType]) grouped[c.costType] = 0;
|
||||||
|
grouped[c.costType] += Number(c.costAmount);
|
||||||
|
});
|
||||||
|
return grouped;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
FileText,
|
FileText,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
LayoutGrid,
|
LayoutGrid,
|
||||||
|
MapPin,
|
||||||
Network,
|
Network,
|
||||||
Package,
|
Package,
|
||||||
PackageCheck,
|
PackageCheck,
|
||||||
@@ -57,6 +58,12 @@ import FileUploadSettingsPage from "./pages/documents/FileUploadSettingsPage";
|
|||||||
import DropdownSettingsPage from "./pages/dropdown_settings/DropdownSettingsPage";
|
import DropdownSettingsPage from "./pages/dropdown_settings/DropdownSettingsPage";
|
||||||
import FleetResourcePage from "./pages/fleet/FleetResourcePage";
|
import FleetResourcePage from "./pages/fleet/FleetResourcePage";
|
||||||
import RoutesPage from "./pages/fleet/RoutesPage";
|
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 { getCategorySidebarChildren } from "./pages/ruleEngine/config/resources";
|
||||||
import RuleEngineLegacyRedirect from "./pages/ruleEngine/RuleEngineLegacyRedirect";
|
import RuleEngineLegacyRedirect from "./pages/ruleEngine/RuleEngineLegacyRedirect";
|
||||||
import RuleEngineResourcePage from "./pages/ruleEngine/RuleEngineResourcePage";
|
import RuleEngineResourcePage from "./pages/ruleEngine/RuleEngineResourcePage";
|
||||||
@@ -164,6 +171,12 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
|||||||
{
|
{
|
||||||
title: "Fleet Management",
|
title: "Fleet Management",
|
||||||
items: [
|
items: [
|
||||||
|
{
|
||||||
|
label: "Fleet Dashboard",
|
||||||
|
href: "/dashboard/fleet-dashboard",
|
||||||
|
icon: <LayoutDashboard />,
|
||||||
|
permission: FREIGHT_PERMS.fleet.view,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: "Routes",
|
label: "Routes",
|
||||||
href: "/dashboard/routes",
|
href: "/dashboard/routes",
|
||||||
@@ -200,6 +213,36 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
|||||||
icon: <Users />,
|
icon: <Users />,
|
||||||
permission: FREIGHT_PERMS.fleet.view,
|
permission: FREIGHT_PERMS.fleet.view,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: "Track Vehicles",
|
||||||
|
href: "/dashboard/tracking",
|
||||||
|
icon: <MapPin />,
|
||||||
|
permission: FREIGHT_PERMS.fleet.view,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Fuel Purchases",
|
||||||
|
href: "/dashboard/fuel-purchases",
|
||||||
|
icon: <Truck />,
|
||||||
|
permission: FREIGHT_PERMS.fleet.view,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Fuel Analytics",
|
||||||
|
href: "/dashboard/fuel-stats",
|
||||||
|
icon: <Truck />,
|
||||||
|
permission: FREIGHT_PERMS.fleet.view,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Maintenance",
|
||||||
|
href: "/dashboard/maintenance",
|
||||||
|
icon: <Truck />,
|
||||||
|
permission: FREIGHT_PERMS.fleet.view,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Financial Reports",
|
||||||
|
href: "/dashboard/financial-reports",
|
||||||
|
icon: <Wallet />,
|
||||||
|
permission: FREIGHT_PERMS.fleet.view,
|
||||||
|
},
|
||||||
// {
|
// {
|
||||||
// label: "Containers",
|
// label: "Containers",
|
||||||
// href: "/dashboard/containers",
|
// href: "/dashboard/containers",
|
||||||
@@ -725,6 +768,54 @@ const App = () => {
|
|||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="fuel-purchases"
|
||||||
|
element={
|
||||||
|
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||||
|
<FuelPurchasePage />
|
||||||
|
</RequirePermission>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="fuel-stats"
|
||||||
|
element={
|
||||||
|
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||||
|
<FuelStatsPage />
|
||||||
|
</RequirePermission>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="maintenance"
|
||||||
|
element={
|
||||||
|
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||||
|
<MaintenancePage />
|
||||||
|
</RequirePermission>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="financial-reports"
|
||||||
|
element={
|
||||||
|
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||||
|
<FinancialReportsPage />
|
||||||
|
</RequirePermission>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="fleet-dashboard"
|
||||||
|
element={
|
||||||
|
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||||
|
<FleetDashboard />
|
||||||
|
</RequirePermission>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="tracking"
|
||||||
|
element={
|
||||||
|
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||||
|
<TrackingPage />
|
||||||
|
</RequirePermission>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="locomotives"
|
path="locomotives"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -98,6 +98,12 @@ export const QUERY_KEYS = {
|
|||||||
list: (resource: FleetResourceSlug | string) => ["fleet", "list", resource] as const,
|
list: (resource: FleetResourceSlug | string) => ["fleet", "list", resource] as const,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
VEHICLES: {
|
||||||
|
ROOT: ["vehicles"] as const,
|
||||||
|
list: (filter?: Record<string, unknown>) => ["vehicles", "list", filter ?? {}] as const,
|
||||||
|
byId: (id: string) => ["vehicles", "detail", id] as const,
|
||||||
|
},
|
||||||
|
|
||||||
FIRST_MILE: {
|
FIRST_MILE: {
|
||||||
ROOT: ["first-mile"] as const,
|
ROOT: ["first-mile"] as const,
|
||||||
list: (filter?: Record<string, unknown>) => ["first-mile", "list", filter ?? {}] as const,
|
list: (filter?: Record<string, unknown>) => ["first-mile", "list", filter ?? {}] as const,
|
||||||
@@ -135,4 +141,24 @@ export const QUERY_KEYS = {
|
|||||||
customersTab: (range?: string) => ["overview", "customers", range ?? "30d"] as const,
|
customersTab: (range?: string) => ["overview", "customers", range ?? "30d"] as const,
|
||||||
staffTab: (range?: string) => ["overview", "staff", 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;
|
} as const;
|
||||||
|
|||||||
@@ -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<string, number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CombinedReport {
|
||||||
|
vehicleId: string;
|
||||||
|
fuelCost: number;
|
||||||
|
maintenanceCost: number;
|
||||||
|
totalOperatingCost: number;
|
||||||
|
fuelPercentage: number;
|
||||||
|
maintenancePercentage: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FinancialReportsPage() {
|
||||||
|
const [selectedVehicle, setSelectedVehicle] = useState<string | null>(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 }) => (
|
||||||
|
<Card withBorder>
|
||||||
|
<Card.Section p="md">
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
<Text fw={700} size="lg">
|
||||||
|
{value}
|
||||||
|
</Text>
|
||||||
|
</Card.Section>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container size="xl" py="xl" px="lg">
|
||||||
|
<Stack gap="md">
|
||||||
|
<Card>
|
||||||
|
<Card.Section p="md" withBorder>
|
||||||
|
<Text fw={500}>Fleet Financial Analysis</Text>
|
||||||
|
</Card.Section>
|
||||||
|
<Card.Section p="md">
|
||||||
|
<Group>
|
||||||
|
<Select
|
||||||
|
label="Vehicle"
|
||||||
|
placeholder="Select a vehicle"
|
||||||
|
data={vehicleOptions}
|
||||||
|
value={selectedVehicle}
|
||||||
|
onChange={setSelectedVehicle}
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label="Period"
|
||||||
|
data={[
|
||||||
|
{ label: 'Last 3 months', value: '3' },
|
||||||
|
{ label: 'Last 6 months', value: '6' },
|
||||||
|
{ label: 'Last 12 months', value: '12' },
|
||||||
|
]}
|
||||||
|
value={months}
|
||||||
|
onChange={v => setMonths(v || '12')}
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
</Card.Section>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{report && (
|
||||||
|
<>
|
||||||
|
<Grid>
|
||||||
|
<Grid.Col span={{ base: 12, sm: 6, md: 3 }}>
|
||||||
|
<StatCard label="Total Operating Cost" value={`$${report.totalOperatingCost.toFixed(2)}`} />
|
||||||
|
</Grid.Col>
|
||||||
|
<Grid.Col span={{ base: 12, sm: 6, md: 3 }}>
|
||||||
|
<StatCard label="Fuel Cost" value={`$${report.fuelCost.toFixed(2)}`} />
|
||||||
|
</Grid.Col>
|
||||||
|
<Grid.Col span={{ base: 12, sm: 6, md: 3 }}>
|
||||||
|
<StatCard label="Maintenance Cost" value={`$${report.maintenanceCost.toFixed(2)}`} />
|
||||||
|
</Grid.Col>
|
||||||
|
<Grid.Col span={{ base: 12, sm: 6, md: 3 }}>
|
||||||
|
<Card withBorder>
|
||||||
|
<Card.Section p="md">
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
Monthly Avg
|
||||||
|
</Text>
|
||||||
|
<Text fw={700} size="lg">
|
||||||
|
${(report.totalOperatingCost / parseInt(months)).toFixed(2)}
|
||||||
|
</Text>
|
||||||
|
</Card.Section>
|
||||||
|
</Card>
|
||||||
|
</Grid.Col>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid>
|
||||||
|
<Grid.Col span={{ base: 12, sm: 6 }}>
|
||||||
|
<Card>
|
||||||
|
<Card.Section p="md" withBorder>
|
||||||
|
<Text fw={500}>Cost Breakdown</Text>
|
||||||
|
</Card.Section>
|
||||||
|
<Card.Section p="md">
|
||||||
|
<Stack gap="lg">
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Stack gap={0}>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
Fuel
|
||||||
|
</Text>
|
||||||
|
<Text fw={500}>{report.fuelPercentage}%</Text>
|
||||||
|
</Stack>
|
||||||
|
<RingProgress
|
||||||
|
sections={[{ value: report.fuelPercentage, color: 'edr-accent' }]}
|
||||||
|
label={
|
||||||
|
<Text size="xs" align="center">
|
||||||
|
{report.fuelPercentage}%
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
size={100}
|
||||||
|
thickness={4}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Stack gap={0}>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
Maintenance
|
||||||
|
</Text>
|
||||||
|
<Text fw={500}>{report.maintenancePercentage}%</Text>
|
||||||
|
</Stack>
|
||||||
|
<RingProgress
|
||||||
|
sections={[{ value: report.maintenancePercentage, color: 'edr-red' }]}
|
||||||
|
label={
|
||||||
|
<Text size="xs" align="center">
|
||||||
|
{report.maintenancePercentage}%
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
size={100}
|
||||||
|
thickness={4}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Card.Section>
|
||||||
|
</Card>
|
||||||
|
</Grid.Col>
|
||||||
|
|
||||||
|
<Grid.Col span={{ base: 12, sm: 6 }}>
|
||||||
|
<Card>
|
||||||
|
<Card.Section p="md" withBorder>
|
||||||
|
<Text fw={500}>Operational Insights</Text>
|
||||||
|
</Card.Section>
|
||||||
|
<Card.Section p="md">
|
||||||
|
<Stack gap="sm">
|
||||||
|
<div>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
Fuel Purchases
|
||||||
|
</Text>
|
||||||
|
<Text fw={500}>{fuelStats?.totalPurchases || 0} transactions</Text>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
Fuel Efficiency
|
||||||
|
</Text>
|
||||||
|
<Text fw={500}>
|
||||||
|
{fuelStats?.fuelEfficiency?.toFixed(2) || 'N/A'} km/L
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
Maintenance Items
|
||||||
|
</Text>
|
||||||
|
<Text fw={500}>{maintenanceStats?.numberOfMaintenanceItems || 0} records</Text>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
Avg Maintenance Cost
|
||||||
|
</Text>
|
||||||
|
<Text fw={500}>${maintenanceStats?.averageCostPerMaintenance?.toFixed(2) || '0.00'}</Text>
|
||||||
|
</div>
|
||||||
|
</Stack>
|
||||||
|
</Card.Section>
|
||||||
|
</Card>
|
||||||
|
</Grid.Col>
|
||||||
|
</Grid>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!selectedVehicle && (
|
||||||
|
<Card>
|
||||||
|
<Card.Section p="md">
|
||||||
|
<Text c="dimmed">Select a vehicle to view financial reports</Text>
|
||||||
|
</Card.Section>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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) => (
|
||||||
|
<Card withBorder p="lg" style={{ borderTop: `3px solid ${freightBrand.primary}` }}>
|
||||||
|
<Group justify="space-between" mb="sm">
|
||||||
|
<ThemeIcon size="xl" radius="md" color={color} variant="light">
|
||||||
|
<Icon size={28} />
|
||||||
|
</ThemeIcon>
|
||||||
|
</Group>
|
||||||
|
<Stack gap="xs">
|
||||||
|
<Text size="xs" c="dimmed" fw={500} tt="uppercase">
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Text fw={700} size="xl" c="edr-ink">
|
||||||
|
{value}
|
||||||
|
</Text>
|
||||||
|
{change && <Badge color={change > 0 ? 'edr-green' : 'edr-red'} size="lg">{change > 0 ? '+' : ''}{change}%</Badge>}
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Container size="xl" py="xl" px="lg">
|
||||||
|
<Breadcrumbs items={[{ label: 'Fleet' }, { label: 'Dashboard' }]} />
|
||||||
|
|
||||||
|
<Box mb="xl">
|
||||||
|
<Title order={1} mb="xs">
|
||||||
|
Fleet Management Dashboard
|
||||||
|
</Title>
|
||||||
|
<Text c="dimmed" size="sm">
|
||||||
|
Real-time fleet overview, vehicle & driver management
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Primary Metrics */}
|
||||||
|
<Grid mb="xl">
|
||||||
|
<Grid.Col span={{ base: 12, sm: 6, md: 3 }}>
|
||||||
|
<StatCard icon={Truck} label="Total Vehicles" value={metrics.totalVehicles} color="edr-green" />
|
||||||
|
</Grid.Col>
|
||||||
|
<Grid.Col span={{ base: 12, sm: 6, md: 3 }}>
|
||||||
|
<StatCard icon={Users} label="Total Drivers" value={metrics.totalDrivers} color="edr-blue" />
|
||||||
|
</Grid.Col>
|
||||||
|
<Grid.Col span={{ base: 12, sm: 6, md: 3 }}>
|
||||||
|
<StatCard icon={Fuel} label="Fuel Spend" value={`$${metrics.totalFuelSpend.toFixed(0)}`} color="edr-accent" />
|
||||||
|
</Grid.Col>
|
||||||
|
<Grid.Col span={{ base: 12, sm: 6, md: 3 }}>
|
||||||
|
<StatCard icon={Wrench} label="Maintenance" value={`$${metrics.totalMaintenanceSpend.toFixed(0)}`} color="edr-red" />
|
||||||
|
</Grid.Col>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{/* Fleet Status */}
|
||||||
|
<Grid mb="lg">
|
||||||
|
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||||
|
<Card withBorder>
|
||||||
|
<Card.Section p="md" withBorder>
|
||||||
|
<Text fw={500}>Fleet Status</Text>
|
||||||
|
</Card.Section>
|
||||||
|
<Card.Section p="md">
|
||||||
|
<Stack gap="lg">
|
||||||
|
<div>
|
||||||
|
<Group justify="space-between" mb="xs">
|
||||||
|
<Text size="sm">Active Vehicles</Text>
|
||||||
|
<Text fw={700}>{metrics.activeVehicles} / {metrics.totalVehicles}</Text>
|
||||||
|
</Group>
|
||||||
|
<Progress value={(metrics.activeVehicles / metrics.totalVehicles) * 100} color="edr-green" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Group justify="space-between" mb="xs">
|
||||||
|
<Text size="sm">Maintenance Overdue</Text>
|
||||||
|
<Badge color="edr-red">{metrics.maintenanceOverdue}</Badge>
|
||||||
|
</Group>
|
||||||
|
<Progress value={0} color="edr-red" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Group justify="space-between" mb="xs">
|
||||||
|
<Text size="sm">Idle / Under Maintenance</Text>
|
||||||
|
<Text fw={700}>{metrics.totalVehicles - metrics.activeVehicles}</Text>
|
||||||
|
</Group>
|
||||||
|
<Progress value={((metrics.totalVehicles - metrics.activeVehicles) / metrics.totalVehicles) * 100} color="edr-amber-soft" />
|
||||||
|
</div>
|
||||||
|
</Stack>
|
||||||
|
</Card.Section>
|
||||||
|
</Card>
|
||||||
|
</Grid.Col>
|
||||||
|
|
||||||
|
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||||
|
<Card withBorder>
|
||||||
|
<Card.Section p="md" withBorder>
|
||||||
|
<Text fw={500}>Operating Cost Breakdown</Text>
|
||||||
|
</Card.Section>
|
||||||
|
<Card.Section p="md">
|
||||||
|
<Stack gap="lg">
|
||||||
|
<Group justify="center">
|
||||||
|
<RingProgress
|
||||||
|
sections={[
|
||||||
|
{ value: fuelPercent, color: 'edr-accent' },
|
||||||
|
{ value: 100 - fuelPercent, color: 'edr-red' },
|
||||||
|
]}
|
||||||
|
label={
|
||||||
|
<div style={{ textAlign: 'center' }}>
|
||||||
|
<Text fw={700} size="sm">
|
||||||
|
${operatingCost.toFixed(0)}
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
Total Cost
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
size={120}
|
||||||
|
thickness={4}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Group gap="xs">
|
||||||
|
<ThemeIcon size="sm" color="edr-accent" variant="light">
|
||||||
|
<Fuel size={14} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Text size="sm">Fuel</Text>
|
||||||
|
</Group>
|
||||||
|
<Text fw={700}>{fuelPercent}%</Text>
|
||||||
|
</Group>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Group gap="xs">
|
||||||
|
<ThemeIcon size="sm" color="edr-red" variant="light">
|
||||||
|
<Wrench size={14} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Text size="sm">Maintenance</Text>
|
||||||
|
</Group>
|
||||||
|
<Text fw={700}>{100 - fuelPercent}%</Text>
|
||||||
|
</Group>
|
||||||
|
</div>
|
||||||
|
</Stack>
|
||||||
|
</Card.Section>
|
||||||
|
</Card>
|
||||||
|
</Grid.Col>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{/* Vehicles & Drivers Tabs */}
|
||||||
|
<Card withBorder>
|
||||||
|
<Tabs defaultValue="vehicles" p="md">
|
||||||
|
<Tabs.List>
|
||||||
|
<Tabs.Tab value="vehicles" leftSection={<Truck size={16} />}>
|
||||||
|
Vehicles ({(vehicles as Vehicle[]).length})
|
||||||
|
</Tabs.Tab>
|
||||||
|
<Tabs.Tab value="drivers" leftSection={<Users size={16} />}>
|
||||||
|
Drivers ({(drivers as Driver[]).length})
|
||||||
|
</Tabs.Tab>
|
||||||
|
</Tabs.List>
|
||||||
|
|
||||||
|
<Tabs.Panel value="vehicles" pt="md">
|
||||||
|
{(vehicles as Vehicle[]).length > 0 ? (
|
||||||
|
<Table striped highlightOnHover>
|
||||||
|
<Table.Thead>
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Th>Registration</Table.Th>
|
||||||
|
<Table.Th>Plate</Table.Th>
|
||||||
|
<Table.Th>Model</Table.Th>
|
||||||
|
<Table.Th>Status</Table.Th>
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Thead>
|
||||||
|
<Table.Tbody>
|
||||||
|
{(vehicles as Vehicle[]).slice(0, 15).map(v => (
|
||||||
|
<Table.Tr key={v.id}>
|
||||||
|
<Table.Td fw={500}>{v.registrationNumber}</Table.Td>
|
||||||
|
<Table.Td>{v.plateNumber}</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
{v.manufacturer} {v.model}
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Badge color={v.status === 'ACTIVE' ? 'edr-green' : v.status === 'MAINTENANCE' ? 'edr-amber-soft' : 'gray'}>
|
||||||
|
{v.status || 'UNKNOWN'}
|
||||||
|
</Badge>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
))}
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
) : (
|
||||||
|
<Group gap="xs" p="md">
|
||||||
|
<AlertCircle size={20} />
|
||||||
|
<Text c="dimmed">No vehicles in fleet</Text>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
</Tabs.Panel>
|
||||||
|
|
||||||
|
<Tabs.Panel value="drivers" pt="md">
|
||||||
|
{(drivers as Driver[]).length > 0 ? (
|
||||||
|
<Table striped highlightOnHover>
|
||||||
|
<Table.Thead>
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Th>Name</Table.Th>
|
||||||
|
<Table.Th>License</Table.Th>
|
||||||
|
<Table.Th>Contact</Table.Th>
|
||||||
|
<Table.Th>Assigned Vehicle</Table.Th>
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Thead>
|
||||||
|
<Table.Tbody>
|
||||||
|
{(drivers as Driver[]).slice(0, 15).map(d => (
|
||||||
|
<Table.Tr key={d.id}>
|
||||||
|
<Table.Td fw={500}>
|
||||||
|
<Group gap="xs">
|
||||||
|
<ThemeIcon size="sm" radius="xl" variant="light" color="blue">
|
||||||
|
<User size={14} />
|
||||||
|
</ThemeIcon>
|
||||||
|
{d.firstName} {d.lastName}
|
||||||
|
</Group>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>{d.licenseNumber || 'N/A'}</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Stack gap={0} size="xs">
|
||||||
|
{d.phone && (
|
||||||
|
<Text size="xs">
|
||||||
|
<Group gap={4} inline>
|
||||||
|
<MapPin size={12} /> {d.phone}
|
||||||
|
</Group>
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{d.email && <Text size="xs">{d.email}</Text>}
|
||||||
|
</Stack>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
{d.assignedVehicle ? (
|
||||||
|
<Badge color="edr-green">Assigned</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge color="edr-slate">Unassigned</Badge>
|
||||||
|
)}
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
))}
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
) : (
|
||||||
|
<Group gap="xs" p="md">
|
||||||
|
<AlertCircle size={20} />
|
||||||
|
<Text c="dimmed">No drivers in system</Text>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
</Tabs.Panel>
|
||||||
|
</Tabs>
|
||||||
|
</Card>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<Container size="xl" py="xl" px="lg">
|
||||||
|
<Breadcrumbs items={[{ label: "Fleet" }, { label: "Fuel Management" }, { label: "Record Purchase" }]} />
|
||||||
|
|
||||||
|
<Group justify="space-between" mb="lg">
|
||||||
|
<Title order={1}>Fuel Purchases</Title>
|
||||||
|
<Button leftSection={<Plus size={16} />} onClick={() => setModalOpen(true)} color="edr-green">
|
||||||
|
Record Purchase
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{/* Stats Cards */}
|
||||||
|
<Grid mb="lg">
|
||||||
|
<Grid.Col span={{ base: 12, sm: 6, md: 3 }}>
|
||||||
|
<Card withBorder padding="lg">
|
||||||
|
<Text size="sm" c="dimmed" fw={500}>
|
||||||
|
Total Purchases
|
||||||
|
</Text>
|
||||||
|
<Text fw={700} size="lg">
|
||||||
|
{purchasesData.length}
|
||||||
|
</Text>
|
||||||
|
</Card>
|
||||||
|
</Grid.Col>
|
||||||
|
<Grid.Col span={{ base: 12, sm: 6, md: 3 }}>
|
||||||
|
<Card withBorder padding="lg">
|
||||||
|
<Text size="sm" c="dimmed" fw={500}>
|
||||||
|
Total Liters
|
||||||
|
</Text>
|
||||||
|
<Text fw={700} size="lg">
|
||||||
|
{purchasesData
|
||||||
|
.reduce((sum: number, p: FuelPurchase) => sum + Number(p.liters), 0)
|
||||||
|
.toFixed(2)}{" "}
|
||||||
|
L
|
||||||
|
</Text>
|
||||||
|
</Card>
|
||||||
|
</Grid.Col>
|
||||||
|
<Grid.Col span={{ base: 12, sm: 6, md: 3 }}>
|
||||||
|
<Card withBorder padding="lg">
|
||||||
|
<Text size="sm" c="dimmed" fw={500}>
|
||||||
|
Total Cost
|
||||||
|
</Text>
|
||||||
|
<Text fw={700} size="lg">
|
||||||
|
ETB {purchasesData
|
||||||
|
.reduce((sum: number, p: FuelPurchase) => sum + Number(p.totalCost), 0)
|
||||||
|
.toLocaleString("en-US", { maximumFractionDigits: 2 })}
|
||||||
|
</Text>
|
||||||
|
</Card>
|
||||||
|
</Grid.Col>
|
||||||
|
<Grid.Col span={{ base: 12, sm: 6, md: 3 }}>
|
||||||
|
<Card withBorder padding="lg">
|
||||||
|
<Text size="sm" c="dimmed" fw={500}>
|
||||||
|
Avg Price/L
|
||||||
|
</Text>
|
||||||
|
<Text fw={700} size="lg">
|
||||||
|
ETB{" "}
|
||||||
|
{(
|
||||||
|
purchasesData.reduce((sum: number, p: FuelPurchase) => sum + Number(p.totalCost), 0) /
|
||||||
|
purchasesData.reduce((sum: number, p: FuelPurchase) => sum + Number(p.liters), 0) || 0
|
||||||
|
).toFixed(2)}
|
||||||
|
</Text>
|
||||||
|
</Card>
|
||||||
|
</Grid.Col>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{/* Purchases Table */}
|
||||||
|
<Card withBorder>
|
||||||
|
<Table striped highlightOnHover>
|
||||||
|
<Table.Thead>
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Th>Vehicle</Table.Th>
|
||||||
|
<Table.Th>Date</Table.Th>
|
||||||
|
<Table.Th align="right">Liters</Table.Th>
|
||||||
|
<Table.Th align="right">Cost/L</Table.Th>
|
||||||
|
<Table.Th align="right">Total</Table.Th>
|
||||||
|
<Table.Th>Station</Table.Th>
|
||||||
|
<Table.Th>Payment</Table.Th>
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Thead>
|
||||||
|
<Table.Tbody>
|
||||||
|
{(purchasesData as FuelPurchase[])?.map((purchase) => (
|
||||||
|
<Table.Tr key={purchase.id}>
|
||||||
|
<Table.Td>{(purchase as any).vehicle?.registrationNumber || (purchase as any).vehicle?.plateNumber || purchase.vehicleId}</Table.Td>
|
||||||
|
<Table.Td>{new Date(purchase.purchaseDate).toLocaleDateString()}</Table.Td>
|
||||||
|
<Table.Td align="right">{Number(purchase.liters).toFixed(2)}</Table.Td>
|
||||||
|
<Table.Td align="right">ETB {Number(purchase.costPerLiter).toFixed(2)}</Table.Td>
|
||||||
|
<Table.Td align="right">ETB {Number(purchase.totalCost).toLocaleString("en-US", { maximumFractionDigits: 2 })}</Table.Td>
|
||||||
|
<Table.Td>{purchase.fuelStation || "—"}</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Badge size="sm">{purchase.paymentMethod}</Badge>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
))}
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Modal */}
|
||||||
|
<Modal opened={modalOpen} onClose={() => setModalOpen(false)} title="Record Fuel Purchase" size="lg">
|
||||||
|
<Stack gap="md">
|
||||||
|
<Select
|
||||||
|
label="Vehicle"
|
||||||
|
placeholder="Select vehicle"
|
||||||
|
data={vehicleOptions}
|
||||||
|
value={formData.vehicleId}
|
||||||
|
onChange={(val) => setFormData({ ...formData, vehicleId: val || "" })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
label="Purchase Date"
|
||||||
|
type="date"
|
||||||
|
value={formData.purchaseDate}
|
||||||
|
onChange={(e) => setFormData({ ...formData, purchaseDate: e.currentTarget.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<NumberInput
|
||||||
|
label="Liters"
|
||||||
|
placeholder="0.00"
|
||||||
|
value={formData.liters}
|
||||||
|
onChange={(val) => setFormData({ ...formData, liters: val as number })}
|
||||||
|
decimalScale={2}
|
||||||
|
min={0}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<NumberInput
|
||||||
|
label="Cost per Liter"
|
||||||
|
placeholder="0.00"
|
||||||
|
value={formData.costPerLiter}
|
||||||
|
onChange={(val) => setFormData({ ...formData, costPerLiter: val as number })}
|
||||||
|
decimalScale={2}
|
||||||
|
min={0}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Card withBorder bg="gray.0" padding="md">
|
||||||
|
<Text fw={600} size="lg">
|
||||||
|
Total Cost: ETB {totalCost.toFixed(2)}
|
||||||
|
</Text>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
label="Fuel Station"
|
||||||
|
placeholder="Station name"
|
||||||
|
value={formData.fuelStation}
|
||||||
|
onChange={(e) => setFormData({ ...formData, fuelStation: e.currentTarget.value })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
label="Payment Method"
|
||||||
|
data={["CASH", "CARD", "FUEL_CARD", "TRANSFER", "CHEQUE"]}
|
||||||
|
value={formData.paymentMethod}
|
||||||
|
onChange={(val) => setFormData({ ...formData, paymentMethod: val || "CASH" })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<NumberInput
|
||||||
|
label="Odometer Reading (KM)"
|
||||||
|
placeholder="Optional"
|
||||||
|
value={formData.odometerReading}
|
||||||
|
onChange={(val) => setFormData({ ...formData, odometerReading: val as number | undefined })}
|
||||||
|
decimalScale={0}
|
||||||
|
min={0}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
label="Receipt Number"
|
||||||
|
placeholder="Optional"
|
||||||
|
value={formData.receiptNumber}
|
||||||
|
onChange={(e) => setFormData({ ...formData, receiptNumber: e.currentTarget.value })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
label="Notes"
|
||||||
|
placeholder="Optional notes"
|
||||||
|
value={formData.notes}
|
||||||
|
onChange={(e) => setFormData({ ...formData, notes: e.currentTarget.value })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Group justify="flex-end">
|
||||||
|
<Button variant="light" onClick={() => setModalOpen(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => recordMutation.mutate(formData)}
|
||||||
|
loading={recordMutation.isPending}
|
||||||
|
disabled={!formData.vehicleId || formData.liters <= 0 || formData.costPerLiter <= 0}
|
||||||
|
>
|
||||||
|
Record Purchase
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<string>("");
|
||||||
|
const [monthsBack, setMonthsBack] = useState<string>("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 (
|
||||||
|
<Container size="xl" py="xl" px="lg">
|
||||||
|
<Breadcrumbs items={[{ label: "Fleet" }, { label: "Fuel Management" }, { label: "Statistics" }]} />
|
||||||
|
|
||||||
|
<Group justify="space-between" mb="lg">
|
||||||
|
<Title order={1}>Fuel Consumption Analysis</Title>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<Card withBorder mb="lg" padding="md">
|
||||||
|
<Grid>
|
||||||
|
<Grid.Col span={{ base: 12, sm: 6 }}>
|
||||||
|
<Select
|
||||||
|
label="Vehicle"
|
||||||
|
placeholder="Select vehicle to analyze"
|
||||||
|
data={vehicleOptions}
|
||||||
|
value={selectedVehicleId}
|
||||||
|
onChange={(val) => setSelectedVehicleId(val || "")}
|
||||||
|
searchable
|
||||||
|
/>
|
||||||
|
</Grid.Col>
|
||||||
|
<Grid.Col span={{ base: 12, sm: 6 }}>
|
||||||
|
<Select
|
||||||
|
label="Period"
|
||||||
|
data={[
|
||||||
|
{ value: "3", label: "Last 3 months" },
|
||||||
|
{ value: "6", label: "Last 6 months" },
|
||||||
|
{ value: "12", label: "Last 12 months" },
|
||||||
|
]}
|
||||||
|
value={monthsBack}
|
||||||
|
onChange={(val) => setMonthsBack(val || "12")}
|
||||||
|
/>
|
||||||
|
</Grid.Col>
|
||||||
|
</Grid>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{selectedVehicleId && statsData ? (
|
||||||
|
<>
|
||||||
|
{/* Stats Cards */}
|
||||||
|
<Grid mb="lg">
|
||||||
|
<Grid.Col span={{ base: 12, sm: 6, md: 3 }}>
|
||||||
|
<Card withBorder padding="lg">
|
||||||
|
<Text size="sm" c="dimmed" fw={500}>
|
||||||
|
Total Purchases
|
||||||
|
</Text>
|
||||||
|
<Text fw={700} size="lg">
|
||||||
|
{statsData.totalPurchases}
|
||||||
|
</Text>
|
||||||
|
</Card>
|
||||||
|
</Grid.Col>
|
||||||
|
<Grid.Col span={{ base: 12, sm: 6, md: 3 }}>
|
||||||
|
<Card withBorder padding="lg">
|
||||||
|
<Text size="sm" c="dimmed" fw={500}>
|
||||||
|
Total Fuel
|
||||||
|
</Text>
|
||||||
|
<Text fw={700} size="lg">
|
||||||
|
{statsData.totalLiters.toFixed(2)} L
|
||||||
|
</Text>
|
||||||
|
</Card>
|
||||||
|
</Grid.Col>
|
||||||
|
<Grid.Col span={{ base: 12, sm: 6, md: 3 }}>
|
||||||
|
<Card withBorder padding="lg">
|
||||||
|
<Text size="sm" c="dimmed" fw={500}>
|
||||||
|
Total Cost
|
||||||
|
</Text>
|
||||||
|
<Text fw={700} size="lg">
|
||||||
|
ETB {statsData.totalCost.toLocaleString("en-US", { maximumFractionDigits: 2 })}
|
||||||
|
</Text>
|
||||||
|
</Card>
|
||||||
|
</Grid.Col>
|
||||||
|
<Grid.Col span={{ base: 12, sm: 6, md: 3 }}>
|
||||||
|
<Card withBorder padding="lg">
|
||||||
|
<Text size="sm" c="dimmed" fw={500}>
|
||||||
|
Avg Price/L
|
||||||
|
</Text>
|
||||||
|
<Text fw={700} size="lg">
|
||||||
|
ETB {statsData.averagePricePerLiter.toFixed(2)}
|
||||||
|
</Text>
|
||||||
|
</Card>
|
||||||
|
</Grid.Col>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{/* Efficiency Metrics */}
|
||||||
|
<Grid mb="lg">
|
||||||
|
<Grid.Col span={{ base: 12, sm: 6 }}>
|
||||||
|
<Card withBorder padding="lg">
|
||||||
|
<Text size="sm" c="dimmed" fw={500}>
|
||||||
|
Fuel Efficiency
|
||||||
|
</Text>
|
||||||
|
<Text fw={700} size="lg">
|
||||||
|
{efficiency} km/L
|
||||||
|
</Text>
|
||||||
|
</Card>
|
||||||
|
</Grid.Col>
|
||||||
|
<Grid.Col span={{ base: 12, sm: 6 }}>
|
||||||
|
<Card withBorder padding="lg">
|
||||||
|
<Text size="sm" c="dimmed" fw={500}>
|
||||||
|
Cost per KM
|
||||||
|
</Text>
|
||||||
|
<Text fw={700} size="lg">
|
||||||
|
ETB {costPerKm}
|
||||||
|
</Text>
|
||||||
|
</Card>
|
||||||
|
</Grid.Col>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{/* Summary */}
|
||||||
|
<Card withBorder padding="lg">
|
||||||
|
<Stack gap="md">
|
||||||
|
<div>
|
||||||
|
<Text fw={600} mb="xs">
|
||||||
|
Summary
|
||||||
|
</Text>
|
||||||
|
<Text size="sm">
|
||||||
|
{selectedVehicle?.plateNumber} consumed{" "}
|
||||||
|
<Text fw={700} span>
|
||||||
|
{statsData.totalLiters.toFixed(2)} liters
|
||||||
|
</Text>{" "}
|
||||||
|
over the last {monthsBack} months, costing{" "}
|
||||||
|
<Text fw={700} span>
|
||||||
|
ETB {statsData.totalCost.toLocaleString("en-US", { maximumFractionDigits: 2 })}
|
||||||
|
</Text>
|
||||||
|
. Average fuel price was{" "}
|
||||||
|
<Text fw={700} span>
|
||||||
|
ETB {statsData.averagePricePerLiter.toFixed(2)} per liter
|
||||||
|
</Text>
|
||||||
|
.
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Card withBorder padding="lg">
|
||||||
|
<Text c="dimmed" ta="center">
|
||||||
|
Select a vehicle to view fuel consumption statistics
|
||||||
|
</Text>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<string | null>(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<string, string> = {
|
||||||
|
SCHEDULED: 'edr-blue',
|
||||||
|
IN_PROGRESS: 'edr-amber-soft',
|
||||||
|
COMPLETED: 'edr-green',
|
||||||
|
OVERDUE: 'edr-red',
|
||||||
|
};
|
||||||
|
return colors[status] || 'edr-slate';
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container size="xl" py="xl" px="lg">
|
||||||
|
<Stack gap="md">
|
||||||
|
<Card>
|
||||||
|
<Card.Section p="md" withBorder>
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Text fw={500}>Schedule Maintenance</Text>
|
||||||
|
<Button onClick={() => setOpenScheduleModal(true)} color="edr-green" leftSection={<Plus size={16} />}>
|
||||||
|
New Schedule
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Card.Section>
|
||||||
|
<Card.Section p="md">
|
||||||
|
<Select
|
||||||
|
label="Select Vehicle"
|
||||||
|
placeholder="Pick a vehicle"
|
||||||
|
data={vehicleOptions}
|
||||||
|
value={selectedVehicle}
|
||||||
|
onChange={setSelectedVehicle}
|
||||||
|
/>
|
||||||
|
</Card.Section>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{selectedVehicle && (
|
||||||
|
<Card>
|
||||||
|
<Card.Section p="md" withBorder>
|
||||||
|
<Text fw={500}>Upcoming Maintenance</Text>
|
||||||
|
</Card.Section>
|
||||||
|
<Card.Section p="md">
|
||||||
|
{isLoading ? (
|
||||||
|
<Text>Loading...</Text>
|
||||||
|
) : (upcoming || []).length > 0 ? (
|
||||||
|
<Table>
|
||||||
|
<Table.Thead>
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Th>Type</Table.Th>
|
||||||
|
<Table.Th>Description</Table.Th>
|
||||||
|
<Table.Th>Scheduled</Table.Th>
|
||||||
|
<Table.Th>Est. Cost</Table.Th>
|
||||||
|
<Table.Th>Status</Table.Th>
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Thead>
|
||||||
|
<Table.Tbody>
|
||||||
|
{(upcoming as MaintenanceSchedule[]).map(m => (
|
||||||
|
<Table.Tr key={m.id}>
|
||||||
|
<Table.Td>{m.maintenanceType}</Table.Td>
|
||||||
|
<Table.Td>{m.description}</Table.Td>
|
||||||
|
<Table.Td>{new Date(m.scheduledDate).toLocaleDateString()}</Table.Td>
|
||||||
|
<Table.Td>${m.estimatedCost?.toFixed(2) || '—'}</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Badge color={statusColor(m.status)}>{m.status}</Badge>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
))}
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
) : (
|
||||||
|
<Text c="dimmed">No upcoming maintenance</Text>
|
||||||
|
)}
|
||||||
|
</Card.Section>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
opened={openScheduleModal}
|
||||||
|
onClose={() => setOpenScheduleModal(false)}
|
||||||
|
title="Schedule Maintenance"
|
||||||
|
size="md"
|
||||||
|
>
|
||||||
|
<Stack gap="md">
|
||||||
|
<Select
|
||||||
|
label="Type"
|
||||||
|
data={['PREVENTIVE', 'CORRECTIVE', 'INSPECTION', 'REPAIR']}
|
||||||
|
value={formData.maintenanceType}
|
||||||
|
onChange={v => setFormData({ ...formData, maintenanceType: v || 'PREVENTIVE' })}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
label="Description"
|
||||||
|
placeholder="What needs to be done?"
|
||||||
|
value={formData.description}
|
||||||
|
onChange={e => setFormData({ ...formData, description: e.currentTarget.value })}
|
||||||
|
/>
|
||||||
|
<DateInput
|
||||||
|
label="Scheduled Date"
|
||||||
|
value={formData.scheduledDate}
|
||||||
|
onChange={d => setFormData({ ...formData, scheduledDate: d || new Date() })}
|
||||||
|
/>
|
||||||
|
<NumberInput
|
||||||
|
label="Estimated Cost"
|
||||||
|
value={formData.estimatedCost}
|
||||||
|
onChange={v => setFormData({ ...formData, estimatedCost: Number(v) })}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
label="Service Provider"
|
||||||
|
placeholder="e.g., John's Auto Repair"
|
||||||
|
value={formData.serviceProvider}
|
||||||
|
onChange={e => setFormData({ ...formData, serviceProvider: e.currentTarget.value })}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
label="Notes"
|
||||||
|
placeholder="Additional notes"
|
||||||
|
value={formData.notes}
|
||||||
|
onChange={e => setFormData({ ...formData, notes: e.currentTarget.value })}
|
||||||
|
/>
|
||||||
|
<Group justify="flex-end">
|
||||||
|
<Button variant="light" onClick={() => setOpenScheduleModal(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => scheduleMutation.mutate()} loading={scheduleMutation.isPending}>
|
||||||
|
Schedule
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
</Stack>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
359
apps/edr-freight-web/backoffice/src/pages/fleet/TrackingPage.tsx
Normal file
359
apps/edr-freight-web/backoffice/src/pages/fleet/TrackingPage.tsx
Normal file
@@ -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<string | null>(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 (
|
||||||
|
<Container size="xl" py="xl" px="lg">
|
||||||
|
<Breadcrumbs items={[{ label: 'Fleet' }, { label: 'Vehicle Tracking' }]} />
|
||||||
|
|
||||||
|
<Stack gap="xl">
|
||||||
|
<Group justify="space-between">
|
||||||
|
<div>
|
||||||
|
<Text fw={700} size="xl">
|
||||||
|
Real-Time Vehicle Tracking
|
||||||
|
</Text>
|
||||||
|
<Text c="dimmed" size="sm">
|
||||||
|
Monitor vehicle locations, speed, and status
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Grid>
|
||||||
|
{/* Map Section */}
|
||||||
|
<Grid.Col span={{ base: 12, lg: 8 }}>
|
||||||
|
<Card withBorder p="lg">
|
||||||
|
<Card.Section p="md" withBorder>
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Text fw={500}>Map View</Text>
|
||||||
|
<Group gap="xs">
|
||||||
|
<Badge color="edr-green" leftSection={<Radio size={12} />}>
|
||||||
|
{trackableVehicles.length} Tracked
|
||||||
|
</Badge>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
</Card.Section>
|
||||||
|
|
||||||
|
<Card.Section p="md">
|
||||||
|
<Box
|
||||||
|
pos="relative"
|
||||||
|
style={{
|
||||||
|
width: mapWidth,
|
||||||
|
height: mapHeight,
|
||||||
|
backgroundColor: '#f0f8f7',
|
||||||
|
border: `2px solid ${freightBrand.primary}`,
|
||||||
|
borderRadius: '8px',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Grid background */}
|
||||||
|
<svg
|
||||||
|
width={mapWidth}
|
||||||
|
height={mapHeight}
|
||||||
|
style={{ position: 'absolute', top: 0, left: 0 }}
|
||||||
|
>
|
||||||
|
{/* Latitude lines */}
|
||||||
|
{[0, 1, 2, 3, 4, 5, 6].map(i => (
|
||||||
|
<line
|
||||||
|
key={`lat-${i}`}
|
||||||
|
x1={0}
|
||||||
|
y1={(i / 6) * mapHeight}
|
||||||
|
x2={mapWidth}
|
||||||
|
y2={(i / 6) * mapHeight}
|
||||||
|
stroke="#e0e0e0"
|
||||||
|
strokeWidth={1}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{/* Longitude lines */}
|
||||||
|
{[0, 1, 2, 3, 4, 5, 6].map(i => (
|
||||||
|
<line
|
||||||
|
key={`lng-${i}`}
|
||||||
|
x1={(i / 6) * mapWidth}
|
||||||
|
y1={0}
|
||||||
|
x2={(i / 6) * mapWidth}
|
||||||
|
y2={mapHeight}
|
||||||
|
stroke="#e0e0e0"
|
||||||
|
strokeWidth={1}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
{/* Vehicle markers */}
|
||||||
|
{trackableVehicles.map((vehicle) => {
|
||||||
|
const coords = getMapCoords(vehicle.gps.lat, vehicle.gps.lng);
|
||||||
|
const isSelected = vehicle.id === selectedVehicleId;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
key={vehicle.id}
|
||||||
|
pos="absolute"
|
||||||
|
style={{
|
||||||
|
left: coords.x - 15,
|
||||||
|
top: coords.y - 15,
|
||||||
|
width: 30,
|
||||||
|
height: 30,
|
||||||
|
cursor: 'pointer',
|
||||||
|
zIndex: isSelected ? 100 : 10,
|
||||||
|
}}
|
||||||
|
onClick={() => setSelectedVehicleId(vehicle.id)}
|
||||||
|
title={vehicle.registrationNumber}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
pos="absolute"
|
||||||
|
inset={0}
|
||||||
|
style={{
|
||||||
|
backgroundColor: isSelected ? freightBrand.primary : '#3498db',
|
||||||
|
borderRadius: '50%',
|
||||||
|
border: isSelected ? `3px solid ${freightBrand.primaryDark}` : 'none',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
color: 'white',
|
||||||
|
fontSize: '16px',
|
||||||
|
boxShadow: isSelected ? `0 0 0 8px ${freightBrand.ring}` : 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Navigation size={16} />
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Map labels */}
|
||||||
|
<Box pos="absolute" bottom={8} left={8} style={{ zIndex: 50 }}>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
📍 Addis Ababa, Ethiopia
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Card.Section>
|
||||||
|
</Card>
|
||||||
|
</Grid.Col>
|
||||||
|
|
||||||
|
{/* Sidebar */}
|
||||||
|
<Grid.Col span={{ base: 12, lg: 4 }}>
|
||||||
|
<Stack gap="md">
|
||||||
|
{/* Vehicle Selector */}
|
||||||
|
<Card withBorder p="lg">
|
||||||
|
<Stack gap="md">
|
||||||
|
<Select
|
||||||
|
label="Track Vehicle"
|
||||||
|
placeholder="Select a vehicle to track"
|
||||||
|
data={vehicleOptions}
|
||||||
|
value={selectedVehicleId}
|
||||||
|
onChange={setSelectedVehicleId}
|
||||||
|
searchable
|
||||||
|
/>
|
||||||
|
{selectedVehicle && (
|
||||||
|
<Box p="md" style={{ backgroundColor: freightBrand.mutedBg, borderRadius: '8px' }}>
|
||||||
|
<Stack gap="sm">
|
||||||
|
<div>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
Registration
|
||||||
|
</Text>
|
||||||
|
<Text fw={600}>{selectedVehicle.registrationNumber}</Text>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
Vehicle
|
||||||
|
</Text>
|
||||||
|
<Text fw={600}>
|
||||||
|
{selectedVehicle.manufacturer} {selectedVehicle.model}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
Status
|
||||||
|
</Text>
|
||||||
|
<Badge color={selectedVehicle.status === 'ACTIVE' ? 'edr-green' : 'gray'}>
|
||||||
|
{selectedVehicle.status || 'Unknown'}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* GPS Details */}
|
||||||
|
{selectedVehicle && (
|
||||||
|
<Card withBorder p="lg">
|
||||||
|
<Stack gap="md">
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Text fw={500}>GPS Location</Text>
|
||||||
|
<Badge color="edr-green" leftSection={<Activity size={12} />}>
|
||||||
|
Live
|
||||||
|
</Badge>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<SimpleGrid cols={2} spacing="sm">
|
||||||
|
<Box p="sm" style={{ backgroundColor: '#f8f9fa', borderRadius: '8px' }}>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
Latitude
|
||||||
|
</Text>
|
||||||
|
<Text fw={600} size="sm">
|
||||||
|
{selectedVehicle.gps.lat.toFixed(4)}°
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Box p="sm" style={{ backgroundColor: '#f8f9fa', borderRadius: '8px' }}>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
Longitude
|
||||||
|
</Text>
|
||||||
|
<Text fw={600} size="sm">
|
||||||
|
{selectedVehicle.gps.lng.toFixed(4)}°
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Box p="sm" style={{ backgroundColor: '#f8f9fa', borderRadius: '8px' }}>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
Speed
|
||||||
|
</Text>
|
||||||
|
<Text fw={600} size="sm">
|
||||||
|
{selectedVehicle.gps.speed} km/h
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Box p="sm" style={{ backgroundColor: '#f8f9fa', borderRadius: '8px' }}>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
Heading
|
||||||
|
</Text>
|
||||||
|
<Text fw={600} size="sm">
|
||||||
|
{selectedVehicle.gps.heading}°
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</SimpleGrid>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
Last Update
|
||||||
|
</Text>
|
||||||
|
<Text fw={500}>{selectedVehicle.gps.lastUpdate}</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button color="edr-green" fullWidth leftSection={<MapPin size={16} />}>
|
||||||
|
View Full History
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tracked Vehicles List */}
|
||||||
|
<Card withBorder p="lg">
|
||||||
|
<Stack gap="md">
|
||||||
|
<Text fw={500}>Tracked Vehicles ({trackableVehicles.length})</Text>
|
||||||
|
<div style={{ maxHeight: '300px', overflowY: 'auto' }}>
|
||||||
|
<Table size="sm">
|
||||||
|
<Table.Tbody>
|
||||||
|
{trackableVehicles.map(v => (
|
||||||
|
<Table.Tr
|
||||||
|
key={v.id}
|
||||||
|
style={{
|
||||||
|
cursor: 'pointer',
|
||||||
|
backgroundColor: v.id === selectedVehicleId ? freightBrand.mutedBg : 'transparent',
|
||||||
|
}}
|
||||||
|
onClick={() => setSelectedVehicleId(v.id)}
|
||||||
|
>
|
||||||
|
<Table.Td>
|
||||||
|
<Stack gap={0}>
|
||||||
|
<Text size="sm" fw={600}>
|
||||||
|
{v.registrationNumber}
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{v.gps.speed} km/h
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td align="right">
|
||||||
|
<Badge
|
||||||
|
color={v.status === 'ACTIVE' ? 'edr-green' : 'gray'}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
{v.status || 'N/A'}
|
||||||
|
</Badge>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
))}
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
</Stack>
|
||||||
|
</Grid.Col>
|
||||||
|
</Grid>
|
||||||
|
</Stack>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
16
apps/edr-freight-web/backoffice/src/pages/fleet/format.ts
Normal file
16
apps/edr-freight-web/backoffice/src/pages/fleet/format.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
/** Shared formatting helpers for the fleet-management pages. */
|
||||||
|
|
||||||
|
/** Format a number as Ethiopian Birr, e.g. 12345.6 → "ETB 12,346". */
|
||||||
|
export function formatETB(amount: number, fractionDigits = 0): string {
|
||||||
|
const value = Number.isFinite(amount) ? amount : 0;
|
||||||
|
return `ETB ${value.toLocaleString("en-US", {
|
||||||
|
minimumFractionDigits: fractionDigits,
|
||||||
|
maximumFractionDigits: fractionDigits,
|
||||||
|
})}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Safe percentage of `part` over `total`, rounded, 0 when total is 0. */
|
||||||
|
export function pct(part: number, total: number): number {
|
||||||
|
if (!total || !Number.isFinite(total) || !Number.isFinite(part)) return 0;
|
||||||
|
return Math.round((part / total) * 100);
|
||||||
|
}
|
||||||
@@ -441,10 +441,10 @@ const FirstMilePage = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const allocateMutation = useMutation({
|
const allocateMutation = useMutation({
|
||||||
mutationFn: (data) => apiClient.post(`/first-mile/${containerAllocationFirstMileId}/allocate-containers`, data),
|
mutationFn: (data) => api.post(`/first-mile/${containerAllocationFirstMileId}/allocate-containers`, data),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast({ title: "Containers allocated" });
|
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);
|
setContainerAllocationOpen(false);
|
||||||
setContainerAllocationFirstMileId(null);
|
setContainerAllocationFirstMileId(null);
|
||||||
},
|
},
|
||||||
@@ -771,9 +771,25 @@ const FirstMilePage = () => {
|
|||||||
meta: { headerClassName, cellClassName },
|
meta: { headerClassName, cellClassName },
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const hasDistance = row.original.exactKm != null && row.original.exactKm > 0;
|
const hasDistance = row.original.exactKm != null && row.original.exactKm > 0;
|
||||||
|
const isPaid = (row.original as any).paid;
|
||||||
if (!hasDistance) {
|
if (!hasDistance) {
|
||||||
return <Text c="dimmed">—</Text>;
|
return <Text c="dimmed">—</Text>;
|
||||||
}
|
}
|
||||||
|
if (isPaid) {
|
||||||
|
return (
|
||||||
|
<Group gap="xs" wrap="nowrap">
|
||||||
|
<UnstyledButton
|
||||||
|
onClick={() => openInvoice(row.original)}
|
||||||
|
c="blue"
|
||||||
|
fw={500}
|
||||||
|
style={{ textDecoration: "underline", cursor: "pointer" }}
|
||||||
|
>
|
||||||
|
#345
|
||||||
|
</UnstyledButton>
|
||||||
|
<Badge color="green" variant="light" size="sm">Paid</Badge>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<UnstyledButton
|
<UnstyledButton
|
||||||
onClick={() => openInvoice(row.original)}
|
onClick={() => openInvoice(row.original)}
|
||||||
|
|||||||
@@ -395,7 +395,7 @@ const LastMilePage = () => {
|
|||||||
api.post(`/last-mile/${activeId}/allocate-containers`, data),
|
api.post(`/last-mile/${activeId}/allocate-containers`, data),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast({ title: "Containers allocated", variant: "default" });
|
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();
|
closeAllocation();
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: () => {
|
||||||
@@ -751,9 +751,25 @@ const LastMilePage = () => {
|
|||||||
meta: { headerClassName, cellClassName },
|
meta: { headerClassName, cellClassName },
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const hasDistance = row.original.exactKm != null && row.original.exactKm > 0;
|
const hasDistance = row.original.exactKm != null && row.original.exactKm > 0;
|
||||||
|
const isPaid = (row.original as any).paid;
|
||||||
if (!hasDistance) {
|
if (!hasDistance) {
|
||||||
return <Text c="dimmed">—</Text>;
|
return <Text c="dimmed">—</Text>;
|
||||||
}
|
}
|
||||||
|
if (isPaid) {
|
||||||
|
return (
|
||||||
|
<Group gap="xs" wrap="nowrap">
|
||||||
|
<UnstyledButton
|
||||||
|
onClick={() => openInvoice(row.original)}
|
||||||
|
c="blue"
|
||||||
|
fw={500}
|
||||||
|
style={{ textDecoration: "underline", cursor: "pointer" }}
|
||||||
|
>
|
||||||
|
#345
|
||||||
|
</UnstyledButton>
|
||||||
|
<Badge color="green" variant="light" size="sm">Paid</Badge>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<UnstyledButton
|
<UnstyledButton
|
||||||
onClick={() => openInvoice(row.original)}
|
onClick={() => openInvoice(row.original)}
|
||||||
|
|||||||
@@ -149,6 +149,8 @@ export enum InvoiceSource {
|
|||||||
Booking = "booking",
|
Booking = "booking",
|
||||||
Warehouse = "warehouse",
|
Warehouse = "warehouse",
|
||||||
Demurrage = "demurrage",
|
Demurrage = "demurrage",
|
||||||
|
FirstMile = "firstmile",
|
||||||
|
LastMile = "lastmile"
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum SchedulingStatus {
|
export enum SchedulingStatus {
|
||||||
|
|||||||
Reference in New Issue
Block a user