From 1f4d659ffb36dd903092d3e9e3becf40fcf3fd58 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Wed, 5 Aug 2026 19:58:41 +0000 Subject: [PATCH] Last mile confirmation request , approval, payment Feature --- apps/edr-freight-api/src/app.module.ts | 2 + .../3250000000000-CreateLastMileRequests.ts | 87 +++++ .../dto/approve-last-mile-request.dto.ts | 15 + .../dto/reject-last-mile-request.dto.ts | 10 + .../dto/submit-last-mile-request.dto.ts | 15 + .../entities/last-mile-request.entity.ts | 71 ++++ .../last-mile-requests.controller.ts | 86 +++++ .../last-mile-requests.module.ts | 25 ++ .../last-mile-requests.repository.ts | 16 + .../last-mile-requests.service.ts | 337 ++++++++++++++++++ .../src/scripts/seed-warehouse-demo.ts | 25 +- .../src/seed/edr-freight.seed.ts | 1 + .../src/seed/freight-permissions.registry.ts | 19 + .../src/seed/freight-staff-users.seeder.ts | 1 + .../paid-import-export-mile-demo.seeder.ts | 25 +- .../operations/LastMileRequestsPanel.tsx | 290 +++++++++++++++ .../backoffice/src/constants/QUERY_KEYS.ts | 7 + .../backoffice/src/constants/URLS.ts | 8 + .../backoffice/src/lib/permissions.ts | 3 + .../src/pages/operations/LastMilePage.tsx | 31 ++ .../services/last-mile-requests.service.ts | 51 +++ apps/edr-freight-web/portal/src/App.tsx | 5 + .../portal/src/constants/URLS.ts | 5 + .../last-mile-confirm/LastMileConfirmPage.tsx | 160 +++++++++ .../services/last-mile-requests.service.ts | 33 ++ packages/types/src/freight/index.ts | 14 + 26 files changed, 1334 insertions(+), 8 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/3250000000000-CreateLastMileRequests.ts create mode 100644 apps/edr-freight-api/src/modules/last-mile-requests/dto/approve-last-mile-request.dto.ts create mode 100644 apps/edr-freight-api/src/modules/last-mile-requests/dto/reject-last-mile-request.dto.ts create mode 100644 apps/edr-freight-api/src/modules/last-mile-requests/dto/submit-last-mile-request.dto.ts create mode 100644 apps/edr-freight-api/src/modules/last-mile-requests/entities/last-mile-request.entity.ts create mode 100644 apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.controller.ts create mode 100644 apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.module.ts create mode 100644 apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.repository.ts create mode 100644 apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.service.ts create mode 100644 apps/edr-freight-web/backoffice/src/components/operations/LastMileRequestsPanel.tsx create mode 100644 apps/edr-freight-web/backoffice/src/services/last-mile-requests.service.ts create mode 100644 apps/edr-freight-web/portal/src/pages/bookings/last-mile-confirm/LastMileConfirmPage.tsx create mode 100644 apps/edr-freight-web/portal/src/services/last-mile-requests.service.ts diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index adb92c3b0..d41b77dce 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -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, diff --git a/apps/edr-freight-api/src/migrations/3250000000000-CreateLastMileRequests.ts b/apps/edr-freight-api/src/migrations/3250000000000-CreateLastMileRequests.ts new file mode 100644 index 000000000..0763e32a3 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3250000000000-CreateLastMileRequests.ts @@ -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 { + 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 { + const exists = await queryRunner.hasTable('freight.last_mile_requests'); + if (exists) { + await queryRunner.dropTable('freight.last_mile_requests'); + } + } +} diff --git a/apps/edr-freight-api/src/modules/last-mile-requests/dto/approve-last-mile-request.dto.ts b/apps/edr-freight-api/src/modules/last-mile-requests/dto/approve-last-mile-request.dto.ts new file mode 100644 index 000000000..170eafb25 --- /dev/null +++ b/apps/edr-freight-api/src/modules/last-mile-requests/dto/approve-last-mile-request.dto.ts @@ -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; +} diff --git a/apps/edr-freight-api/src/modules/last-mile-requests/dto/reject-last-mile-request.dto.ts b/apps/edr-freight-api/src/modules/last-mile-requests/dto/reject-last-mile-request.dto.ts new file mode 100644 index 000000000..10cee19c3 --- /dev/null +++ b/apps/edr-freight-api/src/modules/last-mile-requests/dto/reject-last-mile-request.dto.ts @@ -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; +} diff --git a/apps/edr-freight-api/src/modules/last-mile-requests/dto/submit-last-mile-request.dto.ts b/apps/edr-freight-api/src/modules/last-mile-requests/dto/submit-last-mile-request.dto.ts new file mode 100644 index 000000000..b84595f7c --- /dev/null +++ b/apps/edr-freight-api/src/modules/last-mile-requests/dto/submit-last-mile-request.dto.ts @@ -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[]; +} diff --git a/apps/edr-freight-api/src/modules/last-mile-requests/entities/last-mile-request.entity.ts b/apps/edr-freight-api/src/modules/last-mile-requests/entities/last-mile-request.entity.ts new file mode 100644 index 000000000..0077d7f0a --- /dev/null +++ b/apps/edr-freight-api/src/modules/last-mile-requests/entities/last-mile-request.entity.ts @@ -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; +} diff --git a/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.controller.ts b/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.controller.ts new file mode 100644 index 000000000..0c71a21c6 --- /dev/null +++ b/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.controller.ts @@ -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); + } +} diff --git a/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.module.ts b/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.module.ts new file mode 100644 index 000000000..944954e65 --- /dev/null +++ b/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.module.ts @@ -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 {} diff --git a/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.repository.ts b/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.repository.ts new file mode 100644 index 000000000..536a6c022 --- /dev/null +++ b/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.repository.ts @@ -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 { + constructor( + @InjectRepository(LastMileRequest) + repository: Repository, + ) { + super(repository); + } +} diff --git a/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.service.ts b/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.service.ts new file mode 100644 index 000000000..e21706c9f --- /dev/null +++ b/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.service.ts @@ -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 { + 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 { + 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 { + 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 { + 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 = {}; + 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 { + 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 { + return this.dataSource.manager.count(Vehicle, { + where: { status: VehicleStatus.ACTIVE, availability: VehicleAvailability.FREE }, + }); + } + + async submit(id: string, userId: string | null, containerNumbers: string[]): Promise { + 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); + + 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 { + 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); + + 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 { + 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); + + 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); + } +} diff --git a/apps/edr-freight-api/src/scripts/seed-warehouse-demo.ts b/apps/edr-freight-api/src/scripts/seed-warehouse-demo.ts index 593abb305..4c226f8ab 100644 --- a/apps/edr-freight-api/src/scripts/seed-warehouse-demo.ts +++ b/apps/edr-freight-api/src/scripts/seed-warehouse-demo.ts @@ -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) => { + 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 { diff --git a/apps/edr-freight-api/src/seed/edr-freight.seed.ts b/apps/edr-freight-api/src/seed/edr-freight.seed.ts index 7ebcfb89c..22f7fe5ef 100644 --- a/apps/edr-freight-api/src/seed/edr-freight.seed.ts +++ b/apps/edr-freight-api/src/seed/edr-freight.seed.ts @@ -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] }, ]; diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts index 0c44e704d..11cccd782 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -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. */ diff --git a/apps/edr-freight-api/src/seed/freight-staff-users.seeder.ts b/apps/edr-freight-api/src/seed/freight-staff-users.seeder.ts index 6de3bc4de..1b5d776e9 100644 --- a/apps/edr-freight-api/src/seed/freight-staff-users.seeder.ts +++ b/apps/edr-freight-api/src/seed/freight-staff-users.seeder.ts @@ -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() diff --git a/apps/edr-freight-api/src/seed/paid-import-export-mile-demo.seeder.ts b/apps/edr-freight-api/src/seed/paid-import-export-mile-demo.seeder.ts index f60d7bb75..654f4f77f 100644 --- a/apps/edr-freight-api/src/seed/paid-import-export-mile-demo.seeder.ts +++ b/apps/edr-freight-api/src/seed/paid-import-export-mile-demo.seeder.ts @@ -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), diff --git a/apps/edr-freight-web/backoffice/src/components/operations/LastMileRequestsPanel.tsx b/apps/edr-freight-web/backoffice/src/components/operations/LastMileRequestsPanel.tsx new file mode 100644 index 000000000..6b5e15d99 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/operations/LastMileRequestsPanel.tsx @@ -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 = { + 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("SUBMITTED"); + const { pagination, setPagination } = usePagination({ pageSize: 10 }); + const [approveTarget, setApproveTarget] = useState(null); + const [rejectTarget, setRejectTarget] = useState(null); + const [advanceAmount, setAdvanceAmount] = useState(""); + 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[] = [ + { + id: "booking", + header: () => Booking, + cell: ({ row }) => { + const r = row.original; + return ( + + {r.booking?.reference ?? r.bookingId} + {r.booking?.company?.name ?? "—"} + + ); + }, + }, + { + id: "containers", + header: () => Requested Containers, + cell: ({ row }) => { + const nums = row.original.requestedContainerNumbers; + return {nums?.length ? nums.join(", ") : "—"}; + }, + }, + { + id: "submittedAt", + header: () => Submitted, + cell: ({ row }) => {fmtDate(row.original.submittedAt)}, + }, + { + id: "status", + header: () => Status, + cell: ({ row }) => { + const meta = STATUS_META[row.original.status]; + return ( + + {meta.label} + + ); + }, + }, + ...(canApprove + ? [ + { + id: "actions", + header: () => Actions, + cell: ({ row }: { row: { original: LastMileRequest } }) => { + const r = row.original; + if (r.status !== "SUBMITTED") return null; + return ( + + + + + ); + }, + } as ColumnDef, + ] + : []), + ]; + + return ( + + + + + + {freeTrucks?.count ?? 0} truck{freeTrucks?.count === 1 ? "" : "s"} currently free + + + {FILTER_OPTIONS.map((option) => { + const active = statusFilter === option.value; + return ( + + ); + })} + + + + + ( + + )} + /> + + + setApproveTarget(null)} + title={Approve request{approveTarget?.booking?.reference ? ` · ${approveTarget.booking.reference}` : ""}} + centered + > + + + + + + + + + + setRejectTarget(null)} + title={Reject request{rejectTarget?.booking?.reference ? ` · ${rejectTarget.booking.reference}` : ""}} + centered + > + +