Last mile confirmation request , approval, payment Feature

This commit is contained in:
Hagernesh
2026-08-05 19:58:41 +00:00
parent 37d0cc966a
commit 1f4d659ffb
26 changed files with 1334 additions and 8 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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. */

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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