diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index a310e496b..8ff24fd8e 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -116,6 +116,7 @@ import { FacilitiesModule } from "./modules/facilities/facilities.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 { EmptyReturnRequestsModule } from "./modules/empty-return-requests/empty-return-requests.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"; @@ -258,6 +259,7 @@ if (!process.env.APPLICATION_NAME) { FirstMileModule, LastMileModule, LastMileRequestsModule, + EmptyReturnRequestsModule, InterchangeDocumentsModule, ImportOperationsModule, VerifaydaModule, diff --git a/apps/edr-freight-api/src/migrations/3840000000000-EmptyReturnRequests.ts b/apps/edr-freight-api/src/migrations/3840000000000-EmptyReturnRequests.ts new file mode 100644 index 000000000..157b95570 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3840000000000-EmptyReturnRequests.ts @@ -0,0 +1,67 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Customer-initiated empty container return, for a booking that did NOT buy + * the return service up front. The customer names the containers coming back, + * operations approves and prices it off the contract's WITH_RETURN rate, the + * customer pays that invoice and then books the date and truck. The empty + * itself is still recorded through `empty_container_returns` when the truck + * actually arrives — this table only carries the request up to that point. + */ +export class EmptyReturnRequests3840000000000 implements MigrationInterface { + name = 'EmptyReturnRequests3840000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.empty_return_requests ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + booking_id uuid NOT NULL, + company_id uuid, + status varchar(30) NOT NULL DEFAULT 'SUBMITTED', + container_numbers text[] NOT NULL DEFAULT '{}', + container_count smallint NOT NULL DEFAULT 0, + quoted_unit_amount numeric(14,2), + quoted_total_amount numeric(14,2), + currency varchar(8), + invoice_id uuid, + paid_at timestamptz, + requested_return_date date, + truck_plate_number varchar(32), + truck_driver_name varchar(120), + truck_type varchar(60), + scheduled_at timestamptz, + submitted_by_user_id uuid, + submitted_at timestamptz NOT NULL DEFAULT now(), + reviewed_by_staff_id uuid, + reviewed_at timestamptz, + rejection_reason text, + completed_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz + ) + `); + + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_empty_return_requests_booking + ON freight.empty_return_requests (booking_id) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_empty_return_requests_status + ON freight.empty_return_requests (status) + `); + + // A container number may only be owed back once at a time. That guard is + // per array element, so it lives in the service (see assertContainersFree) + // rather than in a unique index — this GIN index is what makes the check + // cheap. + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_empty_return_requests_containers + ON freight.empty_return_requests USING gin (container_numbers) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.empty_return_requests`); + } +} diff --git a/apps/edr-freight-api/src/modules/empty-return-requests/dto/empty-return-request.dto.ts b/apps/edr-freight-api/src/modules/empty-return-requests/dto/empty-return-request.dto.ts new file mode 100644 index 000000000..c588a5b03 --- /dev/null +++ b/apps/edr-freight-api/src/modules/empty-return-requests/dto/empty-return-request.dto.ts @@ -0,0 +1,87 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { + ArrayNotEmpty, + ArrayUnique, + IsArray, + IsDateString, + IsNumber, + IsOptional, + IsPositive, + IsString, + IsUUID, + MaxLength, + MinLength, +} from 'class-validator'; + +export class CreateEmptyReturnRequestDto { + @ApiProperty({ description: 'Booking the empties came in on.' }) + @IsUUID() + bookingId!: string; + + @ApiProperty({ + type: [String], + description: + 'One container number per empty being returned — the customer types as many as they said they are sending back.', + example: ['TEMU1234567', 'MSCU7654321'], + }) + @IsArray() + @ArrayNotEmpty() + @ArrayUnique() + @IsString({ each: true }) + @MinLength(4, { each: true }) + @MaxLength(64, { each: true }) + containerNumbers!: string[]; +} + +export class ApproveEmptyReturnRequestDto { + @ApiPropertyOptional({ + description: + 'Per-container price to bill. Defaults to the route WITH_RETURN rate the quote was built from.', + }) + @IsOptional() + @IsNumber() + @IsPositive() + unitAmount?: number; + + @ApiPropertyOptional({ + description: 'Currency of `unitAmount`. Defaults to the quote currency (ETB).', + }) + @IsOptional() + @IsString() + @MaxLength(8) + currency?: string; +} + +export class RejectEmptyReturnRequestDto { + @ApiProperty({ description: 'Why the request was turned down — shown to the customer.' }) + @IsString() + @MinLength(3) + reason!: string; +} + +export class ScheduleEmptyReturnRequestDto { + @ApiProperty({ + description: 'The day the customer will hand the empties over.', + example: '2026-09-20', + }) + @IsDateString() + returnDate!: string; + + @ApiProperty({ description: 'Plate of the truck bringing the empties back.' }) + @IsString() + @MinLength(2) + @MaxLength(32) + truckPlateNumber!: string; + + @ApiProperty({ description: 'Driver bringing the empties back.' }) + @IsString() + @MinLength(2) + @MaxLength(120) + truckDriverName!: string; + + @ApiPropertyOptional({ description: 'Truck type (flatbed, container chassis…).' }) + @IsOptional() + @IsString() + @MaxLength(60) + truckType?: string; +} diff --git a/apps/edr-freight-api/src/modules/empty-return-requests/empty-return-requests.controller.ts b/apps/edr-freight-api/src/modules/empty-return-requests/empty-return-requests.controller.ts new file mode 100644 index 000000000..1451f86c7 --- /dev/null +++ b/apps/edr-freight-api/src/modules/empty-return-requests/empty-return-requests.controller.ts @@ -0,0 +1,126 @@ +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 { BookingStaff, MixedAudience, PortalCustomer } from '../../common/booking-guards'; +import { hasFreightPermission } from '../../common/freight-permission.util'; +import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; +import { + ApproveEmptyReturnRequestDto, + CreateEmptyReturnRequestDto, + RejectEmptyReturnRequestDto, + ScheduleEmptyReturnRequestDto, +} from './dto/empty-return-request.dto'; +import { EmptyReturnRequestsService } from './empty-return-requests.service'; +import type { EmptyReturnRequestStatus } from './entities/empty-return-request.entity'; + +@ApiTags('empty-return-requests') +@ApiBearerAuth() +@Controller('empty-return-requests') +export class EmptyReturnRequestsController { + constructor(private readonly service: EmptyReturnRequestsService) {} + + @Get() + @BookingStaff(FREIGHT_PERMS.emptyReturnRequests.view) + @ApiOperation({ summary: 'Empty container return requests queue' }) + findAll(@Query('status') status?: string, @Query('bookingId') bookingId?: string) { + return this.service.findAll({ + status: status as EmptyReturnRequestStatus | undefined, + bookingId, + }); + } + + @Get('planned') + @BookingStaff(FREIGHT_PERMS.warehouseInventory.view) + @ApiOperation({ + summary: 'Scheduled empty returns the warehouse is expecting, with date and truck', + }) + planned() { + return this.service.plannedReturns(); + } + + @Get('eligibility/:bookingId') + @MixedAudience(FREIGHT_PERMS.emptyReturnRequests.view) + @ApiOperation({ + summary: + 'Whether a booking may request an empty return, its free containers, and the price per container', + }) + eligibility( + @Param('bookingId', ParseUUIDPipe) bookingId: string, + @CurrentUser() user: TCurrentUser, + ) { + return this.service.eligibility(bookingId, this.portalUserId(user)); + } + + @Get('by-booking/:bookingId') + @MixedAudience(FREIGHT_PERMS.emptyReturnRequests.view) + @ApiOperation({ summary: "A booking's empty return requests, newest first" }) + findForBooking(@Param('bookingId', ParseUUIDPipe) bookingId: string) { + return this.service.findForBooking(bookingId); + } + + @Get(':id') + @MixedAudience(FREIGHT_PERMS.emptyReturnRequests.view) + @ApiOperation({ summary: 'Get an empty return request by ID' }) + findOne(@Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: TCurrentUser) { + return this.service.findById(id, this.portalUserId(user)); + } + + @Post() + @PortalCustomer() + @ApiOperation({ + summary: 'Customer requests to return empty containers on a booking sold without return', + }) + create(@Body() dto: CreateEmptyReturnRequestDto, @CurrentUser() user: TCurrentUser) { + return this.service.create(dto, user?.id ?? null); + } + + @Post(':id/schedule') + @PortalCustomer() + @ApiOperation({ + summary: 'Customer sets the return date and the truck bringing the empties back', + }) + schedule( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: ScheduleEmptyReturnRequestDto, + @CurrentUser() user: TCurrentUser, + ) { + return this.service.schedule(id, user?.id ?? null, dto); + } + + @Post(':id/approve') + @BookingStaff(FREIGHT_PERMS.emptyReturnRequests.review) + @ApiOperation({ + summary: + 'Approve and bill the request — the price defaults to the route WITH_RETURN rate per container', + }) + approve( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: ApproveEmptyReturnRequestDto, + @CurrentUser() user: TCurrentUser, + ) { + return this.service.approve(id, user?.id ?? null, dto); + } + + @Post(':id/reject') + @BookingStaff(FREIGHT_PERMS.emptyReturnRequests.review) + @ApiOperation({ summary: 'Reject the request with a reason shown to the customer' }) + reject( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: RejectEmptyReturnRequestDto, + @CurrentUser() user: TCurrentUser, + ) { + return this.service.reject(id, user?.id ?? null, dto); + } + + /** + * Staff read any booking's request; a customer is held to their own. Passing + * the user id is what turns the ownership check on, so staff pass null. + */ + private portalUserId(user: TCurrentUser): string | null { + if (hasFreightPermission(user, FREIGHT_PERMS.emptyReturnRequests.review)) return null; + return user?.id ?? null; + } +} diff --git a/apps/edr-freight-api/src/modules/empty-return-requests/empty-return-requests.module.ts b/apps/edr-freight-api/src/modules/empty-return-requests/empty-return-requests.module.ts new file mode 100644 index 000000000..f682f80e5 --- /dev/null +++ b/apps/edr-freight-api/src/modules/empty-return-requests/empty-return-requests.module.ts @@ -0,0 +1,27 @@ +import { Module, forwardRef } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { registerExchangeModule } from '../exchange-settings/exchange-module-options'; +import { BillingModule } from '../billing/billing.module'; +import { BookingsModule } from '../bookings/bookings.module'; +import { NotificationInboxModule } from '../notification-inbox/notification-inbox.module'; +import { RuleEngineModule } from '../rule-engine/rule-engine.module'; +import { EmptyReturnRequest } from './entities/empty-return-request.entity'; +import { EmptyReturnRequestsController } from './empty-return-requests.controller'; +import { EmptyReturnRequestsRepository } from './empty-return-requests.repository'; +import { EmptyReturnRequestsService } from './empty-return-requests.service'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([EmptyReturnRequest]), + BillingModule, + forwardRef(() => BookingsModule), + NotificationInboxModule, + RuleEngineModule, + registerExchangeModule(), + ], + controllers: [EmptyReturnRequestsController], + providers: [EmptyReturnRequestsRepository, EmptyReturnRequestsService], + exports: [EmptyReturnRequestsService], +}) +export class EmptyReturnRequestsModule {} diff --git a/apps/edr-freight-api/src/modules/empty-return-requests/empty-return-requests.repository.ts b/apps/edr-freight-api/src/modules/empty-return-requests/empty-return-requests.repository.ts new file mode 100644 index 000000000..067c869ff --- /dev/null +++ b/apps/edr-freight-api/src/modules/empty-return-requests/empty-return-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 { EmptyReturnRequest } from './entities/empty-return-request.entity'; + +@Injectable() +export class EmptyReturnRequestsRepository extends BaseRepository { + constructor( + @InjectRepository(EmptyReturnRequest) + repository: Repository, + ) { + super(repository); + } +} diff --git a/apps/edr-freight-api/src/modules/empty-return-requests/empty-return-requests.service.spec.ts b/apps/edr-freight-api/src/modules/empty-return-requests/empty-return-requests.service.spec.ts new file mode 100644 index 000000000..9b8c29e64 --- /dev/null +++ b/apps/edr-freight-api/src/modules/empty-return-requests/empty-return-requests.service.spec.ts @@ -0,0 +1,344 @@ +import { BadRequestException } from '@nestjs/common'; + +import { EmptyReturnRequestsService } from './empty-return-requests.service'; +import type { EmptyReturnRequest } from './entities/empty-return-request.entity'; + +/** + * The service is mostly gates and pricing over raw SQL, so the SQL is stubbed + * by matching a distinctive fragment of each statement. Every stub returns the + * shape the real query returns. + */ +type QueryStub = Array<[string, unknown]>; + +const booking = { + id: 'b1', + reference: 'BK-2026-000300', + companyId: 'co1', + companyProfileId: 'cp1', + status: 'ARRIVED', + freightType: 'CONTAINER', + equipmentReturn: 'WITHOUT_RETURN', + tradeDirection: 'IMPORT', + originYardId: 'y-dj', + destinationYardId: 'y-mojo', + paymentCurrency: 'ETB', +}; + +function build( + overrides: { + booking?: Partial; + request?: Partial; + rates?: unknown[]; + queries?: QueryStub; + } = {}, +) { + const merged = { ...booking, ...overrides.booking }; + + const requestRow: EmptyReturnRequest = { + id: 'r1', + bookingId: merged.id, + companyId: merged.companyId, + status: 'SUBMITTED', + containerNumbers: ['TEMU1111111', 'TEMU2222222', 'TEMU3333333'], + containerCount: 3, + submittedAt: new Date(), + ...overrides.request, + } as EmptyReturnRequest; + + const stubs: QueryStub = [ + ['FROM freight.booking_container\n', [{ containerTypeId: 'ct-40' }]], + [ + 'upper(bcu.container_number)', + [{ containerNumber: 'TEMU1111111' }, { containerNumber: 'TEMU2222222' }], + ], + ['COALESCE(SUM(quantity), 0)', [{ quantity: '5' }]], + ['unnest(r.container_numbers)', []], + ['COUNT(*) AS outstanding', [{ outstanding: '0' }]], + ...(overrides.queries ?? []), + ]; + + const query = jest.fn(async (sql: string) => { + // Later stubs win, so a test can override one of the defaults. + for (let i = stubs.length - 1; i >= 0; i -= 1) { + if (sql.includes(stubs[i][0])) return stubs[i][1]; + } + return []; + }); + + const requests = { + findById: jest.fn(async () => requestRow), + findAll: jest.fn(async () => [requestRow]), + create: jest.fn(async (data: Partial) => ({ ...requestRow, ...data })), + update: jest.fn(async () => requestRow), + }; + const bookingsService = { + findById: jest.fn(async () => merged), + assertCustomerCanAccessBooking: jest.fn(async () => undefined), + }; + const billing = { generateInvoice: jest.fn(async () => ({ id: 'inv1' })) }; + const notifications = { notify: jest.fn(async () => undefined) }; + const ratesService = { + findLiveRatesDetailed: jest.fn( + async () => + overrides.rates ?? [ + { + trigger: 'WITH_RETURN', + currency: 'USD', + tradeDirection: 'IMPORT', + originYardId: 'y-dj', + destinationYardId: 'y-mojo', + containerTypeId: 'ct-40', + rateValue: '100', + }, + ], + ), + }; + const exchange = { getRate: jest.fn(async () => 120) }; + + const service = new EmptyReturnRequestsService( + requests as never, + { findById: jest.fn(async () => merged) } as never, + bookingsService as never, + billing as never, + notifications as never, + ratesService as never, + exchange as never, + { query } as never, + ); + + return { + service, + requests, + bookingsService, + billing, + notifications, + query, + requestRow, + booking: merged, + }; +} + +describe('EmptyReturnRequestsService — eligibility', () => { + it('lets an arrived container booking sold without return ask for one', async () => { + const { service } = build(); + const result = await service.eligibility('b1', 'user1'); + + expect(result.eligible).toBe(true); + expect(result.reason).toBeNull(); + expect(result.availableContainerNumbers).toEqual(['TEMU1111111', 'TEMU2222222']); + }); + + it('refuses bulk freight — there is no equipment to give back', async () => { + const { service } = build({ booking: { freightType: 'BULK' } }); + const result = await service.eligibility('b1', 'user1'); + + expect(result.eligible).toBe(false); + expect(result.reason).toMatch(/container freight only/i); + }); + + it('refuses a booking that already bought the return service', async () => { + const withReturn = build({ booking: { equipmentReturn: 'WITH_RETURN' } }); + const legacy = build({ booking: { equipmentReturn: 'RETURN' } }); + + expect((await withReturn.service.eligibility('b1', null)).reason).toMatch( + /already ships with/i, + ); + expect((await legacy.service.eligibility('b1', null)).reason).toMatch(/already ships with/i); + }); + + it('refuses a booking that has not shipped yet', async () => { + const { service } = build({ booking: { status: 'PAID' } }); + const result = await service.eligibility('b1', null); + + expect(result.eligible).toBe(false); + expect(result.reason).toMatch(/once the booking is in transit/i); + }); + + it('allows it after delivery, when the empty actually comes back', async () => { + const { service } = build({ booking: { status: 'COMPLETED' } }); + expect((await service.eligibility('b1', null)).eligible).toBe(true); + }); + + it('refuses when every container is already on a request', async () => { + const { service } = build({ + queries: [ + [ + 'unnest(r.container_numbers)', + [{ containerNumber: 'TEMU1111111' }, { containerNumber: 'TEMU2222222' }], + ], + ], + }); + const result = await service.eligibility('b1', null); + + expect(result.eligible).toBe(false); + expect(result.reason).toMatch(/already on an empty return request/i); + }); + + it('checks booking ownership for a portal caller, and skips it for staff', async () => { + const portal = build(); + await portal.service.eligibility('b1', 'user1'); + expect(portal.bookingsService.assertCustomerCanAccessBooking).toHaveBeenCalled(); + + const staff = build(); + await staff.service.eligibility('b1', null); + expect(staff.bookingsService.assertCustomerCanAccessBooking).not.toHaveBeenCalled(); + }); +}); + +describe('EmptyReturnRequestsService — pricing', () => { + it('prices a container at the route WITH_RETURN rate, converted to birr', async () => { + const { service, booking: b } = build(); + const quote = await service.quote(b as never); + + // 100 USD × 120 ETB/USD + expect(quote).toMatchObject({ unitAmount: 12000, currency: 'ETB', sourceRateUsd: 100 }); + expect(quote.unavailableReason).toBeNull(); + }); + + it('falls back to the route rate that names no container type', async () => { + const { service, booking: b } = build({ + rates: [ + { + trigger: 'WITH_RETURN', + currency: 'USD', + tradeDirection: 'IMPORT', + originYardId: 'y-dj', + destinationYardId: 'y-mojo', + containerTypeId: null, + rateValue: '80', + }, + ], + }); + + expect((await service.quote(b as never)).unitAmount).toBe(9600); + }); + + it('reports no price when no rate covers the route', async () => { + const { service, booking: b } = build({ + rates: [ + { + trigger: 'WITH_RETURN', + currency: 'USD', + tradeDirection: 'EXPORT', + originYardId: 'other', + destinationYardId: 'other', + containerTypeId: null, + rateValue: '80', + }, + ], + }); + const quote = await service.quote(b as never); + + expect(quote.unitAmount).toBeNull(); + expect(quote.unavailableReason).toMatch(/no empty-return rate/i); + }); +}); + +describe('EmptyReturnRequestsService — approval', () => { + it('bills container count × the route rate and stores the invoice', async () => { + const { service, billing, requests } = build(); + await service.approve('r1', 'staff1', {}); + + expect(billing.generateInvoice).toHaveBeenCalledWith( + expect.objectContaining({ + source: 'empty_return_request', + sourceId: 'r1', + currency: 'ETB', + totalAmount: 36000, // 3 × 12,000 + }), + ); + expect(requests.update).toHaveBeenCalledWith( + 'r1', + expect.objectContaining({ + status: 'APPROVED', + quotedUnitAmount: 12000, + quotedTotalAmount: 36000, + invoiceId: 'inv1', + }), + ); + }); + + it("bills the reviewer's override instead of the route rate", async () => { + const { service, billing } = build(); + await service.approve('r1', 'staff1', { unitAmount: 5000 }); + + expect(billing.generateInvoice).toHaveBeenCalledWith( + expect.objectContaining({ totalAmount: 15000 }), + ); + }); + + it('refuses to approve without a price when no rate covers the route', async () => { + const { service } = build({ rates: [] }); + await expect(service.approve('r1', 'staff1', {})).rejects.toBeInstanceOf(BadRequestException); + }); + + it('only approves a submitted request', async () => { + const { service } = build({ request: { status: 'APPROVED' } }); + await expect(service.approve('r1', 'staff1', {})).rejects.toThrow(/Only a submitted request/); + }); +}); + +describe('EmptyReturnRequestsService — scheduling', () => { + const details = { + returnDate: '2026-09-20', + truckPlateNumber: '3-a12345', + truckDriverName: 'Abebe K.', + }; + + it('takes the date and truck once the invoice is paid', async () => { + const { service, requests } = build({ request: { status: 'PAID' } }); + await service.schedule('r1', 'user1', details); + + expect(requests.update).toHaveBeenCalledWith( + 'r1', + expect.objectContaining({ + status: 'SCHEDULED', + requestedReturnDate: '2026-09-20', + truckPlateNumber: '3-A12345', + }), + ); + }); + + it('tells an unpaid customer to pay first', async () => { + const { service } = build({ request: { status: 'APPROVED' } }); + await expect(service.schedule('r1', 'user1', details)).rejects.toThrow( + /Pay the empty return invoice/, + ); + }); +}); + +describe('EmptyReturnRequestsService — payment and completion', () => { + it('moves an approved request to PAID when its invoice settles', async () => { + const { service, requests } = build({ request: { status: 'APPROVED' } }); + await service.onInvoicePaid({ sourceId: 'r1' }); + + expect(requests.update).toHaveBeenCalledWith('r1', expect.objectContaining({ status: 'PAID' })); + }); + + it('ignores a settlement for a request that is not awaiting payment', async () => { + const { service, requests } = build({ request: { status: 'SCHEDULED' } }); + await service.onInvoicePaid({ sourceId: 'r1' }); + + expect(requests.update).not.toHaveBeenCalled(); + }); + + it('completes a scheduled request once every container is recorded back', async () => { + const { service, requests } = build({ request: { status: 'SCHEDULED' } }); + await service.settleScheduledForBooking('b1'); + + expect(requests.update).toHaveBeenCalledWith( + 'r1', + expect.objectContaining({ status: 'COMPLETED' }), + ); + }); + + it('leaves it scheduled while any container is still outstanding', async () => { + const { service, requests } = build({ + request: { status: 'SCHEDULED' }, + queries: [['COUNT(*) AS outstanding', [{ outstanding: '2' }]]], + }); + await service.settleScheduledForBooking('b1'); + + expect(requests.update).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/edr-freight-api/src/modules/empty-return-requests/empty-return-requests.service.ts b/apps/edr-freight-api/src/modules/empty-return-requests/empty-return-requests.service.ts new file mode 100644 index 000000000..a35934fab --- /dev/null +++ b/apps/edr-freight-api/src/modules/empty-return-requests/empty-return-requests.service.ts @@ -0,0 +1,610 @@ +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { OnEvent } from '@nestjs/event-emitter'; +import { DataSource } from 'typeorm'; + +import { ExchangeService } from '@edr/api-common'; +import { Freight, NotificationAudience, NotificationPriority, NotificationType } from '@edr/types'; + +import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; +import { BillingService } from '../billing/billing.service'; +import { BookingsRepository } from '../bookings/bookings.repository'; +import { BookingsService } from '../bookings/bookings.service'; +import { Booking } from '../bookings/entities/booking.entity'; +import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; +import { RatesService } from '../rule-engine/services/rates.service'; +import { + ApproveEmptyReturnRequestDto, + CreateEmptyReturnRequestDto, + RejectEmptyReturnRequestDto, + ScheduleEmptyReturnRequestDto, +} from './dto/empty-return-request.dto'; +import { + EmptyReturnRequest, + type EmptyReturnRequestStatus, +} from './entities/empty-return-request.entity'; +import { EmptyReturnRequestsRepository } from './empty-return-requests.repository'; + +/** The invoice `source` this module owns — also the `${source}.invoice.paid` event prefix. */ +const INVOICE_SOURCE = 'empty_return_request'; + +/** + * Booking statuses that may still ask for an empty return. The empty only goes + * back after the cargo is delivered, so everything from departure onward + * qualifies — cutting it off at ARRIVED would take the option away exactly + * when the customer needs it. + */ +const REQUESTABLE_BOOKING_STATUSES = ['IN_TRANSIT', 'ARRIVED', 'COMPLETED']; + +/** Requests that still hold their container numbers — a rejected one releases them. */ +const OPEN_STATUSES: EmptyReturnRequestStatus[] = [ + 'SUBMITTED', + 'APPROVED', + 'PAID', + 'SCHEDULED', + 'COMPLETED', +]; + +export interface EmptyReturnQuote { + /** Per-container price in `currency`; null when no rate covers this route. */ + unitAmount: number | null; + currency: string; + /** The USD route rate the quote came from, before conversion. */ + sourceRateUsd: number | null; + /** Why there is no price, for the UI to show instead of a number. */ + unavailableReason: string | null; +} + +export interface EmptyReturnEligibility { + eligible: boolean; + /** Why the customer cannot request one, when `eligible` is false. */ + reason: string | null; + /** Containers on the booking that are not already spoken for. */ + availableContainerNumbers: string[]; + maxContainers: number; + quote: EmptyReturnQuote; +} + +@Injectable() +export class EmptyReturnRequestsService { + constructor( + private readonly requests: EmptyReturnRequestsRepository, + private readonly bookingsRepository: BookingsRepository, + private readonly bookingsService: BookingsService, + private readonly billing: BillingService, + private readonly notifications: NotificationInboxService, + private readonly ratesService: RatesService, + private readonly exchange: ExchangeService, + private readonly dataSource: DataSource, + ) {} + + // ── reads ──────────────────────────────────────────────────────────────── + + async findAll(filter: { + status?: EmptyReturnRequestStatus; + bookingId?: string; + }): Promise< + Array + > { + return this.dataSource.query( + `SELECT r.*, + b.reference AS "bookingReference", + c.name AS "companyName" + FROM freight.empty_return_requests r + LEFT JOIN freight.bookings b ON b.id = r.booking_id AND b.deleted_at IS NULL + LEFT JOIN freight.companies c ON c.id = r.company_id + WHERE r.deleted_at IS NULL + AND ($1::text IS NULL OR r.status = $1) + AND ($2::uuid IS NULL OR r.booking_id = $2) + ORDER BY r.submitted_at DESC`, + [filter.status ?? null, filter.bookingId ?? null], + ); + } + + /** One request. A portal caller must own the booking; staff pass `null`. */ + async findById(id: string, userId: string | null = null): Promise { + const request = await this.requests.findById(id); + if (!request) throw new NotFoundException(`Empty return request ${id} not found`); + if (userId) { + const booking = await this.bookingsService.findById(request.bookingId); + await this.bookingsService.assertCustomerCanAccessBooking(userId, booking); + } + return request; + } + + /** A booking's own requests — the portal card's history. */ + findForBooking(bookingId: string): Promise { + return this.requests.findAll({ + where: { bookingId }, + order: { submittedAt: 'DESC' }, + }); + } + + /** + * Can this booking ask for an empty return, how many containers are left to + * ask for, and what one would cost. Drives the portal card: the customer + * sees the price before committing, and staff see the same number prefilled + * at approval. + */ + async eligibility(bookingId: string, userId: string | null): Promise { + const booking = await this.bookingsService.findById(bookingId); + if (userId) await this.bookingsService.assertCustomerCanAccessBooking(userId, booking); + + const quote = await this.quote(booking); + const spoken = await this.spokenForContainers(bookingId); + const all = await this.bookingContainerNumbers(bookingId); + const available = all.filter((number) => !spoken.has(number)); + + const reason = this.ineligibilityReason(booking, available.length); + return { + eligible: reason === null, + reason, + availableContainerNumbers: available, + // A booking whose container numbers were never captured still gets to + // ask — the customer types the numbers, so the line quantity is the cap. + maxContainers: available.length || (await this.bookingContainerQuantity(bookingId)), + quote, + }; + } + + private ineligibilityReason(booking: Booking, availableCount: number): string | null { + if (booking.freightType !== 'CONTAINER') { + return 'Empty container return applies to container freight only.'; + } + if (booking.equipmentReturn === 'WITH_RETURN' || booking.equipmentReturn === 'RETURN') { + return 'This booking already ships with empty container return included.'; + } + if (!REQUESTABLE_BOOKING_STATUSES.includes(booking.status)) { + return `An empty return can be requested once the booking is in transit (current status: ${booking.status}).`; + } + if (availableCount === 0) { + return 'Every container on this booking is already on an empty return request.'; + } + return null; + } + + // ── pricing ────────────────────────────────────────────────────────────── + + /** + * Per-container price for returning an empty on this booking, taken from the + * same live WITH_RETURN rate the rule engine bills when the service is + * bought up front (route + trade direction + container type, priced in USD). + * Billed in ETB, converted at the current rate, because this is collected + * locally rather than on the freight invoice. + * + * ponytail: prices off the booking's FIRST container line. A booking mixing + * 20ft and 40ft therefore quotes one size's rate for every box — split the + * quote per container if mixed-size bookings start returning empties. + */ + async quote(booking: Booking): Promise { + const currency = 'ETB'; + if (booking.freightType !== 'CONTAINER') { + return { + unitAmount: null, + currency, + sourceRateUsd: null, + unavailableReason: 'Not container freight.', + }; + } + + const [line]: Array<{ containerTypeId: string | null }> = await this.dataSource.query( + `SELECT container_type_id AS "containerTypeId" + FROM freight.booking_container + WHERE booking_id = $1 AND deleted_at IS NULL + ORDER BY created_at ASC + LIMIT 1`, + [booking.id], + ); + + const rates = await this.ratesService.findLiveRatesDetailed(); + const onLeg = rates.filter( + (rate) => + rate.trigger === 'WITH_RETURN' && + rate.currency === 'USD' && + rate.tradeDirection === booking.tradeDirection && + rate.originYardId === booking.originYardId && + rate.destinationYardId === booking.destinationYardId, + ); + const rate = + onLeg.find((r) => r.containerTypeId === (line?.containerTypeId ?? null)) ?? + onLeg.find((r) => !r.containerTypeId); + + if (!rate) { + return { + unitAmount: null, + currency, + sourceRateUsd: null, + unavailableReason: + 'No empty-return rate covers this route and container type — enter the amount manually.', + }; + } + + const usdToEtb = await this.exchange.getRate('USD', 'ETB'); + const rateUsd = Number(rate.rateValue); + return { + unitAmount: Math.round(rateUsd * usdToEtb * 100) / 100, + currency, + sourceRateUsd: rateUsd, + unavailableReason: null, + }; + } + + // ── customer actions ───────────────────────────────────────────────────── + + async create( + dto: CreateEmptyReturnRequestDto, + userId: string | null, + ): Promise { + const booking = await this.bookingsService.findById(dto.bookingId); + if (userId) await this.bookingsService.assertCustomerCanAccessBooking(userId, booking); + + const numbers = dto.containerNumbers.map((n) => n.trim().toUpperCase()).filter(Boolean); + if (numbers.length === 0) { + throw new BadRequestException('Give at least one container number.'); + } + if (new Set(numbers).size !== numbers.length) { + throw new BadRequestException('The same container number appears twice.'); + } + + const reason = this.ineligibilityReason(booking, numbers.length); + if (reason) throw new BadRequestException(reason); + + await this.assertContainersFree(numbers); + + const saved = await this.requests.create({ + bookingId: booking.id, + companyId: booking.companyId ?? null, + status: 'SUBMITTED', + containerNumbers: numbers, + containerCount: numbers.length, + submittedByUserId: userId, + submittedAt: new Date(), + } as Partial); + + void this.notifications.notify({ + recipients: { permissionKeys: [FREIGHT_PERMS.emptyReturnRequests.review] }, + audience: NotificationAudience.BACKOFFICE, + type: NotificationType.BOOKING_STATUS, + title: 'Empty container return requested', + body: `${booking.reference ?? booking.id}: a customer asked to return ${numbers.length} empty container${ + numbers.length === 1 ? '' : 's' + }.`, + link: '/dashboard/empty-return-requests', + data: { bookingId: booking.id, requestId: saved.id }, + priority: NotificationPriority.HIGH, + }); + + return saved; + } + + /** Date + truck, once the invoice is settled. This is what the warehouse then expects. */ + async schedule( + id: string, + userId: string | null, + dto: ScheduleEmptyReturnRequestDto, + ): Promise { + const request = await this.findById(id, userId); + if (request.status !== 'PAID' && request.status !== 'SCHEDULED') { + throw new BadRequestException( + request.status === 'APPROVED' + ? 'Pay the empty return invoice before booking a date.' + : `This request cannot be scheduled (current status: ${request.status}).`, + ); + } + + await this.requests.update(id, { + status: 'SCHEDULED', + requestedReturnDate: dto.returnDate, + truckPlateNumber: dto.truckPlateNumber.trim().toUpperCase(), + truckDriverName: dto.truckDriverName.trim(), + truckType: dto.truckType?.trim() ?? null, + scheduledAt: new Date(), + } as Partial); + + void this.notifications.notify({ + recipients: { permissionKeys: [FREIGHT_PERMS.emptyReturnRequests.review] }, + audience: NotificationAudience.BACKOFFICE, + type: NotificationType.BOOKING_STATUS, + title: 'Empty return scheduled', + body: `${request.containerCount} empty container${request.containerCount === 1 ? '' : 's'} arriving ${ + dto.returnDate + } on truck ${dto.truckPlateNumber}.`, + link: '/dashboard/container-returns', + data: { bookingId: request.bookingId, requestId: id }, + }); + + return this.findById(id); + } + + // ── staff actions ──────────────────────────────────────────────────────── + + /** + * Approve and bill. The reviewer's `unitAmount` wins; otherwise the route + * rate stands. The invoice is issued here, so the customer can pay straight + * away — payment lands back on `onInvoicePaid`. + */ + async approve( + id: string, + staffId: string | null, + dto: ApproveEmptyReturnRequestDto, + ): Promise { + const request = await this.findById(id); + if (request.status !== 'SUBMITTED') { + throw new BadRequestException( + `Only a submitted request can be approved (current status: ${request.status}).`, + ); + } + + const booking = await this.bookingsService.findById(request.bookingId); + // `chk_invoices_single_payer` requires exactly one payer, and this invoice + // is always billed to the customer — so a booking with no company cannot + // be invoiced at all. Say so here rather than at the constraint. + if (!booking.companyId) { + throw new BadRequestException( + `Booking ${booking.reference ?? booking.id} has no company to bill — the empty return cannot be invoiced.`, + ); + } + + const quote = await this.quote(booking); + const unitAmount = dto.unitAmount ?? quote.unitAmount; + if (!unitAmount || unitAmount <= 0) { + throw new BadRequestException( + quote.unavailableReason ?? 'No price for this return — enter the per-container amount.', + ); + } + + const currency = dto.currency ?? quote.currency; + const totalAmount = Math.round(unitAmount * request.containerCount * 100) / 100; + + const invoice = await this.billing.generateInvoice({ + source: INVOICE_SOURCE as Freight.InvoiceSource, + sourceId: request.id, + type: 'EMPTY_RETURN', + companyId: booking.companyId, + companyProfileId: booking.companyProfileId || '', + currency, + lines: [ + { + chargeType: 'CONTAINER_WITH_RETURN', + description: `Empty container return — ${request.containerCount} container${ + request.containerCount === 1 ? '' : 's' + } on booking ${booking.reference ?? booking.id}`, + amount: totalAmount, + }, + ], + totalAmount, + }); + + await this.requests.update(id, { + status: 'APPROVED', + quotedUnitAmount: unitAmount, + quotedTotalAmount: totalAmount, + currency, + invoiceId: invoice.id, + reviewedByStaffId: staffId, + reviewedAt: new Date(), + } as Partial); + + if (booking.companyId) { + void this.notifications.notify({ + recipients: { companyId: booking.companyId }, + audience: NotificationAudience.PORTAL, + type: NotificationType.INVOICE_ISSUED, + title: 'Empty container return approved — payment due', + body: `Your empty return request for booking ${booking.reference ?? booking.id} was approved: ${totalAmount.toLocaleString()} ${currency} for ${request.containerCount} container${ + request.containerCount === 1 ? '' : 's' + }. Pay the invoice, then choose your return date and truck.`, + link: `/bookings/${booking.id}`, + data: { bookingId: booking.id, requestId: id, invoiceId: invoice.id }, + priority: NotificationPriority.HIGH, + }); + } + + return this.findById(id); + } + + async reject( + id: string, + staffId: string | null, + dto: RejectEmptyReturnRequestDto, + ): Promise { + const request = await this.findById(id); + if (request.status !== 'SUBMITTED') { + throw new BadRequestException( + `Only a submitted request can be rejected (current status: ${request.status}).`, + ); + } + + await this.requests.update(id, { + status: 'REJECTED', + reviewedByStaffId: staffId, + reviewedAt: new Date(), + rejectionReason: dto.reason, + } as Partial); + + const booking = await this.bookingsRepository.findById(request.bookingId); + if (booking?.companyId) { + void this.notifications.notify({ + recipients: { companyId: booking.companyId }, + audience: NotificationAudience.PORTAL, + type: NotificationType.BOOKING_STATUS, + title: 'Empty container return rejected', + body: `Your empty return request for booking ${booking.reference ?? request.bookingId} was rejected: ${dto.reason}`, + link: `/bookings/${request.bookingId}`, + data: { bookingId: request.bookingId, requestId: id }, + priority: NotificationPriority.HIGH, + }); + } + + return this.findById(id); + } + + // ── warehouse handoff ──────────────────────────────────────────────────── + + /** + * Scheduled requests the warehouse is waiting on — the planned side of the + * Container Returns screen. Containers already recorded as returned are + * carried per request so staff confirm only what is still outstanding. + */ + async plannedReturns(): Promise< + Array<{ + requestId: string; + bookingId: string; + bookingReference: string | null; + companyName: string | null; + companyId: string | null; + requestedReturnDate: string | null; + truckPlateNumber: string | null; + truckDriverName: string | null; + truckType: string | null; + containers: Array<{ containerNumber: string; returnId: string | null }>; + }> + > { + return this.dataSource.query( + `SELECT r.id AS "requestId", + r.booking_id AS "bookingId", + b.reference AS "bookingReference", + c.name AS "companyName", + r.company_id AS "companyId", + r.requested_return_date AS "requestedReturnDate", + r.truck_plate_number AS "truckPlateNumber", + r.truck_driver_name AS "truckDriverName", + r.truck_type AS "truckType", + ( + SELECT json_agg(json_build_object( + 'containerNumber', n, + 'returnId', ( + SELECT er.id FROM freight.empty_container_returns er + WHERE er.deleted_at IS NULL + AND er.booking_id = r.booking_id + AND upper(er.container_number) = upper(n) + ORDER BY er.created_at DESC LIMIT 1 + ) + ) ORDER BY ord) + FROM unnest(r.container_numbers) WITH ORDINALITY AS t(n, ord) + ) AS containers + FROM freight.empty_return_requests r + LEFT JOIN freight.bookings b ON b.id = r.booking_id AND b.deleted_at IS NULL + LEFT JOIN freight.companies c ON c.id = r.company_id + WHERE r.deleted_at IS NULL + AND r.status = 'SCHEDULED' + ORDER BY r.requested_return_date ASC NULLS LAST, r.scheduled_at ASC`, + ); + } + + /** + * Close a scheduled request once every container it covers has been recorded + * as returned. Called after the warehouse records the returns; a request + * with anything still outstanding stays SCHEDULED. + */ + async settleScheduledForBooking(bookingId: string): Promise { + const open = await this.requests.findAll({ + where: { bookingId, status: 'SCHEDULED' }, + }); + + for (const request of open) { + const [{ outstanding }]: Array<{ outstanding: string }> = await this.dataSource.query( + `SELECT COUNT(*) AS outstanding + FROM unnest($2::text[]) AS n + WHERE NOT EXISTS ( + SELECT 1 FROM freight.empty_container_returns er + WHERE er.deleted_at IS NULL + AND er.booking_id = $1 + AND upper(er.container_number) = upper(n) + )`, + [bookingId, request.containerNumbers], + ); + if (Number(outstanding) > 0) continue; + + await this.requests.update(request.id, { + status: 'COMPLETED', + completedAt: new Date(), + } as Partial); + } + } + + // ── payment ────────────────────────────────────────────────────────────── + + /** Gateway and manual settlements both land here (`${source}.invoice.paid`). */ + @OnEvent(`${INVOICE_SOURCE}.invoice.paid`) + async onInvoicePaid(payload: { sourceId: string }): Promise { + const request = await this.requests.findById(payload.sourceId); + if (!request || request.status !== 'APPROVED') return; + + await this.requests.update(request.id, { + status: 'PAID', + paidAt: new Date(), + } as Partial); + + const booking = await this.bookingsRepository.findById(request.bookingId); + if (!booking?.companyId) return; + void this.notifications.notify({ + recipients: { companyId: booking.companyId }, + audience: NotificationAudience.PORTAL, + type: NotificationType.PAYMENT_RECEIVED, + title: 'Empty return paid — choose your return date', + body: `Payment received for the empty return on booking ${booking.reference ?? request.bookingId}. Tell us the date and the truck bringing the containers back.`, + link: `/bookings/${request.bookingId}`, + data: { bookingId: request.bookingId, requestId: request.id }, + priority: NotificationPriority.HIGH, + }); + } + + // ── helpers ────────────────────────────────────────────────────────────── + + /** Container numbers captured on the booking, upper-cased. */ + private async bookingContainerNumbers(bookingId: string): Promise { + const rows: Array<{ containerNumber: string }> = await this.dataSource.query( + `SELECT DISTINCT upper(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 + AND bcu.container_number IS NOT NULL + ORDER BY 1`, + [bookingId], + ); + return rows.map((row) => row.containerNumber); + } + + /** How many containers the booking bought, for a booking with no captured numbers. */ + private async bookingContainerQuantity(bookingId: string): Promise { + const [row]: Array<{ quantity: string | null }> = await this.dataSource.query( + `SELECT COALESCE(SUM(quantity), 0) AS quantity + FROM freight.booking_container + WHERE booking_id = $1 AND deleted_at IS NULL`, + [bookingId], + ); + return Number(row?.quantity ?? 0); + } + + /** Numbers already claimed by a live request on this booking. */ + private async spokenForContainers(bookingId: string): Promise> { + const rows: Array<{ containerNumber: string }> = await this.dataSource.query( + `SELECT DISTINCT upper(n) AS "containerNumber" + FROM freight.empty_return_requests r, unnest(r.container_numbers) AS n + WHERE r.deleted_at IS NULL + AND r.booking_id = $1 + AND r.status = ANY($2)`, + [bookingId, OPEN_STATUSES], + ); + return new Set(rows.map((row) => row.containerNumber)); + } + + /** A container may only sit on one live request at a time, on any booking. */ + private async assertContainersFree(numbers: string[]): Promise { + const rows: Array<{ containerNumber: string }> = await this.dataSource.query( + `SELECT DISTINCT upper(n) AS "containerNumber" + FROM freight.empty_return_requests r, unnest(r.container_numbers) AS n + WHERE r.deleted_at IS NULL + AND r.status = ANY($1) + AND upper(n) = ANY($2)`, + [OPEN_STATUSES, numbers], + ); + if (rows.length > 0) { + throw new BadRequestException( + `Already on an empty return request: ${rows.map((r) => r.containerNumber).join(', ')}`, + ); + } + } +} diff --git a/apps/edr-freight-api/src/modules/empty-return-requests/entities/empty-return-request.entity.ts b/apps/edr-freight-api/src/modules/empty-return-requests/entities/empty-return-request.entity.ts new file mode 100644 index 000000000..6bb8257be --- /dev/null +++ b/apps/edr-freight-api/src/modules/empty-return-requests/entities/empty-return-request.entity.ts @@ -0,0 +1,127 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; + +import { Booking } from '../../bookings/entities/booking.entity'; + +export const EMPTY_RETURN_REQUEST_STATUSES = [ + /** Customer named the containers; waiting on operations. */ + 'SUBMITTED', + /** Operations approved and priced it; the invoice is out, waiting on payment. */ + 'APPROVED', + 'REJECTED', + /** Invoice settled; waiting on the customer to book a date and a truck. */ + 'PAID', + /** Date and truck given — the warehouse now expects these empties. */ + 'SCHEDULED', + /** The empties arrived and were recorded as returns. */ + 'COMPLETED', + 'CANCELLED', +] as const; + +export type EmptyReturnRequestStatus = (typeof EMPTY_RETURN_REQUEST_STATUSES)[number]; + +/** + * A customer's request to return empties on a booking that did NOT buy the + * return service up front (`equipment_return` is not WITH_RETURN). Container + * freight only — a bulk booking has no equipment to give back. + * + * The request carries the commercial half of the flow: which containers, what + * operations priced it at, the invoice, and the date/truck the customer + * booked. The physical return is still recorded in `empty_container_returns` + * when the truck arrives, which is what closes this row out as COMPLETED. + */ +@Entity({ schema: 'freight', name: 'empty_return_requests' }) +@Index(['bookingId']) +@Index(['status']) +export class EmptyReturnRequest extends BaseEntity { + @Column({ name: 'booking_id', type: 'uuid' }) + bookingId!: string; + + @ManyToOne(() => Booking, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'booking_id' }) + booking?: Booking; + + /** Denormalised at submit so the queue and the invoice agree on the payer. */ + @Column({ name: 'company_id', type: 'uuid', nullable: true }) + companyId?: string | null; + + @Column({ name: 'status', type: 'varchar', length: 30, default: 'SUBMITTED' }) + status!: EmptyReturnRequestStatus; + + /** The container numbers the customer is sending back, as typed. */ + @Column({ name: 'container_numbers', type: 'text', array: true, default: () => "'{}'" }) + containerNumbers!: string[]; + + @Column({ name: 'container_count', type: 'smallint', default: 0 }) + containerCount!: number; + + /** Per-container price at approval — the route's WITH_RETURN rate, or the reviewer's override. */ + @Column({ + name: 'quoted_unit_amount', + type: 'numeric', + precision: 14, + scale: 2, + nullable: true, + transformer: { + to: (v?: number | null) => v, + from: (v?: string | null) => (v == null ? null : Number(v)), + }, + }) + quotedUnitAmount?: number | null; + + @Column({ + name: 'quoted_total_amount', + type: 'numeric', + precision: 14, + scale: 2, + nullable: true, + transformer: { + to: (v?: number | null) => v, + from: (v?: string | null) => (v == null ? null : Number(v)), + }, + }) + quotedTotalAmount?: number | null; + + @Column({ name: 'currency', type: 'varchar', length: 8, nullable: true }) + currency?: string | null; + + @Column({ name: 'invoice_id', type: 'uuid', nullable: true }) + invoiceId?: string | null; + + @Column({ name: 'paid_at', type: 'timestamptz', nullable: true }) + paidAt?: Date | null; + + /** Customer's chosen day for handing the empties over. */ + @Column({ name: 'requested_return_date', type: 'date', nullable: true }) + requestedReturnDate?: string | null; + + @Column({ name: 'truck_plate_number', type: 'varchar', length: 32, nullable: true }) + truckPlateNumber?: string | null; + + @Column({ name: 'truck_driver_name', type: 'varchar', length: 120, nullable: true }) + truckDriverName?: string | null; + + @Column({ name: 'truck_type', type: 'varchar', length: 60, nullable: true }) + truckType?: string | null; + + @Column({ name: 'scheduled_at', type: 'timestamptz', nullable: true }) + scheduledAt?: Date | null; + + @Column({ name: 'submitted_by_user_id', type: 'uuid', nullable: true }) + submittedByUserId?: string | null; + + @Column({ name: 'submitted_at', type: 'timestamptz', default: () => 'now()' }) + submittedAt!: Date; + + @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: 'completed_at', type: 'timestamptz', nullable: true }) + completedAt?: Date | null; +} diff --git a/apps/edr-freight-api/src/modules/import-operations/import-operations.module.ts b/apps/edr-freight-api/src/modules/import-operations/import-operations.module.ts index 8cdc83853..d6cb00fd2 100644 --- a/apps/edr-freight-api/src/modules/import-operations/import-operations.module.ts +++ b/apps/edr-freight-api/src/modules/import-operations/import-operations.module.ts @@ -2,6 +2,7 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { BookingsModule } from '../bookings/bookings.module'; +import { EmptyReturnRequestsModule } from '../empty-return-requests/empty-return-requests.module'; import { NotificationInboxModule } from '../notification-inbox/notification-inbox.module'; import { NotificationsModule } from '../notifications/notifications.module'; import { WarehousesModule } from '../warehouses/warehouses.module'; @@ -26,6 +27,9 @@ import { ImportOperationsService } from './import-operations.service'; BookingsModule, NotificationInboxModule, NotificationsModule, + // Recording a return is what closes out the customer's scheduled empty + // return request, once every container on it is back. + EmptyReturnRequestsModule, ], controllers: [ImportOperationsController], providers: [ImportOperationsService], diff --git a/apps/edr-freight-api/src/modules/import-operations/import-operations.service.ts b/apps/edr-freight-api/src/modules/import-operations/import-operations.service.ts index e9c53f882..6333af97e 100644 --- a/apps/edr-freight-api/src/modules/import-operations/import-operations.service.ts +++ b/apps/edr-freight-api/src/modules/import-operations/import-operations.service.ts @@ -8,6 +8,7 @@ import { logoImageCss, logoMarkup } from '../billing/documents/logo-markup.util' import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; import { NotificationsService } from '../notifications/notifications.service'; import { sendCompanyChannels } from '../notifications/notify-company.util'; +import { EmptyReturnRequestsService } from '../empty-return-requests/empty-return-requests.service'; import { WarehouseReleaseDocumentService } from '../warehouses/warehouse-release-document.service'; import { BulkCreateEmptyContainerReturnsDto, @@ -64,6 +65,7 @@ export class ImportOperationsService { private readonly logoSettings: LogoSettingsService, private readonly inbox: NotificationInboxService, private readonly notifications: NotificationsService, + private readonly emptyReturnRequests: EmptyReturnRequestsService, ) {} listIncidents(bookingId?: string) { @@ -288,6 +290,8 @@ export class ImportOperationsService { // Standalone returns (no booking) have no company to notify. if (saved.bookingId) { await this.notifyEquipmentInterchangeReady(saved); + // Closes the customer's scheduled request once its last container is in. + await this.emptyReturnRequests.settleScheduledForBooking(saved.bookingId); } return saved; } 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 2a8ac578e..cf574f588 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -1490,6 +1490,22 @@ export const ADDITIONAL_CHARGE_PERMISSIONS: FreightPermissionSeed[] = [ ), ]; +// E''. Empty container return requests — customer asks to send empties back on +// a booking that was sold without the return service; operations price and +// approve it, the customer pays, then books the date and truck. +export const EMPTY_RETURN_REQUEST_PERMISSIONS: FreightPermissionSeed[] = [ + perm( + "f2e00002-0001-4000-8000-000000000001", + "edr_freight_app:empty_return_requests:view", + "View empty container return requests", + ), + perm( + "f2e00002-0001-4000-8000-000000000002", + "edr_freight_app:empty_return_requests:review", + "Approve or reject an empty container return request", + ), +]; + // E'. Train-scheduling finer actions (augment existing view/manage) export const SCHEDULING_EXTRA_PERMISSIONS: FreightPermissionSeed[] = [ perm( @@ -1937,6 +1953,7 @@ export const ADVANCED_BACKOFFICE_PERMISSIONS: FreightPermissionSeed[] = [ ...WAREHOUSE_PERMISSIONS, ...PORT_TERMINAL_PERMISSIONS, ...ADDITIONAL_CHARGE_PERMISSIONS, + ...EMPTY_RETURN_REQUEST_PERMISSIONS, ...SCHEDULING_EXTRA_PERMISSIONS, ...CONFIG_SETTINGS_PERMISSIONS, ...STAFF_IAM_PERMISSIONS, @@ -2418,6 +2435,10 @@ export const FREIGHT_PERMS = { // Notification selector, not a route guard — see NOTIFICATION_PERMISSIONS. getNotification: "edr_freight_app:additional_charges:get_notification", }, + emptyReturnRequests: { + view: "edr_freight_app:empty_return_requests:view", + review: "edr_freight_app:empty_return_requests:review", + }, settings: { fileUpload: { view: "edr_freight_app:settings:file_upload:view", diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 539f2ee98..ff03bd5da 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -96,6 +96,7 @@ import TrainBuilderDetailPage from "./pages/trainBuilder/TrainBuilderDetailPage" import TrainBuilderListPage from "./pages/trainBuilder/TrainBuilderListPage"; import ArrivalQueuePage from "./pages/warehouses/ArrivalQueuePage"; import ContainerReturnsPage from "./pages/warehouses/ContainerReturnsPage"; +import EmptyReturnRequestsPage from "./pages/warehouses/EmptyReturnRequestsPage"; import RegisterFullContainersPage from "./pages/warehouses/RegisterFullContainersPage"; import DispatchQueuePage from "./pages/warehouses/DispatchQueuePage"; import ExportDjiboutiUnloadingQueuePage from "./pages/warehouses/ExportDjiboutiUnloadingQueuePage"; @@ -709,6 +710,16 @@ const App = () => { } /> + + + + } + /> , permission: FREIGHT_PERMS.warehouseInventory.view, }, + { + label: "Empty Return Requests", + href: "/dashboard/empty-return-requests", + icon: , + permission: FREIGHT_PERMS.emptyReturnRequests.view, + }, { label: "Register Full Containers", href: "/dashboard/register-full-containers", diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts index 1e8a1c602..8de3bd320 100644 --- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts @@ -800,6 +800,15 @@ export const URL_CONSTANTS = { CANCEL: (id: string) => `/interchange-documents/${id}/cancel`, }, + EMPTY_RETURN_REQUESTS: { + BASE: "/empty-return-requests", + PLANNED: "/empty-return-requests/planned", + ELIGIBILITY: (bookingId: string) => `/empty-return-requests/eligibility/${bookingId}`, + BY_BOOKING: (bookingId: string) => `/empty-return-requests/by-booking/${bookingId}`, + APPROVE: (id: string) => `/empty-return-requests/${id}/approve`, + REJECT: (id: string) => `/empty-return-requests/${id}/reject`, + }, + IMPORT_OPERATIONS: { DJIBOUTI_INCIDENTS: "/import-operations/djibouti-incidents", CUSTOMS: (bookingId: string) => `/import-operations/customs/${bookingId}`, diff --git a/apps/edr-freight-web/backoffice/src/lib/permissions.ts b/apps/edr-freight-web/backoffice/src/lib/permissions.ts index 62137496f..bc03bfcb5 100644 --- a/apps/edr-freight-web/backoffice/src/lib/permissions.ts +++ b/apps/edr-freight-web/backoffice/src/lib/permissions.ts @@ -358,6 +358,10 @@ export const FREIGHT_PERMS = { send: "edr_freight_app:additional_charges:send", cancel: "edr_freight_app:additional_charges:cancel", }, + emptyReturnRequests: { + view: "edr_freight_app:empty_return_requests:view", + review: "edr_freight_app:empty_return_requests:review", + }, /** * Audit trail. View-only — the API exposes no write routes for audit rows, * so there is no manage/delete counterpart to grant. diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/ContainerReturnsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/ContainerReturnsPage.tsx index 4b931d9cd..60b349437 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/ContainerReturnsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/ContainerReturnsPage.tsx @@ -40,11 +40,13 @@ import { useWarehouseYards, useWarehouseZones } from "@/hooks/useWarehouses"; import { api } from "@/services/api"; import { warehouseService } from "@/services/warehouse.service"; import { importOperationsService } from "@/services/importOperations.service"; +import { emptyReturnRequestsService } from "@/services/emptyReturnRequests.service"; import type { EmptyContainerReturn, EmptyContainerReturnStatus, EmptyContainerSize, EmptyReturnBooking, + PlannedEmptyReturn, } from "@/types/importOperations"; import type { TrainScheduleListItem } from "@/types/trainScheduling"; import { formatDateTime, localNowForInput } from "@/lib/format"; @@ -78,6 +80,32 @@ const RETURNED_BY_SERIES = [ { key: "customer", label: "Customer Self-Haul", color: "#b45309" }, ]; +/** + * A scheduled request seen as the booking shape `BookingEmptyReturnModal` + * takes, so confirming an arrival runs through exactly the same recording + * path as any other empty return. + */ +const plannedAsBooking = (planned: PlannedEmptyReturn): EmptyReturnBooking => ({ + bookingId: planned.bookingId, + bookingReference: planned.bookingReference ?? planned.bookingId, + bookingStatus: "SCHEDULED_RETURN", + equipmentReturn: "REQUESTED", + customerId: planned.companyId, + companyName: planned.companyName, + containers: planned.containers.map((container) => ({ + key: `${planned.requestId}-${container.containerNumber}`, + unitId: `${planned.requestId}-${container.containerNumber}`, + containerNumber: container.containerNumber, + containerSize: null, + containerType: null, + returnId: container.returnId, + returnStatus: null, + })), + expectedCount: planned.containers.length, + recordedCount: planned.containers.filter((c) => c.returnId).length, + pendingCount: planned.containers.filter((c) => !c.returnId).length, +}); + interface ContainerReturnRow { key: string; containerNumber: string; @@ -114,6 +142,7 @@ export default function ContainerReturnsPage() { const [allocateRow, setAllocateRow] = useState(null); const [emptyReturnBooking, setEmptyReturnBooking] = useState(null); const [expandedBooking, setExpandedBooking] = useState(null); + const [arrivingReturn, setArrivingReturn] = useState(null); const [documentBusyId, setDocumentBusyId] = useState(null); const viewInterchangeDocument = async (ret: EmptyContainerReturn) => { @@ -159,6 +188,14 @@ export default function ContainerReturnsPage() { }); const emptyReturnBookings = emptyReturnBookingsQuery.data ?? []; + // Requests the customer already paid for and booked a truck against — the + // warehouse confirms these on arrival, which is what records the containers. + const plannedReturnsQuery = useQuery({ + queryKey: ["planned-empty-returns"], + queryFn: () => emptyReturnRequestsService.planned(), + }); + const plannedReturns = plannedReturnsQuery.data ?? []; + const bookingIds = unloadedQueue.map((item) => item.bookingId).filter(Boolean) as string[]; const containerReturnsQuery = useQuery({ queryKey: ["container-returns", bookingIds], @@ -350,10 +387,12 @@ export default function ContainerReturnsPage() { qc.invalidateQueries({ queryKey: ["container-returns", bookingIds] }); qc.invalidateQueries({ queryKey: ["empty-container-returns"] }); qc.invalidateQueries({ queryKey: ["empty-return-bookings"] }); + qc.invalidateQueries({ queryKey: ["planned-empty-returns"] }); setReturnModalOpen(false); setStandaloneModalOpen(false); setActiveKey(null); setEmptyReturnBooking(null); + setArrivingReturn(null); }, onError: (error: any) => { toast({ @@ -567,6 +606,83 @@ export default function ContainerReturnsPage() { + {plannedReturns.length > 0 && ( + + + +
+ Planned Empty Returns + + Customers who paid for an empty return and booked a truck. Confirm the arrival + to record the containers. + +
+ + {plannedReturns.length} expected + +
+ + + + + + Booking Ref + Company + Return Date + Truck + Containers + Action + + + + {plannedReturns.map((planned) => { + const outstanding = planned.containers.filter((c) => !c.returnId); + return ( + + + {planned.bookingReference ?? planned.bookingId} + + {planned.companyName ?? "—"} + {planned.requestedReturnDate ?? "—"} + + + {planned.truckPlateNumber ?? "—"} + + {planned.truckDriverName ?? "—"} + {planned.truckType ? ` · ${planned.truckType}` : ""} + + + + + + + {outstanding.length} of {planned.containers.length} outstanding + + + {planned.containers.map((c) => c.containerNumber).join(", ")} + + + + + + + + ); + })} + +
+
+
+
+ )} + @@ -904,6 +1020,24 @@ export default function ContainerReturnsPage() { loading={createReturnsMutation.isPending} /> + {/* A scheduled return arrives on the customer's own truck, so the modal + opens pre-set to self-haul with that truck already noted. */} + setArrivingReturn(null)} + onSubmit={(payload) => createReturnsMutation.mutate(payload)} + loading={createReturnsMutation.isPending} + defaultReturnedBy="CUSTOMER" + defaultHandoverNote={ + arrivingReturn + ? `Scheduled empty return · truck ${arrivingReturn.truckPlateNumber ?? "—"}${ + arrivingReturn.truckDriverName ? ` · driver ${arrivingReturn.truckDriverName}` : "" + }` + : undefined + } + /> + setBulkModalOpen(false)} @@ -1217,6 +1351,11 @@ interface BookingEmptyReturnModalProps { onClose: () => void; onSubmit: (payload: any) => void; loading: boolean; + /** Pre-set for a scheduled return, where the truck type is already known. */ + defaultReturnedBy?: "EDR" | "CUSTOMER"; + /** Pre-set for a scheduled return — the truck the customer told us about. */ + defaultHandoverNote?: string; + title?: string; } /** @@ -1226,7 +1365,15 @@ interface BookingEmptyReturnModalProps { * cannot be ticked again. A legacy booking that never captured container * numbers shows numberless slots — the number is typed here instead. */ -function BookingEmptyReturnModal({ booking, onClose, onSubmit, loading }: BookingEmptyReturnModalProps) { +function BookingEmptyReturnModal({ + booking, + onClose, + onSubmit, + loading, + defaultReturnedBy, + defaultHandoverNote, + title = "Empty Container Return", +}: BookingEmptyReturnModalProps) { const [selected, setSelected] = useState([]); const [returnedBy, setReturnedBy] = useState<"EDR" | "CUSTOMER" | null>(null); const [returnDate, setReturnDate] = useState(localNowForInput()); @@ -1242,14 +1389,14 @@ function BookingEmptyReturnModal({ booking, onClose, onSubmit, loading }: Bookin // ticks, typed numbers, or placement. useEffect(() => { setSelected([]); - setReturnedBy(null); + setReturnedBy(defaultReturnedBy ?? null); setReturnDate(localNowForInput()); setWarehouse(null); setYardId(null); setZoneId(null); setCondition(""); - setHandoverNote(""); - }, [bookingId]); + setHandoverNote(defaultHandoverNote ?? ""); + }, [bookingId, defaultReturnedBy, defaultHandoverNote]); const { data: warehousesResponse } = useQuery({ queryKey: ["warehouses-list"], @@ -1332,12 +1479,7 @@ function BookingEmptyReturnModal({ booking, onClose, onSubmit, loading }: Bookin }; return ( - + {booking && ( diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/EmptyReturnRequestsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/EmptyReturnRequestsPage.tsx new file mode 100644 index 000000000..0a3765d01 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/EmptyReturnRequestsPage.tsx @@ -0,0 +1,477 @@ +import { useEffect, useMemo, useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + Alert, + Badge, + Button, + Card, + Divider, + Group, + Loader, + Modal, + NumberInput, + Select, + SimpleGrid, + Stack, + Text, + Textarea, +} from "@mantine/core"; +import { DataTable, type ColumnDef } from "@edr/ui-common"; + +import { PageContainer, PageHeader } from "@/components/page"; +import ListControls from "@/components/common/ListControls"; +import { extractErrorMessage } from "@/components/warehouses/options"; +import { useListControls } from "@/hooks/useListControls"; +import { useToast } from "@/hooks/use-toast"; +import { emptyReturnRequestsService } from "@/services/emptyReturnRequests.service"; +import type { + EmptyReturnRequest, + EmptyReturnRequestStatus, +} from "@/types/importOperations"; +import { formatDateTime } from "@/lib/format"; + +const STATUS_META: Record = { + SUBMITTED: { label: "Awaiting review", color: "orange" }, + APPROVED: { label: "Awaiting payment", color: "yellow" }, + REJECTED: { label: "Rejected", color: "red" }, + PAID: { label: "Paid — awaiting date", color: "blue" }, + SCHEDULED: { label: "Scheduled", color: "edr-green" }, + COMPLETED: { label: "Returned", color: "gray" }, + CANCELLED: { label: "Cancelled", color: "gray" }, +}; + +const money = (amount: number | null | undefined, currency: string | null | undefined) => + amount == null + ? "—" + : `${Number(amount).toLocaleString(undefined, { minimumFractionDigits: 2 })} ${currency ?? ""}`.trim(); + +/** + * The queue for customer-initiated empty container returns: a booking sold + * WITHOUT the return service, whose customer now wants to send the empties + * back. Staff price and approve — which invoices the customer — or reject with + * a reason. Everything after payment (date, truck) happens in the portal, and + * the containers themselves are recorded on Container Returns. + */ +export default function EmptyReturnRequestsPage() { + const { toast } = useToast(); + const qc = useQueryClient(); + const [statusFilter, setStatusFilter] = useState(null); + const [approving, setApproving] = useState(null); + const [rejecting, setRejecting] = useState(null); + + const requestsQuery = useQuery({ + queryKey: ["empty-return-requests"], + queryFn: () => emptyReturnRequestsService.list(), + }); + + const requests = useMemo(() => { + const rows = requestsQuery.data ?? []; + return statusFilter ? rows.filter((row) => row.status === statusFilter) : rows; + }, [requestsQuery.data, statusFilter]); + + const controls = useListControls(requests, { + dateKey: "submittedAt", + searchValue: (row) => + `${row.bookingReference ?? ""} ${row.companyName ?? ""} ${row.containerNumbers.join(" ")}`, + }); + + const invalidate = () => { + qc.invalidateQueries({ queryKey: ["empty-return-requests"] }); + qc.invalidateQueries({ queryKey: ["planned-empty-returns"] }); + }; + + const approveMutation = useMutation({ + mutationFn: ({ id, unitAmount }: { id: string; unitAmount?: number }) => + emptyReturnRequestsService.approve(id, { unitAmount }), + onSuccess: () => { + toast({ title: "Approved — invoice sent to the customer" }); + invalidate(); + setApproving(null); + }, + onError: (error: unknown) => { + toast({ + variant: "destructive", + title: "Could not approve the request", + description: extractErrorMessage(error), + }); + }, + }); + + const rejectMutation = useMutation({ + mutationFn: ({ id, reason }: { id: string; reason: string }) => + emptyReturnRequestsService.reject(id, reason), + onSuccess: () => { + toast({ title: "Request rejected" }); + invalidate(); + setRejecting(null); + }, + onError: (error: unknown) => { + toast({ + variant: "destructive", + title: "Could not reject the request", + description: extractErrorMessage(error), + }); + }, + }); + + const columns: ColumnDef[] = [ + { + id: "booking", + header: "Booking", + cell: ({ row }) => ( + + + {row.original.bookingReference ?? row.original.bookingId} + + + {row.original.companyName ?? "—"} + + + ), + }, + { + id: "containers", + header: "Containers", + cell: ({ row }) => ( + + {row.original.containerCount} + + {row.original.containerNumbers.join(", ")} + + + ), + }, + { + id: "submittedAt", + header: "Requested", + cell: ({ row }) => formatDateTime(row.original.submittedAt), + }, + { + id: "price", + header: "Price", + cell: ({ row }) => + row.original.quotedTotalAmount == null ? ( + "—" + ) : ( + + + {money(row.original.quotedTotalAmount, row.original.currency)} + + + {money(row.original.quotedUnitAmount, row.original.currency)} × {row.original.containerCount} + + + ), + }, + { + id: "return", + header: "Return", + cell: ({ row }) => + row.original.requestedReturnDate ? ( + + {row.original.requestedReturnDate} + + {row.original.truckPlateNumber ?? "—"} + {row.original.truckDriverName ? ` · ${row.original.truckDriverName}` : ""} + + + ) : ( + "—" + ), + }, + { + id: "status", + header: "Status", + cell: ({ row }) => { + const meta = STATUS_META[row.original.status]; + return ( + + + {meta?.label ?? row.original.status} + + {row.original.rejectionReason && ( + + {row.original.rejectionReason} + + )} + + ); + }, + }, + { + id: "action", + header: "Action", + cell: ({ row }) => + row.original.status === "SUBMITTED" ? ( + + + + + ) : ( + + {row.original.status === "APPROVED" ? "Awaiting customer payment" : "No action"} + + ), + }, + ]; + + const pending = (requestsQuery.data ?? []).filter((row) => row.status === "SUBMITTED").length; + + return ( + + + + + + + + Requests + {pending > 0 && ( + + {pending} awaiting review + + )} + + + + { + controls.reset(); + setStatusFilter(null); + }} + > +