mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 09:58:12 +00:00
fix
This commit is contained in:
@@ -77,6 +77,7 @@ import { LastMileModule } from './modules/last-mile/last-mile.module';
|
|||||||
import { InterchangeDocumentsModule } from './modules/interchange-documents/interchange-documents.module';
|
import { InterchangeDocumentsModule } from './modules/interchange-documents/interchange-documents.module';
|
||||||
import { ImportOperationsModule } from './modules/import-operations/import-operations.module';
|
import { ImportOperationsModule } from './modules/import-operations/import-operations.module';
|
||||||
import { VerifaydaModule } from './modules/verifayda/verifayda.module';
|
import { VerifaydaModule } from './modules/verifayda/verifayda.module';
|
||||||
|
import { FleetHistoryModule } from './modules/fleet-history/fleet-history.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -144,6 +145,7 @@ import { VerifaydaModule } from './modules/verifayda/verifayda.module';
|
|||||||
InterchangeDocumentsModule,
|
InterchangeDocumentsModule,
|
||||||
ImportOperationsModule,
|
ImportOperationsModule,
|
||||||
VerifaydaModule,
|
VerifaydaModule,
|
||||||
|
FleetHistoryModule,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
EdrOrgSeeder,
|
EdrOrgSeeder,
|
||||||
|
|||||||
@@ -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`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,13 +14,17 @@ import { FleetManage, FleetView } from '../../common/booking-guards';
|
|||||||
import { DriversService } from './drivers.service';
|
import { DriversService } from './drivers.service';
|
||||||
import { CreateDriverDto } from './dto/create-driver.dto';
|
import { CreateDriverDto } from './dto/create-driver.dto';
|
||||||
import { UpdateDriverDto } from './dto/update-driver.dto';
|
import { UpdateDriverDto } from './dto/update-driver.dto';
|
||||||
|
import { FleetHistoryService } from '../fleet-history/fleet-history.service';
|
||||||
|
|
||||||
@ApiTags('drivers')
|
@ApiTags('drivers')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@Controller('drivers')
|
@Controller('drivers')
|
||||||
@FleetView()
|
@FleetView()
|
||||||
export class DriversController {
|
export class DriversController {
|
||||||
constructor(private readonly driversService: DriversService) {}
|
constructor(
|
||||||
|
private readonly driversService: DriversService,
|
||||||
|
private readonly fleetHistory: FleetHistoryService,
|
||||||
|
) {}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@FleetManage()
|
@FleetManage()
|
||||||
@@ -55,6 +59,12 @@ export class DriversController {
|
|||||||
return this.driversService.findById(id);
|
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')
|
@Patch(':id')
|
||||||
@FleetManage()
|
@FleetManage()
|
||||||
@ApiOperation({ summary: 'Update a driver' })
|
@ApiOperation({ summary: 'Update a driver' })
|
||||||
|
|||||||
@@ -4,12 +4,15 @@ import { Repository } from 'typeorm';
|
|||||||
import { CreateDriverDto } from './dto/create-driver.dto';
|
import { CreateDriverDto } from './dto/create-driver.dto';
|
||||||
import { UpdateDriverDto } from './dto/update-driver.dto';
|
import { UpdateDriverDto } from './dto/update-driver.dto';
|
||||||
import { Driver, DriverStatus } from './entities/driver.entity';
|
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()
|
@Injectable()
|
||||||
export class DriversService {
|
export class DriversService {
|
||||||
constructor(
|
constructor(
|
||||||
@InjectRepository(Driver)
|
@InjectRepository(Driver)
|
||||||
private readonly driverRepo: Repository<Driver>,
|
private readonly driverRepo: Repository<Driver>,
|
||||||
|
private readonly history: FleetHistoryService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async create(dto: CreateDriverDto): Promise<Driver> {
|
async create(dto: CreateDriverDto): Promise<Driver> {
|
||||||
@@ -51,7 +54,16 @@ export class DriversService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const driver = this.driverRepo.create(dto);
|
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: {
|
async findAll(query: {
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import { FirstMileContainerAllocation } from "./entities/first-mile-container-al
|
|||||||
import { FirstMileRepository } from "./first-mile.repository";
|
import { FirstMileRepository } from "./first-mile.repository";
|
||||||
import { OnEvent } from "@nestjs/event-emitter";
|
import { OnEvent } from "@nestjs/event-emitter";
|
||||||
import { InvoiceEventPayload } from "../billing/billing.service";
|
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 = {
|
type FirstMileListFilter = {
|
||||||
status?: FirstMileStatus;
|
status?: FirstMileStatus;
|
||||||
@@ -43,8 +45,21 @@ export class FirstMileService {
|
|||||||
private readonly vehiclesService: VehiclesService,
|
private readonly vehiclesService: VehiclesService,
|
||||||
private readonly driversService: DriversService,
|
private readonly driversService: DriversService,
|
||||||
private readonly smsClient: SmsClientService,
|
private readonly smsClient: SmsClientService,
|
||||||
|
private readonly history: FleetHistoryService,
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
|
/** Resolve the driver currently assigned to a vehicle, for stamping mile
|
||||||
|
* events onto that driver's timeline. Best-effort — never throws. */
|
||||||
|
private async resolveDriverId(vehicleId?: string | null): Promise<string | null> {
|
||||||
|
if (!vehicleId) return null;
|
||||||
|
try {
|
||||||
|
const vehicle = await this.vehiclesService.findById(vehicleId);
|
||||||
|
return vehicle.assignedDriverId ?? null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Look up a booking by its human-readable reference and confirm it has been
|
* 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
|
* paid before any first-mile work proceeds. Throws if the reference is
|
||||||
@@ -210,6 +225,14 @@ export class FirstMileService {
|
|||||||
|
|
||||||
if (dto.vehicleId) {
|
if (dto.vehicleId) {
|
||||||
await this.vehiclesService.setAvailability(dto.vehicleId, VehicleAvailability.BUSY);
|
await this.vehiclesService.setAvailability(dto.vehicleId, VehicleAvailability.BUSY);
|
||||||
|
await this.history.record({
|
||||||
|
eventType: FleetEventType.MILE_VEHICLE_ASSIGNED,
|
||||||
|
vehicleId: dto.vehicleId,
|
||||||
|
firstMileId: record.id,
|
||||||
|
driverId: await this.resolveDriverId(dto.vehicleId),
|
||||||
|
label: record.status,
|
||||||
|
metadata: { mile: 'FIRST' },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return record;
|
return record;
|
||||||
@@ -278,6 +301,26 @@ export class FirstMileService {
|
|||||||
if (existing.vehicleId) {
|
if (existing.vehicleId) {
|
||||||
await this.vehiclesService.releaseIfUnused([existing.vehicleId]);
|
await this.vehiclesService.releaseIfUnused([existing.vehicleId]);
|
||||||
}
|
}
|
||||||
|
// Audit the mile↔vehicle (re)assignment on both vehicle and driver lines.
|
||||||
|
if (existing.vehicleId) {
|
||||||
|
await this.history.record({
|
||||||
|
eventType: FleetEventType.MILE_VEHICLE_RELEASED,
|
||||||
|
vehicleId: existing.vehicleId,
|
||||||
|
firstMileId: id,
|
||||||
|
driverId: await this.resolveDriverId(existing.vehicleId),
|
||||||
|
metadata: { mile: 'FIRST' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (dto.vehicleId) {
|
||||||
|
await this.history.record({
|
||||||
|
eventType: FleetEventType.MILE_VEHICLE_ASSIGNED,
|
||||||
|
vehicleId: dto.vehicleId,
|
||||||
|
firstMileId: id,
|
||||||
|
driverId: await this.resolveDriverId(dto.vehicleId),
|
||||||
|
label: updated.status,
|
||||||
|
metadata: { mile: 'FIRST' },
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Notify assigned driver on every explicit vehicle assignment or reassignment
|
// Notify assigned driver on every explicit vehicle assignment or reassignment
|
||||||
@@ -285,6 +328,19 @@ export class FirstMileService {
|
|||||||
void this.notifyDriverAssignment(dto.vehicleId, existing);
|
void this.notifyDriverAssignment(dto.vehicleId, existing);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (dto.status !== undefined && dto.status !== existing.status) {
|
||||||
|
const vehicleId = updated.vehicleId ?? existing.vehicleId ?? null;
|
||||||
|
await this.history.record({
|
||||||
|
eventType: FleetEventType.MILE_STATUS_CHANGED,
|
||||||
|
firstMileId: id,
|
||||||
|
vehicleId,
|
||||||
|
driverId: await this.resolveDriverId(vehicleId),
|
||||||
|
fromValue: existing.status,
|
||||||
|
toValue: dto.status,
|
||||||
|
metadata: { mile: 'FIRST' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Trip finished — release the vehicles it was holding
|
// Trip finished — release the vehicles it was holding
|
||||||
if (dto.status === 'RECEIVED_TO_PORT' && existing.status !== 'RECEIVED_TO_PORT') {
|
if (dto.status === 'RECEIVED_TO_PORT' && existing.status !== 'RECEIVED_TO_PORT') {
|
||||||
await this.releaseVehicles(updated);
|
await this.releaseVehicles(updated);
|
||||||
@@ -301,6 +357,19 @@ export class FirstMileService {
|
|||||||
throw new NotFoundException(`First-mile record ${id} not found`);
|
throw new NotFoundException(`First-mile record ${id} not found`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (status !== existing.status) {
|
||||||
|
const vehicleId = updated.vehicleId ?? existing.vehicleId ?? null;
|
||||||
|
await this.history.record({
|
||||||
|
eventType: FleetEventType.MILE_STATUS_CHANGED,
|
||||||
|
firstMileId: id,
|
||||||
|
vehicleId,
|
||||||
|
driverId: await this.resolveDriverId(vehicleId),
|
||||||
|
fromValue: existing.status,
|
||||||
|
toValue: status,
|
||||||
|
metadata: { mile: 'FIRST' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (status === 'RECEIVED_TO_PORT' && existing.status !== 'RECEIVED_TO_PORT') {
|
if (status === 'RECEIVED_TO_PORT' && existing.status !== 'RECEIVED_TO_PORT') {
|
||||||
await this.releaseVehicles(updated);
|
await this.releaseVehicles(updated);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -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' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,8 @@ import { LastMileContainerAllocation } from './entities/last-mile-container-allo
|
|||||||
import { LastMileRepository } from './last-mile.repository';
|
import { LastMileRepository } from './last-mile.repository';
|
||||||
import { InvoiceEventPayload } from '../billing/billing.service';
|
import { InvoiceEventPayload } from '../billing/billing.service';
|
||||||
import { OnEvent } from '@nestjs/event-emitter';
|
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 = {
|
type LastMileListFilter = {
|
||||||
status?: LastMileStatus;
|
status?: LastMileStatus;
|
||||||
@@ -41,8 +43,21 @@ export class LastMileService {
|
|||||||
private readonly driversService: DriversService,
|
private readonly driversService: DriversService,
|
||||||
private readonly smsClient: SmsClientService,
|
private readonly smsClient: SmsClientService,
|
||||||
private readonly dataSource: DataSource,
|
private readonly dataSource: DataSource,
|
||||||
|
private readonly history: FleetHistoryService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
/** Resolve the driver currently assigned to a vehicle, for stamping mile
|
||||||
|
* events onto that driver's timeline. Best-effort — never throws. */
|
||||||
|
private async resolveDriverId(vehicleId?: string | null): Promise<string | null> {
|
||||||
|
if (!vehicleId) return null;
|
||||||
|
try {
|
||||||
|
const vehicle = await this.vehiclesService.findById(vehicleId);
|
||||||
|
return vehicle.assignedDriverId ?? null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async acceptBooking(bookingReference: string): Promise<LastMile | null> {
|
async acceptBooking(bookingReference: string): Promise<LastMile | null> {
|
||||||
const booking = await this.bookingsRepository.findByReference(bookingReference);
|
const booking = await this.bookingsRepository.findByReference(bookingReference);
|
||||||
|
|
||||||
@@ -132,7 +147,7 @@ export class LastMileService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async create(dto: CreateLastMileDto): Promise<LastMile> {
|
async create(dto: CreateLastMileDto): Promise<LastMile> {
|
||||||
return this.lastMileRepository.create({
|
const record = await this.lastMileRepository.create({
|
||||||
bookingId: dto.bookingId,
|
bookingId: dto.bookingId,
|
||||||
status: dto.status ?? 'READY_TO_TRANSIT',
|
status: dto.status ?? 'READY_TO_TRANSIT',
|
||||||
advancedPayment: dto.advancedPayment ?? 0,
|
advancedPayment: dto.advancedPayment ?? 0,
|
||||||
@@ -142,6 +157,19 @@ export class LastMileService {
|
|||||||
vehicleId: dto.vehicleId ?? null,
|
vehicleId: dto.vehicleId ?? null,
|
||||||
paid: (dto as any).paid ?? false,
|
paid: (dto as any).paid ?? false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (dto.vehicleId) {
|
||||||
|
await this.history.record({
|
||||||
|
eventType: FleetEventType.MILE_VEHICLE_ASSIGNED,
|
||||||
|
vehicleId: dto.vehicleId,
|
||||||
|
lastMileId: record.id,
|
||||||
|
driverId: await this.resolveDriverId(dto.vehicleId),
|
||||||
|
label: record.status,
|
||||||
|
metadata: { mile: 'LAST' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return record;
|
||||||
}
|
}
|
||||||
|
|
||||||
@OnEvent("lastmile.invoice.paid")
|
@OnEvent("lastmile.invoice.paid")
|
||||||
@@ -180,6 +208,42 @@ export class LastMileService {
|
|||||||
void this.notifyDriverAssignment(dto.vehicleId, existing);
|
void this.notifyDriverAssignment(dto.vehicleId, existing);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Audit the mile↔vehicle (re)assignment on both vehicle and driver lines.
|
||||||
|
if (dto.vehicleId !== undefined && dto.vehicleId !== existing.vehicleId) {
|
||||||
|
if (existing.vehicleId) {
|
||||||
|
await this.history.record({
|
||||||
|
eventType: FleetEventType.MILE_VEHICLE_RELEASED,
|
||||||
|
vehicleId: existing.vehicleId,
|
||||||
|
lastMileId: id,
|
||||||
|
driverId: await this.resolveDriverId(existing.vehicleId),
|
||||||
|
metadata: { mile: 'LAST' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (dto.vehicleId) {
|
||||||
|
await this.history.record({
|
||||||
|
eventType: FleetEventType.MILE_VEHICLE_ASSIGNED,
|
||||||
|
vehicleId: dto.vehicleId,
|
||||||
|
lastMileId: id,
|
||||||
|
driverId: await this.resolveDriverId(dto.vehicleId),
|
||||||
|
label: updated.status,
|
||||||
|
metadata: { mile: 'LAST' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dto.status !== undefined && dto.status !== existing.status) {
|
||||||
|
const vehicleId = updated.vehicleId ?? existing.vehicleId ?? null;
|
||||||
|
await this.history.record({
|
||||||
|
eventType: FleetEventType.MILE_STATUS_CHANGED,
|
||||||
|
lastMileId: id,
|
||||||
|
vehicleId,
|
||||||
|
driverId: await this.resolveDriverId(vehicleId),
|
||||||
|
fromValue: existing.status,
|
||||||
|
toValue: dto.status,
|
||||||
|
metadata: { mile: 'LAST' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return updated;
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,13 +14,17 @@ import { FleetManage, FleetView } from '../../common/booking-guards';
|
|||||||
import { VehiclesService } from './vehicles.service';
|
import { VehiclesService } from './vehicles.service';
|
||||||
import { CreateVehicleDto } from './dto/create-vehicle.dto';
|
import { CreateVehicleDto } from './dto/create-vehicle.dto';
|
||||||
import { UpdateVehicleDto } from './dto/update-vehicle.dto';
|
import { UpdateVehicleDto } from './dto/update-vehicle.dto';
|
||||||
|
import { FleetHistoryService } from '../fleet-history/fleet-history.service';
|
||||||
|
|
||||||
@ApiTags('vehicles')
|
@ApiTags('vehicles')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@Controller('vehicles')
|
@Controller('vehicles')
|
||||||
@FleetView()
|
@FleetView()
|
||||||
export class VehiclesController {
|
export class VehiclesController {
|
||||||
constructor(private readonly vehiclesService: VehiclesService) {}
|
constructor(
|
||||||
|
private readonly vehiclesService: VehiclesService,
|
||||||
|
private readonly fleetHistory: FleetHistoryService,
|
||||||
|
) {}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@FleetManage()
|
@FleetManage()
|
||||||
@@ -57,6 +61,12 @@ export class VehiclesController {
|
|||||||
return this.vehiclesService.findById(id);
|
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')
|
@Patch(':id')
|
||||||
@FleetManage()
|
@FleetManage()
|
||||||
@ApiOperation({ summary: 'Update a vehicle' })
|
@ApiOperation({ summary: 'Update a vehicle' })
|
||||||
|
|||||||
@@ -9,12 +9,15 @@ import { FirstMileContainerAllocation } from '../first-mile/entities/first-mile-
|
|||||||
import { LastMile, LastMileStatus } from '../last-mile/entities/last-mile.entity';
|
import { LastMile, LastMileStatus } from '../last-mile/entities/last-mile.entity';
|
||||||
import { LastMileContainerAllocation } from '../last-mile/entities/last-mile-container-allocation.entity';
|
import { LastMileContainerAllocation } from '../last-mile/entities/last-mile-container-allocation.entity';
|
||||||
import { BookingContainerAllocation } from '../bookings/entities/booking-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()
|
@Injectable()
|
||||||
export class VehiclesService {
|
export class VehiclesService {
|
||||||
constructor(
|
constructor(
|
||||||
@InjectRepository(Vehicle)
|
@InjectRepository(Vehicle)
|
||||||
private readonly vehicleRepo: Repository<Vehicle>,
|
private readonly vehicleRepo: Repository<Vehicle>,
|
||||||
|
private readonly history: FleetHistoryService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async create(dto: CreateVehicleDto): Promise<Vehicle> {
|
async create(dto: CreateVehicleDto): Promise<Vehicle> {
|
||||||
@@ -34,7 +37,24 @@ export class VehiclesService {
|
|||||||
registrationNumber,
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return saved;
|
||||||
}
|
}
|
||||||
|
|
||||||
async findAll(query: {
|
async findAll(query: {
|
||||||
@@ -97,12 +117,73 @@ export class VehiclesService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const prev = {
|
||||||
|
assignedDriverId: vehicle.assignedDriverId,
|
||||||
|
assignedDriverName: vehicle.assignedDriverName,
|
||||||
|
status: vehicle.status,
|
||||||
|
availability: vehicle.availability,
|
||||||
|
};
|
||||||
|
|
||||||
Object.assign(vehicle, dto);
|
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
|
||||||
|
) {
|
||||||
|
if (prev.assignedDriverId) {
|
||||||
|
await this.history.record({
|
||||||
|
eventType: FleetEventType.DRIVER_UNASSIGNED,
|
||||||
|
vehicleId: id,
|
||||||
|
driverId: prev.assignedDriverId,
|
||||||
|
label: prev.assignedDriverName ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (saved.assignedDriverId) {
|
||||||
|
await this.history.record({
|
||||||
|
eventType: FleetEventType.DRIVER_ASSIGNED,
|
||||||
|
vehicleId: id,
|
||||||
|
driverId: saved.assignedDriverId,
|
||||||
|
label: 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> {
|
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 });
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,176 @@
|
|||||||
|
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 ?? "—"}`;
|
||||||
|
|
||||||
|
function describe(e: FleetHistoryEvent, entity: "driver" | "vehicle") {
|
||||||
|
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${e.label ? `: ${e.label}` : ""}`
|
||||||
|
: "Assigned to a vehicle",
|
||||||
|
text: "",
|
||||||
|
};
|
||||||
|
case "DRIVER_UNASSIGNED":
|
||||||
|
return {
|
||||||
|
icon: <UserMinus size={14} />,
|
||||||
|
title:
|
||||||
|
entity === "vehicle"
|
||||||
|
? `Driver unassigned${e.label ? `: ${e.label}` : ""}`
|
||||||
|
: "Unassigned from a vehicle",
|
||||||
|
text: "",
|
||||||
|
};
|
||||||
|
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: e.label ? `Status: ${e.label}` : "",
|
||||||
|
};
|
||||||
|
case "MILE_VEHICLE_RELEASED":
|
||||||
|
return {
|
||||||
|
icon: <Route size={14} />,
|
||||||
|
title: `${mileLabel(e)}: vehicle released`,
|
||||||
|
text: "",
|
||||||
|
};
|
||||||
|
case "MILE_STATUS_CHANGED":
|
||||||
|
return {
|
||||||
|
icon: <Route size={14} />,
|
||||||
|
title: `${mileLabel(e)} status`,
|
||||||
|
text: 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;
|
||||||
@@ -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 { ActionIcon, Menu, MenuItem, Tooltip } from "@mantine/core";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
@@ -11,6 +11,7 @@ export interface FleetRecordActionsProps {
|
|||||||
onEdit: (record: FleetRecord) => void;
|
onEdit: (record: FleetRecord) => void;
|
||||||
onRemove: (record: FleetRecord) => void;
|
onRemove: (record: FleetRecord) => void;
|
||||||
onAssignDriver?: (record: FleetRecord) => void;
|
onAssignDriver?: (record: FleetRecord) => void;
|
||||||
|
onHistory?: (record: FleetRecord) => void;
|
||||||
layout?: "row" | "compact";
|
layout?: "row" | "compact";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -20,12 +21,16 @@ const FleetRecordActions = ({
|
|||||||
onEdit,
|
onEdit,
|
||||||
onRemove,
|
onRemove,
|
||||||
onAssignDriver,
|
onAssignDriver,
|
||||||
|
onHistory,
|
||||||
layout = "row",
|
layout = "row",
|
||||||
}: FleetRecordActionsProps) => {
|
}: FleetRecordActionsProps) => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const removeLabel = config.removeActionLabel ?? "Delete";
|
const removeLabel = config.removeActionLabel ?? "Delete";
|
||||||
const showDetail = Boolean(config.detailPath && "id" in record);
|
const showDetail = Boolean(config.detailPath && "id" in record);
|
||||||
const isVehicle = config.slug === "vehicles";
|
const isVehicle = config.slug === "vehicles";
|
||||||
|
const showHistory =
|
||||||
|
Boolean(onHistory) &&
|
||||||
|
(config.slug === "drivers" || config.slug === "vehicles");
|
||||||
|
|
||||||
const handleDetail = () => {
|
const handleDetail = () => {
|
||||||
if (!config.detailPath || !("id" in record)) return;
|
if (!config.detailPath || !("id" in record)) return;
|
||||||
@@ -57,6 +62,14 @@ const FleetRecordActions = ({
|
|||||||
>
|
>
|
||||||
Edit
|
Edit
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
|
{showHistory ? (
|
||||||
|
<MenuItem
|
||||||
|
onClick={() => onHistory?.(record)}
|
||||||
|
leftSection={<History size={14} strokeWidth={2} />}
|
||||||
|
>
|
||||||
|
History
|
||||||
|
</MenuItem>
|
||||||
|
) : null}
|
||||||
{showDetail ? (
|
{showDetail ? (
|
||||||
<MenuItem
|
<MenuItem
|
||||||
onClick={handleDetail}
|
onClick={handleDetail}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { Navigate, useLocation } from "react-router-dom";
|
|||||||
|
|
||||||
import FleetCardGrid from "@/components/fleet/FleetCardGrid";
|
import FleetCardGrid from "@/components/fleet/FleetCardGrid";
|
||||||
import FleetFormDialog from "@/components/fleet/FleetFormDialog";
|
import FleetFormDialog from "@/components/fleet/FleetFormDialog";
|
||||||
|
import FleetHistoryModal from "@/components/fleet/FleetHistoryModal";
|
||||||
import FleetRecordActions from "@/components/fleet/FleetRecordActions";
|
import FleetRecordActions from "@/components/fleet/FleetRecordActions";
|
||||||
import FleetToolbar from "@/components/fleet/FleetToolbar";
|
import FleetToolbar from "@/components/fleet/FleetToolbar";
|
||||||
import { formatFleetCell, registerFleetOptionLabels } from "@/components/fleet/fleetFormat";
|
import { formatFleetCell, registerFleetOptionLabels } from "@/components/fleet/fleetFormat";
|
||||||
@@ -42,6 +43,7 @@ const FleetResourcePage = () => {
|
|||||||
const [editing, setEditing] = useState<FleetRecord | null>(null);
|
const [editing, setEditing] = useState<FleetRecord | null>(null);
|
||||||
const [removeTarget, setRemoveTarget] = useState<FleetRecord | null>(null);
|
const [removeTarget, setRemoveTarget] = useState<FleetRecord | null>(null);
|
||||||
const [assigningDriver, setAssigningDriver] = useState<FleetRecord | null>(null);
|
const [assigningDriver, setAssigningDriver] = useState<FleetRecord | null>(null);
|
||||||
|
const [historyTarget, setHistoryTarget] = useState<FleetRecord | null>(null);
|
||||||
const [selectedDriver, setSelectedDriver] = useState<string>("");
|
const [selectedDriver, setSelectedDriver] = useState<string>("");
|
||||||
const { viewMode, setViewMode } = useFleetViewMode(slug);
|
const { viewMode, setViewMode } = useFleetViewMode(slug);
|
||||||
|
|
||||||
@@ -273,6 +275,7 @@ const FleetResourcePage = () => {
|
|||||||
}}
|
}}
|
||||||
onRemove={setRemoveTarget}
|
onRemove={setRemoveTarget}
|
||||||
onAssignDriver={setAssigningDriver}
|
onAssignDriver={setAssigningDriver}
|
||||||
|
onHistory={setHistoryTarget}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
@@ -581,6 +584,13 @@ const FleetResourcePage = () => {
|
|||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
<FleetHistoryModal
|
||||||
|
opened={Boolean(historyTarget)}
|
||||||
|
onClose={() => setHistoryTarget(null)}
|
||||||
|
entity={slug === "vehicles" ? "vehicle" : "driver"}
|
||||||
|
record={historyTarget}
|
||||||
|
/>
|
||||||
</Container>
|
</Container>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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),
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user