Merge pull request #428 from Tria-plc/freight/feature/first_mile_invoice

Freight/feature/first mile invoice
This commit is contained in:
yaschalew10
2026-07-03 20:09:46 +03:00
committed by GitHub
22 changed files with 1201 additions and 29 deletions

View File

@@ -67,6 +67,7 @@ import { ApprovedFirstLastMileDemoBookingsSeeder } from "./seed/approved-first-l
//New Trains, Wagons, Container and Cargo management modules
import { TrainsModule } from "./modules/trains/trains.module";
import { VerifaydaModule } from './modules/verifayda/verifayda.module';
import { FleetHistoryModule } from './modules/fleet-history/fleet-history.module';
import { WagonsModule } from "./modules/wagons/wagons.module";
import { ContainersModule } from "./modules/container-management/containers.module";
import { CargoesModule } from "./modules/cargoes/cargoes.module";
@@ -149,6 +150,7 @@ import { LoggerMiddleware } from "./logger.middleware";
InterchangeDocumentsModule,
ImportOperationsModule,
VerifaydaModule,
FleetHistoryModule,
],
providers: [
EdrOrgSeeder,

View File

@@ -0,0 +1,43 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Append-only audit log for fleet activity (driver↔vehicle assignments, vehicle
* status/availability transitions, first/last-mile vehicle assignments + mile
* status changes). Queried by vehicle_id or driver_id to build a per-record
* timeline. Populated going forward — existing records have no back-history.
*/
export class AddFleetEvents1890000000008 implements MigrationInterface {
name = "AddFleetEvents1890000000008";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.fleet_events (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
event_type varchar NOT NULL,
vehicle_id uuid,
driver_id uuid,
first_mile_id uuid,
last_mile_id uuid,
from_value varchar,
to_value varchar,
label varchar,
metadata jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz
)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_FLEET_EVENTS_VEHICLE"
ON freight.fleet_events (vehicle_id, created_at)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_FLEET_EVENTS_DRIVER"
ON freight.fleet_events (driver_id, created_at)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.fleet_events`);
}
}

View File

@@ -14,13 +14,17 @@ import { FleetManage, FleetView } from '../../common/booking-guards';
import { DriversService } from './drivers.service';
import { CreateDriverDto } from './dto/create-driver.dto';
import { UpdateDriverDto } from './dto/update-driver.dto';
import { FleetHistoryService } from '../fleet-history/fleet-history.service';
@ApiTags('drivers')
@ApiBearerAuth()
@Controller('drivers')
@FleetView()
export class DriversController {
constructor(private readonly driversService: DriversService) {}
constructor(
private readonly driversService: DriversService,
private readonly fleetHistory: FleetHistoryService,
) {}
@Post()
@FleetManage()
@@ -55,6 +59,12 @@ export class DriversController {
return this.driversService.findById(id);
}
@Get(':id/history')
@ApiOperation({ summary: 'Get driver assignment & activity history' })
history(@Param('id', ParseUUIDPipe) id: string) {
return this.fleetHistory.getDriverHistory(id);
}
@Patch(':id')
@FleetManage()
@ApiOperation({ summary: 'Update a driver' })

View File

@@ -4,12 +4,15 @@ import { Repository } from 'typeorm';
import { CreateDriverDto } from './dto/create-driver.dto';
import { UpdateDriverDto } from './dto/update-driver.dto';
import { Driver, DriverStatus } from './entities/driver.entity';
import { FleetHistoryService } from '../fleet-history/fleet-history.service';
import { FleetEventType } from '../fleet-history/entities/fleet-event.entity';
@Injectable()
export class DriversService {
constructor(
@InjectRepository(Driver)
private readonly driverRepo: Repository<Driver>,
private readonly history: FleetHistoryService,
) {}
async create(dto: CreateDriverDto): Promise<Driver> {
@@ -51,7 +54,16 @@ export class DriversService {
}
const driver = this.driverRepo.create(dto);
return this.driverRepo.save(driver);
const saved = await this.driverRepo.save(driver);
await this.history.record({
eventType: FleetEventType.DRIVER_REGISTERED,
driverId: saved.id,
label: `${saved.firstName ?? ''} ${saved.lastName ?? ''}`.trim() || null,
toValue: saved.status ?? null,
});
return saved;
}
async findAll(query: {

View File

@@ -71,7 +71,7 @@ export class FirstMileInvoiceService {
type: 'DELIVERY_FEE',
companyId: fm.booking!.companyId,
companyProfileId: fm.booking!.companyProfileId || '',
currency: 'ETB',
currency: fm.booking!.paymentCurrency || 'ETB',
lines: [
{
chargeType: 'DELIVERY',

View File

@@ -92,6 +92,7 @@ export class FirstMileController {
const record = await this.firstMileService.update(id, dto);
// Auto-generate invoice if distance or payment was updated
const booking = await this.bookingsService.findById(record.bookingId);
const currency = booking.paymentCurrency || "ETB";
if (dto.exactKm !== undefined || dto.exactKm != record.exactKm || dto.remainingPayment !== undefined || dto.remainingPayment !== record.remainingPayment) {
await this.billingService.generateInvoice({
source: Freight.InvoiceSource.FirstMile,
@@ -99,7 +100,7 @@ export class FirstMileController {
type: "FIRST_MILE",
companyId: booking.companyId,
companyProfileId: booking.companyProfileId,
currency: "ETB",
currency,
lines: [
{
@@ -108,7 +109,7 @@ export class FirstMileController {
quantity: 1,
unitRate: record.remainingPayment,
amount: record.remainingPayment,
currency: "ETB",
currency,
},
],

View File

@@ -1,5 +1,5 @@
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { FindOptionsWhere, In } from 'typeorm';
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { FindOptionsWhere, In, IsNull, Not } from 'typeorm';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { VehicleAvailability } from '../vehicles/entities/vehicle.entity';
@@ -14,6 +14,8 @@ import { FirstMileContainerAllocation } from "./entities/first-mile-container-al
import { FirstMileRepository } from "./first-mile.repository";
import { OnEvent } from "@nestjs/event-emitter";
import { InvoiceEventPayload } from "../billing/billing.service";
import { FleetHistoryService } from "../fleet-history/fleet-history.service";
import { FleetEventType } from "../fleet-history/entities/fleet-event.entity";
type FirstMileListFilter = {
status?: FirstMileStatus;
@@ -43,8 +45,54 @@ export class FirstMileService {
private readonly vehiclesService: VehiclesService,
private readonly driversService: DriversService,
private readonly smsClient: SmsClientService,
private readonly history: FleetHistoryService,
) { }
/** Resolve a vehicle's driver + human labels, for stamping mile events onto
* the driver's timeline and naming the vehicle. Best-effort — never throws. */
private async vehicleInfo(
vehicleId?: string | null,
): Promise<{ driverId: string | null; plate: string | null; driverName: string | null }> {
if (!vehicleId) return { driverId: null, plate: null, driverName: null };
try {
const v = await this.vehiclesService.findById(vehicleId);
return {
driverId: v.assignedDriverId ?? null,
plate: v.plateNumber ?? v.code ?? null,
driverName: v.assignedDriverName ?? null,
};
} catch {
return { driverId: null, plate: null, driverName: null };
}
}
/** A leg counts as having a vehicle if it has a direct assignment or at least
* one container allocation carrying a vehicle. Gates the IN_TRANSIT move. */
private async hasAssignedVehicle(
recordId: string,
directVehicleId?: string | null,
): Promise<boolean> {
if (directVehicleId) return true;
const count = await this.dataSource.manager.count(FirstMileContainerAllocation, {
where: { firstMileId: recordId, vehicleId: Not(IsNull()) },
});
return count > 0;
}
/** Human booking reference for a first-mile record, for the history timeline. */
private async resolveBookingRef(record: FirstMile): Promise<string | null> {
const loaded = (record as FirstMile & { booking?: { reference?: string } })
.booking?.reference;
if (loaded) return loaded;
if (!record.bookingId) return null;
try {
const b = await this.bookingsRepository.findById(record.bookingId);
return (b as { reference?: string } | null)?.reference ?? null;
} catch {
return null;
}
}
/**
* Look up a booking by its human-readable reference and confirm it has been
* paid before any first-mile work proceeds. Throws if the reference is
@@ -210,6 +258,20 @@ export class FirstMileService {
if (dto.vehicleId) {
await this.vehiclesService.setAvailability(dto.vehicleId, VehicleAvailability.BUSY);
const info = await this.vehicleInfo(dto.vehicleId);
await this.history.record({
eventType: FleetEventType.MILE_VEHICLE_ASSIGNED,
vehicleId: dto.vehicleId,
firstMileId: record.id,
driverId: info.driverId,
label: record.status,
metadata: {
mile: 'FIRST',
bookingRef: await this.resolveBookingRef(record),
vehiclePlate: info.plate,
driverName: info.driverName,
},
});
}
return record;
@@ -248,6 +310,18 @@ export class FirstMileService {
async update(id: string, dto: UpdateFirstMileDto): Promise<FirstMile> {
const existing = await this.findById(id);
// A leg can only go IN_TRANSIT once a vehicle is assigned (allowing a vehicle
// assigned in this same request).
if (dto.status === 'IN_TRANSIT' && existing.status !== 'IN_TRANSIT') {
const vehicleId =
dto.vehicleId !== undefined ? dto.vehicleId : existing.vehicleId;
if (!(await this.hasAssignedVehicle(id, vehicleId))) {
throw new BadRequestException(
'Assign a vehicle before marking this first-mile leg in transit',
);
}
}
const dtoAny = dto as any;
const updated = await this.firstMileRepository.update(id, {
...(dto.bookingId !== undefined ? { bookingId: dto.bookingId } : {}),
@@ -278,6 +352,39 @@ export class FirstMileService {
if (existing.vehicleId) {
await this.vehiclesService.releaseIfUnused([existing.vehicleId]);
}
// Audit the mile↔vehicle (re)assignment on both vehicle and driver lines.
const bookingRef = await this.resolveBookingRef(existing);
if (existing.vehicleId) {
const info = await this.vehicleInfo(existing.vehicleId);
await this.history.record({
eventType: FleetEventType.MILE_VEHICLE_RELEASED,
vehicleId: existing.vehicleId,
firstMileId: id,
driverId: info.driverId,
metadata: {
mile: 'FIRST',
bookingRef,
vehiclePlate: info.plate,
driverName: info.driverName,
},
});
}
if (dto.vehicleId) {
const info = await this.vehicleInfo(dto.vehicleId);
await this.history.record({
eventType: FleetEventType.MILE_VEHICLE_ASSIGNED,
vehicleId: dto.vehicleId,
firstMileId: id,
driverId: info.driverId,
label: updated.status,
metadata: {
mile: 'FIRST',
bookingRef,
vehiclePlate: info.plate,
driverName: info.driverName,
},
});
}
}
// Notify assigned driver on every explicit vehicle assignment or reassignment
@@ -285,6 +392,25 @@ export class FirstMileService {
void this.notifyDriverAssignment(dto.vehicleId, existing);
}
if (dto.status !== undefined && dto.status !== existing.status) {
const vehicleId = updated.vehicleId ?? existing.vehicleId ?? null;
const info = await this.vehicleInfo(vehicleId);
await this.history.record({
eventType: FleetEventType.MILE_STATUS_CHANGED,
firstMileId: id,
vehicleId,
driverId: info.driverId,
fromValue: existing.status,
toValue: dto.status,
metadata: {
mile: 'FIRST',
bookingRef: await this.resolveBookingRef(existing),
vehiclePlate: info.plate,
driverName: info.driverName,
},
});
}
// Trip finished — release the vehicles it was holding
if (dto.status === 'RECEIVED_TO_PORT' && existing.status !== 'RECEIVED_TO_PORT') {
await this.releaseVehicles(updated);
@@ -295,12 +421,40 @@ export class FirstMileService {
async updateStatus(id: string, status: FirstMileStatus): Promise<FirstMile> {
const existing = await this.findById(id);
if (status === 'IN_TRANSIT' && existing.status !== 'IN_TRANSIT') {
if (!(await this.hasAssignedVehicle(id, existing.vehicleId))) {
throw new BadRequestException(
'Assign a vehicle before marking this first-mile leg in transit',
);
}
}
const updated = await this.firstMileRepository.update(id, { status });
if (!updated) {
throw new NotFoundException(`First-mile record ${id} not found`);
}
if (status !== existing.status) {
const vehicleId = updated.vehicleId ?? existing.vehicleId ?? null;
const info = await this.vehicleInfo(vehicleId);
await this.history.record({
eventType: FleetEventType.MILE_STATUS_CHANGED,
firstMileId: id,
vehicleId,
driverId: info.driverId,
fromValue: existing.status,
toValue: status,
metadata: {
mile: 'FIRST',
bookingRef: await this.resolveBookingRef(existing),
vehiclePlate: info.plate,
driverName: info.driverName,
},
});
}
if (status === 'RECEIVED_TO_PORT' && existing.status !== 'RECEIVED_TO_PORT') {
await this.releaseVehicles(updated);
}
@@ -430,6 +584,34 @@ export class FirstMileService {
previousVehicleIds.filter((id) => !vehicleIds.has(id)),
);
// History: one event per vehicle actually added or removed by this
// multi-car (re)allocation, so reassignments show on every timeline.
const prevSet = new Set(previousVehicleIds);
const bookingRef = await this.resolveBookingRef(firstMile);
for (const vehicleId of vehicleIds) {
if (prevSet.has(vehicleId)) continue;
const info = await this.vehicleInfo(vehicleId);
await this.history.record({
eventType: FleetEventType.MILE_VEHICLE_ASSIGNED,
vehicleId,
firstMileId,
driverId: info.driverId,
label: firstMile.status,
metadata: { mile: 'FIRST', bookingRef, vehiclePlate: info.plate, driverName: info.driverName },
});
}
for (const vehicleId of previousVehicleIds) {
if (vehicleIds.has(vehicleId)) continue;
const info = await this.vehicleInfo(vehicleId);
await this.history.record({
eventType: FleetEventType.MILE_VEHICLE_RELEASED,
vehicleId,
firstMileId,
driverId: info.driverId,
metadata: { mile: 'FIRST', bookingRef, vehiclePlate: info.plate, driverName: info.driverName },
});
}
return {
success: true,
allocated: allocations.length,

View File

@@ -0,0 +1,55 @@
import { Entity, Column, Index } from 'typeorm';
import { BaseEntity } from '@edr/api-common';
/**
* Append-only audit log for fleet activity. One row per transition. Queried by
* `vehicleId` (vehicle timeline) or `driverId` (driver timeline); an event may
* carry both so a driver↔vehicle assignment or a mile assignment shows on both.
* `createdAt` (from BaseEntity) is the event time.
*/
export enum FleetEventType {
DRIVER_REGISTERED = 'DRIVER_REGISTERED',
VEHICLE_REGISTERED = 'VEHICLE_REGISTERED',
DRIVER_ASSIGNED = 'DRIVER_ASSIGNED',
DRIVER_UNASSIGNED = 'DRIVER_UNASSIGNED',
VEHICLE_STATUS_CHANGED = 'VEHICLE_STATUS_CHANGED',
VEHICLE_AVAILABILITY_CHANGED = 'VEHICLE_AVAILABILITY_CHANGED',
MILE_VEHICLE_ASSIGNED = 'MILE_VEHICLE_ASSIGNED',
MILE_VEHICLE_RELEASED = 'MILE_VEHICLE_RELEASED',
MILE_STATUS_CHANGED = 'MILE_STATUS_CHANGED',
}
@Entity({ name: 'fleet_events', schema: 'freight' })
export class FleetEvent extends BaseEntity {
@Column({ name: 'event_type', type: 'varchar' })
eventType!: FleetEventType;
@Index()
@Column({ name: 'vehicle_id', type: 'uuid', nullable: true })
vehicleId?: string | null;
@Index()
@Column({ name: 'driver_id', type: 'uuid', nullable: true })
driverId?: string | null;
@Column({ name: 'first_mile_id', type: 'uuid', nullable: true })
firstMileId?: string | null;
@Column({ name: 'last_mile_id', type: 'uuid', nullable: true })
lastMileId?: string | null;
/** Previous value for a transition (e.g. old status/availability). */
@Column({ name: 'from_value', type: 'varchar', nullable: true })
fromValue?: string | null;
/** New value for a transition (e.g. new status/availability). */
@Column({ name: 'to_value', type: 'varchar', nullable: true })
toValue?: string | null;
/** Human-readable summary token (driver name, plate, booking ref, mile). */
@Column({ name: 'label', type: 'varchar', nullable: true })
label?: string | null;
@Column({ name: 'metadata', type: 'jsonb', nullable: true })
metadata?: Record<string, unknown> | null;
}

View File

@@ -0,0 +1,17 @@
import { Global, Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { FleetEvent } from './entities/fleet-event.entity';
import { FleetHistoryService } from './fleet-history.service';
/**
* Global so any fleet-touching service (vehicles, drivers, first/last-mile) can
* inject FleetHistoryService to append audit events without each module having
* to import this one.
*/
@Global()
@Module({
imports: [TypeOrmModule.forFeature([FleetEvent])],
providers: [FleetHistoryService],
exports: [FleetHistoryService],
})
export class FleetHistoryModule {}

View File

@@ -0,0 +1,54 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { FleetEvent, FleetEventType } from './entities/fleet-event.entity';
export interface FleetEventInput {
eventType: FleetEventType;
vehicleId?: string | null;
driverId?: string | null;
firstMileId?: string | null;
lastMileId?: string | null;
fromValue?: string | null;
toValue?: string | null;
label?: string | null;
metadata?: Record<string, unknown> | null;
}
@Injectable()
export class FleetHistoryService {
private readonly logger = new Logger(FleetHistoryService.name);
constructor(
@InjectRepository(FleetEvent)
private readonly eventRepo: Repository<FleetEvent>,
) {}
/**
* Append an audit event. Best-effort: recording history must never break the
* business operation that triggered it, so failures are logged and swallowed.
*/
async record(input: FleetEventInput): Promise<void> {
try {
await this.eventRepo.save(this.eventRepo.create(input));
} catch (err) {
this.logger.error(
`Failed to record fleet event ${input.eventType}: ${String(err)}`,
);
}
}
getVehicleHistory(vehicleId: string): Promise<FleetEvent[]> {
return this.eventRepo.find({
where: { vehicleId },
order: { createdAt: 'DESC' },
});
}
getDriverHistory(driverId: string): Promise<FleetEvent[]> {
return this.eventRepo.find({
where: { driverId },
order: { createdAt: 'DESC' },
});
}
}

View File

@@ -61,7 +61,7 @@ export class LastMileInvoiceService {
type: 'DELIVERY_FEE',
companyId: lm.booking!.companyId,
companyProfileId: lm.booking!.companyProfileId || '',
currency: 'ETB',
currency: lm.booking!.paymentCurrency || 'ETB',
lines: [
{
chargeType: 'DELIVERY',

View File

@@ -86,6 +86,7 @@ export class LastMileController {
const record = await this.lastMileService.update(id, dto);
// Auto-generate invoice if distance or payment was updated
const booking = await this.bookingsService.findById(record.bookingId);
const currency = booking.paymentCurrency || "ETB";
if (dto.exactKm !== undefined || dto.exactKm != record.exactKm || dto.remainingPayment !== undefined || dto.remainingPayment !== record.remainingPayment) {
await this.billingService.generateInvoice({
source: Freight.InvoiceSource.LastMile,
@@ -93,8 +94,8 @@ export class LastMileController {
type: "LAST_MILE",
companyId: booking.companyId,
companyProfileId: booking.companyProfileId,
currency: "ETB",
currency,
lines: [
{
chargeType: "LAST_MILE",
@@ -102,7 +103,7 @@ export class LastMileController {
quantity: 1,
unitRate: record.remainingPayment,
amount: record.remainingPayment,
currency: "ETB",
currency,
},
],

View File

@@ -1,10 +1,11 @@
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { DataSource, FindOptionsWhere } from 'typeorm';
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { DataSource, FindOptionsWhere, In, IsNull, Not } from 'typeorm';
import { BookingsRepository } from '../bookings/bookings.repository';
import { DriversService } from '../drivers/drivers.service';
import { SmsClientService } from '../notifications/sms-client.service';
import { VehiclesService } from '../vehicles/vehicles.service';
import { VehicleAvailability } from '../vehicles/entities/vehicle.entity';
import { CreateLastMileDto } from './dto/create-last-mile.dto';
import { UpdateLastMileDto } from './dto/update-last-mile.dto';
import { LastMile, LastMileStatus } from './entities/last-mile.entity';
@@ -12,6 +13,8 @@ import { LastMileContainerAllocation } from './entities/last-mile-container-allo
import { LastMileRepository } from './last-mile.repository';
import { InvoiceEventPayload } from '../billing/billing.service';
import { OnEvent } from '@nestjs/event-emitter';
import { FleetHistoryService } from '../fleet-history/fleet-history.service';
import { FleetEventType } from '../fleet-history/entities/fleet-event.entity';
type LastMileListFilter = {
status?: LastMileStatus;
@@ -41,8 +44,57 @@ export class LastMileService {
private readonly driversService: DriversService,
private readonly smsClient: SmsClientService,
private readonly dataSource: DataSource,
private readonly history: FleetHistoryService,
) {}
/** Resolve a vehicle's driver + human labels, for stamping mile events onto
* the driver's timeline and naming the vehicle. Best-effort — never throws. */
private async vehicleInfo(
vehicleId?: string | null,
): Promise<{ driverId: string | null; plate: string | null; driverName: string | null }> {
if (!vehicleId) return { driverId: null, plate: null, driverName: null };
try {
const v = await this.vehiclesService.findById(vehicleId);
return {
driverId: v.assignedDriverId ?? null,
plate: v.plateNumber ?? v.code ?? null,
driverName: v.assignedDriverName ?? null,
};
} catch {
return { driverId: null, plate: null, driverName: null };
}
}
/** A leg counts as having a vehicle if it has a direct assignment or at least
* one container allocation carrying a vehicle. Gates the IN_TRANSIT move. */
private async hasAssignedVehicle(
recordId: string,
directVehicleId?: string | null,
): Promise<boolean> {
if (directVehicleId) return true;
const count = await this.dataSource.manager.count(LastMileContainerAllocation, {
where: { lastMileId: recordId, vehicleId: Not(IsNull()) },
});
return count > 0;
}
/** Human booking reference for a last-mile record, for the history timeline.
* Uses the already-loaded relation when present, else looks it up. */
private async resolveBookingRef(
record: LastMile,
): Promise<string | null> {
const loaded = (record as LastMile & { booking?: { reference?: string } })
.booking?.reference;
if (loaded) return loaded;
if (!record.bookingId) return null;
try {
const booking = await this.bookingsRepository.findById(record.bookingId);
return (booking as { reference?: string } | null)?.reference ?? null;
} catch {
return null;
}
}
async acceptBooking(bookingReference: string): Promise<LastMile | null> {
const booking = await this.bookingsRepository.findByReference(bookingReference);
@@ -132,7 +184,7 @@ export class LastMileService {
}
async create(dto: CreateLastMileDto): Promise<LastMile> {
return this.lastMileRepository.create({
const record = await this.lastMileRepository.create({
bookingId: dto.bookingId,
status: dto.status ?? 'READY_TO_TRANSIT',
advancedPayment: dto.advancedPayment ?? 0,
@@ -142,6 +194,26 @@ export class LastMileService {
vehicleId: dto.vehicleId ?? null,
paid: (dto as any).paid ?? false,
});
if (dto.vehicleId) {
await this.vehiclesService.setAvailability(dto.vehicleId, VehicleAvailability.BUSY);
const info = await this.vehicleInfo(dto.vehicleId);
await this.history.record({
eventType: FleetEventType.MILE_VEHICLE_ASSIGNED,
vehicleId: dto.vehicleId,
lastMileId: record.id,
driverId: info.driverId,
label: record.status,
metadata: {
mile: 'LAST',
bookingRef: await this.resolveBookingRef(record),
vehiclePlate: info.plate,
driverName: info.driverName,
},
});
}
return record;
}
@OnEvent("lastmile.invoice.paid")
@@ -159,6 +231,18 @@ export class LastMileService {
async update(id: string, dto: UpdateLastMileDto): Promise<LastMile> {
const existing = await this.findById(id);
// A leg can only go IN_TRANSIT once a vehicle is assigned (allowing a vehicle
// assigned in this same request).
if (dto.status === 'IN_TRANSIT' && existing.status !== 'IN_TRANSIT') {
const vehicleId =
dto.vehicleId !== undefined ? dto.vehicleId : existing.vehicleId;
if (!(await this.hasAssignedVehicle(id, vehicleId))) {
throw new BadRequestException(
'Assign a vehicle before marking this last-mile leg in transit',
);
}
}
const dtoAny = dto as any;
const updated = await this.lastMileRepository.update(id, {
...(dto.bookingId !== undefined ? { bookingId: dto.bookingId } : {}),
@@ -180,9 +264,95 @@ export class LastMileService {
void this.notifyDriverAssignment(dto.vehicleId, existing);
}
// Audit the mile↔vehicle (re)assignment on both vehicle and driver lines.
const bookingRef = await this.resolveBookingRef(existing);
if (dto.vehicleId !== undefined && dto.vehicleId !== existing.vehicleId) {
// Keep vehicle availability in sync: new vehicle goes BUSY, replaced one
// is freed if no other active trip still holds it.
if (dto.vehicleId) {
await this.vehiclesService.setAvailability(dto.vehicleId, VehicleAvailability.BUSY);
}
if (existing.vehicleId) {
await this.vehiclesService.releaseIfUnused([existing.vehicleId]);
}
if (existing.vehicleId) {
const info = await this.vehicleInfo(existing.vehicleId);
await this.history.record({
eventType: FleetEventType.MILE_VEHICLE_RELEASED,
vehicleId: existing.vehicleId,
lastMileId: id,
driverId: info.driverId,
metadata: {
mile: 'LAST',
bookingRef,
vehiclePlate: info.plate,
driverName: info.driverName,
},
});
}
if (dto.vehicleId) {
const info = await this.vehicleInfo(dto.vehicleId);
await this.history.record({
eventType: FleetEventType.MILE_VEHICLE_ASSIGNED,
vehicleId: dto.vehicleId,
lastMileId: id,
driverId: info.driverId,
label: updated.status,
metadata: {
mile: 'LAST',
bookingRef,
vehiclePlate: info.plate,
driverName: info.driverName,
},
});
}
}
if (dto.status !== undefined && dto.status !== existing.status) {
const vehicleId = updated.vehicleId ?? existing.vehicleId ?? null;
const info = await this.vehicleInfo(vehicleId);
await this.history.record({
eventType: FleetEventType.MILE_STATUS_CHANGED,
lastMileId: id,
vehicleId,
driverId: info.driverId,
fromValue: existing.status,
toValue: dto.status,
metadata: {
mile: 'LAST',
bookingRef,
vehiclePlate: info.plate,
driverName: info.driverName,
},
});
}
// Delivery finished — free the vehicles this trip was holding.
if (dto.status === 'DELIVERED' && existing.status !== 'DELIVERED') {
await this.releaseVehicles(updated);
}
return updated;
}
/**
* Free every vehicle held by this record (direct assignment + container
* allocations), unless still in use by another active trip.
*/
private async releaseVehicles(record: LastMile): Promise<void> {
const recordAllocations = await this.dataSource.manager.find(
LastMileContainerAllocation,
{ where: { lastMileId: 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: LastMile): Promise<void> {
try {
const vehicle = await this.vehiclesService.findById(vehicleId);
@@ -236,6 +406,21 @@ export class LastMileService {
throw new NotFoundException(`Last-mile record ${lastMileId} not found`);
}
// Capture the vehicles currently on these containers so a reallocation can
// be diffed into assigned/released history events below.
const previousAllocations = await this.dataSource.manager.find(
LastMileContainerAllocation,
{
where: {
lastMileId,
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(LastMileContainerAllocation, {
@@ -252,6 +437,46 @@ export class LastMileService {
}
});
// Keep vehicle availability in sync: newly-allocated cars go BUSY, cars no
// longer on any of these containers are freed if unused elsewhere.
const vehicleIds = new Set(allocations.map((a) => a.vehicleId));
await Promise.all(
[...vehicleIds].map((id) =>
this.vehiclesService.setAvailability(id, VehicleAvailability.BUSY),
),
);
await this.vehiclesService.releaseIfUnused(
previousVehicleIds.filter((id) => !vehicleIds.has(id)),
);
// History: one event per vehicle actually added or removed by this
// multi-car (re)allocation, so reassignments show on every timeline.
const prevSet = new Set(previousVehicleIds);
const bookingRef = await this.resolveBookingRef(lastMile);
for (const vehicleId of vehicleIds) {
if (prevSet.has(vehicleId)) continue;
const info = await this.vehicleInfo(vehicleId);
await this.history.record({
eventType: FleetEventType.MILE_VEHICLE_ASSIGNED,
vehicleId,
lastMileId,
driverId: info.driverId,
label: lastMile.status,
metadata: { mile: 'LAST', bookingRef, vehiclePlate: info.plate, driverName: info.driverName },
});
}
for (const vehicleId of previousVehicleIds) {
if (vehicleIds.has(vehicleId)) continue;
const info = await this.vehicleInfo(vehicleId);
await this.history.record({
eventType: FleetEventType.MILE_VEHICLE_RELEASED,
vehicleId,
lastMileId,
driverId: info.driverId,
metadata: { mile: 'LAST', bookingRef, vehiclePlate: info.plate, driverName: info.driverName },
});
}
return {
success: true,
allocated: allocations.length,

View File

@@ -14,13 +14,17 @@ import { FleetManage, FleetView } from '../../common/booking-guards';
import { VehiclesService } from './vehicles.service';
import { CreateVehicleDto } from './dto/create-vehicle.dto';
import { UpdateVehicleDto } from './dto/update-vehicle.dto';
import { FleetHistoryService } from '../fleet-history/fleet-history.service';
@ApiTags('vehicles')
@ApiBearerAuth()
@Controller('vehicles')
@FleetView()
export class VehiclesController {
constructor(private readonly vehiclesService: VehiclesService) {}
constructor(
private readonly vehiclesService: VehiclesService,
private readonly fleetHistory: FleetHistoryService,
) {}
@Post()
@FleetManage()
@@ -57,6 +61,12 @@ export class VehiclesController {
return this.vehiclesService.findById(id);
}
@Get(':id/history')
@ApiOperation({ summary: 'Get vehicle assignment, status & mile history' })
history(@Param('id', ParseUUIDPipe) id: string) {
return this.fleetHistory.getVehicleHistory(id);
}
@Patch(':id')
@FleetManage()
@ApiOperation({ summary: 'Update a vehicle' })

View File

@@ -9,12 +9,15 @@ import { FirstMileContainerAllocation } from '../first-mile/entities/first-mile-
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';
import { FleetHistoryService } from '../fleet-history/fleet-history.service';
import { FleetEventType } from '../fleet-history/entities/fleet-event.entity';
@Injectable()
export class VehiclesService {
constructor(
@InjectRepository(Vehicle)
private readonly vehicleRepo: Repository<Vehicle>,
private readonly history: FleetHistoryService,
) {}
async create(dto: CreateVehicleDto): Promise<Vehicle> {
@@ -34,7 +37,28 @@ export class VehiclesService {
registrationNumber,
});
return this.vehicleRepo.save(vehicle);
const saved = await this.vehicleRepo.save(vehicle);
await this.history.record({
eventType: FleetEventType.VEHICLE_REGISTERED,
vehicleId: saved.id,
label: saved.plateNumber ?? saved.code ?? null,
toValue: saved.availability ?? null,
});
if (saved.assignedDriverId) {
await this.history.record({
eventType: FleetEventType.DRIVER_ASSIGNED,
vehicleId: saved.id,
driverId: saved.assignedDriverId,
label: saved.assignedDriverName ?? null,
metadata: {
vehiclePlate: saved.plateNumber ?? saved.code ?? null,
driverName: saved.assignedDriverName ?? null,
},
});
}
return saved;
}
async findAll(query: {
@@ -97,12 +121,76 @@ export class VehiclesService {
}
}
const prev = {
assignedDriverId: vehicle.assignedDriverId,
assignedDriverName: vehicle.assignedDriverName,
status: vehicle.status,
availability: vehicle.availability,
};
Object.assign(vehicle, dto);
return this.vehicleRepo.save(vehicle);
const saved = await this.vehicleRepo.save(vehicle);
// Driver (re)assignment — emit an unassign for the old driver and/or an
// assign for the new one so both drivers' timelines and the vehicle's line up.
if (
dto.assignedDriverId !== undefined &&
dto.assignedDriverId !== prev.assignedDriverId
) {
const vehiclePlate = saved.plateNumber ?? saved.code ?? null;
if (prev.assignedDriverId) {
await this.history.record({
eventType: FleetEventType.DRIVER_UNASSIGNED,
vehicleId: id,
driverId: prev.assignedDriverId,
label: prev.assignedDriverName ?? null,
metadata: { vehiclePlate, driverName: prev.assignedDriverName ?? null },
});
}
if (saved.assignedDriverId) {
await this.history.record({
eventType: FleetEventType.DRIVER_ASSIGNED,
vehicleId: id,
driverId: saved.assignedDriverId,
label: saved.assignedDriverName ?? null,
metadata: { vehiclePlate, driverName: saved.assignedDriverName ?? null },
});
}
}
if (dto.status !== undefined && dto.status !== prev.status) {
await this.history.record({
eventType: FleetEventType.VEHICLE_STATUS_CHANGED,
vehicleId: id,
fromValue: prev.status ?? null,
toValue: saved.status ?? null,
});
}
if (dto.availability !== undefined && dto.availability !== prev.availability) {
await this.history.record({
eventType: FleetEventType.VEHICLE_AVAILABILITY_CHANGED,
vehicleId: id,
fromValue: prev.availability ?? null,
toValue: saved.availability ?? null,
});
}
return saved;
}
async setAvailability(id: string, availability: VehicleAvailability): Promise<void> {
// Read the current value so the audit event records an accurate from→to and
// we skip logging no-op writes (setAvailability is called in release loops).
const vehicle = await this.vehicleRepo.findOne({ where: { id } });
const previous = vehicle?.availability;
await this.vehicleRepo.update(id, { availability });
if (previous !== availability) {
await this.history.record({
eventType: FleetEventType.VEHICLE_AVAILABILITY_CHANGED,
vehicleId: id,
fromValue: previous ?? null,
toValue: availability,
});
}
}
/**

View File

@@ -0,0 +1,207 @@
import { Center, Loader, Modal, Text, Timeline } from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
import {
Activity,
CircleDot,
Route,
Truck,
UserCheck,
UserMinus,
UserPlus,
} from "lucide-react";
import {
fleetHistoryService,
type FleetHistoryEvent,
} from "@/services/fleet-history.service";
import type { FleetRecord } from "@/services/fleet/fleet.service";
export interface FleetHistoryModalProps {
opened: boolean;
onClose: () => void;
entity: "driver" | "vehicle";
record: FleetRecord | null;
}
const asObj = (r: FleetRecord | null) => (r ?? {}) as Record<string, unknown>;
const titleFor = (entity: "driver" | "vehicle", record: FleetRecord | null) => {
const r = asObj(record);
if (entity === "vehicle") {
return `Vehicle history — ${r.plateNumber ?? r.code ?? ""}`.trim();
}
return `Driver history — ${[r.firstName, r.lastName]
.filter(Boolean)
.join(" ")}`.trim();
};
const mileLabel = (e: FleetHistoryEvent) =>
e.metadata?.mile === "LAST" ? "Last-mile" : "First-mile";
const arrow = (from?: string | null, to?: string | null) =>
`${from ?? "—"}${to ?? "—"}`;
const metaStr = (e: FleetHistoryEvent, key: string) => {
const v = e.metadata?.[key];
return typeof v === "string" && v ? v : null;
};
function describe(e: FleetHistoryEvent, entity: "driver" | "vehicle") {
const vehiclePlate = metaStr(e, "vehiclePlate");
const driverName = metaStr(e, "driverName") ?? (e.label || null);
const bookingRef = metaStr(e, "bookingRef");
// Compose the detail line with whatever the current view doesn't already
// know: on a driver's timeline show which vehicle; always show the booking.
const detail = (extra?: string) =>
[
entity === "driver" && vehiclePlate ? `Vehicle ${vehiclePlate}` : "",
bookingRef ? `Booking ${bookingRef}` : "",
extra ?? "",
]
.filter(Boolean)
.join(" · ");
switch (e.eventType) {
case "DRIVER_REGISTERED":
return {
icon: <UserCheck size={14} />,
title: "Driver registered",
text: e.toValue ? `Status: ${e.toValue}` : "",
};
case "VEHICLE_REGISTERED":
return {
icon: <Truck size={14} />,
title: "Vehicle registered",
text: e.toValue ? `Availability: ${e.toValue}` : "",
};
case "DRIVER_ASSIGNED":
return {
icon: <UserPlus size={14} />,
title: entity === "vehicle" ? "Driver assigned" : "Assigned to vehicle",
text:
entity === "vehicle"
? driverName
? `Driver ${driverName}`
: ""
: vehiclePlate
? `Vehicle ${vehiclePlate}`
: "",
};
case "DRIVER_UNASSIGNED":
return {
icon: <UserMinus size={14} />,
title:
entity === "vehicle"
? "Driver unassigned"
: "Unassigned from vehicle",
text:
entity === "vehicle"
? driverName
? `Driver ${driverName}`
: ""
: vehiclePlate
? `Vehicle ${vehiclePlate}`
: "",
};
case "VEHICLE_STATUS_CHANGED":
return {
icon: <CircleDot size={14} />,
title: "Status changed",
text: arrow(e.fromValue, e.toValue),
};
case "VEHICLE_AVAILABILITY_CHANGED":
return {
icon: <Activity size={14} />,
title: `Marked ${e.toValue ?? ""}`.trim(),
text: e.fromValue ? arrow(e.fromValue, e.toValue) : "",
};
case "MILE_VEHICLE_ASSIGNED":
return {
icon: <Route size={14} />,
title: `${mileLabel(e)}: vehicle assigned`,
text: detail(e.label ? `Status: ${e.label}` : ""),
};
case "MILE_VEHICLE_RELEASED":
return {
icon: <Route size={14} />,
title: `${mileLabel(e)}: vehicle released`,
text: detail(),
};
case "MILE_STATUS_CHANGED":
return {
icon: <Route size={14} />,
title: `${mileLabel(e)} status`,
text: detail(arrow(e.fromValue, e.toValue)),
};
default:
return { icon: <CircleDot size={14} />, title: e.eventType, text: "" };
}
}
const fmt = (iso: string) => {
const d = new Date(iso);
return Number.isNaN(d.getTime()) ? iso : d.toLocaleString();
};
const FleetHistoryModal = ({
opened,
onClose,
entity,
record,
}: FleetHistoryModalProps) => {
const id = asObj(record).id ? String(asObj(record).id) : "";
const { data, isLoading } = useQuery({
queryKey: ["fleet-history", entity, id],
queryFn: () =>
entity === "vehicle"
? fleetHistoryService.vehicle(id)
: fleetHistoryService.driver(id),
enabled: opened && Boolean(id),
});
const events = data ?? [];
return (
<Modal
opened={opened}
onClose={onClose}
title={<Text fw={600}>{titleFor(entity, record)}</Text>}
radius="lg"
size="lg"
centered
>
{isLoading ? (
<Center py="xl">
<Loader size="sm" />
</Center>
) : events.length === 0 ? (
<Text c="dimmed" ta="center" py="lg" size="sm">
No history recorded yet. Activity appears here as this{" "}
{entity} is assigned, reassigned, or its status changes.
</Text>
) : (
<Timeline active={events.length} bulletSize={24} lineWidth={2}>
{events.map((e) => {
const d = describe(e, entity);
return (
<Timeline.Item key={e.id} bullet={d.icon} title={d.title}>
{d.text && (
<Text size="sm" c="dimmed">
{d.text}
</Text>
)}
<Text size="xs" mt={4} c="dimmed">
{fmt(e.createdAt)}
</Text>
</Timeline.Item>
);
})}
</Timeline>
)}
</Modal>
);
};
export default FleetHistoryModal;

View File

@@ -1,4 +1,4 @@
import { Edit2, Trash2, Eye, Users, MoreVertical } from "lucide-react";
import { Edit2, Trash2, Eye, Users, MoreVertical, History } from "lucide-react";
import { ActionIcon, Menu, MenuItem, Tooltip } from "@mantine/core";
import { useNavigate } from "react-router-dom";
@@ -11,6 +11,7 @@ export interface FleetRecordActionsProps {
onEdit: (record: FleetRecord) => void;
onRemove: (record: FleetRecord) => void;
onAssignDriver?: (record: FleetRecord) => void;
onHistory?: (record: FleetRecord) => void;
layout?: "row" | "compact";
}
@@ -20,12 +21,16 @@ const FleetRecordActions = ({
onEdit,
onRemove,
onAssignDriver,
onHistory,
layout = "row",
}: FleetRecordActionsProps) => {
const navigate = useNavigate();
const removeLabel = config.removeActionLabel ?? "Delete";
const showDetail = Boolean(config.detailPath && "id" in record);
const isVehicle = config.slug === "vehicles";
const showHistory =
Boolean(onHistory) &&
(config.slug === "drivers" || config.slug === "vehicles");
const handleDetail = () => {
if (!config.detailPath || !("id" in record)) return;
@@ -57,6 +62,14 @@ const FleetRecordActions = ({
>
Edit
</MenuItem>
{showHistory ? (
<MenuItem
onClick={() => onHistory?.(record)}
leftSection={<History size={14} strokeWidth={2} />}
>
History
</MenuItem>
) : null}
{showDetail ? (
<MenuItem
onClick={handleDetail}

View File

@@ -0,0 +1,93 @@
import { Group, Stack, Text, Timeline, Tooltip } from "@mantine/core";
import { Check } from "lucide-react";
/** One stage of the last-mile delivery workflow. */
export interface LastMileStepState {
label: string;
done: boolean;
active: boolean;
/** Optional stamp/value shown next to the step (plate, time, distance…). */
detail?: string | null;
}
/**
* Compact 6-dot progress bar for a table row — filled = done, ringed = current,
* hollow = pending. Hover a dot for its label + stamp.
*/
export function LastMileStepBar({ steps }: { steps: LastMileStepState[] }) {
return (
<Group gap={4} wrap="nowrap">
{steps.map((s, i) => {
const color = s.done
? "var(--mantine-color-green-6)"
: s.active
? "var(--mantine-color-blue-5)"
: "var(--mantine-color-gray-4)";
return (
<Tooltip
key={i}
withArrow
label={`${s.label}${s.detail ? ` · ${s.detail}` : ""}`}
>
<span
style={{
width: 10,
height: 10,
borderRadius: "50%",
background: s.done ? color : "transparent",
border: `2px solid ${color}`,
boxShadow: s.active
? "0 0 0 2px var(--mantine-color-blue-1)"
: undefined,
display: "inline-block",
flex: "0 0 auto",
}}
/>
</Tooltip>
);
})}
</Group>
);
}
/**
* Vertical stepper for the detail view — completed steps bulleted + green, the
* current step highlighted, each showing its stamp/value when known.
*/
export function LastMileStepper({ steps }: { steps: LastMileStepState[] }) {
const activeIndex = steps.findIndex((s) => s.active);
// Timeline highlights items with index < `active`; count of done steps drives it.
const doneCount = steps.filter((s) => s.done).length;
return (
<Timeline
active={activeIndex === -1 ? steps.length : doneCount}
bulletSize={22}
lineWidth={2}
color="green"
>
{steps.map((s, i) => (
<Timeline.Item
key={i}
bullet={s.done ? <Check size={12} /> : undefined}
title={
<Text size="sm" fw={s.active ? 600 : 500} c={s.active ? "blue" : undefined}>
{s.label}
</Text>
}
lineVariant={s.done ? "solid" : "dashed"}
>
<Stack gap={0}>
<Text size="xs" c="dimmed">
{s.done ? "Done" : s.active ? "Current step" : "Pending"}
</Text>
{s.detail && (
<Text size="xs" c="dimmed">
{s.detail}
</Text>
)}
</Stack>
</Timeline.Item>
))}
</Timeline>
);
}

View File

@@ -10,6 +10,7 @@ import { Navigate, useLocation } from "react-router-dom";
import FleetCardGrid from "@/components/fleet/FleetCardGrid";
import FleetFormDialog from "@/components/fleet/FleetFormDialog";
import FleetHistoryModal from "@/components/fleet/FleetHistoryModal";
import FleetRecordActions from "@/components/fleet/FleetRecordActions";
import FleetToolbar from "@/components/fleet/FleetToolbar";
import { formatFleetCell, registerFleetOptionLabels } from "@/components/fleet/fleetFormat";
@@ -42,6 +43,7 @@ const FleetResourcePage = () => {
const [editing, setEditing] = useState<FleetRecord | null>(null);
const [removeTarget, setRemoveTarget] = useState<FleetRecord | null>(null);
const [assigningDriver, setAssigningDriver] = useState<FleetRecord | null>(null);
const [historyTarget, setHistoryTarget] = useState<FleetRecord | null>(null);
const [selectedDriver, setSelectedDriver] = useState<string>("");
const { viewMode, setViewMode } = useFleetViewMode(slug);
@@ -273,6 +275,7 @@ const FleetResourcePage = () => {
}}
onRemove={setRemoveTarget}
onAssignDriver={setAssigningDriver}
onHistory={setHistoryTarget}
/>
</div>
),
@@ -581,6 +584,13 @@ const FleetResourcePage = () => {
</Group>
</Stack>
</Modal>
<FleetHistoryModal
opened={Boolean(historyTarget)}
onClose={() => setHistoryTarget(null)}
entity={slug === "vehicles" ? "vehicle" : "driver"}
record={historyTarget}
/>
</Container>
);
};

View File

@@ -6,6 +6,7 @@ import {
MoreHorizontal,
PackageCheck,
Printer,
Receipt,
RefreshCw,
Ruler,
Trash,
@@ -879,10 +880,14 @@ const FirstMilePage = () => {
<Menu.Dropdown>
<Menu.Item
leftSection={<ArrowRight size={15} />}
disabled={!nextStatus}
disabled={!nextStatus || (nextStatus === "IN_TRANSIT" && !assigned)}
onClick={() => handleAdvanceStatus(row.original)}
>
{nextStatus ? `Mark ${STATUS_META[nextStatus].label}` : STATUS_META[row.original.status].label}
{nextStatus === "IN_TRANSIT" && !assigned
? "Assign a vehicle first"
: nextStatus
? `Mark ${STATUS_META[nextStatus].label}`
: STATUS_META[row.original.status].label}
</Menu.Item>
<Menu.Divider />
<Menu.Item
@@ -919,6 +924,13 @@ const FirstMilePage = () => {
>
Add distance
</Menu.Item>
<Menu.Item
leftSection={<Receipt size={15} />}
disabled={!(row.original.exactKm != null && row.original.exactKm > 0)}
onClick={() => openInvoice(row.original)}
>
Generate Invoice
</Menu.Item>
{canPrint && (
<Menu.Item
leftSection={<Printer size={15} />}
@@ -954,7 +966,7 @@ const FirstMilePage = () => {
}, [vehicleOptions]);
return (
<Stack gap="md">
<Stack gap="md" p="md">
<Card radius="lg" padding={0} withBorder style={{ borderColor: "var(--mantine-color-gray-2)" }}>
<Stack gap={0}>
<Box px="md" pt="md" pb="sm" w="100%">

View File

@@ -4,6 +4,7 @@ import {
Eye,
MoreHorizontal,
Printer,
Receipt,
RefreshCw,
Ruler,
Trash,
@@ -49,6 +50,7 @@ import { driversService, type Driver } from "@/services/drivers.service";
import { ratesService } from "@/services/rates.service";
import { LastMileContainerAllocationTable, type LastMileContainerRow } from "@/components/LastMileContainerAllocationTable";
import { ReleaseOrderModal, type ReleaseOrderTruckPrefill } from "@/components/warehouses/ReleaseOrderModal";
import { LastMileStepper, type LastMileStepState } from "@/components/operations/LastMileSteps";
import { api } from "@/auth/http";
const formatPrice = (amount: number) =>
@@ -92,6 +94,58 @@ const vehicleLabel = (record: LastMileRecord) => {
const isAssigned = (record: LastMileRecord) => Boolean(record.vehicleId);
const fmtStamp = (iso?: string | null) => {
if (!iso) return null;
const d = new Date(iso);
return Number.isNaN(d.getTime()) ? null : d.toLocaleString();
};
/**
* Derive the 6-step last-mile workflow state for a record. Step completion is
* read from the record + its pickup-ready (warehouse release) row:
* assign→vehicleId, arrived→release order issued, leave→releaseDate,
* in-transit/delivered→status, distance→exactKm.
*/
const computeLastMileSteps = (
record: LastMileRecord,
releaseRow?: ImportUnloadedItem,
): LastMileStepState[] => {
const exactKm = (record as { exactKm?: number | null }).exactKm;
// Truck arrival/leave live in the transient warehouse pickup-ready queue and
// vanish once the item is released. So once the leg is IN_TRANSIT/DELIVERED,
// treat both as done (the truck must have arrived + left to get there).
const past = record.status === "IN_TRANSIT" || record.status === "DELIVERED";
const flags = [
record.status !== "PAYMENT_PENDING",
Boolean(record.vehicleId),
past || Boolean(releaseRow?.releaseOrderReference),
past || Boolean(releaseRow?.releaseDate),
past,
exactKm != null,
exactKm != null, // Generate Invoice — auto-generated when distance is saved
record.status === "DELIVERED",
];
// Current step = earliest incomplete one.
const activeIdx = flags.findIndex((f) => !f);
const labels = ["Ready to Transit", "Assign vehicle", "Truck arrived", "Truck leave", "In transit", "Add distance", "Generate Invoice", "Delivered"];
const details: (string | null)[] = [
null,
record.vehicle?.plateNumber ?? null,
releaseRow?.releaseOrderReference ?? null,
fmtStamp(releaseRow?.releaseDate),
null,
exactKm != null ? `${exactKm} KM` : null,
exactKm != null ? "Invoice ready" : null,
fmtStamp(releaseRow?.deliveredAt),
];
return labels.map((label, i) => ({
label,
done: flags[i],
active: i === activeIdx,
detail: details[i],
}));
};
const bookingRef = (r: LastMileRecord) => r.booking?.reference ?? r.bookingId;
const customerName = (r: LastMileRecord) => r.booking?.company?.name ?? "—";
const deliveryLocation = (r: LastMileRecord) => r.booking?.lastMileDeliveryAddress ?? "—";
@@ -955,7 +1009,24 @@ const LastMilePage = () => {
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";
// Gate on PERSISTENT state (status/vehicle/distance), not the truck
// arrival/leave signals — those live in the warehouse queue and vanish
// once the item is released, so they can't gate the status advance.
const status = row.original.status;
const hasDistance = row.original.exactKm != null;
// Advance: PAYMENT_PENDING→Ready, READY_TO_TRANSIT→In-transit (needs a
// vehicle), IN_TRANSIT→Delivered (needs distance/invoice).
const canAdvance =
status === "PAYMENT_PENDING" ||
(status === "READY_TO_TRANSIT" && assigned) ||
(status === "IN_TRANSIT" && hasDistance);
const canAssignStep = !assigned && status !== "DELIVERED";
const canDistance = status === "IN_TRANSIT";
// Truck arrival/leaving are independent — each driven only by its own
// warehouse state: arrive once assigned & not arrived, leave once
// arrived & not departed.
const canArrive = assigned && !releaseRow?.releaseOrderReference;
const canLeave = Boolean(releaseRow?.releaseOrderReference) && !releaseRow?.releaseDate;
return (
<Group gap={4} justify="flex-end" wrap="nowrap">
<Menu position="bottom-end" width={200} withinPortal>
@@ -967,15 +1038,17 @@ const LastMilePage = () => {
<Menu.Dropdown>
<Menu.Item
leftSection={<ArrowRight size={15} />}
disabled={!nextStatus}
disabled={!nextStatus || !canAdvance}
onClick={() => handleAdvanceStatus(row.original)}
>
{nextStatus ? `Mark ${STATUS_META[nextStatus].label}` : STATUS_META[row.original.status].label}
{nextStatus
? `Mark ${STATUS_META[nextStatus].label}`
: STATUS_META[row.original.status].label}
</Menu.Item>
<Menu.Divider />
<Menu.Item
leftSection={<Truck size={15} />}
disabled={assigned || delivered}
disabled={!canAssignStep}
onClick={() => openAssign(row.original.id)}
>
Assign
@@ -989,10 +1062,17 @@ const LastMilePage = () => {
</Menu.Item>
<Menu.Item
leftSection={<Truck size={15} />}
disabled={!assigned}
disabled={!canArrive}
onClick={() => openTruckArrival(row.original)}
>
{truckArrivalLabel}
Truck Arrival
</Menu.Item>
<Menu.Item
leftSection={<Truck size={15} />}
disabled={!canLeave}
onClick={() => openTruckArrival(row.original)}
>
Truck Leaving
</Menu.Item>
<Menu.Divider />
<Menu.Item
@@ -1003,11 +1083,18 @@ const LastMilePage = () => {
</Menu.Item>
<Menu.Item
leftSection={<Ruler size={15} />}
disabled={delivered}
disabled={!canDistance}
onClick={() => openDistance(row.original.id)}
>
Add distance
</Menu.Item>
<Menu.Item
leftSection={<Receipt size={15} />}
disabled={!(row.original.exactKm != null && row.original.exactKm > 0)}
onClick={() => openInvoice(row.original)}
>
Generate Invoice
</Menu.Item>
{canPrint && (
<Menu.Item
leftSection={<Printer size={15} />}
@@ -1044,7 +1131,7 @@ const LastMilePage = () => {
}, [vehicleOptions, pickupReadyByBooking]);
return (
<Stack gap="md">
<Stack gap="md" p="md">
<Card radius="lg" padding={0} withBorder style={{ borderColor: "var(--mantine-color-gray-2)" }}>
<Stack gap={0}>
<Box px="md" pt="md" pb="sm" w="100%">
@@ -1320,6 +1407,18 @@ const LastMilePage = () => {
>
<Stack gap="md">
{activeRecord && <BookingInfo record={activeRecord} />}
{activeRecord && (
<Card withBorder padding="md" radius="md">
<Text fw={600} size="sm" mb="sm">Delivery steps</Text>
<LastMileStepper
steps={computeLastMileSteps(
activeRecord,
pickupReadyByBooking.get(activeRecord.bookingId) ??
pickupReadyByBooking.get(bookingRef(activeRecord)),
)}
/>
</Card>
)}
<Group justify="flex-end">
<Button variant="default" onClick={() => { setDetailOpen(false); setActiveId(null); }}>Close</Button>
</Group>

View File

@@ -0,0 +1,38 @@
import { api as apiClient } from "../auth/http";
export type FleetEventType =
| "DRIVER_REGISTERED"
| "VEHICLE_REGISTERED"
| "DRIVER_ASSIGNED"
| "DRIVER_UNASSIGNED"
| "VEHICLE_STATUS_CHANGED"
| "VEHICLE_AVAILABILITY_CHANGED"
| "MILE_VEHICLE_ASSIGNED"
| "MILE_VEHICLE_RELEASED"
| "MILE_STATUS_CHANGED";
export interface FleetHistoryEvent {
id: string;
eventType: FleetEventType;
vehicleId?: string | null;
driverId?: string | null;
firstMileId?: string | null;
lastMileId?: string | null;
fromValue?: string | null;
toValue?: string | null;
label?: string | null;
metadata?: Record<string, unknown> | null;
createdAt: string;
}
/** Timeline of fleet events for a driver or a vehicle (newest first). */
export const fleetHistoryService = {
driver: (id: string) =>
apiClient
.get<FleetHistoryEvent[]>(`/drivers/${id}/history`)
.then((r) => r.data),
vehicle: (id: string) =>
apiClient
.get<FleetHistoryEvent[]>(`/vehicles/${id}/history`)
.then((r) => r.data),
};