mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
Merge branch 'dev' of github.com:Tria-plc/edr-platform into contrat-backup2
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.vehicles
|
||||
ADD COLUMN IF NOT EXISTS location_id uuid;
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.vehicles
|
||||
DROP COLUMN IF EXISTS location_id;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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<void> {
|
||||
// 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<void> {
|
||||
// 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
|
||||
}
|
||||
}
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
// 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
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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<FirstMile | null> {
|
||||
@@ -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<FirstMile> {
|
||||
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<void> {
|
||||
/**
|
||||
* 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<void> {
|
||||
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<void> {
|
||||
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,
|
||||
|
||||
@@ -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<LastMile> {
|
||||
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',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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<FirstMileStatus>('RECEIVED_TO_PORT') },
|
||||
}),
|
||||
manager.count(LastMile, {
|
||||
where: { vehicleId, status: Not<LastMileStatus>('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<void> {
|
||||
await this.findById(id);
|
||||
await this.vehicleRepo.softDelete(id);
|
||||
|
||||
@@ -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({
|
||||
<Stack gap="md">
|
||||
{vehicles.length === 0 && (
|
||||
<Alert icon={<AlertCircle size={16} />} color="yellow">
|
||||
No active vehicles available. Add vehicles before allocating containers.
|
||||
No free vehicles available. Free up or add vehicles before allocating containers.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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({
|
||||
<Stack gap="md">
|
||||
{vehicles.length === 0 && (
|
||||
<Alert icon={<AlertCircle size={16} />} color="yellow">
|
||||
No active vehicles available. Add vehicles before allocating containers.
|
||||
No free vehicles available. Free up or add vehicles before allocating containers.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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({
|
||||
<Stack gap="md">
|
||||
{vehicles.length === 0 && (
|
||||
<Alert icon={<AlertCircle size={16} />} color="yellow">
|
||||
No active vehicles available. Add vehicles before allocating containers.
|
||||
No free vehicles available. Free up or add vehicles before allocating containers.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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[] => {
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 }) => (
|
||||
</Stack>
|
||||
);
|
||||
|
||||
const BookingInfo = ({ record }: { record: FirstMileRecord }) => (
|
||||
<Card radius="md" padding="md" withBorder bg="var(--mantine-color-gray-0)">
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between">
|
||||
<Text fw={600}>{bookingRef(record)}</Text>
|
||||
<Group gap="xs">
|
||||
<Badge color={STATUS_META[record.status].color} variant="light" size="sm">
|
||||
{STATUS_META[record.status].label}
|
||||
</Badge>
|
||||
<Badge
|
||||
color={isAssigned(record) ? "green" : "orange"}
|
||||
variant="light"
|
||||
size="sm"
|
||||
>
|
||||
{isAssigned(record) ? "Assigned" : "Unassigned"}
|
||||
</Badge>
|
||||
const BookingInfo = ({ record }: { record: FirstMileRecord }) => {
|
||||
const hasPickupAddress = record.booking?.firstMilePickupAddress != null;
|
||||
return (
|
||||
<Card radius="md" padding="md" withBorder bg="var(--mantine-color-gray-0)">
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between">
|
||||
<Text fw={600}>{bookingRef(record)}</Text>
|
||||
<Group gap="xs">
|
||||
<Badge color={STATUS_META[record.status].color} variant="light" size="sm">
|
||||
{STATUS_META[record.status].label}
|
||||
</Badge>
|
||||
<Badge
|
||||
color={isAssigned(record) ? "green" : "orange"}
|
||||
variant="light"
|
||||
size="sm"
|
||||
>
|
||||
{isAssigned(record) ? "Assigned" : "Unassigned"}
|
||||
</Badge>
|
||||
</Group>
|
||||
</Group>
|
||||
</Group>
|
||||
<SimpleGrid cols={2} spacing="sm">
|
||||
<InfoRow label="Customer" value={customerName(record)} />
|
||||
<InfoRow label="Service type" value={serviceTypeName(record)} />
|
||||
<InfoRow label="Pickup location" value={pickupLocation(record)} />
|
||||
<InfoRow label="Destination (origin yard)" value={destinationYardName(record)} />
|
||||
<InfoRow label="Cargo" value={cargoDesc(record)} />
|
||||
<InfoRow label="Advanced Payment" value={formatPrice(record.advancedPayment)} />
|
||||
<InfoRow label="Post Payment" value={formatPrice(record.remainingPayment)} />
|
||||
<InfoRow label="Contact" value={contactPersonName(record)} />
|
||||
<InfoRow label="Phone" value={contactPhone(record)} />
|
||||
<InfoRow label="Requested date" value={requestedDate(record)} />
|
||||
<InfoRow label="Assigned vehicle" value={vehicleLabel(record) ?? "—"} />
|
||||
<InfoRow label="Est. Distance (KM)" value={record.estimatedKm != null ? String(record.estimatedKm) : "—"} />
|
||||
<InfoRow label="Actual Distance (KM)" value={record.exactKm != null ? String(record.exactKm) : "—"} />
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
<SimpleGrid cols={2} spacing="sm">
|
||||
<InfoRow label="Customer" value={customerName(record)} />
|
||||
<InfoRow label="Service type" value={serviceTypeName(record)} />
|
||||
{hasPickupAddress && <InfoRow label="Pickup location" value={pickupLocation(record)} />}
|
||||
<InfoRow label="Destination (origin yard)" value={destinationYardName(record)} />
|
||||
<InfoRow label="Cargo" value={cargoDesc(record)} />
|
||||
<InfoRow label="Advanced Payment" value={formatPrice(record.advancedPayment)} />
|
||||
<InfoRow label="Post Payment" value={formatPrice(record.remainingPayment)} />
|
||||
<InfoRow label="Contact" value={contactPersonName(record)} />
|
||||
<InfoRow label="Phone" value={contactPhone(record)} />
|
||||
<InfoRow label="Requested date" value={requestedDate(record)} />
|
||||
<InfoRow label="Assigned vehicle" value={vehicleLabel(record) ?? "—"} />
|
||||
<InfoRow label="Est. Distance (KM)" value={record.estimatedKm != null ? String(record.estimatedKm) : "—"} />
|
||||
<InfoRow label="Actual Distance (KM)" value={record.exactKm != null ? String(record.exactKm) : "—"} />
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
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 (
|
||||
<Group gap={4} justify="flex-end" wrap="nowrap">
|
||||
@@ -903,6 +927,22 @@ const FirstMilePage = () => {
|
||||
Print trip slip
|
||||
</Menu.Item>
|
||||
)}
|
||||
{!isPaid && (
|
||||
<>
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
leftSection={<Trash size={15} />}
|
||||
color="red"
|
||||
onClick={() => {
|
||||
if (confirm(`Delete first-mile record ${bookingRef(row.original)}?`)) {
|
||||
deleteMutation.mutate(row.original.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</Menu.Item>
|
||||
</>
|
||||
)}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</Group>
|
||||
@@ -1022,11 +1062,17 @@ const FirstMilePage = () => {
|
||||
<Divider />
|
||||
<Select
|
||||
label="Vehicle"
|
||||
placeholder="Select a vehicle"
|
||||
placeholder={vehicleOptions.length === 0 ? "No free vehicles" : "Select a vehicle"}
|
||||
description={
|
||||
vehicleOptions.length === 0
|
||||
? "No free vehicles available — all vehicles are busy, in maintenance, or retired. Free up a vehicle in Fleet › Vehicles first."
|
||||
: undefined
|
||||
}
|
||||
data={vehicleOptions}
|
||||
value={vehicleValue}
|
||||
onChange={setVehicleValue}
|
||||
searchable
|
||||
disabled={vehicleOptions.length === 0}
|
||||
/>
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button variant="default" onClick={closeAssign}>Cancel</Button>
|
||||
@@ -1200,12 +1246,18 @@ const FirstMilePage = () => {
|
||||
<Divider />
|
||||
<Select
|
||||
label="Assign Vehicle (optional)"
|
||||
placeholder="Select a vehicle"
|
||||
placeholder={vehicleOptions.length === 0 ? "No free vehicles" : "Select a vehicle"}
|
||||
description={
|
||||
vehicleOptions.length === 0
|
||||
? "No free vehicles available — you can still accept and assign a vehicle later."
|
||||
: undefined
|
||||
}
|
||||
data={vehicleOptions}
|
||||
value={acceptVehicleValue}
|
||||
onChange={setAcceptVehicleValue}
|
||||
searchable
|
||||
clearable
|
||||
disabled={vehicleOptions.length === 0}
|
||||
/>
|
||||
<Group justify="space-between" gap="sm">
|
||||
<Button variant="subtle" onClick={() => setAcceptStep(1)}>← Back</Button>
|
||||
|
||||
@@ -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 }) => (
|
||||
</Stack>
|
||||
);
|
||||
|
||||
const BookingInfo = ({ record }: { record: LastMileRecord }) => (
|
||||
<Card radius="md" padding="md" withBorder bg="var(--mantine-color-gray-0)">
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between">
|
||||
<Text fw={600}>{bookingRef(record)}</Text>
|
||||
<Group gap="xs">
|
||||
<Badge color={STATUS_META[record.status].color} variant="light" size="sm">
|
||||
{STATUS_META[record.status].label}
|
||||
</Badge>
|
||||
<Badge color={isAssigned(record) ? "green" : "orange"} variant="light" size="sm">
|
||||
{isAssigned(record) ? "Assigned" : "Unassigned"}
|
||||
</Badge>
|
||||
const BookingInfo = ({ record }: { record: LastMileRecord }) => {
|
||||
const hasDeliveryAddress = record.booking?.lastMileDeliveryAddress != null;
|
||||
return (
|
||||
<Card radius="md" padding="md" withBorder bg="var(--mantine-color-gray-0)">
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between">
|
||||
<Text fw={600}>{bookingRef(record)}</Text>
|
||||
<Group gap="xs">
|
||||
<Badge color={STATUS_META[record.status].color} variant="light" size="sm">
|
||||
{STATUS_META[record.status].label}
|
||||
</Badge>
|
||||
<Badge color={isAssigned(record) ? "green" : "orange"} variant="light" size="sm">
|
||||
{isAssigned(record) ? "Assigned" : "Unassigned"}
|
||||
</Badge>
|
||||
</Group>
|
||||
</Group>
|
||||
</Group>
|
||||
<SimpleGrid cols={2} spacing="sm">
|
||||
<InfoRow label="Customer" value={customerName(record)} />
|
||||
<InfoRow label="Service type" value={serviceTypeName(record)} />
|
||||
<InfoRow label="Pickup (origin yard)" value={originYardName(record)} />
|
||||
<InfoRow label="Destination" value={deliveryLocation(record)} />
|
||||
<InfoRow label="Cargo" value={cargoDesc(record)} />
|
||||
<InfoRow label="Advanced Payment" value={formatPrice(record.advancedPayment)} />
|
||||
<InfoRow label="Post Payment" value={formatPrice(record.remainingPayment)} />
|
||||
<InfoRow label="Contact" value={contactPersonName(record)} />
|
||||
<InfoRow label="Phone" value={contactPhone(record)} />
|
||||
<InfoRow label="Requested date" value={requestedDate(record)} />
|
||||
<InfoRow label="Assigned vehicle" value={vehicleLabel(record) ?? "—"} />
|
||||
<InfoRow label="Est. Distance (KM)" value={record.estimatedKm != null ? String(record.estimatedKm) : "—"} />
|
||||
<InfoRow label="Actual Distance (KM)" value={record.exactKm != null ? String(record.exactKm) : "—"} />
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
<SimpleGrid cols={2} spacing="sm">
|
||||
<InfoRow label="Customer" value={customerName(record)} />
|
||||
<InfoRow label="Service type" value={serviceTypeName(record)} />
|
||||
<InfoRow label="Pickup (origin yard)" value={originYardName(record)} />
|
||||
{hasDeliveryAddress && <InfoRow label="Destination" value={deliveryLocation(record)} />}
|
||||
<InfoRow label="Cargo" value={cargoDesc(record)} />
|
||||
<InfoRow label="Advanced Payment" value={formatPrice(record.advancedPayment)} />
|
||||
<InfoRow label="Post Payment" value={formatPrice(record.remainingPayment)} />
|
||||
<InfoRow label="Contact" value={contactPersonName(record)} />
|
||||
<InfoRow label="Phone" value={contactPhone(record)} />
|
||||
<InfoRow label="Requested date" value={requestedDate(record)} />
|
||||
<InfoRow label="Assigned vehicle" value={vehicleLabel(record) ?? "—"} />
|
||||
<InfoRow label="Est. Distance (KM)" value={record.estimatedKm != null ? String(record.estimatedKm) : "—"} />
|
||||
<InfoRow label="Actual Distance (KM)" value={record.exactKm != null ? String(record.exactKm) : "—"} />
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
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 = () => {
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
leftSection={<Truck size={15} />}
|
||||
disabled={assigned}
|
||||
disabled={assigned || delivered}
|
||||
onClick={() => openAssign(row.original.id)}
|
||||
>
|
||||
Assign
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<RefreshCw size={15} />}
|
||||
disabled={!assigned}
|
||||
disabled={!assigned || delivered}
|
||||
onClick={() => openAssign(row.original.id)}
|
||||
>
|
||||
Reassign
|
||||
@@ -978,6 +1003,7 @@ const LastMilePage = () => {
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<Ruler size={15} />}
|
||||
disabled={delivered}
|
||||
onClick={() => openDistance(row.original.id)}
|
||||
>
|
||||
Add distance
|
||||
@@ -990,6 +1016,23 @@ const LastMilePage = () => {
|
||||
Print trip slip
|
||||
</Menu.Item>
|
||||
)}
|
||||
{!isPaid && (
|
||||
<>
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
leftSection={<Trash size={15} />}
|
||||
color="red"
|
||||
disabled={delivered}
|
||||
onClick={() => {
|
||||
if (confirm(`Delete last-mile record ${bookingRef(row.original)}?`)) {
|
||||
deleteMutation.mutate(row.original.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</Menu.Item>
|
||||
</>
|
||||
)}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</Group>
|
||||
@@ -1187,12 +1230,18 @@ const LastMilePage = () => {
|
||||
<Divider />
|
||||
<Select
|
||||
label="Assign Vehicle (optional)"
|
||||
placeholder="Select a vehicle"
|
||||
placeholder={vehicleOptions.length === 0 ? "No free vehicles" : "Select a vehicle"}
|
||||
description={
|
||||
vehicleOptions.length === 0
|
||||
? "No free vehicles available — you can still accept and assign a vehicle later."
|
||||
: undefined
|
||||
}
|
||||
data={vehicleOptions}
|
||||
value={acceptVehicleValue}
|
||||
onChange={setAcceptVehicleValue}
|
||||
searchable
|
||||
clearable
|
||||
disabled={vehicleOptions.length === 0}
|
||||
/>
|
||||
<Group justify="space-between" gap="sm">
|
||||
<Button variant="subtle" onClick={() => setAcceptStep(1)}>← Back</Button>
|
||||
@@ -1235,11 +1284,17 @@ const LastMilePage = () => {
|
||||
<Divider />
|
||||
<Select
|
||||
label="Vehicle"
|
||||
placeholder="Select a vehicle"
|
||||
placeholder={vehicleOptions.length === 0 ? "No free vehicles" : "Select a vehicle"}
|
||||
description={
|
||||
vehicleOptions.length === 0
|
||||
? "No free vehicles available — all vehicles are busy, in maintenance, or retired. Free up a vehicle in Fleet › Vehicles first."
|
||||
: undefined
|
||||
}
|
||||
data={vehicleOptions}
|
||||
value={vehicleValue}
|
||||
onChange={setVehicleValue}
|
||||
searchable
|
||||
disabled={vehicleOptions.length === 0}
|
||||
/>
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button variant="default" onClick={closeAssign}>Cancel</Button>
|
||||
|
||||
@@ -60,8 +60,10 @@ export const firstMileService = {
|
||||
list: (pageSize = 1000) =>
|
||||
api.get<FirstMileListResponse>(`${FM.BASE}?pageSize=${pageSize}`),
|
||||
getById: (id: string) => api.get<FirstMileRecord>(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<FirstMileRecord>(FM.BY_ID(id), data),
|
||||
accept: (bookingReference: string) =>
|
||||
api.post<FirstMileRecord>(FM.ACCEPT(bookingReference)),
|
||||
remove: (id: string) =>
|
||||
api.delete<void>(FM.BY_ID(id)),
|
||||
};
|
||||
|
||||
@@ -63,8 +63,10 @@ export const lastMileService = {
|
||||
list: (pageSize = 1000) =>
|
||||
api.get<LastMileListResponse>(`${LM.BASE}?pageSize=${pageSize}`),
|
||||
getById: (id: string) => api.get<LastMileRecord>(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<LastMileRecord>(LM.BY_ID(id), data),
|
||||
accept: (bookingReference: string) =>
|
||||
api.post<LastMileRecord>(LM.ACCEPT(encodeURIComponent(bookingReference))),
|
||||
remove: (id: string) =>
|
||||
api.delete<void>(LM.BY_ID(id)),
|
||||
};
|
||||
|
||||
@@ -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<Vehicle>(URL_CONSTANTS.VEHICLES.BY_ID(id)),
|
||||
create: (data: Partial<SaveVehiclePayload>) =>
|
||||
apiClient.post(URL_CONSTANTS.VEHICLES.BASE, data),
|
||||
update: (id: string, data: Partial<SaveVehiclePayload>) =>
|
||||
update: (id: string, data: Partial<SaveVehiclePayload & { locationId?: string | null }>) =>
|
||||
apiClient.patch(URL_CONSTANTS.VEHICLES.BY_ID(id), data),
|
||||
delete: (id: string) => apiClient.delete(URL_CONSTANTS.VEHICLES.BY_ID(id)),
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user