diff --git a/apps/edr-freight-api/src/migrations/1748427600000-AddServiceTypesAndCargoTypes.ts b/apps/edr-freight-api/src/migrations/1748427600000-AddServiceTypesAndCargoTypes.ts index 65052bad4..08ce634eb 100644 --- a/apps/edr-freight-api/src/migrations/1748427600000-AddServiceTypesAndCargoTypes.ts +++ b/apps/edr-freight-api/src/migrations/1748427600000-AddServiceTypesAndCargoTypes.ts @@ -93,20 +93,25 @@ export class AddServiceTypesAndCargoTypes1748427600000 implements MigrationInter ); // Create indexes for service_types - await queryRunner.createIndex( - "freight.service_types", - new TableIndex({ - name: "IDX_SERVICE_TYPES_IS_ACTIVE", - columnNames: ["is_active"], - }), - ); - await queryRunner.createIndex( - "freight.service_types", - new TableIndex({ - name: "IDX_SERVICE_TYPES_DISPLAY_ORDER", - columnNames: ["display_order"], - }), - ); + const table = await queryRunner.getTable("freight.service_types"); + if (table && !table.indices.some((idx) => idx.name === "IDX_SERVICE_TYPES_IS_ACTIVE")) { + await queryRunner.createIndex( + "freight.service_types", + new TableIndex({ + name: "IDX_SERVICE_TYPES_IS_ACTIVE", + columnNames: ["is_active"], + }), + ); + } + if (table && !table.indices.some((idx) => idx.name === "IDX_SERVICE_TYPES_DISPLAY_ORDER")) { + await queryRunner.createIndex( + "freight.service_types", + new TableIndex({ + name: "IDX_SERVICE_TYPES_DISPLAY_ORDER", + columnNames: ["display_order"], + }), + ); + } // Create cargo_types table if (!(await queryRunner.hasTable("freight.cargo_types"))) await queryRunner.createTable( diff --git a/apps/edr-freight-api/src/migrations/1870000000000-AddLocationToVehicles.ts b/apps/edr-freight-api/src/migrations/1870000000000-AddLocationToVehicles.ts new file mode 100644 index 000000000..3434631a2 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1870000000000-AddLocationToVehicles.ts @@ -0,0 +1,22 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Add location_id column to vehicles table to track vehicle base location. + */ +export class AddLocationToVehicles1870000000000 implements MigrationInterface { + name = "AddLocationToVehicles1870000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.vehicles + ADD COLUMN IF NOT EXISTS location_id uuid; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.vehicles + DROP COLUMN IF EXISTS location_id; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1880000000000-AddVehicleStatuses.ts b/apps/edr-freight-api/src/migrations/1880000000000-AddVehicleStatuses.ts new file mode 100644 index 000000000..484a2686b --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1880000000000-AddVehicleStatuses.ts @@ -0,0 +1,29 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Add FREE and BUSY statuses to vehicle status enum. + */ +export class AddVehicleStatuses1880000000000 implements MigrationInterface { + name = "AddVehicleStatuses1880000000000"; + + public async up(queryRunner: QueryRunner): Promise { + // Create enum type if it doesn't exist + await queryRunner.query(` + DO $$ + BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'vehicles_status_enum' AND typnamespace = (SELECT oid FROM pg_namespace WHERE nspname = 'freight')) THEN + CREATE TYPE freight.vehicles_status_enum AS ENUM ('ACTIVE', 'FREE', 'BUSY', 'MAINTENANCE', 'RETIRED', 'OUT_OF_SERVICE'); + ELSE + -- Add values if enum already exists but doesn't have them + ALTER TYPE freight.vehicles_status_enum ADD VALUE IF NOT EXISTS 'FREE' BEFORE 'MAINTENANCE'; + ALTER TYPE freight.vehicles_status_enum ADD VALUE IF NOT EXISTS 'BUSY' AFTER 'FREE'; + END IF; + END $$; + `); + } + + public async down(_queryRunner: QueryRunner): Promise { + // Note: Postgres cannot drop individual enum values, so the down migration is a no-op + // The enum values FREE and BUSY will remain but will be unused after downgrade + } +} diff --git a/apps/edr-freight-api/src/migrations/1890000000000-SeparateVehicleAvailability.ts b/apps/edr-freight-api/src/migrations/1890000000000-SeparateVehicleAvailability.ts new file mode 100644 index 000000000..c0015c3c6 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1890000000000-SeparateVehicleAvailability.ts @@ -0,0 +1,40 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Split the mixed vehicle status into two fields: + * - status: operational state (ACTIVE, MAINTENANCE, RETIRED, OUT_OF_SERVICE) + * - availability: assignment state (FREE, BUSY) + * + * Existing FREE/BUSY statuses are moved to availability and the status is + * normalized back to ACTIVE. + */ +export class SeparateVehicleAvailability1890000000000 implements MigrationInterface { + name = "SeparateVehicleAvailability1890000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.vehicles + ADD COLUMN IF NOT EXISTS availability varchar DEFAULT 'FREE' + `); + await queryRunner.query(` + UPDATE freight.vehicles SET availability = 'BUSY' WHERE status = 'BUSY' + `); + await queryRunner.query(` + UPDATE freight.vehicles SET availability = 'FREE' WHERE availability IS NULL + `); + await queryRunner.query(` + UPDATE freight.vehicles SET status = 'ACTIVE' WHERE status IN ('FREE', 'BUSY') + `); + } + + public async down(queryRunner: QueryRunner): Promise { + // Fold availability back into status before dropping the column + await queryRunner.query(` + UPDATE freight.vehicles SET status = availability + WHERE status = 'ACTIVE' AND availability IN ('FREE', 'BUSY') + `); + await queryRunner.query(` + ALTER TABLE freight.vehicles DROP COLUMN IF EXISTS availability + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1890000000001-AddVehicleCodeAndPlates.ts b/apps/edr-freight-api/src/migrations/1890000000001-AddVehicleCodeAndPlates.ts new file mode 100644 index 000000000..6f9faa1f8 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1890000000001-AddVehicleCodeAndPlates.ts @@ -0,0 +1,28 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Add code, power_plate_no and trailer_plate_no columns to vehicles. + * These fields existed in the DTO and UI form but had no entity columns, + * so submitted values were silently dropped. + */ +export class AddVehicleCodeAndPlates1890000000001 implements MigrationInterface { + name = "AddVehicleCodeAndPlates1890000000001"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.vehicles + ADD COLUMN IF NOT EXISTS code varchar, + ADD COLUMN IF NOT EXISTS power_plate_no varchar, + ADD COLUMN IF NOT EXISTS trailer_plate_no varchar + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.vehicles + DROP COLUMN IF EXISTS code, + DROP COLUMN IF EXISTS power_plate_no, + DROP COLUMN IF EXISTS trailer_plate_no + `); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts index 2cc23b3fa..b54b6b49e 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts @@ -33,14 +33,14 @@ import { BookingContractSignature } from './entities/booking-contract-signature. import { BookingReviewNote } from './entities/booking-review-note.entity'; import { Booking } from './entities/booking.entity'; import { ContractPdfService } from '../../contracts/contract-pdf.service'; -import { ContractPricingScheduleBuilder } from '../../contracts/contract-pricing-schedule.builder'; -import { ContractRendererService } from '../../contracts/contract-renderer.service'; -import { ContractTemplateResolver } from '../../contracts/contract-template.resolver'; -import { ContractViewModelBuilder } from '../../contracts/contract-view-model.builder'; -import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.module'; import { ContractsModule } from '../contracts/contracts.module'; import { BookingContainerAllocation } from "./entities/booking-container-allocation.entity"; - +import { ContractPricingScheduleBuilder } from "../../contracts/contract-pricing-schedule.builder"; +import { ContractRendererService } from "../../contracts/contract-renderer.service"; +import { ContractTemplateResolver } from "../../contracts/contract-template.resolver"; +import { ContractViewModelBuilder } from "../../contracts/contract-view-model.builder"; +import { TrainSchedulingModule } from "../train-scheduling/train-scheduling.module"; +import { VehiclesModule } from "../vehicles/vehicles.module"; @Module({ imports: [ @@ -62,6 +62,7 @@ import { BookingContainerAllocation } from "./entities/booking-container-allocat forwardRef(() => ContractsModule), FilesModule, MinioModule, + VehiclesModule, CompaniesModule, // CustomersModule, RuleEngineModule, diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts index 80165cb7e..ac639a4bd 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -31,6 +31,8 @@ import { ServiceType } from '../rule-engine/entities/service-type.entity'; import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity'; import { BookingsRepository } from './bookings.repository'; import { ConsolidationService } from './consolidation.service'; +import { VehiclesService } from '../vehicles/vehicles.service'; +import { VehicleAvailability } from '../vehicles/entities/vehicle.entity'; import { assertFreightShape } from './booking-freight.util'; import { CreateBookingContainerDto, CreateBookingDto } from './dto/create-booking.dto'; import { mapStatusCountsToTabs } from './booking-list-tabs.config'; @@ -94,6 +96,7 @@ export class BookingsService { private readonly ruleEngineService: RuleEngineService, private readonly containerTypesService: ContainerTypesService, private readonly consolidationService: ConsolidationService, + private readonly vehiclesService: VehiclesService, private readonly contractPdfService: ContractPdfService, ) {} @@ -1520,6 +1523,16 @@ export class BookingsService { throw new NotFoundException(`Booking ${bookingId} not found`); } + const previousAllocations = await this.dataSource.manager.find(BookingContainerAllocation, { + where: { + bookingId, + containerId: In(allocations.map((a) => a.containerId)), + }, + }); + const previousVehicleIds = previousAllocations + .map((a) => a.vehicleId) + .filter((id): id is string => Boolean(id)); + await this.dataSource.transaction(async (manager) => { for (const allocation of allocations) { await manager.delete(BookingContainerAllocation, { @@ -1536,6 +1549,16 @@ export class BookingsService { } }); + const vehicleIds = new Set(allocations.map((a) => a.vehicleId)); + await Promise.all( + [...vehicleIds].map((vehicleId) => + this.vehiclesService.setAvailability(vehicleId, VehicleAvailability.BUSY), + ), + ); + await this.vehiclesService.releaseIfUnused( + previousVehicleIds.filter((id) => !vehicleIds.has(id)), + ); + return { success: true, allocated: allocations.length, diff --git a/apps/edr-freight-api/src/modules/drivers/drivers.service.ts b/apps/edr-freight-api/src/modules/drivers/drivers.service.ts index d5176d14b..2464a26ac 100644 --- a/apps/edr-freight-api/src/modules/drivers/drivers.service.ts +++ b/apps/edr-freight-api/src/modules/drivers/drivers.service.ts @@ -48,12 +48,10 @@ export class DriversService { const qb = this.driverRepo.createQueryBuilder('d'); if (query.search) { - const searchTerm = `%${query.search}%`; - qb.where('d.firstName ILIKE :search', { search: searchTerm }) - .orWhere('d.lastName ILIKE :search', { search: searchTerm }) - .orWhere('d.email ILIKE :search', { search: searchTerm }) - .orWhere('d.licenseNumber ILIKE :search', { search: searchTerm }) - .orWhere('d.phoneNumber ILIKE :search', { search: searchTerm }); + qb.where( + '(d.firstName ILIKE :search OR d.lastName ILIKE :search OR d.email ILIKE :search OR d.licenseNumber ILIKE :search OR d.phoneNumber ILIKE :search)', + { search: `%${query.search}%` }, + ); } if (query.status) { diff --git a/apps/edr-freight-api/src/modules/first-mile/first-mile.controller.ts b/apps/edr-freight-api/src/modules/first-mile/first-mile.controller.ts index 680bb5d09..d5f53ae0c 100644 --- a/apps/edr-freight-api/src/modules/first-mile/first-mile.controller.ts +++ b/apps/edr-freight-api/src/modules/first-mile/first-mile.controller.ts @@ -65,6 +65,12 @@ export class FirstMileController { return this.firstMileService.findById(id); } + @Get('acceptitem/:id') + @ApiOperation({ summary: 'Get a first-mile accep by ID' }) + acceptItem(@Param('id', ParseUUIDPipe) id: string) { + return this.firstMileService.acceptBooking(id); + } + @Post('accept/:reference') @TrainSchedulingManage() @ApiOperation({ summary: 'Accept a paid booking and create a first-mile leg' }) diff --git a/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts b/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts index d05964878..40d163220 100644 --- a/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts +++ b/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts @@ -1,8 +1,8 @@ -import { Injectable, Logger, NotFoundException } from "@nestjs/common"; -import { FindOptionsWhere } from "typeorm"; -import { InjectDataSource } from "@nestjs/typeorm"; -import { DataSource } from "typeorm"; - +import { Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { FindOptionsWhere, In } from 'typeorm'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { DataSource } from 'typeorm'; +import { VehicleAvailability } from '../vehicles/entities/vehicle.entity'; import { BookingsRepository } from "../bookings/bookings.repository"; import { DriversService } from "../drivers/drivers.service"; import { SmsClientService } from "../notifications/sms-client.service"; @@ -95,7 +95,6 @@ export class FirstMileService { if (booking.paymentStatus !== "PAID") { return null; } - if (!this.bookingRequestsFirstMile(booking)) { return null; } @@ -198,7 +197,7 @@ export class FirstMileService { return existing; } - return this.firstMileRepository.create({ + const record = await this.firstMileRepository.create({ bookingId: dto.bookingId, status: dto.status ?? "READY_TO_TRANSIT", advancedPayment: dto.advancedPayment ?? 0, @@ -208,6 +207,12 @@ export class FirstMileService { vehicleId: dto.vehicleId ?? null, paid: (dto as any).paid ?? false, }); + + if (dto.vehicleId) { + await this.vehiclesService.setAvailability(dto.vehicleId, VehicleAvailability.BUSY); + } + + return record; } private async findByBookingId(bookingId: string): Promise { @@ -233,12 +238,10 @@ export class FirstMileService { firstMilePickupAddress?: string | null; serviceType?: { includesFirstMile?: boolean | null } | null; }): boolean { - // Export bookings always need a first mile (pickup → origin yard); the - // pickup address is captured at assignment time, not required upfront. return Boolean( - booking.tradeDirection === "EXPORT" || - booking.firstMilePickupAddress?.trim() || - booking.serviceType?.includesFirstMile, + booking.tradeDirection === 'EXPORT' && + (booking.firstMilePickupAddress?.trim() || + booking.serviceType?.includesFirstMile), ); } @@ -267,28 +270,62 @@ export class FirstMileService { throw new NotFoundException(`First-mile record ${id} not found`); } + // Keep vehicle statuses in sync: new vehicle goes BUSY, replaced one goes back to FREE + if (dto.vehicleId !== undefined && dto.vehicleId !== existing.vehicleId) { + if (dto.vehicleId) { + await this.vehiclesService.setAvailability(dto.vehicleId, VehicleAvailability.BUSY); + } + if (existing.vehicleId) { + await this.vehiclesService.releaseIfUnused([existing.vehicleId]); + } + } + // Notify assigned driver on every explicit vehicle assignment or reassignment if (dto.vehicleId) { void this.notifyDriverAssignment(dto.vehicleId, existing); } + // Trip finished — release the vehicles it was holding + if (dto.status === 'RECEIVED_TO_PORT' && existing.status !== 'RECEIVED_TO_PORT') { + await this.releaseVehicles(updated); + } + return updated; } async updateStatus(id: string, status: FirstMileStatus): Promise { + const existing = await this.findById(id); const updated = await this.firstMileRepository.update(id, { status }); if (!updated) { throw new NotFoundException(`First-mile record ${id} not found`); } + if (status === 'RECEIVED_TO_PORT' && existing.status !== 'RECEIVED_TO_PORT') { + await this.releaseVehicles(updated); + } + return updated; } - private async notifyDriverAssignment( - vehicleId: string, - record: FirstMile, - ): Promise { + /** + * Free every vehicle held by this record (direct assignment + container + * allocations), unless still in use by another active trip. + */ + private async releaseVehicles(record: FirstMile): Promise { + const recordAllocations = await this.dataSource.manager.find(FirstMileContainerAllocation, { + where: { firstMileId: record.id }, + }); + const vehicleIds = recordAllocations + .map((a) => a.vehicleId) + .filter((id): id is string => Boolean(id)); + if (record.vehicleId) { + vehicleIds.push(record.vehicleId); + } + await this.vehiclesService.releaseIfUnused(vehicleIds); + } + + private async notifyDriverAssignment(vehicleId: string, record: FirstMile): Promise { try { const vehicle = await this.vehiclesService.findById(vehicleId); if (!vehicle.assignedDriverId) { @@ -359,6 +396,16 @@ export class FirstMileService { throw new NotFoundException(`First-mile record ${firstMileId} not found`); } + const previousAllocations = await this.dataSource.manager.find(FirstMileContainerAllocation, { + where: { + firstMileId, + containerId: In(allocations.map((a) => a.containerId)), + }, + }); + const previousVehicleIds = previousAllocations + .map((a) => a.vehicleId) + .filter((id): id is string => Boolean(id)); + await this.dataSource.transaction(async (manager) => { for (const allocation of allocations) { await manager.delete(FirstMileContainerAllocation, { @@ -375,6 +422,14 @@ export class FirstMileService { } }); + const vehicleIds = new Set(allocations.map((a) => a.vehicleId)); + await Promise.all( + [...vehicleIds].map((vehicleId) => this.vehiclesService.setAvailability(vehicleId, VehicleAvailability.BUSY)), + ); + await this.vehiclesService.releaseIfUnused( + previousVehicleIds.filter((id) => !vehicleIds.has(id)), + ); + return { success: true, allocated: allocations.length, diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts index df439f28f..69eec29ae 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts @@ -71,6 +71,7 @@ export class LastMileService { return null; } + return this.create({ bookingId: booking.id, advancedPayment: 0, @@ -131,12 +132,6 @@ export class LastMileService { } async create(dto: CreateLastMileDto): Promise { - const [existing] = await this.lastMileRepository.findAll({ - where: { bookingId: dto.bookingId }, - take: 1, - }); - if (existing) return existing; - return this.lastMileRepository.create({ bookingId: dto.bookingId, status: dto.status ?? 'READY_TO_TRANSIT', diff --git a/apps/edr-freight-api/src/modules/vehicles/dto/create-vehicle.dto.ts b/apps/edr-freight-api/src/modules/vehicles/dto/create-vehicle.dto.ts index b5f9abcb8..9158ecca0 100644 --- a/apps/edr-freight-api/src/modules/vehicles/dto/create-vehicle.dto.ts +++ b/apps/edr-freight-api/src/modules/vehicles/dto/create-vehicle.dto.ts @@ -1,5 +1,5 @@ import { IsString, IsEnum, IsNumber, IsOptional, IsUUID } from 'class-validator'; -import { VehicleType, FuelType, VehicleStatus } from '../entities/vehicle.entity'; +import { VehicleType, FuelType, VehicleStatus, VehicleAvailability } from '../entities/vehicle.entity'; export class CreateVehicleDto { @IsString() @@ -26,6 +26,10 @@ export class CreateVehicleDto { @IsEnum(VehicleStatus) status!: VehicleStatus; + @IsOptional() + @IsEnum(VehicleAvailability) + availability?: VehicleAvailability; + @IsOptional() @IsString() description?: string; @@ -57,4 +61,8 @@ export class CreateVehicleDto { @IsOptional() @IsNumber() actualDistanceKm?: number; + + @IsOptional() + @IsUUID() + locationId?: string; } diff --git a/apps/edr-freight-api/src/modules/vehicles/entities/vehicle.entity.ts b/apps/edr-freight-api/src/modules/vehicles/entities/vehicle.entity.ts index fef078ef8..416cddee6 100644 --- a/apps/edr-freight-api/src/modules/vehicles/entities/vehicle.entity.ts +++ b/apps/edr-freight-api/src/modules/vehicles/entities/vehicle.entity.ts @@ -25,11 +25,25 @@ export enum VehicleStatus { OUT_OF_SERVICE = 'OUT_OF_SERVICE', } +export enum VehicleAvailability { + FREE = 'FREE', + BUSY = 'BUSY', +} + @Entity({ name: 'vehicles', schema: 'freight' }) export class Vehicle extends BaseEntity { + @Column({ nullable: true }) + code?: string; + @Column({ name: 'plate_number', unique: true, nullable: true }) plateNumber?: string; + @Column({ name: 'power_plate_no', nullable: true }) + powerPlateNo?: string; + + @Column({ name: 'trailer_plate_no', nullable: true }) + trailerPlateNo?: string; + @Column({ name: 'registration_number', unique: true, nullable: true }) registrationNumber?: string; @@ -54,6 +68,9 @@ export class Vehicle extends BaseEntity { @Column({ name: 'status', type: 'varchar', default: VehicleStatus.ACTIVE, nullable: true }) status?: VehicleStatus; + @Column({ name: 'availability', type: 'varchar', default: VehicleAvailability.FREE, nullable: true }) + availability?: VehicleAvailability; + @Column({ type: 'text', nullable: true }) description?: string | null; @@ -68,4 +85,7 @@ export class Vehicle extends BaseEntity { @Column({ name: 'actual_distance_km', type: 'numeric', nullable: true }) actualDistanceKm?: number; + + @Column({ name: 'location_id', type: 'uuid', nullable: true }) + locationId?: string; } diff --git a/apps/edr-freight-api/src/modules/vehicles/vehicles.controller.ts b/apps/edr-freight-api/src/modules/vehicles/vehicles.controller.ts index 24ff2d022..8e6d8a0a8 100644 --- a/apps/edr-freight-api/src/modules/vehicles/vehicles.controller.ts +++ b/apps/edr-freight-api/src/modules/vehicles/vehicles.controller.ts @@ -34,6 +34,7 @@ export class VehiclesController { findAll( @Query('search') search?: string, @Query('status') status?: string, + @Query('availability') availability?: string, @Query('page') page?: string, @Query('limit') limit?: string, @Query('sortBy') sortBy?: string, @@ -42,6 +43,7 @@ export class VehiclesController { return this.vehiclesService.findAll({ search, status: status as any, + availability: availability as any, page: page ? parseInt(page) : undefined, limit: limit ? parseInt(limit) : undefined, sortBy, diff --git a/apps/edr-freight-api/src/modules/vehicles/vehicles.service.ts b/apps/edr-freight-api/src/modules/vehicles/vehicles.service.ts index 2bc882591..69568e483 100644 --- a/apps/edr-freight-api/src/modules/vehicles/vehicles.service.ts +++ b/apps/edr-freight-api/src/modules/vehicles/vehicles.service.ts @@ -1,9 +1,14 @@ import { Injectable, NotFoundException, ConflictException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { Not, Repository } from 'typeorm'; import { CreateVehicleDto } from './dto/create-vehicle.dto'; import { UpdateVehicleDto } from './dto/update-vehicle.dto'; -import { Vehicle, VehicleStatus } from './entities/vehicle.entity'; +import { Vehicle, VehicleAvailability, VehicleStatus } from './entities/vehicle.entity'; +import { FirstMile, FirstMileStatus } from '../first-mile/entities/first-mile.entity'; +import { FirstMileContainerAllocation } from '../first-mile/entities/first-mile-container-allocation.entity'; +import { LastMile, LastMileStatus } from '../last-mile/entities/last-mile.entity'; +import { LastMileContainerAllocation } from '../last-mile/entities/last-mile-container-allocation.entity'; +import { BookingContainerAllocation } from '../bookings/entities/booking-container-allocation.entity'; @Injectable() export class VehiclesService { @@ -35,6 +40,7 @@ export class VehiclesService { async findAll(query: { search?: string; status?: VehicleStatus | string; + availability?: VehicleAvailability | string; page?: number; limit?: number; sortBy?: string; @@ -44,7 +50,7 @@ export class VehiclesService { if (query.search) { qb = qb.where( - 'v.plateNumber ILIKE :search OR v.manufacturer ILIKE :search', + '(v.plateNumber ILIKE :search OR v.manufacturer ILIKE :search OR v.model ILIKE :search OR v.code ILIKE :search OR v.trailerPlateNo ILIKE :search)', { search: `%${query.search}%` }, ); } @@ -53,6 +59,10 @@ export class VehiclesService { qb = qb.andWhere('v.status = :status', { status: query.status }); } + if (query.availability) { + qb = qb.andWhere('v.availability = :availability', { availability: query.availability }); + } + const sortBy = ['plateNumber', 'status', 'year', 'createdAt'].includes( query.sortBy ?? '', ) @@ -91,6 +101,48 @@ export class VehiclesService { return this.vehicleRepo.save(vehicle); } + async setAvailability(id: string, availability: VehicleAvailability): Promise { + await this.vehicleRepo.update(id, { availability }); + } + + /** + * Set vehicles back to FREE, but only when no active (non-completed) + * first/last-mile record or container allocation still references them. + * First-mile trips ending in RECEIVED_TO_PORT and last-mile trips ending + * in DELIVERED no longer hold the vehicle. + */ + async releaseIfUnused(vehicleIds: string[]): Promise { + const manager = this.vehicleRepo.manager; + for (const vehicleId of [...new Set(vehicleIds)]) { + const [fmRecords, lmRecords, fmAllocations, lmAllocations, bookingAllocations] = await Promise.all([ + manager.count(FirstMile, { + where: { vehicleId, status: Not('RECEIVED_TO_PORT') }, + }), + manager.count(LastMile, { + where: { vehicleId, status: Not('DELIVERED') }, + }), + manager + .createQueryBuilder(FirstMileContainerAllocation, 'alloc') + .innerJoin(FirstMile, 'fm', 'fm.id = alloc.firstMileId') + .where('alloc.vehicleId = :vehicleId', { vehicleId }) + .andWhere('fm.status != :done', { done: 'RECEIVED_TO_PORT' }) + .andWhere('fm.deletedAt IS NULL') + .getCount(), + manager + .createQueryBuilder(LastMileContainerAllocation, 'alloc') + .innerJoin(LastMile, 'lm', 'lm.id = alloc.lastMileId') + .where('alloc.vehicleId = :vehicleId', { vehicleId }) + .andWhere('lm.status != :done', { done: 'DELIVERED' }) + .andWhere('lm.deletedAt IS NULL') + .getCount(), + manager.count(BookingContainerAllocation, { where: { vehicleId } }), + ]); + if (fmRecords + lmRecords + fmAllocations + lmAllocations + bookingAllocations === 0) { + await this.setAvailability(vehicleId, VehicleAvailability.FREE); + } + } + } + async remove(id: string): Promise { await this.findById(id); await this.vehicleRepo.softDelete(id); diff --git a/apps/edr-freight-web/backoffice/src/components/ContainerAllocationTable.tsx b/apps/edr-freight-web/backoffice/src/components/ContainerAllocationTable.tsx index 950cfd476..8971d4ccb 100644 --- a/apps/edr-freight-web/backoffice/src/components/ContainerAllocationTable.tsx +++ b/apps/edr-freight-web/backoffice/src/components/ContainerAllocationTable.tsx @@ -42,8 +42,8 @@ export function ContainerAllocationTable({ ); const { data: vehicles = [], isLoading: vehiclesLoading } = useQuery({ - queryKey: ["vehicles", "active"], - queryFn: () => vehiclesService.getAll({ status: "ACTIVE" }), + queryKey: ["vehicles", "free"], + queryFn: () => vehiclesService.getAll({ status: "ACTIVE", availability: "FREE" }), }); const vehicleOptions = useMemo( @@ -99,7 +99,7 @@ export function ContainerAllocationTable({ {vehicles.length === 0 && ( } color="yellow"> - No active vehicles available. Add vehicles before allocating containers. + No free vehicles available. Free up or add vehicles before allocating containers. )} diff --git a/apps/edr-freight-web/backoffice/src/components/FirstMileContainerAllocationTable.tsx b/apps/edr-freight-web/backoffice/src/components/FirstMileContainerAllocationTable.tsx index 85bba1dc4..78c5160ca 100644 --- a/apps/edr-freight-web/backoffice/src/components/FirstMileContainerAllocationTable.tsx +++ b/apps/edr-freight-web/backoffice/src/components/FirstMileContainerAllocationTable.tsx @@ -42,8 +42,8 @@ export function FirstMileContainerAllocationTable({ ); const { data: vehicles = [], isLoading: vehiclesLoading } = useQuery({ - queryKey: ["vehicles", "active"], - queryFn: () => vehiclesService.getAll({ status: "ACTIVE" }), + queryKey: ["vehicles", "free"], + queryFn: () => vehiclesService.getAll({ status: "ACTIVE", availability: "FREE" }), }); const vehicleOptions = useMemo( @@ -99,7 +99,7 @@ export function FirstMileContainerAllocationTable({ {vehicles.length === 0 && ( } color="yellow"> - No active vehicles available. Add vehicles before allocating containers. + No free vehicles available. Free up or add vehicles before allocating containers. )} diff --git a/apps/edr-freight-web/backoffice/src/components/LastMileContainerAllocationTable.tsx b/apps/edr-freight-web/backoffice/src/components/LastMileContainerAllocationTable.tsx index d11d99a4a..cc619cb9a 100644 --- a/apps/edr-freight-web/backoffice/src/components/LastMileContainerAllocationTable.tsx +++ b/apps/edr-freight-web/backoffice/src/components/LastMileContainerAllocationTable.tsx @@ -42,8 +42,8 @@ export function LastMileContainerAllocationTable({ ); const { data: vehicles = [], isLoading: vehiclesLoading } = useQuery({ - queryKey: ["vehicles", "active"], - queryFn: () => vehiclesService.getAll({ status: "ACTIVE" }), + queryKey: ["vehicles", "free"], + queryFn: () => vehiclesService.getAll({ status: "ACTIVE", availability: "FREE" }), }); const vehicleOptions = useMemo( @@ -99,7 +99,7 @@ export function LastMileContainerAllocationTable({ {vehicles.length === 0 && ( } color="yellow"> - No active vehicles available. Add vehicles before allocating containers. + No free vehicles available. Free up or add vehicles before allocating containers. )} diff --git a/apps/edr-freight-web/backoffice/src/components/fleet/fleetFormat.tsx b/apps/edr-freight-web/backoffice/src/components/fleet/fleetFormat.tsx index 5c997af9f..709f288ce 100644 --- a/apps/edr-freight-web/backoffice/src/components/fleet/fleetFormat.tsx +++ b/apps/edr-freight-web/backoffice/src/components/fleet/fleetFormat.tsx @@ -25,6 +25,8 @@ export const formatFleetCell = ( const getStatusColor = (st: string): string => { const s = st.toUpperCase(); if (s === "ACTIVE" || s === "AVAILABLE") return "green"; + if (s === "FREE") return "teal"; + if (s === "BUSY") return "blue"; if (s === "INACTIVE") return "gray"; if (s === "SUSPENDED" || s === "OUT_OF_SERVICE") return "red"; if (s === "MAINTENANCE" || s === "ON_LEAVE") return "orange"; diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingDetailPage.tsx index bf01c4818..d49e24f95 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingDetailPage.tsx @@ -33,6 +33,7 @@ const BookingDetailPage = () => { onSuccess: () => { toast.success("Containers allocated"); qc.invalidateQueries({ queryKey: QUERY_KEYS.BOOKINGS.byId(id ?? "") }); + qc.invalidateQueries({ queryKey: ["vehicles"] }); }, onError: () => { toast.error("Failed to allocate containers"); diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx index 615e706f5..13be9ef71 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx @@ -46,17 +46,22 @@ const FleetResourcePage = () => { const { viewMode, setViewMode } = useFleetViewMode(slug); const serverListFilters = useMemo((): FleetListFilters | undefined => { - if (slug !== "wagons" && slug !== "locomotives") return undefined; + const serverFilteredSlugs: FleetResourceSlug[] = ["wagons", "locomotives", "vehicles", "drivers"]; + if (!serverFilteredSlugs.includes(slug)) return undefined; const filters: FleetListFilters = {}; const status = listFilterValues.status; const currentYardId = listFilterValues.currentYardId; + const availability = listFilterValues.availability; if (status && status !== "ALL") { (filters as { status?: string }).status = status; } if (currentYardId && currentYardId !== "ALL") { filters.currentYardId = currentYardId; } - if (slug === "wagons" && search.trim()) { + if (availability && availability !== "ALL") { + (filters as { availability?: string }).availability = availability; + } + if (slug !== "locomotives" && search.trim()) { filters.search = search.trim(); } return filters; @@ -189,6 +194,7 @@ const FleetResourcePage = () => { registerFleetOptionLabels("wagonId", dynamicOptions.wagons); registerFleetOptionLabels("containerId", dynamicOptions.containers); registerFleetOptionLabels("currentYardId", dynamicOptions.yards); + registerFleetOptionLabels("locationId", dynamicOptions.yards); }, [dynamicOptions]); const formFields = useMemo((): FleetFormFieldDef[] => { diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/config/vehicles.ts b/apps/edr-freight-web/backoffice/src/pages/fleet/config/vehicles.ts index 92da9b9dc..de6eb09c7 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/config/vehicles.ts +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/config/vehicles.ts @@ -24,6 +24,11 @@ const VEHICLE_STATUS_OPTIONS = [ { label: "Out of service", value: "OUT_OF_SERVICE" }, ]; +const VEHICLE_AVAILABILITY_OPTIONS = [ + { label: "Free", value: "FREE" }, + { label: "Busy", value: "BUSY" }, +]; + export const vehiclesConfig: FleetResourceConfig = { slug: "vehicles", label: "Vehicles", @@ -44,24 +49,25 @@ export const vehiclesConfig: FleetResourceConfig = { allLabel: "All statuses", options: VEHICLE_STATUS_OPTIONS, }, + { + key: "availability", + label: "Availability", + allLabel: "All availability", + options: VEHICLE_AVAILABILITY_OPTIONS, + }, ], searchKeys: ["plateNumber", "registrationNumber", "manufacturer", "model", "vehicleType", "status"], columns: [ - { id: "code", header: "Code", accessorKey: "code", format: "code", size: 110 }, - { id: "plateNumber", header: "Plate Number", accessorKey: "plateNumber", format: "code", size: 130 }, - { id: "powerPlateNo", header: "Power Plate No", accessorKey: "powerPlateNo", format: "code", size: 140 }, - { id: "trailerPlateNo", header: "Trailer Plate No", accessorKey: "trailerPlateNo", format: "code", size: 140 }, - { id: "registrationNumber", header: "Registration", accessorKey: "registrationNumber", format: "code", size: 140 }, - { id: "manufacturer", header: "Manufacturer", accessorKey: "manufacturer", format: "code", size: 140 }, - { id: "model", header: "Model", accessorKey: "model", format: "code", size: 120 }, - { id: "vehicleType", header: "Type", accessorKey: "vehicleType", format: "code", size: 100 }, - { id: "year", header: "Year", accessorKey: "year", format: "number", size: 80 }, - { id: "fuelType", header: "Fuel Type", accessorKey: "fuelType", format: "code", size: 110 }, - { id: "capacity", header: "Capacity (tons)", accessorKey: "capacity", format: "number", size: 130 }, - { id: "assignedDriverName", header: "Assigned Driver", accessorKey: "assignedDriverName", format: "code", size: 140 }, - { id: "estimatedDistanceKm", header: "Est. Distance (KM)", accessorKey: "estimatedDistanceKm", format: "number", size: 150 }, - { id: "actualDistanceKm", header: "Actual Distance (KM)", accessorKey: "actualDistanceKm", format: "number", size: 150 }, + { id: "code", header: "Code", accessorKey: "code", size: 90 }, + { id: "plateNumber", header: "Plate Number", accessorKey: "plateNumber", size: 120 }, + { id: "trailerPlateNo", header: "Trailer Plate No", accessorKey: "trailerPlateNo", size: 130 }, + { id: "manufacturer", header: "Manufacturer", accessorKey: "manufacturer", size: 120 }, + { id: "model", header: "Model", accessorKey: "model", size: 100 }, + { id: "vehicleType", header: "Type", accessorKey: "vehicleType", size: 75 }, + { id: "capacity", header: "Capacity (tons)", accessorKey: "capacity", format: "number", size: 100 }, + { id: "locationId", header: "Location", accessorKey: "locationId", size: 140 }, { id: "status", header: "Status", accessorKey: "status", format: "statusBadge", size: 100 }, + { id: "availability", header: "Availability", accessorKey: "availability", format: "statusBadge", size: 100 }, ], formFields: [ { name: "code", label: "Code", type: "text" }, @@ -74,9 +80,11 @@ export const vehiclesConfig: FleetResourceConfig = { { name: "year", label: "Year", type: "number", required: true }, { name: "fuelType", label: "Fuel Type", type: "select", required: true, options: FUEL_TYPE_OPTIONS }, { name: "capacity", label: "Capacity", type: "number", required: true }, + { name: "locationId", label: "Location", type: "select", dynamicOptions: "yards" }, { name: "estimatedDistanceKm", label: "Estimated Distance (KM)", type: "number" }, { name: "actualDistanceKm", label: "Actual Distance (KM)", type: "number" }, { name: "status", label: "Status", type: "select", required: true, options: VEHICLE_STATUS_OPTIONS }, + { name: "availability", label: "Availability", type: "select", required: true, options: VEHICLE_AVAILABILITY_OPTIONS }, { name: "description", label: "Description", type: "textarea" }, ], emptyValues: { @@ -90,11 +98,13 @@ export const vehiclesConfig: FleetResourceConfig = { year: new Date().getFullYear(), fuelType: "DIESEL", capacity: 0, + locationId: null, estimatedDistanceKm: "", actualDistanceKm: "", status: "ACTIVE", + availability: "FREE", description: "", }, }; -export { VEHICLE_TYPE_OPTIONS, FUEL_TYPE_OPTIONS, VEHICLE_STATUS_OPTIONS }; +export { VEHICLE_TYPE_OPTIONS, FUEL_TYPE_OPTIONS, VEHICLE_STATUS_OPTIONS, VEHICLE_AVAILABILITY_OPTIONS }; diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx index a33d9a3c2..cec4d4351 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx @@ -8,6 +8,7 @@ import { Printer, RefreshCw, Ruler, + Trash, Truck, } from "lucide-react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; @@ -124,42 +125,45 @@ const InfoRow = ({ label, value }: { label: string; value: string }) => ( ); -const BookingInfo = ({ record }: { record: FirstMileRecord }) => ( - - - - {bookingRef(record)} - - - {STATUS_META[record.status].label} - - - {isAssigned(record) ? "Assigned" : "Unassigned"} - +const BookingInfo = ({ record }: { record: FirstMileRecord }) => { + const hasPickupAddress = record.booking?.firstMilePickupAddress != null; + return ( + + + + {bookingRef(record)} + + + {STATUS_META[record.status].label} + + + {isAssigned(record) ? "Assigned" : "Unassigned"} + + - - - - - - - - - - - - - - - - - - -); + + + + {hasPickupAddress && } + + + + + + + + + + + + + + ); +}; const tripSlipRows = (record: FirstMileRecord): [string, string][] => [ ["Customer", customerName(record)], @@ -355,9 +359,9 @@ const FirstMilePage = () => { }); const { data: vehiclesData } = useQuery({ - queryKey: ["vehicles", "list"], + queryKey: ["vehicles", "free"], queryFn: async () => { - const res = await vehiclesService.getAll({ status: "ACTIVE" }); + const res = await vehiclesService.getAll({ status: "ACTIVE", availability: "FREE" }); return res.data; }, }); @@ -400,6 +404,7 @@ const FirstMilePage = () => { firstMileService.update(id, data), onSuccess: () => { void qc.invalidateQueries({ queryKey: QUERY_KEYS.FIRST_MILE.list() }); + void qc.invalidateQueries({ queryKey: ["vehicles"] }); }, onError: () => { toast({ title: "Update failed", variant: "destructive" }); @@ -421,6 +426,17 @@ const FirstMilePage = () => { }, }); + const deleteMutation = useMutation({ + mutationFn: (id: string) => firstMileService.remove(id), + onSuccess: () => { + void qc.invalidateQueries({ queryKey: QUERY_KEYS.FIRST_MILE.list() }); + toast({ title: "Record deleted", description: "First-mile record removed successfully." }); + }, + onError: () => { + toast({ title: "Delete failed", variant: "destructive" }); + }, + }); + const acceptMutation = useMutation({ mutationFn: async ({ reference, vehicleId }: { reference: string; vehicleId: string | null }) => { const res = await firstMileService.accept(reference); @@ -433,6 +449,7 @@ const FirstMilePage = () => { }, onSuccess: () => { void qc.invalidateQueries({ queryKey: QUERY_KEYS.FIRST_MILE.ROOT }); + void qc.invalidateQueries({ queryKey: ["vehicles"] }); toast({ title: "Booking accepted", description: "First-mile leg created successfully." }); closeAccept(); }, @@ -449,6 +466,7 @@ const FirstMilePage = () => { onSuccess: () => { toast({ title: "Containers allocated" }); void qc.invalidateQueries({ queryKey: QUERY_KEYS.FIRST_MILE.byId(containerAllocationFirstMileId ?? "") }); + void qc.invalidateQueries({ queryKey: ["vehicles"] }); setContainerAllocationOpen(false); setContainerAllocationFirstMileId(null); }, @@ -470,9 +488,14 @@ const FirstMilePage = () => { const firstMileEligiblePaidBookings = useMemo( () => paidBookings.filter( - (booking) => - booking.tradeDirection === "EXPORT" && - !existingFirstMileBookingIds.has(booking.id), + (booking) => { + if (existingFirstMileBookingIds.has(booking.id)) return false; + if (booking.paymentStatus !== "PAID") return false; + if (booking.tradeDirection !== "EXPORT") return false; + const hasPickupAddress = booking.firstMilePickupAddress?.trim() ?? false; + const includesFirstMile = booking.serviceType?.includesFirstMile ?? false; + return hasPickupAddress || includesFirstMile; + }, ), [existingFirstMileBookingIds, paidBookings], ); @@ -843,6 +866,7 @@ const FirstMilePage = () => { const assigned = isAssigned(row.original); const nextStatus = NEXT_STATUS[row.original.status]; const canPrint = row.original.status !== "PAYMENT_PENDING"; + const isPaid = (row.original as any).paid; const canReceiveToWarehouse = row.original.status === "RECEIVED_TO_PORT"; return ( @@ -903,6 +927,22 @@ const FirstMilePage = () => { Print trip slip )} + {!isPaid && ( + <> + + } + color="red" + onClick={() => { + if (confirm(`Delete first-mile record ${bookingRef(row.original)}?`)) { + deleteMutation.mutate(row.original.id); + } + }} + > + Delete + + + )} @@ -1022,11 +1062,17 @@ const FirstMilePage = () => { diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx index da0bc281b..7ec9d1b33 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx @@ -6,6 +6,7 @@ import { Printer, RefreshCw, Ruler, + Trash, Truck, } from "lucide-react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; @@ -172,38 +173,41 @@ const InfoRow = ({ label, value }: { label: string; value: string }) => ( ); -const BookingInfo = ({ record }: { record: LastMileRecord }) => ( - - - - {bookingRef(record)} - - - {STATUS_META[record.status].label} - - - {isAssigned(record) ? "Assigned" : "Unassigned"} - +const BookingInfo = ({ record }: { record: LastMileRecord }) => { + const hasDeliveryAddress = record.booking?.lastMileDeliveryAddress != null; + return ( + + + + {bookingRef(record)} + + + {STATUS_META[record.status].label} + + + {isAssigned(record) ? "Assigned" : "Unassigned"} + + - - - - - - - - - - - - - - - - - - -); + + + + + {hasDeliveryAddress && } + + + + + + + + + + + + + ); +}; const tripSlipRows = (record: LastMileRecord): [string, string][] => [ ["Customer", customerName(record)], @@ -392,9 +396,9 @@ const LastMilePage = () => { }); const { data: vehiclesData } = useQuery({ - queryKey: ["vehicles", "list"], + queryKey: ["vehicles", "free"], queryFn: async () => { - const res = await vehiclesService.getAll({ status: "ACTIVE" }); + const res = await vehiclesService.getAll({ status: "ACTIVE", availability: "FREE" }); return res.data; }, }); @@ -408,6 +412,10 @@ const LastMilePage = () => { }); const records = listData?.data ?? []; + const existingLastMileBookingIds = useMemo( + () => new Set(records.map((record) => record.bookingId)), + [records], + ); const needsDriverLookup = records.some((record) => record.vehicle?.assignedDriverId); @@ -447,6 +455,7 @@ const LastMilePage = () => { lastMileService.update(id, data), onSuccess: () => { void qc.invalidateQueries({ queryKey: QUERY_KEYS.LAST_MILE.ROOT }); + void qc.invalidateQueries({ queryKey: ["vehicles"] }); }, onError: () => { toast({ title: "Update failed", variant: "destructive" }); @@ -468,12 +477,24 @@ const LastMilePage = () => { }, }); + const deleteMutation = useMutation({ + mutationFn: (id: string) => lastMileService.remove(id), + onSuccess: () => { + void qc.invalidateQueries({ queryKey: QUERY_KEYS.LAST_MILE.list() }); + toast({ title: "Record deleted", description: "Last-mile record removed successfully." }); + }, + onError: () => { + toast({ title: "Delete failed", variant: "destructive" }); + }, + }); + const allocateMutation = useMutation({ mutationFn: (data: Array<{ containerId: string; vehicleId: string }>) => api.post(`/last-mile/${activeId}/allocate-containers`, data), onSuccess: () => { toast({ title: "Containers allocated", variant: "default" }); void qc.invalidateQueries({ queryKey: QUERY_KEYS.LAST_MILE.byId(activeId ?? "") }); + void qc.invalidateQueries({ queryKey: ["vehicles"] }); closeAllocation(); }, onError: () => { @@ -489,15 +510,16 @@ const LastMilePage = () => { const arrivalQueue = arrivalQueueData ?? []; const filteredArrivalQueue = useMemo(() => { + let filtered = arrivalQueue.filter((item) => !existingLastMileBookingIds.has(item.bookingId)); const term = arrivalSearch.trim().toLowerCase(); - if (!term) return arrivalQueue; - return arrivalQueue.filter((item) => + if (!term) return filtered; + return filtered.filter((item) => [item.bookingReference, item.customer, item.cargo, item.warehouse, item.yard] .join(" ") .toLowerCase() .includes(term), ); - }, [arrivalQueue, arrivalSearch]); + }, [arrivalQueue, arrivalSearch, existingLastMileBookingIds]); const acceptMutation = useMutation({ mutationFn: async ({ items, vehicleId }: { items: ArrivalQueueItem[]; vehicleId: string | null }) => { @@ -511,6 +533,7 @@ const LastMilePage = () => { }, onSuccess: (created) => { void qc.invalidateQueries({ queryKey: QUERY_KEYS.LAST_MILE.ROOT }); + void qc.invalidateQueries({ queryKey: ["vehicles"] }); toast({ title: "Last-mile leg created", description: `${created.length} ${created.length === 1 ? "delivery" : "deliveries"} accepted successfully.`, @@ -928,6 +951,8 @@ const LastMilePage = () => { const assigned = isAssigned(row.original); const nextStatus = NEXT_STATUS[row.original.status]; const canPrint = row.original.status !== "PAYMENT_PENDING"; + const isPaid = (row.original as any).paid; + const delivered = row.original.status === "DELIVERED"; const releaseRow = pickupReadyByBooking.get(row.original.bookingId) ?? pickupReadyByBooking.get(bookingRef(row.original)); const truckArrivalLabel = releaseRow?.releaseOrderReference ? "Truck Leaving" : "Truck Arrival"; @@ -950,14 +975,14 @@ const LastMilePage = () => { } - disabled={assigned} + disabled={assigned || delivered} onClick={() => openAssign(row.original.id)} > Assign } - disabled={!assigned} + disabled={!assigned || delivered} onClick={() => openAssign(row.original.id)} > Reassign @@ -978,6 +1003,7 @@ const LastMilePage = () => { } + disabled={delivered} onClick={() => openDistance(row.original.id)} > Add distance @@ -990,6 +1016,23 @@ const LastMilePage = () => { Print trip slip )} + {!isPaid && ( + <> + + } + color="red" + disabled={delivered} + onClick={() => { + if (confirm(`Delete last-mile record ${bookingRef(row.original)}?`)) { + deleteMutation.mutate(row.original.id); + } + }} + > + Delete + + + )} @@ -1187,12 +1230,18 @@ const LastMilePage = () => { diff --git a/apps/edr-freight-web/backoffice/src/services/first-mile.service.ts b/apps/edr-freight-web/backoffice/src/services/first-mile.service.ts index b182a16d0..1c81f5dc5 100644 --- a/apps/edr-freight-web/backoffice/src/services/first-mile.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/first-mile.service.ts @@ -60,8 +60,10 @@ export const firstMileService = { list: (pageSize = 1000) => api.get(`${FM.BASE}?pageSize=${pageSize}`), getById: (id: string) => api.get(FM.BY_ID(id)), - update: (id: string, data: { status?: FirstMileApiStatus; vehicleId?: string | null; estimatedKm?: number | null; exactKm?: number | null }) => + update: (id: string, data: { status?: FirstMileApiStatus; vehicleId?: string | null; estimatedKm?: number | null; exactKm?: number | null; paid?: boolean }) => api.patch(FM.BY_ID(id), data), accept: (bookingReference: string) => api.post(FM.ACCEPT(bookingReference)), + remove: (id: string) => + api.delete(FM.BY_ID(id)), }; diff --git a/apps/edr-freight-web/backoffice/src/services/last-mile.service.ts b/apps/edr-freight-web/backoffice/src/services/last-mile.service.ts index 046db87e1..27f47cc7a 100644 --- a/apps/edr-freight-web/backoffice/src/services/last-mile.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/last-mile.service.ts @@ -63,8 +63,10 @@ export const lastMileService = { list: (pageSize = 1000) => api.get(`${LM.BASE}?pageSize=${pageSize}`), getById: (id: string) => api.get(LM.BY_ID(id)), - update: (id: string, data: { status?: LastMileApiStatus; vehicleId?: string | null; estimatedKm?: number | null; exactKm?: number | null }) => + update: (id: string, data: { status?: LastMileApiStatus; vehicleId?: string | null; estimatedKm?: number | null; exactKm?: number | null; paid?: boolean }) => api.patch(LM.BY_ID(id), data), accept: (bookingReference: string) => api.post(LM.ACCEPT(encodeURIComponent(bookingReference))), + remove: (id: string) => + api.delete(LM.BY_ID(id)), }; diff --git a/apps/edr-freight-web/backoffice/src/services/vehicles.service.ts b/apps/edr-freight-web/backoffice/src/services/vehicles.service.ts index d3805df1a..fd34b5e62 100644 --- a/apps/edr-freight-web/backoffice/src/services/vehicles.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/vehicles.service.ts @@ -4,9 +4,11 @@ import { URL_CONSTANTS } from '@/constants/URLS'; export type VehicleType = 'TRUCK' | 'VAN' | 'CAR' | 'BUS' | 'TRAILER' | 'TANKER' | 'FLATBED'; export type FuelType = 'PETROL' | 'DIESEL' | 'ELECTRIC' | 'HYBRID'; export type VehicleStatus = 'ACTIVE' | 'MAINTENANCE' | 'RETIRED' | 'OUT_OF_SERVICE'; +export type VehicleAvailability = 'FREE' | 'BUSY'; export interface VehicleListFilters { status?: VehicleStatus; + availability?: VehicleAvailability; search?: string; page?: number; limit?: number; @@ -25,12 +27,14 @@ export interface Vehicle { fuelType: FuelType; capacity: number; status: VehicleStatus; + availability: VehicleAvailability; description?: string | null; assignedDriverId?: string | null; assignedDriverName?: string | null; code?: string | null; powerPlateNo?: string | null; trailerPlateNo?: string | null; + locationId?: string | null; createdAt: string; updatedAt: string; } @@ -44,6 +48,7 @@ export const vehiclesService = { getAll: (filters: VehicleListFilters = {}) => { const params = new URLSearchParams(); if (filters.status) params.set('status', filters.status); + if (filters.availability) params.set('availability', filters.availability); if (filters.search) params.set('search', filters.search); if (filters.page) params.set('page', filters.page.toString()); if (filters.limit) params.set('limit', filters.limit.toString()); @@ -57,7 +62,7 @@ export const vehiclesService = { getById: (id: string) => apiClient.get(URL_CONSTANTS.VEHICLES.BY_ID(id)), create: (data: Partial) => apiClient.post(URL_CONSTANTS.VEHICLES.BASE, data), - update: (id: string, data: Partial) => + update: (id: string, data: Partial) => apiClient.patch(URL_CONSTANTS.VEHICLES.BY_ID(id), data), delete: (id: string) => apiClient.delete(URL_CONSTANTS.VEHICLES.BY_ID(id)), }; diff --git a/apps/edr-freight-web/portal/src/pages/billing/InvoiceDetailPage.tsx b/apps/edr-freight-web/portal/src/pages/billing/InvoiceDetailPage.tsx index a06c4c775..5dca05b15 100644 --- a/apps/edr-freight-web/portal/src/pages/billing/InvoiceDetailPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/billing/InvoiceDetailPage.tsx @@ -10,7 +10,6 @@ import { Group, Loader, Paper, - Select, SimpleGrid, Stack, Table, @@ -24,7 +23,6 @@ import { ExternalLink, Receipt, } from "lucide-react"; -import { useState } from "react"; import toast from "react-hot-toast"; import { api } from "@/services/api"; @@ -67,7 +65,9 @@ function MetaItem({ label, value }: { label: string; value: string }) { export default function InvoiceDetailPage() { const { id = "" } = useParams(); const navigate = useNavigate(); - const [paymentMethod, setPaymentMethod] = useState<"TELEBIRR" | "WAAFI">("TELEBIRR"); + const [paymentMethod, setPaymentMethod] = useState<"TELEBIRR" | "WAAFI">( + "TELEBIRR", + ); const { data: invoice,