mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Last mile confirmation request , approval, payment Feature
This commit is contained in:
@@ -103,6 +103,7 @@ import { ProcurementModule } from "./modules/procurement/procurement.module";
|
||||
import { GpsTrackingModule } from "./modules/gps-tracking/gps-tracking.module";
|
||||
import { FirstMileModule } from "./modules/first-mile/first-mile.module";
|
||||
import { LastMileModule } from "./modules/last-mile/last-mile.module";
|
||||
import { LastMileRequestsModule } from "./modules/last-mile-requests/last-mile-requests.module";
|
||||
import { InterchangeDocumentsModule } from "./modules/interchange-documents/interchange-documents.module";
|
||||
import { ImportOperationsModule } from "./modules/import-operations/import-operations.module";
|
||||
import { AiModule } from "./modules/ai/ai.module";
|
||||
@@ -241,6 +242,7 @@ if (!process.env.APPLICATION_NAME) {
|
||||
GpsTrackingModule,
|
||||
FirstMileModule,
|
||||
LastMileModule,
|
||||
LastMileRequestsModule,
|
||||
InterchangeDocumentsModule,
|
||||
ImportOperationsModule,
|
||||
VerifaydaModule,
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { MigrationInterface, QueryRunner, Table, TableForeignKey } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Create freight.last_mile_requests — the pre-approval confirmation stage that
|
||||
* sits in front of freight.last_mile: a train departs Djibouti, the customer
|
||||
* confirms which containers go via EDR last-mile, and the Truck & Machinery
|
||||
* chief approves/rejects before a freight.last_mile execution record exists.
|
||||
*/
|
||||
export class CreateLastMileRequests3250000000000 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
const exists = await queryRunner.hasTable('freight.last_mile_requests');
|
||||
if (exists) return;
|
||||
|
||||
await queryRunner.createTable(
|
||||
new Table({
|
||||
name: 'freight.last_mile_requests',
|
||||
columns: [
|
||||
{ name: 'id', type: 'uuid', isPrimary: true, default: 'gen_random_uuid()' },
|
||||
{ name: 'booking_id', type: 'uuid', isNullable: false },
|
||||
{ name: 'train_schedule_id', type: 'uuid', isNullable: false },
|
||||
{
|
||||
name: 'status',
|
||||
type: 'varchar',
|
||||
length: '30',
|
||||
default: `'AWAITING_CONFIRMATION'`,
|
||||
isNullable: false,
|
||||
},
|
||||
{ name: 'requested_container_numbers', type: 'text', isArray: true, isNullable: true },
|
||||
{ name: 'reminder_sent_at', type: 'timestamptz', isNullable: true },
|
||||
{ name: 'submitted_by_user_id', type: 'uuid', isNullable: true },
|
||||
{ name: 'submitted_at', type: 'timestamptz', isNullable: true },
|
||||
{ name: 'reviewed_by_staff_id', type: 'uuid', isNullable: true },
|
||||
{ name: 'reviewed_at', type: 'timestamptz', isNullable: true },
|
||||
{ name: 'rejection_reason', type: 'text', isNullable: true },
|
||||
{ name: 'resulting_last_mile_id', type: 'uuid', isNullable: true },
|
||||
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
|
||||
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
|
||||
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
|
||||
],
|
||||
}),
|
||||
true,
|
||||
);
|
||||
|
||||
await queryRunner.createForeignKey(
|
||||
'freight.last_mile_requests',
|
||||
new TableForeignKey({
|
||||
columnNames: ['booking_id'],
|
||||
referencedTableName: 'freight.bookings',
|
||||
referencedColumnNames: ['id'],
|
||||
onDelete: 'CASCADE',
|
||||
}),
|
||||
);
|
||||
await queryRunner.createForeignKey(
|
||||
'freight.last_mile_requests',
|
||||
new TableForeignKey({
|
||||
columnNames: ['train_schedule_id'],
|
||||
referencedTableName: 'freight.train_schedules',
|
||||
referencedColumnNames: ['id'],
|
||||
onDelete: 'CASCADE',
|
||||
}),
|
||||
);
|
||||
await queryRunner.createForeignKey(
|
||||
'freight.last_mile_requests',
|
||||
new TableForeignKey({
|
||||
columnNames: ['resulting_last_mile_id'],
|
||||
referencedTableName: 'freight.last_mile',
|
||||
referencedColumnNames: ['id'],
|
||||
onDelete: 'SET NULL',
|
||||
}),
|
||||
);
|
||||
|
||||
// One request per booking per departure — remind()/submit() are idempotent on this.
|
||||
await queryRunner.query(
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS "IDX_last_mile_requests_booking_schedule" ON "freight"."last_mile_requests" ("booking_id", "train_schedule_id") WHERE "deleted_at" IS NULL`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX IF NOT EXISTS "IDX_last_mile_requests_status" ON "freight"."last_mile_requests" ("status")`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
const exists = await queryRunner.hasTable('freight.last_mile_requests');
|
||||
if (exists) {
|
||||
await queryRunner.dropTable('freight.last_mile_requests');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Transform } from 'class-transformer';
|
||||
import { IsNumber, Min } from 'class-validator';
|
||||
|
||||
export class ApproveLastMileRequestDto {
|
||||
// ponytail: flat manual advance amount — no rate model exists yet at this
|
||||
// pre-distance stage (delivery-fee invoicing needs assigned-truck distance,
|
||||
// which isn't known until after payment). Wire a FeeRule-based estimate
|
||||
// (see double-handling/truck-detention fee rules) once one exists.
|
||||
@ApiProperty({ description: 'Advance amount the customer must pay before execution proceeds', example: 3000 })
|
||||
@Transform(({ value }) => Number(value))
|
||||
@IsNumber()
|
||||
@Min(0.01)
|
||||
advanceAmount!: number;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsNotEmpty, IsString, MaxLength } from 'class-validator';
|
||||
|
||||
export class RejectLastMileRequestDto {
|
||||
@ApiProperty({ description: 'Why the request is rejected (e.g. no truck available)' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(500)
|
||||
reason!: string;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { ArrayNotEmpty, ArrayUnique, IsArray, IsString } from 'class-validator';
|
||||
|
||||
export class SubmitLastMileRequestDto {
|
||||
@ApiProperty({
|
||||
type: [String],
|
||||
description:
|
||||
'Container numbers the customer wants delivered via EDR last-mile — pass every booking container to select "all".',
|
||||
})
|
||||
@IsArray()
|
||||
@ArrayNotEmpty()
|
||||
@ArrayUnique()
|
||||
@IsString({ each: true })
|
||||
containerNumbers!: string[];
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { LastMileRequestStatus } from '@edr/types';
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
|
||||
|
||||
import { Booking } from '../../bookings/entities/booking.entity';
|
||||
import { TrainSchedule } from '../../train-schedules/entities/train-schedule.entity';
|
||||
import { LastMile } from '../../last-mile/entities/last-mile.entity';
|
||||
|
||||
export const LAST_MILE_REQUEST_STATUSES = [
|
||||
LastMileRequestStatus.AwaitingConfirmation,
|
||||
LastMileRequestStatus.Submitted,
|
||||
LastMileRequestStatus.Approved,
|
||||
LastMileRequestStatus.Rejected,
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* The pre-approval confirmation stage in front of `LastMile`: fired when a
|
||||
* train departs Djibouti, filled by the customer, reviewed by the Truck &
|
||||
* Machinery chief. One row per (bookingId, trainScheduleId) — a booking whose
|
||||
* containers arrive across several departures gets a request per departure.
|
||||
*/
|
||||
@Entity({ name: 'last_mile_requests', schema: 'freight' })
|
||||
@Index(['bookingId'])
|
||||
@Index(['status'])
|
||||
export class LastMileRequest extends BaseEntity {
|
||||
@Column({ name: 'booking_id', type: 'uuid' })
|
||||
bookingId!: string;
|
||||
|
||||
@ManyToOne(() => Booking, { nullable: false, eager: false })
|
||||
@JoinColumn({ name: 'booking_id' })
|
||||
booking?: Booking;
|
||||
|
||||
@Column({ name: 'train_schedule_id', type: 'uuid' })
|
||||
trainScheduleId!: string;
|
||||
|
||||
@ManyToOne(() => TrainSchedule, { nullable: false, eager: false })
|
||||
@JoinColumn({ name: 'train_schedule_id' })
|
||||
trainSchedule?: TrainSchedule;
|
||||
|
||||
@Column({ name: 'status', type: 'varchar', length: 30, default: LastMileRequestStatus.AwaitingConfirmation })
|
||||
status!: LastMileRequestStatus;
|
||||
|
||||
/** Customer's container selection — "all" is just every booking container listed here. */
|
||||
@Column({ name: 'requested_container_numbers', type: 'text', array: true, nullable: true })
|
||||
requestedContainerNumbers?: string[] | null;
|
||||
|
||||
@Column({ name: 'reminder_sent_at', type: 'timestamptz', nullable: true })
|
||||
reminderSentAt?: Date | null;
|
||||
|
||||
@Column({ name: 'submitted_by_user_id', type: 'uuid', nullable: true })
|
||||
submittedByUserId?: string | null;
|
||||
|
||||
@Column({ name: 'submitted_at', type: 'timestamptz', nullable: true })
|
||||
submittedAt?: Date | null;
|
||||
|
||||
@Column({ name: 'reviewed_by_staff_id', type: 'uuid', nullable: true })
|
||||
reviewedByStaffId?: string | null;
|
||||
|
||||
@Column({ name: 'reviewed_at', type: 'timestamptz', nullable: true })
|
||||
reviewedAt?: Date | null;
|
||||
|
||||
@Column({ name: 'rejection_reason', type: 'text', nullable: true })
|
||||
rejectionReason?: string | null;
|
||||
|
||||
@Column({ name: 'resulting_last_mile_id', type: 'uuid', nullable: true })
|
||||
resultingLastMileId?: string | null;
|
||||
|
||||
@ManyToOne(() => LastMile, { nullable: true, eager: false })
|
||||
@JoinColumn({ name: 'resulting_last_mile_id' })
|
||||
resultingLastMile?: LastMile | null;
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { Body, Controller, Get, Param, ParseUUIDPipe, Post, Query } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { CurrentUser } from '@edr/api-common';
|
||||
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
||||
import { LastMileRequestStatus } from '@edr/types';
|
||||
|
||||
import { BookingStaff } from '../../common/booking-guards';
|
||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||
import { ApproveLastMileRequestDto } from './dto/approve-last-mile-request.dto';
|
||||
import { RejectLastMileRequestDto } from './dto/reject-last-mile-request.dto';
|
||||
import { SubmitLastMileRequestDto } from './dto/submit-last-mile-request.dto';
|
||||
import { LastMileRequestsService } from './last-mile-requests.service';
|
||||
|
||||
@ApiTags('last-mile-requests')
|
||||
@ApiBearerAuth()
|
||||
@Controller('last-mile-requests')
|
||||
export class LastMileRequestsController {
|
||||
constructor(private readonly requestsService: LastMileRequestsService) {}
|
||||
|
||||
@Get()
|
||||
@BookingStaff(FREIGHT_PERMS.lastMile.requestView)
|
||||
@ApiOperation({ summary: 'List last-mile confirmation requests' })
|
||||
findAll(
|
||||
@Query('status') status?: string,
|
||||
@Query('bookingId') bookingId?: string,
|
||||
@Query('page') page?: string,
|
||||
@Query('pageSize') pageSize?: string,
|
||||
) {
|
||||
return this.requestsService.findAll({
|
||||
status: status as LastMileRequestStatus | undefined,
|
||||
bookingId,
|
||||
page: page ? parseInt(page, 10) : undefined,
|
||||
pageSize: pageSize ? parseInt(pageSize, 10) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
@Get('free-truck-count')
|
||||
@BookingStaff(FREIGHT_PERMS.lastMile.requestView)
|
||||
@ApiOperation({ summary: 'Free (ACTIVE + unassigned) trucks — informational context for approval' })
|
||||
freeTruckCount() {
|
||||
return this.requestsService.freeTruckCount().then((count) => ({ count }));
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@BookingStaff(FREIGHT_PERMS.lastMile.requestView)
|
||||
@ApiOperation({ summary: 'Get a last-mile confirmation request by ID' })
|
||||
findOne(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.requestsService.findById(id);
|
||||
}
|
||||
|
||||
// No @BookingStaff — the customer (portal) fills this, not backoffice staff.
|
||||
// TODO: integrate @edr/auth — @CurrentUser is a stub until then; the service
|
||||
// still cross-checks the request's booking against the resolved company.
|
||||
@Post(':id/submit')
|
||||
@ApiOperation({ summary: "Customer confirms which containers go via EDR last-mile" })
|
||||
submit(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: SubmitLastMileRequestDto,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
return this.requestsService.submit(id, user?.id ?? null, dto.containerNumbers);
|
||||
}
|
||||
|
||||
@Post(':id/approve')
|
||||
@BookingStaff(FREIGHT_PERMS.lastMile.requestApprove)
|
||||
@ApiOperation({ summary: 'Truck & Machinery chief approves the request — generates the advance invoice' })
|
||||
approve(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: ApproveLastMileRequestDto,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
return this.requestsService.approve(id, user?.id ?? null, dto.advanceAmount);
|
||||
}
|
||||
|
||||
@Post(':id/reject')
|
||||
@BookingStaff(FREIGHT_PERMS.lastMile.requestApprove)
|
||||
@ApiOperation({ summary: 'Truck & Machinery chief rejects the request with a reason' })
|
||||
reject(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: RejectLastMileRequestDto,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
return this.requestsService.reject(id, user?.id ?? null, dto.reason);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Module, forwardRef } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { BillingModule } from '../billing/billing.module';
|
||||
import { BookingsModule } from '../bookings/bookings.module';
|
||||
import { LastMileModule } from '../last-mile/last-mile.module';
|
||||
import { NotificationInboxModule } from '../notification-inbox/notification-inbox.module';
|
||||
import { LastMileRequest } from './entities/last-mile-request.entity';
|
||||
import { LastMileRequestsController } from './last-mile-requests.controller';
|
||||
import { LastMileRequestsRepository } from './last-mile-requests.repository';
|
||||
import { LastMileRequestsService } from './last-mile-requests.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([LastMileRequest]),
|
||||
BillingModule,
|
||||
forwardRef(() => BookingsModule),
|
||||
LastMileModule,
|
||||
NotificationInboxModule,
|
||||
],
|
||||
controllers: [LastMileRequestsController],
|
||||
providers: [LastMileRequestsRepository, LastMileRequestsService],
|
||||
exports: [LastMileRequestsService],
|
||||
})
|
||||
export class LastMileRequestsModule {}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { BaseRepository } from '@edr/api-common';
|
||||
|
||||
import { LastMileRequest } from './entities/last-mile-request.entity';
|
||||
|
||||
@Injectable()
|
||||
export class LastMileRequestsRepository extends BaseRepository<LastMileRequest> {
|
||||
constructor(
|
||||
@InjectRepository(LastMileRequest)
|
||||
repository: Repository<LastMileRequest>,
|
||||
) {
|
||||
super(repository);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { Cron } from '@nestjs/schedule';
|
||||
import { DataSource, FindOptionsWhere } from 'typeorm';
|
||||
import { Freight, LastMileRequestStatus } from '@edr/types';
|
||||
|
||||
import { usesEdrMileService } from '../../common/mile-haulage.util';
|
||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||
import { BookingsRepository } from '../bookings/bookings.repository';
|
||||
import { BookingsService } from '../bookings/bookings.service';
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
import { BillingService } from '../billing/billing.service';
|
||||
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
|
||||
import { NotificationAudience, NotificationPriority, NotificationType } from '@edr/types';
|
||||
import { LastMileService } from '../last-mile/last-mile.service';
|
||||
import { Vehicle, VehicleAvailability, VehicleStatus } from '../vehicles/entities/vehicle.entity';
|
||||
import { LastMileRequest } from './entities/last-mile-request.entity';
|
||||
import { LastMileRequestsRepository } from './last-mile-requests.repository';
|
||||
|
||||
type ListFilter = {
|
||||
status?: LastMileRequestStatus;
|
||||
bookingId?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
};
|
||||
|
||||
/** Just what remind() needs off a departed schedule — deliberately not the full
|
||||
* `TrainSchedule` entity so this module never has to import train-scheduling code. */
|
||||
type DepartedSchedule = { id: string; trainNumber?: string | null };
|
||||
|
||||
@Injectable()
|
||||
export class LastMileRequestsService {
|
||||
private readonly logger = new Logger(LastMileRequestsService.name);
|
||||
|
||||
constructor(
|
||||
private readonly requestsRepository: LastMileRequestsRepository,
|
||||
private readonly bookingsRepository: BookingsRepository,
|
||||
private readonly bookingsService: BookingsService,
|
||||
private readonly lastMileService: LastMileService,
|
||||
private readonly billing: BillingService,
|
||||
private readonly notifications: NotificationInboxService,
|
||||
private readonly dataSource: DataSource,
|
||||
) {}
|
||||
|
||||
/** Container numbers on the booking (upper-cased) — mirrors LastMileService's own helper. */
|
||||
private async bookingContainerNumbers(bookingId: string): Promise<string[]> {
|
||||
const rows: Array<{ containerNumber: string }> = await this.dataSource.query(
|
||||
`SELECT bcu.container_number AS "containerNumber"
|
||||
FROM freight.booking_container_units bcu
|
||||
JOIN freight.booking_container bc
|
||||
ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL
|
||||
WHERE bc.booking_id = $1 AND bcu.deleted_at IS NULL`,
|
||||
[bookingId],
|
||||
);
|
||||
return rows.map((r) => r.containerNumber.trim().toUpperCase());
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll for trains that have departed Djibouti and remind their eligible
|
||||
* bookings. Deliberately a self-contained poller (raw SQL against
|
||||
* `import_djibouti_operations`/`train_schedules`, no import of train-scheduling
|
||||
* module code) rather than a hook inside `TrainSchedulingService.dispatchSchedule`
|
||||
* — keeps this feature decoupled from that module entirely. `remindForDeparture`
|
||||
* is idempotent per (bookingId, scheduleId), so re-scanning the same recent
|
||||
* window on every tick is safe — a schedule already fully reminded is a no-op.
|
||||
*/
|
||||
@Cron('*/2 * * * *', { name: 'last-mile-request-departure-scan' })
|
||||
async scanDepartedSchedules(): Promise<void> {
|
||||
let schedules: DepartedSchedule[] = [];
|
||||
try {
|
||||
schedules = await this.dataSource.query(
|
||||
`SELECT ts.id AS "id", ts.train_number AS "trainNumber"
|
||||
FROM freight.import_djibouti_operations op
|
||||
JOIN freight.train_schedules ts
|
||||
ON ts.id = op.train_schedule_id AND ts.deleted_at IS NULL
|
||||
WHERE op.deleted_at IS NULL
|
||||
AND op.departed_from_djibouti_at IS NOT NULL
|
||||
AND op.departed_from_djibouti_at > now() - interval '14 days'`,
|
||||
);
|
||||
} catch (err) {
|
||||
this.logger.warn(`Failed to scan for departed schedules: ${(err as Error).message}`);
|
||||
return;
|
||||
}
|
||||
for (const schedule of schedules) {
|
||||
await this.remindForDeparture(schedule);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fired for a train that has departed Djibouti (import direction). For every booking
|
||||
* already loaded on this schedule that bought EDR last-mile, idempotently
|
||||
* creates the AWAITING_CONFIRMATION request and reminds both the customer and
|
||||
* the Truck & Machinery department. Fire-and-forget per booking — one bad
|
||||
* booking must never block the rest of the departure notification.
|
||||
*/
|
||||
async remindForDeparture(schedule: DepartedSchedule): Promise<void> {
|
||||
let bookingIds: string[] = [];
|
||||
try {
|
||||
const rows: Array<{ bookingId: string }> = await this.dataSource.query(
|
||||
`SELECT booking_id AS "bookingId"
|
||||
FROM freight.train_schedule_bookings
|
||||
WHERE train_schedule_id = $1 AND loading_status = 'LOADED' AND deleted_at IS NULL`,
|
||||
[schedule.id],
|
||||
);
|
||||
bookingIds = rows.map((r) => r.bookingId);
|
||||
} catch (err) {
|
||||
this.logger.warn(`Failed to load schedule bookings for ${schedule.id}: ${(err as Error).message}`);
|
||||
return;
|
||||
}
|
||||
if (!bookingIds.length) return;
|
||||
|
||||
for (const bookingId of bookingIds) {
|
||||
try {
|
||||
const booking = await this.bookingsRepository.findById(bookingId);
|
||||
if (!booking) continue;
|
||||
if (
|
||||
!usesEdrMileService({
|
||||
tradeDirection: booking.tradeDirection,
|
||||
firstMile: booking.firstMilePickupAddress ?? null,
|
||||
lastMile: booking.lastMileDeliveryAddress ?? null,
|
||||
})
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
await this.remind(booking, schedule);
|
||||
} catch (err) {
|
||||
this.logger.warn(`Failed to remind booking ${bookingId} for schedule ${schedule.id}: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async remind(booking: Booking, schedule: DepartedSchedule): Promise<void> {
|
||||
const [existing] = await this.requestsRepository.findAll({
|
||||
where: { bookingId: booking.id, trainScheduleId: schedule.id },
|
||||
take: 1,
|
||||
});
|
||||
if (existing) return; // already reminded for this departure
|
||||
|
||||
const request = await this.requestsRepository.create({
|
||||
bookingId: booking.id,
|
||||
trainScheduleId: schedule.id,
|
||||
status: LastMileRequestStatus.AwaitingConfirmation,
|
||||
reminderSentAt: new Date(),
|
||||
});
|
||||
|
||||
const trainLabel = schedule.trainNumber ? `train ${schedule.trainNumber}` : 'your train';
|
||||
if (booking.companyId) {
|
||||
void this.notifications.notify({
|
||||
recipients: { companyId: booking.companyId },
|
||||
audience: NotificationAudience.PORTAL,
|
||||
type: NotificationType.SCHEDULE_UPDATE,
|
||||
title: 'Confirm your last-mile delivery',
|
||||
body: `${trainLabel} carrying booking ${booking.reference ?? booking.id} has departed Djibouti. Confirm which containers go via EDR last-mile.`,
|
||||
link: `/bookings/${booking.id}/last-mile-confirm?requestId=${request.id}`,
|
||||
data: { bookingId: booking.id, requestId: request.id },
|
||||
priority: NotificationPriority.HIGH,
|
||||
});
|
||||
}
|
||||
void this.notifications.notify({
|
||||
recipients: { permissionKeys: [FREIGHT_PERMS.lastMile.requestReview] },
|
||||
audience: NotificationAudience.BACKOFFICE,
|
||||
type: NotificationType.SCHEDULE_UPDATE,
|
||||
title: 'Last-mile confirmation expected',
|
||||
body: `${trainLabel} carrying booking ${booking.reference ?? booking.id} has departed Djibouti — awaiting the customer's last-mile confirmation.`,
|
||||
link: `/dashboard/operations/last-mile?tab=requests`,
|
||||
data: { bookingId: booking.id, requestId: request.id },
|
||||
priority: NotificationPriority.HIGH,
|
||||
});
|
||||
}
|
||||
|
||||
async findAll(filter: ListFilter = {}): Promise<{
|
||||
data: LastMileRequest[];
|
||||
meta: { total: number; page: number; pageSize: number; totalPages: number };
|
||||
}> {
|
||||
const page = filter.page ?? 1;
|
||||
const pageSize = filter.pageSize ?? 50;
|
||||
const where: FindOptionsWhere<LastMileRequest> = {};
|
||||
if (filter.status) where.status = filter.status;
|
||||
if (filter.bookingId) where.bookingId = filter.bookingId;
|
||||
|
||||
const [data, total] = await this.requestsRepository.findAndCount({
|
||||
where,
|
||||
relations: { booking: { company: true } },
|
||||
order: { createdAt: 'DESC' },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
});
|
||||
|
||||
return {
|
||||
data,
|
||||
meta: { total, page, pageSize, totalPages: Math.max(1, Math.ceil(total / pageSize)) },
|
||||
};
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<LastMileRequest> {
|
||||
const record = await this.requestsRepository.findById(id, {
|
||||
relations: { booking: { company: true } },
|
||||
});
|
||||
if (!record) throw new NotFoundException(`Last-mile request ${id} not found`);
|
||||
return record;
|
||||
}
|
||||
|
||||
/** Free (ACTIVE + unassigned) truck count — informational only for the approval screen. */
|
||||
async freeTruckCount(): Promise<number> {
|
||||
return this.dataSource.manager.count(Vehicle, {
|
||||
where: { status: VehicleStatus.ACTIVE, availability: VehicleAvailability.FREE },
|
||||
});
|
||||
}
|
||||
|
||||
async submit(id: string, userId: string | null, containerNumbers: string[]): Promise<LastMileRequest> {
|
||||
const request = await this.findById(id);
|
||||
if (request.status !== LastMileRequestStatus.AwaitingConfirmation) {
|
||||
throw new BadRequestException(`Request is already ${request.status.toLowerCase()}`);
|
||||
}
|
||||
|
||||
if (userId) {
|
||||
const companyId = await this.bookingsService.resolveCustomerCompanyId(userId);
|
||||
if (companyId && request.booking?.companyId && companyId !== request.booking.companyId) {
|
||||
throw new BadRequestException('This request does not belong to your company');
|
||||
}
|
||||
}
|
||||
|
||||
const bookingNumbers = await this.bookingContainerNumbers(request.bookingId);
|
||||
const selected = containerNumbers.map((n) => n.trim().toUpperCase());
|
||||
const unknown = selected.filter((n) => !bookingNumbers.includes(n));
|
||||
if (unknown.length) {
|
||||
throw new BadRequestException(`Container(s) not on this booking: ${unknown.join(', ')}`);
|
||||
}
|
||||
|
||||
await this.requestsRepository.update(id, {
|
||||
requestedContainerNumbers: selected,
|
||||
status: LastMileRequestStatus.Submitted,
|
||||
submittedByUserId: userId,
|
||||
submittedAt: new Date(),
|
||||
} as Partial<LastMileRequest>);
|
||||
|
||||
void this.notifications.notify({
|
||||
recipients: { permissionKeys: [FREIGHT_PERMS.lastMile.requestReview] },
|
||||
audience: NotificationAudience.BACKOFFICE,
|
||||
type: NotificationType.REQUEST_SUBMITTED,
|
||||
title: 'Last-mile request ready for review',
|
||||
body: `Booking ${request.booking?.reference ?? request.bookingId} confirmed ${selected.length} container(s) for EDR last-mile.`,
|
||||
link: `/dashboard/operations/last-mile?tab=requests`,
|
||||
data: { bookingId: request.bookingId, requestId: request.id },
|
||||
priority: NotificationPriority.NORMAL,
|
||||
});
|
||||
|
||||
return this.findById(id);
|
||||
}
|
||||
|
||||
async approve(id: string, staffId: string | null, advanceAmount: number): Promise<LastMileRequest> {
|
||||
const request = await this.findById(id);
|
||||
if (request.status !== LastMileRequestStatus.Submitted) {
|
||||
throw new BadRequestException(`Only a submitted request can be approved (current status: ${request.status})`);
|
||||
}
|
||||
const booking = request.booking ?? (await this.bookingsRepository.findById(request.bookingId));
|
||||
if (!booking) throw new NotFoundException(`Booking ${request.bookingId} not found`);
|
||||
|
||||
// Idempotent per booking — reuses the record if one already exists.
|
||||
const lastMile = await this.lastMileService.create({
|
||||
bookingId: request.bookingId,
|
||||
status: 'PAYMENT_PENDING',
|
||||
advancedPayment: 0,
|
||||
});
|
||||
|
||||
await this.billing.generateInvoice({
|
||||
// 'last_mile' (not the InvoiceSource.LastMile enum value "lastmile") to
|
||||
// match the existing source string LastMileInvoiceService/LastMileService
|
||||
// already query by (findBySourceIds/findPayable/attachInvoices).
|
||||
source: 'last_mile' as Freight.InvoiceSource,
|
||||
sourceId: lastMile.id,
|
||||
type: 'LAST_MILE_ADVANCE',
|
||||
companyId: booking.companyId,
|
||||
companyProfileId: booking.companyProfileId || '',
|
||||
currency: booking.paymentCurrency || 'ETB',
|
||||
lines: [
|
||||
{
|
||||
chargeType: 'LAST_MILE_ADVANCE',
|
||||
description: 'Last-mile delivery advance',
|
||||
amount: advanceAmount,
|
||||
},
|
||||
],
|
||||
totalAmount: advanceAmount,
|
||||
});
|
||||
|
||||
await this.requestsRepository.update(id, {
|
||||
status: LastMileRequestStatus.Approved,
|
||||
reviewedByStaffId: staffId,
|
||||
reviewedAt: new Date(),
|
||||
resultingLastMileId: lastMile.id,
|
||||
} as Partial<LastMileRequest>);
|
||||
|
||||
if (booking.companyId) {
|
||||
void this.notifications.notify({
|
||||
recipients: { companyId: booking.companyId },
|
||||
audience: NotificationAudience.PORTAL,
|
||||
type: NotificationType.INVOICE_ISSUED,
|
||||
title: 'Last-mile request approved — payment due',
|
||||
body: `Your last-mile request for booking ${booking.reference ?? booking.id} was approved. Pay the advance invoice to proceed.`,
|
||||
link: '/billing/invoices',
|
||||
data: { bookingId: booking.id, requestId: id, lastMileId: lastMile.id },
|
||||
priority: NotificationPriority.HIGH,
|
||||
});
|
||||
}
|
||||
|
||||
return this.findById(id);
|
||||
}
|
||||
|
||||
async reject(id: string, staffId: string | null, reason: string): Promise<LastMileRequest> {
|
||||
const request = await this.findById(id);
|
||||
if (request.status !== LastMileRequestStatus.Submitted) {
|
||||
throw new BadRequestException(`Only a submitted request can be rejected (current status: ${request.status})`);
|
||||
}
|
||||
const booking = request.booking ?? (await this.bookingsRepository.findById(request.bookingId));
|
||||
|
||||
await this.requestsRepository.update(id, {
|
||||
status: LastMileRequestStatus.Rejected,
|
||||
reviewedByStaffId: staffId,
|
||||
reviewedAt: new Date(),
|
||||
rejectionReason: reason,
|
||||
} as Partial<LastMileRequest>);
|
||||
|
||||
if (booking?.companyId) {
|
||||
void this.notifications.notify({
|
||||
recipients: { companyId: booking.companyId },
|
||||
audience: NotificationAudience.PORTAL,
|
||||
type: NotificationType.BOOKING_STATUS,
|
||||
title: 'Last-mile request rejected',
|
||||
body: `Your last-mile request for booking ${booking.reference ?? booking.id} was rejected: ${reason}`,
|
||||
link: `/bookings/${booking.id}`,
|
||||
data: { bookingId: booking.id, requestId: id },
|
||||
priority: NotificationPriority.HIGH,
|
||||
});
|
||||
}
|
||||
|
||||
return this.findById(id);
|
||||
}
|
||||
}
|
||||
@@ -35,13 +35,24 @@ async function main() {
|
||||
// Demo seeders are intentionally not AppModule providers (they'd run on every
|
||||
// boot), so construct them against the app's DataSource instead of via DI.
|
||||
const dataSource = app.get(DataSource);
|
||||
await new PricingDataSeeder(dataSource).run();
|
||||
await new IndodeFacilitySeeder(dataSource).run();
|
||||
await new Batch14TestDataSeeder(dataSource).run();
|
||||
await new Batch5TestDataSeeder(dataSource).run();
|
||||
await new Batch7TestDataSeeder(dataSource).run();
|
||||
await new Batch8TestDataSeeder(dataSource).run();
|
||||
await new WarehouseDemoSeeder(dataSource).run();
|
||||
|
||||
// Each bucket is independent: a seeder that has drifted from the current
|
||||
// schema shouldn't stop the rest of the demo data from landing.
|
||||
const step = async (name: string, run: () => Promise<void>) => {
|
||||
try {
|
||||
await run();
|
||||
} catch (error) {
|
||||
console.warn(` ! ${name} skipped: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
};
|
||||
|
||||
await step('PricingDataSeeder', () => new PricingDataSeeder(dataSource).run());
|
||||
await step('IndodeFacilitySeeder', () => new IndodeFacilitySeeder(dataSource).run());
|
||||
await step('Batch14TestDataSeeder', () => new Batch14TestDataSeeder(dataSource).run());
|
||||
await step('Batch5TestDataSeeder', () => new Batch5TestDataSeeder(dataSource).run());
|
||||
await step('Batch7TestDataSeeder', () => new Batch7TestDataSeeder(dataSource).run());
|
||||
await step('Batch8TestDataSeeder', () => new Batch8TestDataSeeder(dataSource).run());
|
||||
await step('WarehouseDemoSeeder', () => new WarehouseDemoSeeder(dataSource).run());
|
||||
|
||||
console.log('Warehouse demo data seeded.');
|
||||
} finally {
|
||||
|
||||
@@ -306,4 +306,5 @@ export const EDR_FREIGHT_POSITIONS: FreightSeedPosition[] = [
|
||||
{ key: "operation", name: { en: "Operation" }, rank: 4, permissionKeys: [...POSITION_PERMISSION_PRESETS.operation] },
|
||||
{ key: "operations_chief", name: { en: "Operations Chief" }, rank: 2, permissionKeys: [...POSITION_PERMISSION_PRESETS.operationsChief] },
|
||||
{ key: "dispatcher", name: { en: "Dispatcher" }, rank: 4, permissionKeys: [...POSITION_PERMISSION_PRESETS.dispatcher] },
|
||||
{ key: "truck_machinery_chief", name: { en: "Truck & Machinery Chief" }, rank: 2, permissionKeys: [...POSITION_PERMISSION_PRESETS.truckMachineryChief] },
|
||||
];
|
||||
|
||||
@@ -227,6 +227,9 @@ export const MILE_PERMISSIONS: FreightPermissionSeed[] = [
|
||||
perm('d3b00001-0001-4000-8000-000000000006', 'edr_freight_app:last_mile:assign_vehicles', 'Assign last-mile vehicles'),
|
||||
perm('d3b00001-0001-4000-8000-000000000007', 'edr_freight_app:last_mile:set_distances', 'Set last-mile distances'),
|
||||
perm('d3b00001-0001-4000-8000-000000000008', 'edr_freight_app:last_mile:generate_invoice', 'Generate last-mile invoice'),
|
||||
perm('d3b00001-0001-4000-8000-000000000009', 'edr_freight_app:last_mile:request_view', 'View last-mile confirmation requests'),
|
||||
perm('d3b00001-0001-4000-8000-00000000000a', 'edr_freight_app:last_mile:request_review', 'Review last-mile confirmation requests (T&M dept)'),
|
||||
perm('d3b00001-0001-4000-8000-00000000000b', 'edr_freight_app:last_mile:request_approve', 'Approve/reject last-mile confirmation requests'),
|
||||
];
|
||||
|
||||
// F. Fleet — rail assets (splits the flat fleet:view/manage)
|
||||
@@ -528,6 +531,11 @@ export const FREIGHT_PERMS = {
|
||||
assignVehicles: 'edr_freight_app:last_mile:assign_vehicles',
|
||||
setDistances: 'edr_freight_app:last_mile:set_distances',
|
||||
generateInvoice: 'edr_freight_app:last_mile:generate_invoice',
|
||||
// Pre-approval confirmation stage (Truck & Machinery department): view/review
|
||||
// a submitted request, approve/reject it.
|
||||
requestView: 'edr_freight_app:last_mile:request_view',
|
||||
requestReview: 'edr_freight_app:last_mile:request_review',
|
||||
requestApprove: 'edr_freight_app:last_mile:request_approve',
|
||||
},
|
||||
locomotives: {
|
||||
view: 'edr_freight_app:locomotives:view',
|
||||
@@ -1002,6 +1010,17 @@ export const POSITION_PERMISSION_PRESETS = {
|
||||
FREIGHT_PERMS.trainScheduling.view,
|
||||
FREIGHT_PERMS.bookings.operations,
|
||||
]),
|
||||
// Truck & Machinery chief: reviews and approves/rejects last-mile
|
||||
// confirmation requests (the pre-approval gate ahead of vehicle assignment),
|
||||
// plus enough fleet visibility to judge truck availability.
|
||||
truckMachineryChief: dedupe([
|
||||
FREIGHT_PERMS.lastMile.view,
|
||||
FREIGHT_PERMS.lastMile.requestView,
|
||||
FREIGHT_PERMS.lastMile.requestReview,
|
||||
FREIGHT_PERMS.lastMile.requestApprove,
|
||||
FREIGHT_PERMS.fleetDashboard.view,
|
||||
FREIGHT_PERMS.vehicles.view,
|
||||
]),
|
||||
} as const;
|
||||
|
||||
/** Derive the module bucket from the resource segment of a permission key. */
|
||||
|
||||
@@ -29,6 +29,7 @@ const STAFF_USERS = [
|
||||
{ email: 'operation@edr.local', username: 'operation', roleKey: 'edr_operations_officer', positionKey: 'operation' },
|
||||
{ email: 'gl-et@edr.local', username: 'gl_et', roleKey: 'edr_gl_ethiopia', positionKey: 'ethiopian_gl' },
|
||||
{ email: 'gl-dj@edr.local', username: 'gl_dj', roleKey: 'edr_gl_djibouti', positionKey: 'djibouti_gl' },
|
||||
{ email: 'tm-chief@edr.local', username: 'tm_chief', roleKey: 'edr_operations_officer', positionKey: 'truck_machinery_chief' },
|
||||
] as const;
|
||||
|
||||
@Injectable()
|
||||
|
||||
@@ -4,6 +4,11 @@ import { DataSource } from 'typeorm';
|
||||
|
||||
import { BookingContainer } from '../modules/bookings/entities/booking-container.entity';
|
||||
import { Booking } from '../modules/bookings/entities/booking.entity';
|
||||
import {
|
||||
CompanyProfile,
|
||||
ProfileStatus,
|
||||
ProfileType,
|
||||
} from '../modules/companies/entities/company-profile.entity';
|
||||
import { Company, CompanyStatus, CompanyType } from '../modules/companies/entities/company.entity';
|
||||
import { FirstMile } from '../modules/first-mile/entities/first-mile.entity';
|
||||
import { LastMile } from '../modules/last-mile/entities/last-mile.entity';
|
||||
@@ -13,7 +18,8 @@ import { ServiceType } from '../modules/rule-engine/entities/service-type.entity
|
||||
import { Yard } from '../modules/rule-engine/entities/yard.entity';
|
||||
|
||||
const SERVICE_TYPE_CODE = 'RAIL_CONTAINER_PAID_MILE';
|
||||
const COMPANY_TIN = 'PAIDMILE001';
|
||||
// companies.tin is varchar(10) — an 11-char TIN 22001s the whole seeder.
|
||||
const COMPANY_TIN = 'PAIDMILE01';
|
||||
const COMPANY_EMAIL = 'paid-mile-demo@edr.local';
|
||||
|
||||
const YARDS = [
|
||||
@@ -183,6 +189,22 @@ export class PaidImportExportMileDemoSeeder {
|
||||
manager.getRepository(ContainerType).find(),
|
||||
]);
|
||||
|
||||
// bookings.company_profile_id is NOT NULL — the demo company needs an
|
||||
// approved importer profile of its own (no unique key to upsert on).
|
||||
const profileRepo = manager.getRepository(CompanyProfile);
|
||||
const companyProfile =
|
||||
(await profileRepo.findOne({
|
||||
where: { companyId: company.id, type: ProfileType.importer },
|
||||
})) ??
|
||||
(await profileRepo.save(
|
||||
profileRepo.create({
|
||||
companyId: company.id,
|
||||
type: ProfileType.importer,
|
||||
status: ProfileStatus.Active,
|
||||
businessLicense: 'PMD-LIC-0001',
|
||||
}),
|
||||
));
|
||||
|
||||
const yardByCode = new Map(yards.map((yard) => [yard.code, yard]));
|
||||
const containerTypeByCode = new Map(
|
||||
containerTypes.map((containerType) => [containerType.code, containerType]),
|
||||
@@ -206,6 +228,7 @@ export class PaidImportExportMileDemoSeeder {
|
||||
{
|
||||
reference: demoBooking.reference,
|
||||
companyId: company.id,
|
||||
companyProfileId: companyProfile.id,
|
||||
status: 'APPROVED',
|
||||
scheduledDate: new Date(demoBooking.scheduledDate),
|
||||
estimatedShipmentDate: new Date(demoBooking.scheduledDate),
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
Modal,
|
||||
NumberInput,
|
||||
Stack,
|
||||
Text,
|
||||
Textarea,
|
||||
} from "@mantine/core";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type { ColumnDef } from "@edr/ui-common";
|
||||
import { DataTable, DataTableFooter, usePagination } from "@edr/ui-common";
|
||||
|
||||
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||
import {
|
||||
lastMileRequestsService,
|
||||
type LastMileRequest,
|
||||
type LastMileRequestStatus,
|
||||
} from "@/services/last-mile-requests.service";
|
||||
|
||||
const STATUS_META: Record<LastMileRequestStatus, { label: string; color: string }> = {
|
||||
AWAITING_CONFIRMATION: { label: "Awaiting Confirmation", color: "gray" },
|
||||
SUBMITTED: { label: "Submitted", color: "yellow" },
|
||||
APPROVED: { label: "Approved", color: "green" },
|
||||
REJECTED: { label: "Rejected", color: "red" },
|
||||
};
|
||||
|
||||
type StatusFilter = "ALL" | LastMileRequestStatus;
|
||||
|
||||
const FILTER_OPTIONS: { value: StatusFilter; label: string }[] = [
|
||||
{ value: "SUBMITTED", label: "Submitted" },
|
||||
{ value: "APPROVED", label: "Approved" },
|
||||
{ value: "REJECTED", label: "Rejected" },
|
||||
{ value: "AWAITING_CONFIRMATION", label: "Awaiting Confirmation" },
|
||||
{ value: "ALL", label: "All" },
|
||||
];
|
||||
|
||||
const fmtDate = (iso?: string | null) =>
|
||||
iso ? new Date(iso).toLocaleString("en-US", { dateStyle: "medium", timeStyle: "short" }) : "—";
|
||||
|
||||
export function LastMileRequestsPanel() {
|
||||
const { toast } = useToast();
|
||||
const qc = useQueryClient();
|
||||
const { user } = useAuth();
|
||||
const canApprove = hasPermission(user, FREIGHT_PERMS.lastMile.requestApprove);
|
||||
|
||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>("SUBMITTED");
|
||||
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
||||
const [approveTarget, setApproveTarget] = useState<LastMileRequest | null>(null);
|
||||
const [rejectTarget, setRejectTarget] = useState<LastMileRequest | null>(null);
|
||||
const [advanceAmount, setAdvanceAmount] = useState<number | string>("");
|
||||
const [rejectReason, setRejectReason] = useState("");
|
||||
|
||||
const filter = {
|
||||
...(statusFilter !== "ALL" ? { status: statusFilter } : {}),
|
||||
page: pagination.pageIndex + 1,
|
||||
pageSize: pagination.pageSize,
|
||||
};
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: QUERY_KEYS.LAST_MILE_REQUESTS.list(filter),
|
||||
queryFn: async () => (await lastMileRequestsService.list(filter)).data,
|
||||
});
|
||||
const rows = data?.data ?? [];
|
||||
const meta = data?.meta;
|
||||
|
||||
const { data: freeTrucks } = useQuery({
|
||||
queryKey: QUERY_KEYS.LAST_MILE_REQUESTS.freeTruckCount,
|
||||
queryFn: async () => (await lastMileRequestsService.freeTruckCount()).data,
|
||||
});
|
||||
|
||||
const invalidate = () =>
|
||||
qc.invalidateQueries({ queryKey: QUERY_KEYS.LAST_MILE_REQUESTS.ROOT });
|
||||
|
||||
const approve = useMutation({
|
||||
mutationFn: () =>
|
||||
lastMileRequestsService.approve(approveTarget!.id, Number(advanceAmount)),
|
||||
onSuccess: () => {
|
||||
void invalidate();
|
||||
toast({ title: "Request approved" });
|
||||
setApproveTarget(null);
|
||||
setAdvanceAmount("");
|
||||
},
|
||||
onError: (e: unknown) => {
|
||||
const description = (e as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
||||
toast({ title: "Approve failed", description, variant: "destructive" });
|
||||
},
|
||||
});
|
||||
|
||||
const reject = useMutation({
|
||||
mutationFn: () => lastMileRequestsService.reject(rejectTarget!.id, rejectReason.trim()),
|
||||
onSuccess: () => {
|
||||
void invalidate();
|
||||
toast({ title: "Request rejected" });
|
||||
setRejectTarget(null);
|
||||
setRejectReason("");
|
||||
},
|
||||
onError: (e: unknown) => {
|
||||
const description = (e as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
||||
toast({ title: "Reject failed", description, variant: "destructive" });
|
||||
},
|
||||
});
|
||||
|
||||
const columns: ColumnDef<LastMileRequest>[] = [
|
||||
{
|
||||
id: "booking",
|
||||
header: () => <span>Booking</span>,
|
||||
cell: ({ row }) => {
|
||||
const r = row.original;
|
||||
return (
|
||||
<Stack gap={0}>
|
||||
<Text size="sm" fw={600}>{r.booking?.reference ?? r.bookingId}</Text>
|
||||
<Text size="xs" c="dimmed">{r.booking?.company?.name ?? "—"}</Text>
|
||||
</Stack>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "containers",
|
||||
header: () => <span>Requested Containers</span>,
|
||||
cell: ({ row }) => {
|
||||
const nums = row.original.requestedContainerNumbers;
|
||||
return <Text size="sm">{nums?.length ? nums.join(", ") : "—"}</Text>;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "submittedAt",
|
||||
header: () => <span>Submitted</span>,
|
||||
cell: ({ row }) => <Text size="sm">{fmtDate(row.original.submittedAt)}</Text>,
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
header: () => <span>Status</span>,
|
||||
cell: ({ row }) => {
|
||||
const meta = STATUS_META[row.original.status];
|
||||
return (
|
||||
<Badge color={meta.color} variant="light" size="sm">
|
||||
{meta.label}
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
},
|
||||
...(canApprove
|
||||
? [
|
||||
{
|
||||
id: "actions",
|
||||
header: () => <span>Actions</span>,
|
||||
cell: ({ row }: { row: { original: LastMileRequest } }) => {
|
||||
const r = row.original;
|
||||
if (r.status !== "SUBMITTED") return null;
|
||||
return (
|
||||
<Group gap="xs">
|
||||
<Button size="xs" variant="light" color="green" onClick={() => setApproveTarget(r)}>
|
||||
Approve
|
||||
</Button>
|
||||
<Button size="xs" variant="light" color="red" onClick={() => setRejectTarget(r)}>
|
||||
Reject
|
||||
</Button>
|
||||
</Group>
|
||||
);
|
||||
},
|
||||
} as ColumnDef<LastMileRequest>,
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
return (
|
||||
<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%">
|
||||
<Stack gap="sm">
|
||||
<Text size="sm" c="dimmed">
|
||||
{freeTrucks?.count ?? 0} truck{freeTrucks?.count === 1 ? "" : "s"} currently free
|
||||
</Text>
|
||||
<Group gap="xs" wrap="wrap">
|
||||
{FILTER_OPTIONS.map((option) => {
|
||||
const active = statusFilter === option.value;
|
||||
return (
|
||||
<Button
|
||||
key={option.value}
|
||||
size="xs"
|
||||
variant={active ? "filled" : "default"}
|
||||
styles={{ label: { fontWeight: 500 } }}
|
||||
onClick={() => {
|
||||
setStatusFilter(option.value);
|
||||
setPagination((p) => ({ ...p, pageIndex: 0 }));
|
||||
}}
|
||||
>
|
||||
{option.label}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</Group>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={rows}
|
||||
status={isLoading ? "loading" : "success"}
|
||||
emptyMessage="No last-mile confirmation requests found"
|
||||
pagination={{
|
||||
pageIndex: pagination.pageIndex,
|
||||
pageSize: pagination.pageSize,
|
||||
pageCount: meta?.totalPages ?? 1,
|
||||
totalCount: meta?.total ?? 0,
|
||||
}}
|
||||
tableOptions={{
|
||||
manualPagination: true,
|
||||
pageCount: meta?.totalPages ?? 1,
|
||||
state: { pagination },
|
||||
onPaginationChange: setPagination,
|
||||
}}
|
||||
containerClassName="border-0 shadow-none bg-transparent"
|
||||
footer={({ table, pagination: fp }) => (
|
||||
<DataTableFooter table={table} pagination={fp} options={{ labels: { items: "requests" } }} />
|
||||
)}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Modal
|
||||
opened={Boolean(approveTarget)}
|
||||
onClose={() => setApproveTarget(null)}
|
||||
title={<Text fw={700}>Approve request{approveTarget?.booking?.reference ? ` · ${approveTarget.booking.reference}` : ""}</Text>}
|
||||
centered
|
||||
>
|
||||
<Stack gap="md">
|
||||
<NumberInput
|
||||
label="Advance amount"
|
||||
placeholder="0.00"
|
||||
required
|
||||
min={0.01}
|
||||
value={advanceAmount}
|
||||
onChange={setAdvanceAmount}
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={() => setApproveTarget(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!(Number(advanceAmount) > 0)}
|
||||
loading={approve.isPending}
|
||||
onClick={() => approve.mutate()}
|
||||
>
|
||||
Confirm
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
opened={Boolean(rejectTarget)}
|
||||
onClose={() => setRejectTarget(null)}
|
||||
title={<Text fw={700}>Reject request{rejectTarget?.booking?.reference ? ` · ${rejectTarget.booking.reference}` : ""}</Text>}
|
||||
centered
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Textarea
|
||||
label="Reason"
|
||||
placeholder="Why is this request being rejected?"
|
||||
required
|
||||
minRows={3}
|
||||
value={rejectReason}
|
||||
onChange={(e) => setRejectReason(e.currentTarget.value)}
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={() => setRejectTarget(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="red"
|
||||
disabled={!rejectReason.trim()}
|
||||
loading={reject.isPending}
|
||||
onClick={() => reject.mutate()}
|
||||
>
|
||||
Confirm
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -155,6 +155,13 @@ export const QUERY_KEYS = {
|
||||
byId: (id: string) => ["last-mile", "detail", id] as const,
|
||||
},
|
||||
|
||||
LAST_MILE_REQUESTS: {
|
||||
ROOT: ["last-mile-requests"] as const,
|
||||
list: (filter?: Record<string, unknown>) =>
|
||||
["last-mile-requests", "list", filter ?? {}] as const,
|
||||
freeTruckCount: ["last-mile-requests", "free-truck-count"] as const,
|
||||
},
|
||||
|
||||
RULE_ENGINE: {
|
||||
ROOT: ["rule-engine"] as const,
|
||||
list: (
|
||||
|
||||
@@ -707,6 +707,14 @@ export const URL_CONSTANTS = {
|
||||
PROOF_OF_DELIVERY: (id: string) => `/last-mile/${id}/proof-of-delivery`,
|
||||
},
|
||||
|
||||
LAST_MILE_REQUESTS: {
|
||||
BASE: "/last-mile-requests",
|
||||
BY_ID: (id: string) => `/last-mile-requests/${id}`,
|
||||
FREE_TRUCK_COUNT: "/last-mile-requests/free-truck-count",
|
||||
APPROVE: (id: string) => `/last-mile-requests/${id}/approve`,
|
||||
REJECT: (id: string) => `/last-mile-requests/${id}/reject`,
|
||||
},
|
||||
|
||||
DRIVERS: {
|
||||
BASE: "/drivers",
|
||||
BY_ID: (id: string) => `/drivers/${id}`,
|
||||
|
||||
@@ -116,6 +116,9 @@ export const FREIGHT_PERMS = {
|
||||
assignVehicles: "edr_freight_app:last_mile:assign_vehicles",
|
||||
setDistances: "edr_freight_app:last_mile:set_distances",
|
||||
generateInvoice: "edr_freight_app:last_mile:generate_invoice",
|
||||
requestView: "edr_freight_app:last_mile:request_view",
|
||||
requestReview: "edr_freight_app:last_mile:request_review",
|
||||
requestApprove: "edr_freight_app:last_mile:request_approve",
|
||||
},
|
||||
locomotives: {
|
||||
view: "edr_freight_app:locomotives:view",
|
||||
|
||||
@@ -47,6 +47,9 @@ import { bookingsService } from "@/services/bookings.service";
|
||||
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
|
||||
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||
import { LastMileRequestsPanel } from "@/components/operations/LastMileRequestsPanel";
|
||||
import {
|
||||
LAST_MILE_STATUSES,
|
||||
type LastMileApiStatus,
|
||||
@@ -543,6 +546,9 @@ const buildTripSlipHtml = (record: LastMileRecord, vehicle?: TripSlipVehicle | n
|
||||
const LastMilePage = () => {
|
||||
const { toast } = useToast();
|
||||
const qc = useQueryClient();
|
||||
const { user } = useAuth();
|
||||
const canViewRequests = hasPermission(user, FREIGHT_PERMS.lastMile.requestView);
|
||||
const [view, setView] = useState<"legs" | "requests">("legs");
|
||||
const [podRecord, setPodRecord] = useState<LastMileRecord | null>(null);
|
||||
const navigate = useNavigate();
|
||||
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
||||
@@ -1590,6 +1596,30 @@ const LastMilePage = () => {
|
||||
|
||||
return (
|
||||
<Stack gap="md" p="md">
|
||||
{canViewRequests && (
|
||||
<Group gap="xs">
|
||||
<Button
|
||||
size="xs"
|
||||
variant={view === "legs" ? "filled" : "default"}
|
||||
styles={{ label: { fontWeight: 500 } }}
|
||||
onClick={() => setView("legs")}
|
||||
>
|
||||
Deliveries
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
variant={view === "requests" ? "filled" : "default"}
|
||||
styles={{ label: { fontWeight: 500 } }}
|
||||
onClick={() => setView("requests")}
|
||||
>
|
||||
Requests
|
||||
</Button>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{view === "requests" && canViewRequests ? (
|
||||
<LastMileRequestsPanel />
|
||||
) : (
|
||||
<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%">
|
||||
@@ -1672,6 +1702,7 @@ const LastMilePage = () => {
|
||||
/>
|
||||
</Stack>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 2-step Assign Mile (arrival queue → vehicle) */}
|
||||
<Modal
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { api } from '../auth/http';
|
||||
import { URL_CONSTANTS } from '@/constants/URLS';
|
||||
|
||||
export const LAST_MILE_REQUEST_STATUSES = [
|
||||
'AWAITING_CONFIRMATION',
|
||||
'SUBMITTED',
|
||||
'APPROVED',
|
||||
'REJECTED',
|
||||
] as const;
|
||||
export type LastMileRequestStatus = (typeof LAST_MILE_REQUEST_STATUSES)[number];
|
||||
|
||||
export interface LastMileRequest {
|
||||
id: string;
|
||||
bookingId: string;
|
||||
booking?: {
|
||||
id: string;
|
||||
reference?: string;
|
||||
companyId?: string;
|
||||
company?: { id: string; name?: string } | null;
|
||||
} | null;
|
||||
trainScheduleId: string;
|
||||
status: LastMileRequestStatus;
|
||||
requestedContainerNumbers?: string[] | null;
|
||||
reminderSentAt?: string | null;
|
||||
submittedByUserId?: string | null;
|
||||
submittedAt?: string | null;
|
||||
reviewedByStaffId?: string | null;
|
||||
reviewedAt?: string | null;
|
||||
rejectionReason?: string | null;
|
||||
resultingLastMileId?: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface LastMileRequestListResponse {
|
||||
data: LastMileRequest[];
|
||||
meta: { total: number; page: number; pageSize: number; totalPages: number };
|
||||
}
|
||||
|
||||
const LMR = URL_CONSTANTS.LAST_MILE_REQUESTS;
|
||||
|
||||
export const lastMileRequestsService = {
|
||||
list: (params?: { status?: LastMileRequestStatus; bookingId?: string; page?: number; pageSize?: number }) =>
|
||||
api.get<LastMileRequestListResponse>(LMR.BASE, { params }),
|
||||
getById: (id: string) => api.get<LastMileRequest>(LMR.BY_ID(id)),
|
||||
freeTruckCount: () => api.get<{ count: number }>(LMR.FREE_TRUCK_COUNT),
|
||||
approve: (id: string, advanceAmount: number) =>
|
||||
api.post<LastMileRequest>(LMR.APPROVE(id), { advanceAmount }),
|
||||
reject: (id: string, reason: string) =>
|
||||
api.post<LastMileRequest>(LMR.REJECT(id), { reason }),
|
||||
};
|
||||
@@ -46,6 +46,7 @@ import BookingContractPage from "./pages/bookings/BookingContractPage";
|
||||
import BookingDetailPage from "./pages/bookings/BookingDetailPage";
|
||||
import BookingsListPage from "./pages/bookings/BookingsListPage";
|
||||
import EditBookingPage from "./pages/bookings/EditBookingPage";
|
||||
import LastMileConfirmPage from "./pages/bookings/last-mile-confirm/LastMileConfirmPage";
|
||||
import ContractDetailPage from "./pages/contracts/ContractDetailPage";
|
||||
import ContractViewPage from "./pages/contracts/ContractViewPage";
|
||||
import ContractsList from "./pages/contracts/ContractsList";
|
||||
@@ -318,6 +319,10 @@ const App = () => {
|
||||
/>
|
||||
<Route path="/bookings/:id/edit" element={<EditBookingPage />} />
|
||||
<Route path="/bookings/:id" element={<BookingDetailPage />} />
|
||||
<Route
|
||||
path="/bookings/:id/last-mile-confirm"
|
||||
element={<LastMileConfirmPage />}
|
||||
/>
|
||||
<Route
|
||||
path="/bookings/:id/contract"
|
||||
element={<BookingContractPage />}
|
||||
|
||||
@@ -217,4 +217,9 @@ export const URL_CONSTANTS = {
|
||||
RECEIPT: (id: string) => `/api/warehouse-fee-invoices/${id}/receipt`,
|
||||
PAY_ONLINE: (id: string) => `/api/warehouse-fee-invoices/${id}/pay-online`,
|
||||
},
|
||||
|
||||
LAST_MILE_REQUESTS: {
|
||||
BY_ID: (id: string) => `/last-mile-requests/${id}`,
|
||||
SUBMIT: (id: string) => `/last-mile-requests/${id}/submit`,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
import { Button, Center, Checkbox, Loader, Stack, Text } from "@mantine/core";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import { bookingsService } from "@/services/bookings.service";
|
||||
import { lastMileRequestsService } from "@/services/last-mile-requests.service";
|
||||
|
||||
import { CardTitle, PageShell, SectionCard } from "../BookingDetailPage/components/layout";
|
||||
|
||||
const errorMessage = (error: unknown, fallback: string) => {
|
||||
const data = (error as { response?: { data?: { message?: string | string[] } } })
|
||||
?.response?.data;
|
||||
if (Array.isArray(data?.message)) return data.message.join(", ");
|
||||
if (data?.message) return data.message;
|
||||
return error instanceof Error ? error.message : fallback;
|
||||
};
|
||||
|
||||
const STATUS_MESSAGE: Record<string, string> = {
|
||||
SUBMITTED: "submitted",
|
||||
APPROVED: "approved",
|
||||
REJECTED: "rejected",
|
||||
};
|
||||
|
||||
export default function LastMileConfirmPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const [searchParams] = useSearchParams();
|
||||
const requestId = searchParams.get("requestId");
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [selected, setSelected] = useState<string[]>([]);
|
||||
|
||||
const {
|
||||
data: request,
|
||||
isLoading: requestLoading,
|
||||
isError: requestError,
|
||||
} = useQuery({
|
||||
queryKey: ["last-mile-request", requestId],
|
||||
queryFn: () => lastMileRequestsService.get(requestId!),
|
||||
enabled: !!requestId,
|
||||
});
|
||||
|
||||
const { data: booking, isLoading: bookingLoading } = useQuery({
|
||||
queryKey: ["booking", id],
|
||||
queryFn: () => bookingsService.get(id!),
|
||||
enabled: !!id,
|
||||
});
|
||||
|
||||
const containerNumbers = booking?.containerNumbers ?? [];
|
||||
|
||||
const submitMutation = useMutation({
|
||||
mutationFn: () => lastMileRequestsService.submit(requestId!, selected),
|
||||
onSuccess: () => {
|
||||
toast.success("Last-mile confirmation submitted");
|
||||
queryClient.invalidateQueries({ queryKey: ["last-mile-request", requestId] });
|
||||
navigate(`/bookings/${id}`);
|
||||
},
|
||||
onError: (e) => toast.error(errorMessage(e, "Could not submit confirmation")),
|
||||
});
|
||||
|
||||
if (!requestId) {
|
||||
return (
|
||||
<PageShell>
|
||||
<SectionCard p={22}>
|
||||
<Text c="dimmed">Missing request id.</Text>
|
||||
</SectionCard>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
if (requestLoading || bookingLoading) {
|
||||
return (
|
||||
<Center mih={300} p="xl">
|
||||
<Loader color="edr-green" />
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
if (requestError || !request) {
|
||||
return (
|
||||
<PageShell>
|
||||
<SectionCard p={22}>
|
||||
<Text c="dimmed">Could not load this confirmation request.</Text>
|
||||
</SectionCard>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
if (request.status !== "AWAITING_CONFIRMATION") {
|
||||
return (
|
||||
<PageShell>
|
||||
<SectionCard p={22}>
|
||||
<CardTitle>Last-mile confirmation</CardTitle>
|
||||
<Text mt={12}>
|
||||
This request has already been {STATUS_MESSAGE[request.status]}.
|
||||
</Text>
|
||||
{request.status === "REJECTED" && request.rejectionReason && (
|
||||
<Text mt={8} c="dimmed" fz="sm">
|
||||
Reason: {request.rejectionReason}
|
||||
</Text>
|
||||
)}
|
||||
</SectionCard>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
const allSelected =
|
||||
containerNumbers.length > 0 && selected.length === containerNumbers.length;
|
||||
|
||||
const toggleAll = (checked: boolean) => {
|
||||
setSelected(checked ? [...containerNumbers] : []);
|
||||
};
|
||||
|
||||
const toggleOne = (containerNumber: string, checked: boolean) => {
|
||||
setSelected((prev) =>
|
||||
checked ? [...prev, containerNumber] : prev.filter((c) => c !== containerNumber),
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
<SectionCard p={22}>
|
||||
<CardTitle>Confirm last-mile containers</CardTitle>
|
||||
<Text mt={8} mb={16} fz="sm" c="dimmed">
|
||||
Select which containers on this booking should be delivered via EDR
|
||||
last-mile.
|
||||
</Text>
|
||||
|
||||
<Stack gap={8}>
|
||||
<Checkbox
|
||||
label="Select all"
|
||||
checked={allSelected}
|
||||
onChange={(e) => toggleAll(e.currentTarget.checked)}
|
||||
fw={700}
|
||||
/>
|
||||
{containerNumbers.map((c) => (
|
||||
<Checkbox
|
||||
key={c}
|
||||
label={c}
|
||||
checked={selected.includes(c)}
|
||||
onChange={(e) => toggleOne(c, e.currentTarget.checked)}
|
||||
ml={12}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
|
||||
<Button
|
||||
mt={20}
|
||||
disabled={selected.length === 0}
|
||||
loading={submitMutation.isPending}
|
||||
onClick={() => submitMutation.mutate()}
|
||||
>
|
||||
Submit confirmation
|
||||
</Button>
|
||||
</SectionCard>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { URL_CONSTANTS } from "@/constants/URLS";
|
||||
import { client } from "../utils/api";
|
||||
|
||||
const L = URL_CONSTANTS.LAST_MILE_REQUESTS;
|
||||
|
||||
export interface LastMileRequest {
|
||||
id: string;
|
||||
bookingId: string;
|
||||
booking?: { id: string; reference?: string } | null;
|
||||
trainScheduleId: string;
|
||||
status: "AWAITING_CONFIRMATION" | "SUBMITTED" | "APPROVED" | "REJECTED";
|
||||
requestedContainerNumbers?: string[] | null;
|
||||
rejectionReason?: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export const lastMileRequestsService = {
|
||||
/** One last-mile confirmation request, by id. */
|
||||
get: async (id: string): Promise<LastMileRequest> => {
|
||||
const { data } = await client.get(L.BY_ID(id));
|
||||
return data.data ?? data;
|
||||
},
|
||||
|
||||
/** Confirm which containers on the booking go via EDR last-mile. */
|
||||
submit: async (
|
||||
id: string,
|
||||
containerNumbers: string[],
|
||||
): Promise<LastMileRequest> => {
|
||||
const { data } = await client.post(L.SUBMIT(id), { containerNumbers });
|
||||
return data.data ?? data;
|
||||
},
|
||||
};
|
||||
@@ -233,6 +233,20 @@ export enum LoadingStatus {
|
||||
Unloaded = "UNLOADED",
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-approval last-mile confirmation lifecycle, fired when a train departs
|
||||
* Djibouti: the customer confirms which containers go via EDR last-mile, then
|
||||
* the Truck & Machinery chief approves (truck available) or rejects (reason).
|
||||
* Approval creates/reuses the execution `LastMile` record — this status set is
|
||||
* intentionally separate from `LAST_MILE_STATUSES`.
|
||||
*/
|
||||
export enum LastMileRequestStatus {
|
||||
AwaitingConfirmation = "AWAITING_CONFIRMATION",
|
||||
Submitted = "SUBMITTED",
|
||||
Approved = "APPROVED",
|
||||
Rejected = "REJECTED",
|
||||
}
|
||||
|
||||
export enum WagonStatus {
|
||||
Available = "AVAILABLE",
|
||||
Assigned = "ASSIGNED",
|
||||
|
||||
Reference in New Issue
Block a user