Merge branch 'dev' of github.com:Tria-plc/edr-platform into freight_feature/usermanagement

This commit is contained in:
marshal
2026-07-03 11:28:55 +03:00
28 changed files with 585 additions and 166 deletions

View File

@@ -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(

View File

@@ -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;
`);
}
}

View File

@@ -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
}
}

View File

@@ -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
`);
}
}

View File

@@ -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
`);
}
}

View File

@@ -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,

View File

@@ -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,

View File

@@ -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) {

View File

@@ -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' })

View File

@@ -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,

View File

@@ -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',

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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,

View File

@@ -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);

View File

@@ -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>
)}

View File

@@ -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>
)}

View File

@@ -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>
)}

View File

@@ -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";

View File

@@ -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");

View File

@@ -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[] => {

View File

@@ -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 };

View File

@@ -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>

View File

@@ -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>

View File

@@ -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)),
};

View File

@@ -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)),
};

View File

@@ -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)),
};

View File

@@ -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,