mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-03 09:43:40 +00:00
Merge pull request #1476 from Tria-plc/Emty-container-return
Emty container return
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.empty_return_requests`);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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<EmptyReturnRequest> {
|
||||
constructor(
|
||||
@InjectRepository(EmptyReturnRequest)
|
||||
repository: Repository<EmptyReturnRequest>,
|
||||
) {
|
||||
super(repository);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,413 @@
|
||||
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<typeof booking>;
|
||||
request?: Partial<EmptyReturnRequest>;
|
||||
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<EmptyReturnRequest>) => ({ ...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('refuses a booking with no container numbers to pick from', async () => {
|
||||
const { service } = build({ queries: [['upper(bcu.container_number)', []]] });
|
||||
const result = await service.eligibility('b1', null);
|
||||
|
||||
expect(result.eligible).toBe(false);
|
||||
expect(result.reason).toMatch(/no container numbers are recorded/i);
|
||||
expect(result.availableContainerNumbers).toEqual([]);
|
||||
});
|
||||
|
||||
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 — creating a request', () => {
|
||||
it('accepts containers that came in on the booking', async () => {
|
||||
const { service, requests } = build();
|
||||
await service.create(
|
||||
{ bookingId: 'b1', containerNumbers: ['temu1111111', 'TEMU2222222'] },
|
||||
'user1',
|
||||
);
|
||||
|
||||
expect(requests.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
bookingId: 'b1',
|
||||
containerNumbers: ['TEMU1111111', 'TEMU2222222'],
|
||||
containerCount: 2,
|
||||
status: 'SUBMITTED',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('refuses a container that is not on the booking', async () => {
|
||||
const { service, requests } = build();
|
||||
|
||||
await expect(
|
||||
service.create(
|
||||
{ bookingId: 'b1', containerNumbers: ['TEMU1111111', 'MSCU9999999'] },
|
||||
'user1',
|
||||
),
|
||||
).rejects.toThrow(/Not on booking BK-2026-000300: MSCU9999999/);
|
||||
expect(requests.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refuses the same container twice', async () => {
|
||||
const { service } = build();
|
||||
|
||||
await expect(
|
||||
service.create(
|
||||
{ bookingId: 'b1', containerNumbers: ['TEMU1111111', 'TEMU1111111'] },
|
||||
'user1',
|
||||
),
|
||||
).rejects.toThrow(/selected twice/i);
|
||||
});
|
||||
|
||||
it('refuses a container already sitting on a live request', async () => {
|
||||
const { service } = build({
|
||||
queries: [['unnest(r.container_numbers)', [{ containerNumber: 'TEMU1111111' }]]],
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.create({ bookingId: 'b1', containerNumbers: ['TEMU1111111'] }, 'user1'),
|
||||
).rejects.toThrow(/Already on an empty return request/);
|
||||
});
|
||||
|
||||
it('refuses a booking that already ships with return', async () => {
|
||||
const { service } = build({ booking: { equipmentReturn: 'WITH_RETURN' } });
|
||||
|
||||
await expect(
|
||||
service.create({ bookingId: 'b1', containerNumbers: ['TEMU1111111'] }, 'user1'),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,617 @@
|
||||
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<EmptyReturnRequest & { bookingReference: string | null; companyName: string | null }>
|
||||
> {
|
||||
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<EmptyReturnRequest> {
|
||||
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<EmptyReturnRequest[]> {
|
||||
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<EmptyReturnEligibility> {
|
||||
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, all.length, available.length);
|
||||
return {
|
||||
eligible: reason === null,
|
||||
reason,
|
||||
availableContainerNumbers: available,
|
||||
maxContainers: available.length,
|
||||
quote,
|
||||
};
|
||||
}
|
||||
|
||||
private ineligibilityReason(
|
||||
booking: Booking,
|
||||
bookingContainerCount: number,
|
||||
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}).`;
|
||||
}
|
||||
// The customer picks from this booking's own containers, so a booking that
|
||||
// never captured its container numbers has nothing to pick.
|
||||
if (bookingContainerCount === 0) {
|
||||
return 'No container numbers are recorded on this booking — contact EDR to arrange the return.';
|
||||
}
|
||||
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<EmptyReturnQuote> {
|
||||
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<EmptyReturnRequest> {
|
||||
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('Select at least one container.');
|
||||
}
|
||||
if (new Set(numbers).size !== numbers.length) {
|
||||
throw new BadRequestException('The same container is selected twice.');
|
||||
}
|
||||
|
||||
// Only this booking's own containers can be returned against it. The
|
||||
// portal offers a pick list, so anything else is a stale page or a
|
||||
// hand-made request.
|
||||
const onBooking = new Set(await this.bookingContainerNumbers(booking.id));
|
||||
const foreign = numbers.filter((number) => !onBooking.has(number));
|
||||
if (foreign.length > 0) {
|
||||
throw new BadRequestException(
|
||||
`Not on booking ${booking.reference ?? booking.id}: ${foreign.join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
const reason = this.ineligibilityReason(booking, onBooking.size, 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<EmptyReturnRequest>);
|
||||
|
||||
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<EmptyReturnRequest> {
|
||||
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<EmptyReturnRequest>);
|
||||
|
||||
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<EmptyReturnRequest> {
|
||||
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<EmptyReturnRequest>);
|
||||
|
||||
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<EmptyReturnRequest> {
|
||||
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<EmptyReturnRequest>);
|
||||
|
||||
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<void> {
|
||||
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<EmptyReturnRequest>);
|
||||
}
|
||||
}
|
||||
|
||||
// ── payment ──────────────────────────────────────────────────────────────
|
||||
|
||||
/** Gateway and manual settlements both land here (`${source}.invoice.paid`). */
|
||||
@OnEvent(`${INVOICE_SOURCE}.invoice.paid`)
|
||||
async onInvoicePaid(payload: { sourceId: string }): Promise<void> {
|
||||
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<EmptyReturnRequest>);
|
||||
|
||||
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<string[]> {
|
||||
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);
|
||||
}
|
||||
|
||||
/** Numbers already claimed by a live request on this booking. */
|
||||
private async spokenForContainers(bookingId: string): Promise<Set<string>> {
|
||||
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<void> {
|
||||
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(', ')}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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],
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 = () => {
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="empty-return-requests"
|
||||
element={
|
||||
<RequirePermission
|
||||
permission={FREIGHT_PERMS.emptyReturnRequests.view}
|
||||
>
|
||||
<EmptyReturnRequestsPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="register-full-containers"
|
||||
element={
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
SlidersHorizontal,
|
||||
Train,
|
||||
Truck,
|
||||
Undo2,
|
||||
Users,
|
||||
Wallet,
|
||||
LifeBuoy,
|
||||
@@ -367,6 +368,12 @@ export const buildSidebarSections = (
|
||||
icon: <Container />,
|
||||
permission: FREIGHT_PERMS.warehouseInventory.view,
|
||||
},
|
||||
{
|
||||
label: "Empty Return Requests",
|
||||
href: "/dashboard/empty-return-requests",
|
||||
icon: <Undo2 />,
|
||||
permission: FREIGHT_PERMS.emptyReturnRequests.view,
|
||||
},
|
||||
{
|
||||
label: "Register Full Containers",
|
||||
href: "/dashboard/register-full-containers",
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<EmptyContainerReturn | null>(null);
|
||||
const [emptyReturnBooking, setEmptyReturnBooking] = useState<EmptyReturnBooking | null>(null);
|
||||
const [expandedBooking, setExpandedBooking] = useState<string | null>(null);
|
||||
const [arrivingReturn, setArrivingReturn] = useState<PlannedEmptyReturn | null>(null);
|
||||
const [documentBusyId, setDocumentBusyId] = useState<string | null>(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() {
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{plannedReturns.length > 0 && (
|
||||
<Card withBorder radius="lg" p="md" mb="lg">
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between" align="flex-start">
|
||||
<div>
|
||||
<Text fw={600}>Planned Empty Returns</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
Customers who paid for an empty return and booked a truck. Confirm the arrival
|
||||
to record the containers.
|
||||
</Text>
|
||||
</div>
|
||||
<Badge variant="light" size="lg" color="orange">
|
||||
{plannedReturns.length} expected
|
||||
</Badge>
|
||||
</Group>
|
||||
|
||||
<Table.ScrollContainer minWidth={900}>
|
||||
<Table highlightOnHover verticalSpacing="xs">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Booking Ref</Table.Th>
|
||||
<Table.Th>Company</Table.Th>
|
||||
<Table.Th>Return Date</Table.Th>
|
||||
<Table.Th>Truck</Table.Th>
|
||||
<Table.Th>Containers</Table.Th>
|
||||
<Table.Th ta="right">Action</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{plannedReturns.map((planned) => {
|
||||
const outstanding = planned.containers.filter((c) => !c.returnId);
|
||||
return (
|
||||
<Table.Tr key={planned.requestId}>
|
||||
<Table.Td>
|
||||
<Text fw={600}>{planned.bookingReference ?? planned.bookingId}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>{planned.companyName ?? "—"}</Table.Td>
|
||||
<Table.Td>{planned.requestedReturnDate ?? "—"}</Table.Td>
|
||||
<Table.Td>
|
||||
<Stack gap={2}>
|
||||
<Text size="sm">{planned.truckPlateNumber ?? "—"}</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{planned.truckDriverName ?? "—"}
|
||||
{planned.truckType ? ` · ${planned.truckType}` : ""}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Stack gap={2}>
|
||||
<Badge color={outstanding.length ? "orange" : "edr-green"}>
|
||||
{outstanding.length} of {planned.containers.length} outstanding
|
||||
</Badge>
|
||||
<Text size="xs" c="dimmed" lineClamp={2}>
|
||||
{planned.containers.map((c) => c.containerNumber).join(", ")}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Table.Td>
|
||||
<Table.Td ta="right">
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
disabled={outstanding.length === 0}
|
||||
onClick={() => setArrivingReturn(planned)}
|
||||
>
|
||||
Confirm Arrival
|
||||
</Button>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
);
|
||||
})}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
</Stack>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card withBorder radius="lg" p="md" mb="lg">
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between" align="flex-start">
|
||||
@@ -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. */}
|
||||
<BookingEmptyReturnModal
|
||||
title="Confirm Empty Return Arrival"
|
||||
booking={arrivingReturn ? plannedAsBooking(arrivingReturn) : null}
|
||||
onClose={() => 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
|
||||
}
|
||||
/>
|
||||
|
||||
<BulkContainerReturnModal
|
||||
opened={bulkModalOpen}
|
||||
onClose={() => 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<string[]>([]);
|
||||
const [returnedBy, setReturnedBy] = useState<"EDR" | "CUSTOMER" | null>(null);
|
||||
const [returnDate, setReturnDate] = useState<string>(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 (
|
||||
<Modal
|
||||
opened={!!booking}
|
||||
onClose={onClose}
|
||||
title="Empty Container Return"
|
||||
size="lg"
|
||||
>
|
||||
<Modal opened={!!booking} onClose={onClose} title={title} size="lg">
|
||||
{booking && (
|
||||
<Stack gap="md">
|
||||
<Group gap="sm">
|
||||
|
||||
@@ -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<EmptyReturnRequestStatus, { label: string; color: string }> = {
|
||||
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<string | null>(null);
|
||||
const [approving, setApproving] = useState<EmptyReturnRequest | null>(null);
|
||||
const [rejecting, setRejecting] = useState<EmptyReturnRequest | null>(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<EmptyReturnRequest>[] = [
|
||||
{
|
||||
id: "booking",
|
||||
header: "Booking",
|
||||
cell: ({ row }) => (
|
||||
<Stack gap={2}>
|
||||
<Text fw={600} size="sm">
|
||||
{row.original.bookingReference ?? row.original.bookingId}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{row.original.companyName ?? "—"}
|
||||
</Text>
|
||||
</Stack>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "containers",
|
||||
header: "Containers",
|
||||
cell: ({ row }) => (
|
||||
<Stack gap={2}>
|
||||
<Badge size="sm">{row.original.containerCount}</Badge>
|
||||
<Text size="xs" c="dimmed" lineClamp={2}>
|
||||
{row.original.containerNumbers.join(", ")}
|
||||
</Text>
|
||||
</Stack>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "submittedAt",
|
||||
header: "Requested",
|
||||
cell: ({ row }) => formatDateTime(row.original.submittedAt),
|
||||
},
|
||||
{
|
||||
id: "price",
|
||||
header: "Price",
|
||||
cell: ({ row }) =>
|
||||
row.original.quotedTotalAmount == null ? (
|
||||
"—"
|
||||
) : (
|
||||
<Stack gap={2}>
|
||||
<Text size="sm" fw={600}>
|
||||
{money(row.original.quotedTotalAmount, row.original.currency)}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{money(row.original.quotedUnitAmount, row.original.currency)} × {row.original.containerCount}
|
||||
</Text>
|
||||
</Stack>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "return",
|
||||
header: "Return",
|
||||
cell: ({ row }) =>
|
||||
row.original.requestedReturnDate ? (
|
||||
<Stack gap={2}>
|
||||
<Text size="sm">{row.original.requestedReturnDate}</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{row.original.truckPlateNumber ?? "—"}
|
||||
{row.original.truckDriverName ? ` · ${row.original.truckDriverName}` : ""}
|
||||
</Text>
|
||||
</Stack>
|
||||
) : (
|
||||
"—"
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
header: "Status",
|
||||
cell: ({ row }) => {
|
||||
const meta = STATUS_META[row.original.status];
|
||||
return (
|
||||
<Stack gap={2}>
|
||||
<Badge size="sm" color={meta?.color ?? "gray"} variant="light">
|
||||
{meta?.label ?? row.original.status}
|
||||
</Badge>
|
||||
{row.original.rejectionReason && (
|
||||
<Text size="xs" c="dimmed" lineClamp={2}>
|
||||
{row.original.rejectionReason}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "action",
|
||||
header: "Action",
|
||||
cell: ({ row }) =>
|
||||
row.original.status === "SUBMITTED" ? (
|
||||
<Group gap="xs" justify="flex-end" wrap="nowrap">
|
||||
<Button size="xs" variant="subtle" color="red" onClick={() => setRejecting(row.original)}>
|
||||
Reject
|
||||
</Button>
|
||||
<Button size="xs" variant="light" onClick={() => setApproving(row.original)}>
|
||||
Approve
|
||||
</Button>
|
||||
</Group>
|
||||
) : (
|
||||
<Text size="xs" c="dimmed" ta="right">
|
||||
{row.original.status === "APPROVED" ? "Awaiting customer payment" : "No action"}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const pending = (requestsQuery.data ?? []).filter((row) => row.status === "SUBMITTED").length;
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
title="Empty Return Requests"
|
||||
subtitle="Customers asking to send empty containers back on bookings sold without equipment return"
|
||||
/>
|
||||
|
||||
<Card withBorder radius="lg" p="md">
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between">
|
||||
<Text fw={600}>
|
||||
Requests
|
||||
{pending > 0 && (
|
||||
<Badge ml="sm" color="orange" variant="light">
|
||||
{pending} awaiting review
|
||||
</Badge>
|
||||
)}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
<ListControls
|
||||
search={controls.search}
|
||||
onSearchChange={controls.setSearch}
|
||||
searchPlaceholder="Search booking, company, container…"
|
||||
dateFrom={controls.dateFrom}
|
||||
onDateFromChange={controls.setDateFrom}
|
||||
dateTo={controls.dateTo}
|
||||
onDateToChange={controls.setDateTo}
|
||||
dateLabel="Requested"
|
||||
hasFilters={controls.hasFilters || Boolean(statusFilter)}
|
||||
onReset={() => {
|
||||
controls.reset();
|
||||
setStatusFilter(null);
|
||||
}}
|
||||
>
|
||||
<Select
|
||||
placeholder="Status"
|
||||
value={statusFilter}
|
||||
onChange={setStatusFilter}
|
||||
data={Object.entries(STATUS_META).map(([value, meta]) => ({
|
||||
value,
|
||||
label: meta.label,
|
||||
}))}
|
||||
clearable
|
||||
w={220}
|
||||
/>
|
||||
</ListControls>
|
||||
|
||||
{requestsQuery.isLoading ? (
|
||||
<Group justify="center" py="lg">
|
||||
<Loader size="sm" />
|
||||
</Group>
|
||||
) : requestsQuery.isError ? (
|
||||
<Alert color="red">
|
||||
Could not load empty return requests. {extractErrorMessage(requestsQuery.error)}
|
||||
</Alert>
|
||||
) : controls.pagedRows.length === 0 ? (
|
||||
<Alert color="gray">No empty return requests.</Alert>
|
||||
) : (
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={controls.pagedRows}
|
||||
containerClassName="border-0 shadow-none"
|
||||
{...controls.tableProps}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
<ApproveModal
|
||||
request={approving}
|
||||
onClose={() => setApproving(null)}
|
||||
onApprove={(unitAmount) =>
|
||||
approving && approveMutation.mutate({ id: approving.id, unitAmount })
|
||||
}
|
||||
loading={approveMutation.isPending}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
opened={!!rejecting}
|
||||
onClose={() => setRejecting(null)}
|
||||
title="Reject empty return request"
|
||||
size="md"
|
||||
>
|
||||
{rejecting && (
|
||||
<RejectForm
|
||||
request={rejecting}
|
||||
loading={rejectMutation.isPending}
|
||||
onCancel={() => setRejecting(null)}
|
||||
onReject={(reason) => rejectMutation.mutate({ id: rejecting.id, reason })}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The pricing step. The per-container price is prefilled from the booking's
|
||||
* route WITH_RETURN rate; the reviewer can override it before approving, and
|
||||
* approving is what issues the customer's invoice.
|
||||
*/
|
||||
function ApproveModal({
|
||||
request,
|
||||
onClose,
|
||||
onApprove,
|
||||
loading,
|
||||
}: {
|
||||
request: EmptyReturnRequest | null;
|
||||
onClose: () => void;
|
||||
onApprove: (unitAmount?: number) => void;
|
||||
loading: boolean;
|
||||
}) {
|
||||
const [unitAmount, setUnitAmount] = useState<number | "">("");
|
||||
|
||||
const quoteQuery = useQuery({
|
||||
queryKey: ["empty-return-quote", request?.bookingId],
|
||||
queryFn: () => emptyReturnRequestsService.quote(request!.bookingId),
|
||||
enabled: Boolean(request),
|
||||
});
|
||||
|
||||
// Prefill from the route rate as soon as it lands, and start clean whenever
|
||||
// a different request is opened.
|
||||
useEffect(() => {
|
||||
setUnitAmount(quoteQuery.data?.unitAmount ?? "");
|
||||
}, [quoteQuery.data?.unitAmount, request?.id]);
|
||||
|
||||
const count = request?.containerCount ?? 0;
|
||||
const total = typeof unitAmount === "number" ? unitAmount * count : null;
|
||||
const currency = quoteQuery.data?.currency ?? "ETB";
|
||||
|
||||
return (
|
||||
<Modal opened={!!request} onClose={onClose} title="Approve empty return" size="md">
|
||||
{request && (
|
||||
<Stack gap="md">
|
||||
<Group gap="sm">
|
||||
<Text fw={600}>{request.bookingReference ?? request.bookingId}</Text>
|
||||
<Text c="dimmed">{request.companyName ?? "—"}</Text>
|
||||
</Group>
|
||||
|
||||
<div>
|
||||
<Text size="sm" fw={600} mb={4}>
|
||||
Containers coming back
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{request.containerNumbers.join(", ")}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{quoteQuery.isLoading ? (
|
||||
<Group justify="center" py="sm">
|
||||
<Loader size="sm" />
|
||||
</Group>
|
||||
) : (
|
||||
<>
|
||||
{quoteQuery.data?.unavailableReason && (
|
||||
<Alert color="yellow">{quoteQuery.data.unavailableReason}</Alert>
|
||||
)}
|
||||
|
||||
<NumberInput
|
||||
label={`Price per container (${currency})`}
|
||||
description={
|
||||
quoteQuery.data?.sourceRateUsd
|
||||
? `Contract route rate: ${quoteQuery.data.sourceRateUsd} USD per container`
|
||||
: "No route rate found — enter the amount to bill."
|
||||
}
|
||||
value={unitAmount}
|
||||
onChange={(value) =>
|
||||
setUnitAmount(typeof value === "number" ? value : value === "" ? "" : Number(value))
|
||||
}
|
||||
min={0}
|
||||
decimalScale={2}
|
||||
thousandSeparator=","
|
||||
required
|
||||
/>
|
||||
|
||||
<Divider />
|
||||
|
||||
<SimpleGrid cols={2}>
|
||||
<Text size="sm" c="dimmed">
|
||||
{count} container{count === 1 ? "" : "s"} ×{" "}
|
||||
{typeof unitAmount === "number" ? unitAmount.toLocaleString() : "—"}
|
||||
</Text>
|
||||
<Text size="lg" fw={700} ta="right">
|
||||
{total == null
|
||||
? "—"
|
||||
: `${total.toLocaleString(undefined, { minimumFractionDigits: 2 })} ${currency}`}
|
||||
</Text>
|
||||
</SimpleGrid>
|
||||
|
||||
<Text size="xs" c="dimmed">
|
||||
Approving issues this invoice to the customer. They pay it in the portal, then
|
||||
choose the return date and give the truck details.
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button variant="default" onClick={onClose} disabled={loading}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => onApprove(typeof unitAmount === "number" ? unitAmount : undefined)}
|
||||
disabled={typeof unitAmount !== "number" || unitAmount <= 0}
|
||||
loading={loading}
|
||||
>
|
||||
Approve & invoice
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function RejectForm({
|
||||
request,
|
||||
loading,
|
||||
onCancel,
|
||||
onReject,
|
||||
}: {
|
||||
request: EmptyReturnRequest;
|
||||
loading: boolean;
|
||||
onCancel: () => void;
|
||||
onReject: (reason: string) => void;
|
||||
}) {
|
||||
const [reason, setReason] = useState("");
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Text size="sm">
|
||||
{request.bookingReference ?? request.bookingId} — {request.containerCount} container
|
||||
{request.containerCount === 1 ? "" : "s"}
|
||||
</Text>
|
||||
<Textarea
|
||||
label="Reason"
|
||||
description="Shown to the customer."
|
||||
placeholder="Why this return cannot be accepted"
|
||||
value={reason}
|
||||
onChange={(event) => setReason(event.currentTarget.value)}
|
||||
rows={3}
|
||||
required
|
||||
/>
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button variant="default" onClick={onCancel} disabled={loading}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button color="red" onClick={() => onReject(reason.trim())} disabled={reason.trim().length < 3} loading={loading}>
|
||||
Reject request
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { api as client } from '../auth/http';
|
||||
import { URL_CONSTANTS } from '@/constants/URLS';
|
||||
import { unwrap } from '@/utils/endpoint';
|
||||
import type {
|
||||
EmptyReturnQuote,
|
||||
EmptyReturnRequest,
|
||||
EmptyReturnRequestStatus,
|
||||
PlannedEmptyReturn,
|
||||
} from '@/types/importOperations';
|
||||
|
||||
/**
|
||||
* Empty container return requests — the customer-initiated path for a booking
|
||||
* that was sold WITHOUT the return service. Staff price and approve them here;
|
||||
* the customer pays and books the truck from the portal.
|
||||
*/
|
||||
export const emptyReturnRequestsService = {
|
||||
list: async (params: {
|
||||
status?: EmptyReturnRequestStatus;
|
||||
bookingId?: string;
|
||||
} = {}): Promise<EmptyReturnRequest[]> => {
|
||||
const response = await client.get<EmptyReturnRequest[]>(
|
||||
URL_CONSTANTS.EMPTY_RETURN_REQUESTS.BASE,
|
||||
{ params },
|
||||
);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
/** Scheduled returns the warehouse is expecting, with date and truck. */
|
||||
planned: async (): Promise<PlannedEmptyReturn[]> => {
|
||||
const response = await client.get<PlannedEmptyReturn[]>(
|
||||
URL_CONSTANTS.EMPTY_RETURN_REQUESTS.PLANNED,
|
||||
);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
/** The route price staff see prefilled at approval. */
|
||||
quote: async (bookingId: string): Promise<EmptyReturnQuote> => {
|
||||
const response = await client.get<{ quote: EmptyReturnQuote }>(
|
||||
URL_CONSTANTS.EMPTY_RETURN_REQUESTS.ELIGIBILITY(bookingId),
|
||||
);
|
||||
return unwrap(response.data).quote;
|
||||
},
|
||||
|
||||
approve: async (
|
||||
id: string,
|
||||
payload: { unitAmount?: number; currency?: string } = {},
|
||||
): Promise<EmptyReturnRequest> => {
|
||||
const response = await client.post<EmptyReturnRequest>(
|
||||
URL_CONSTANTS.EMPTY_RETURN_REQUESTS.APPROVE(id),
|
||||
payload,
|
||||
);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
reject: async (id: string, reason: string): Promise<EmptyReturnRequest> => {
|
||||
const response = await client.post<EmptyReturnRequest>(
|
||||
URL_CONSTANTS.EMPTY_RETURN_REQUESTS.REJECT(id),
|
||||
{ reason },
|
||||
);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
};
|
||||
@@ -179,3 +179,60 @@ export interface UpdateEmptyContainerReturnStatusPayload extends ImportOperation
|
||||
wagonAllocationReference?: string;
|
||||
handoverNote?: string;
|
||||
}
|
||||
|
||||
export type EmptyReturnRequestStatus =
|
||||
| 'SUBMITTED'
|
||||
| 'APPROVED'
|
||||
| 'REJECTED'
|
||||
| 'PAID'
|
||||
| 'SCHEDULED'
|
||||
| 'COMPLETED'
|
||||
| 'CANCELLED';
|
||||
|
||||
/** A customer's request to send empties back on a booking sold without return. */
|
||||
export interface EmptyReturnRequest {
|
||||
id: string;
|
||||
bookingId: string;
|
||||
bookingReference: string | null;
|
||||
companyId: string | null;
|
||||
companyName: string | null;
|
||||
status: EmptyReturnRequestStatus;
|
||||
containerNumbers: string[];
|
||||
containerCount: number;
|
||||
quotedUnitAmount: number | null;
|
||||
quotedTotalAmount: number | null;
|
||||
currency: string | null;
|
||||
invoiceId: string | null;
|
||||
paidAt: string | null;
|
||||
requestedReturnDate: string | null;
|
||||
truckPlateNumber: string | null;
|
||||
truckDriverName: string | null;
|
||||
truckType: string | null;
|
||||
scheduledAt: string | null;
|
||||
submittedAt: string;
|
||||
reviewedAt: string | null;
|
||||
rejectionReason: string | null;
|
||||
completedAt: string | null;
|
||||
}
|
||||
|
||||
/** Per-container price for an empty return, off the route's WITH_RETURN rate. */
|
||||
export interface EmptyReturnQuote {
|
||||
unitAmount: number | null;
|
||||
currency: string;
|
||||
sourceRateUsd: number | null;
|
||||
unavailableReason: string | null;
|
||||
}
|
||||
|
||||
/** A scheduled empty return the warehouse is waiting on. */
|
||||
export interface PlannedEmptyReturn {
|
||||
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 }>;
|
||||
}
|
||||
|
||||
@@ -229,6 +229,13 @@ export const URL_CONSTANTS = {
|
||||
PUBLIC: "/api/support-content",
|
||||
},
|
||||
|
||||
EMPTY_RETURN_REQUESTS: {
|
||||
BASE: "/api/empty-return-requests",
|
||||
ELIGIBILITY: (bookingId: string) => `/api/empty-return-requests/eligibility/${bookingId}`,
|
||||
BY_BOOKING: (bookingId: string) => `/api/empty-return-requests/by-booking/${bookingId}`,
|
||||
SCHEDULE: (id: string) => `/api/empty-return-requests/${id}/schedule`,
|
||||
},
|
||||
|
||||
LAST_MILE_REQUESTS: {
|
||||
BY_BOOKING: (bookingId: string) => `/api/last-mile-requests/by-booking/${bookingId}`,
|
||||
BY_ID: (id: string) => `/api/last-mile-requests/${id}`,
|
||||
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
} from "./components/Notices";
|
||||
import { BookingPaymentPanel } from "./components/BookingPaymentPanel";
|
||||
import { AdditionalChargesPanel } from "./components/AdditionalChargesPanel";
|
||||
import { EmptyReturnRequestPanel } from "./components/EmptyReturnRequestPanel";
|
||||
import { HeaderButton, PageHeader } from "./components/PageHeader";
|
||||
import { PaymentMethodModal } from "./components/PaymentMethodModal";
|
||||
import { ScheduleCard } from "./components/ScheduleCard";
|
||||
@@ -415,6 +416,7 @@ export function ReadonlyBookingView({
|
||||
showCountdown={showCountdown}
|
||||
/>
|
||||
<AdditionalChargesPanel bookingId={booking.id} />
|
||||
<EmptyReturnRequestPanel bookingId={booking.id} />
|
||||
<ScheduleCard
|
||||
booking={booking}
|
||||
title="Consignment & Schedule"
|
||||
|
||||
@@ -0,0 +1,446 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Checkbox,
|
||||
Group,
|
||||
Select,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
} from "@mantine/core";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Container as ContainerIcon, CreditCard } from "lucide-react";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import { useInvoicePayment } from "@/hooks/useInvoicePayment";
|
||||
import { ModalSafeWrapper } from "@/components/customer-actions/ModalSafeWrapper";
|
||||
import {
|
||||
emptyReturnRequestsService,
|
||||
type EmptyReturnRequest,
|
||||
type EmptyReturnRequestStatus,
|
||||
} from "@/services/empty-return-requests.service";
|
||||
|
||||
import { PaymentMethodModal } from "./PaymentMethodModal";
|
||||
import { CardTitle, SectionCard } from "./layout";
|
||||
|
||||
const TRUCK_TYPES = ["Flatbed", "Container Chassis", "Lowboy", "Box Truck", "Tipper"];
|
||||
|
||||
const STATUS_META: Record<EmptyReturnRequestStatus, { label: string; color: string }> = {
|
||||
SUBMITTED: { label: "Awaiting EDR review", color: "#B07D14" },
|
||||
APPROVED: { label: "Awaiting payment", color: "#B07D14" },
|
||||
REJECTED: { label: "Rejected", color: "red" },
|
||||
PAID: { label: "Paid — choose your date", color: "#1F6FEB" },
|
||||
SCHEDULED: { label: "Scheduled", color: "#0A6F4D" },
|
||||
COMPLETED: { label: "Returned", color: "#0A6F4D" },
|
||||
CANCELLED: { label: "Cancelled", color: "#9AA8B5" },
|
||||
};
|
||||
|
||||
const money = (amount: number | null, currency: string | null) =>
|
||||
amount == null
|
||||
? "—"
|
||||
: `${amount.toLocaleString(undefined, { minimumFractionDigits: 2 })} ${currency ?? "ETB"}`;
|
||||
|
||||
const errorMessage = (error: unknown, fallback: string) => {
|
||||
const data = (error as { response?: { data?: { message?: string | string[] } } })?.response?.data;
|
||||
if (Array.isArray(data?.message)) return data.message.join(", ");
|
||||
return data?.message ?? fallback;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returning empties on a booking that did NOT buy the return service up front.
|
||||
* The customer picks the containers off this booking; EDR prices and approves
|
||||
* it; the customer pays here and then books the date and the truck that brings
|
||||
* them in.
|
||||
*/
|
||||
export function EmptyReturnRequestPanel({ bookingId }: { bookingId: string }) {
|
||||
const qc = useQueryClient();
|
||||
|
||||
const eligibilityQuery = useQuery({
|
||||
queryKey: ["empty-return-eligibility", bookingId],
|
||||
queryFn: () => emptyReturnRequestsService.eligibility(bookingId),
|
||||
});
|
||||
|
||||
const requestsQuery = useQuery({
|
||||
queryKey: ["empty-return-requests", bookingId],
|
||||
queryFn: () => emptyReturnRequestsService.listForBooking(bookingId),
|
||||
});
|
||||
|
||||
const requests = requestsQuery.data ?? [];
|
||||
const live = requests.filter((r) => r.status !== "REJECTED" && r.status !== "CANCELLED");
|
||||
const eligibility = eligibilityQuery.data;
|
||||
|
||||
const refresh = () => {
|
||||
qc.invalidateQueries({ queryKey: ["empty-return-requests", bookingId] });
|
||||
qc.invalidateQueries({ queryKey: ["empty-return-eligibility", bookingId] });
|
||||
};
|
||||
|
||||
// Nothing to offer and nothing to show — stay out of the way entirely.
|
||||
if (!eligibility?.eligible && requests.length === 0) return null;
|
||||
|
||||
return (
|
||||
<SectionCard p={22}>
|
||||
<CardTitle>Empty container return</CardTitle>
|
||||
|
||||
<Stack gap={14} mt={12}>
|
||||
{live.map((request) => (
|
||||
<RequestRow key={request.id} request={request} onChanged={refresh} />
|
||||
))}
|
||||
|
||||
{requests
|
||||
.filter((r) => r.status === "REJECTED")
|
||||
.map((request) => (
|
||||
<Box key={request.id} p={12} style={{ borderRadius: 10, border: "1px solid #FDE2E1" }}>
|
||||
<Group justify="space-between">
|
||||
<Text fz="13px" fw={600} c="#10202F">
|
||||
{request.containerCount} container{request.containerCount === 1 ? "" : "s"}
|
||||
</Text>
|
||||
<Badge radius="sm" variant="light" color="red">
|
||||
Rejected
|
||||
</Badge>
|
||||
</Group>
|
||||
{request.rejectionReason && (
|
||||
<Text fz="12px" c="#9AA8B5" mt={4}>
|
||||
{request.rejectionReason}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
|
||||
{eligibility?.eligible ? (
|
||||
<NewRequestForm
|
||||
bookingId={bookingId}
|
||||
availableNumbers={eligibility.availableContainerNumbers}
|
||||
unitAmount={eligibility.quote.unitAmount}
|
||||
currency={eligibility.quote.currency}
|
||||
onCreated={refresh}
|
||||
/>
|
||||
) : (
|
||||
eligibility?.reason &&
|
||||
live.length === 0 && (
|
||||
<Text fz="12px" c="#9AA8B5">
|
||||
{eligibility.reason}
|
||||
</Text>
|
||||
)
|
||||
)}
|
||||
</Stack>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
|
||||
/** One live request: what it costs, what it is waiting on, and the next step. */
|
||||
function RequestRow({
|
||||
request,
|
||||
onChanged,
|
||||
}: {
|
||||
request: EmptyReturnRequest;
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
const meta = STATUS_META[request.status];
|
||||
|
||||
return (
|
||||
<Box p={14} style={{ borderRadius: 10, border: "1px solid #EEF2F6" }}>
|
||||
<Group justify="space-between" align="flex-start" wrap="nowrap">
|
||||
<Box style={{ minWidth: 0 }}>
|
||||
<Group gap={6}>
|
||||
<ContainerIcon size={13} color="#9AA8B5" />
|
||||
<Text fz="13.5px" fw={700} c="#10202F">
|
||||
{request.containerCount} empty container{request.containerCount === 1 ? "" : "s"}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text fz="12px" c="#9AA8B5" mt={2}>
|
||||
{request.containerNumbers.join(", ")}
|
||||
</Text>
|
||||
{request.quotedTotalAmount != null && (
|
||||
<Text fz="12px" c="#9AA8B5" mt={2}>
|
||||
{money(request.quotedTotalAmount, request.currency)}
|
||||
{request.quotedUnitAmount != null &&
|
||||
` · ${money(request.quotedUnitAmount, request.currency)} per container`}
|
||||
</Text>
|
||||
)}
|
||||
{request.requestedReturnDate && (
|
||||
<Text fz="12px" c="#9AA8B5" mt={2}>
|
||||
Returning {request.requestedReturnDate} · truck {request.truckPlateNumber}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
<Badge
|
||||
radius="sm"
|
||||
variant="light"
|
||||
styles={{ root: { backgroundColor: `${meta.color}22`, color: meta.color } }}
|
||||
>
|
||||
{meta.label}
|
||||
</Badge>
|
||||
</Group>
|
||||
|
||||
{request.status === "APPROVED" && request.invoiceId && (
|
||||
<PayButton
|
||||
invoiceId={request.invoiceId}
|
||||
amount={request.quotedTotalAmount ?? 0}
|
||||
currency={request.currency ?? "ETB"}
|
||||
/>
|
||||
)}
|
||||
|
||||
{request.status === "PAID" && <ScheduleForm request={request} onScheduled={onChanged} />}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function PayButton({
|
||||
invoiceId,
|
||||
amount,
|
||||
currency,
|
||||
}: {
|
||||
invoiceId: string;
|
||||
amount: number;
|
||||
currency: string;
|
||||
}) {
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const flow = useInvoicePayment();
|
||||
|
||||
const close = () => {
|
||||
if (!flow.processing) {
|
||||
setModalOpen(false);
|
||||
flow.reset();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ModalSafeWrapper>
|
||||
<Button
|
||||
mt={10}
|
||||
size="xs"
|
||||
radius="md"
|
||||
fw={700}
|
||||
color="edr-green"
|
||||
leftSection={<CreditCard size={14} />}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
setModalOpen(true);
|
||||
}}
|
||||
>
|
||||
Pay now
|
||||
</Button>
|
||||
<PaymentMethodModal
|
||||
opened={modalOpen}
|
||||
onClose={close}
|
||||
amountLabel={`${amount.toLocaleString(undefined, { minimumFractionDigits: 2 })} ${currency}`}
|
||||
currency={currency}
|
||||
processing={flow.processing}
|
||||
error={flow.error}
|
||||
otp={flow.otp}
|
||||
bill={flow.bill}
|
||||
onConfirm={(method, payerAccount) => flow.pay(invoiceId, method, payerAccount)}
|
||||
/>
|
||||
</ModalSafeWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
/** After payment: when the empties come back, and on whose truck. */
|
||||
function ScheduleForm({
|
||||
request,
|
||||
onScheduled,
|
||||
}: {
|
||||
request: EmptyReturnRequest;
|
||||
onScheduled: () => void;
|
||||
}) {
|
||||
const [returnDate, setReturnDate] = useState("");
|
||||
const [plate, setPlate] = useState("");
|
||||
const [driver, setDriver] = useState("");
|
||||
const [truckType, setTruckType] = useState<string | null>(null);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () =>
|
||||
emptyReturnRequestsService.schedule(request.id, {
|
||||
returnDate,
|
||||
truckPlateNumber: plate.trim(),
|
||||
truckDriverName: driver.trim(),
|
||||
truckType: truckType ?? undefined,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
toast.success("Return date and truck saved");
|
||||
onScheduled();
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
toast.error(errorMessage(error, "Could not save the return details"));
|
||||
},
|
||||
});
|
||||
|
||||
const ready = Boolean(returnDate && plate.trim() && driver.trim());
|
||||
|
||||
return (
|
||||
<Stack gap={10} mt={12}>
|
||||
<Text fz="12px" fw={600} c="#10202F">
|
||||
Tell us when the containers are coming back
|
||||
</Text>
|
||||
|
||||
<TextInput
|
||||
size="xs"
|
||||
type="date"
|
||||
label="Return date"
|
||||
value={returnDate}
|
||||
onChange={(event) => setReturnDate(event.currentTarget.value)}
|
||||
required
|
||||
/>
|
||||
<TextInput
|
||||
size="xs"
|
||||
label="Truck plate"
|
||||
placeholder="3-A12345"
|
||||
value={plate}
|
||||
onChange={(event) => setPlate(event.currentTarget.value.toUpperCase())}
|
||||
required
|
||||
/>
|
||||
<TextInput
|
||||
size="xs"
|
||||
label="Driver name"
|
||||
value={driver}
|
||||
onChange={(event) => setDriver(event.currentTarget.value)}
|
||||
required
|
||||
/>
|
||||
<Select
|
||||
size="xs"
|
||||
label="Truck type"
|
||||
placeholder="Select"
|
||||
data={TRUCK_TYPES}
|
||||
value={truckType}
|
||||
onChange={setTruckType}
|
||||
clearable
|
||||
/>
|
||||
|
||||
<Button
|
||||
size="xs"
|
||||
radius="md"
|
||||
fw={700}
|
||||
color="edr-green"
|
||||
disabled={!ready}
|
||||
loading={mutation.isPending}
|
||||
onClick={() => mutation.mutate()}
|
||||
>
|
||||
Confirm return details
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The booking's own containers, ticked. Only a container that came in on this
|
||||
* booking can go back on it, so the customer picks from that list rather than
|
||||
* typing numbers, and the count follows the ticks.
|
||||
*/
|
||||
function NewRequestForm({
|
||||
bookingId,
|
||||
availableNumbers,
|
||||
unitAmount,
|
||||
currency,
|
||||
onCreated,
|
||||
}: {
|
||||
bookingId: string;
|
||||
availableNumbers: string[];
|
||||
unitAmount: number | null;
|
||||
currency: string;
|
||||
onCreated: () => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [selected, setSelected] = useState<string[]>([]);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () => emptyReturnRequestsService.create(bookingId, selected),
|
||||
onSuccess: () => {
|
||||
toast.success("Empty return requested — EDR will review and price it");
|
||||
setOpen(false);
|
||||
setSelected([]);
|
||||
onCreated();
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
toast.error(errorMessage(error, "Could not submit the request"));
|
||||
},
|
||||
});
|
||||
|
||||
if (!open) {
|
||||
return (
|
||||
<Stack gap={6}>
|
||||
<Button
|
||||
size="xs"
|
||||
radius="md"
|
||||
fw={700}
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
Request empty return
|
||||
</Button>
|
||||
{unitAmount != null && (
|
||||
<Text fz="11.5px" c="#9AA8B5">
|
||||
{money(unitAmount, currency)} per container, payable after EDR approves.
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap={10}>
|
||||
<Group justify="space-between" align="center">
|
||||
<Text fz="12px" fw={600} c="#10202F">
|
||||
Select the containers you are returning
|
||||
</Text>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="subtle"
|
||||
color="edr-green"
|
||||
onClick={() =>
|
||||
setSelected(selected.length === availableNumbers.length ? [] : [...availableNumbers])
|
||||
}
|
||||
>
|
||||
{selected.length === availableNumbers.length ? "Clear all" : "Select all"}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<Stack gap={6}>
|
||||
{availableNumbers.map((number) => (
|
||||
<Checkbox
|
||||
key={number}
|
||||
size="xs"
|
||||
label={number}
|
||||
checked={selected.includes(number)}
|
||||
onChange={(event) =>
|
||||
setSelected((current) =>
|
||||
event.currentTarget.checked
|
||||
? [...current, number]
|
||||
: current.filter((value) => value !== number),
|
||||
)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
|
||||
{unitAmount != null && selected.length > 0 && (
|
||||
<Alert color="gray" p={10}>
|
||||
<Text fz="12px">
|
||||
Estimated {money(unitAmount * selected.length, currency)} for {selected.length} container
|
||||
{selected.length === 1 ? "" : "s"}. EDR confirms the price when it approves your request.
|
||||
</Text>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Group gap={8}>
|
||||
<Button size="xs" variant="default" radius="md" onClick={() => setOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
radius="md"
|
||||
fw={700}
|
||||
color="edr-green"
|
||||
disabled={selected.length === 0}
|
||||
loading={mutation.isPending}
|
||||
onClick={() => mutation.mutate()}
|
||||
>
|
||||
Submit request{selected.length > 0 ? ` (${selected.length})` : ""}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { URL_CONSTANTS } from "@/constants/URLS";
|
||||
import { client } from "../utils/api";
|
||||
|
||||
const E = URL_CONSTANTS.EMPTY_RETURN_REQUESTS;
|
||||
|
||||
export type EmptyReturnRequestStatus =
|
||||
| "SUBMITTED"
|
||||
| "APPROVED"
|
||||
| "REJECTED"
|
||||
| "PAID"
|
||||
| "SCHEDULED"
|
||||
| "COMPLETED"
|
||||
| "CANCELLED";
|
||||
|
||||
export interface EmptyReturnRequest {
|
||||
id: string;
|
||||
bookingId: string;
|
||||
status: EmptyReturnRequestStatus;
|
||||
containerNumbers: string[];
|
||||
containerCount: number;
|
||||
quotedUnitAmount: number | null;
|
||||
quotedTotalAmount: number | null;
|
||||
currency: string | null;
|
||||
invoiceId: string | null;
|
||||
paidAt: string | null;
|
||||
requestedReturnDate: string | null;
|
||||
truckPlateNumber: string | null;
|
||||
truckDriverName: string | null;
|
||||
truckType: string | null;
|
||||
rejectionReason: string | null;
|
||||
submittedAt: string;
|
||||
}
|
||||
|
||||
/** Per-container price for the return, off the booking's contract route rate. */
|
||||
export interface EmptyReturnQuote {
|
||||
unitAmount: number | null;
|
||||
currency: string;
|
||||
sourceRateUsd: number | null;
|
||||
unavailableReason: string | null;
|
||||
}
|
||||
|
||||
export interface EmptyReturnEligibility {
|
||||
eligible: boolean;
|
||||
reason: string | null;
|
||||
availableContainerNumbers: string[];
|
||||
maxContainers: number;
|
||||
quote: EmptyReturnQuote;
|
||||
}
|
||||
|
||||
export interface ScheduleEmptyReturnPayload {
|
||||
returnDate: string;
|
||||
truckPlateNumber: string;
|
||||
truckDriverName: string;
|
||||
truckType?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returning empties on a booking that was sold WITHOUT the return service:
|
||||
* the customer names the containers, EDR prices and approves, the customer
|
||||
* pays and then books the date and truck.
|
||||
*/
|
||||
export const emptyReturnRequestsService = {
|
||||
/** Whether this booking can ask, which containers are free, and the price. */
|
||||
eligibility: async (bookingId: string): Promise<EmptyReturnEligibility> => {
|
||||
const { data } = await client.get(E.ELIGIBILITY(bookingId));
|
||||
return data.data ?? data;
|
||||
},
|
||||
|
||||
listForBooking: async (bookingId: string): Promise<EmptyReturnRequest[]> => {
|
||||
const { data } = await client.get(E.BY_BOOKING(bookingId));
|
||||
return data.data ?? data;
|
||||
},
|
||||
|
||||
create: async (bookingId: string, containerNumbers: string[]): Promise<EmptyReturnRequest> => {
|
||||
const { data } = await client.post(E.BASE, { bookingId, containerNumbers });
|
||||
return data.data ?? data;
|
||||
},
|
||||
|
||||
/** Date + truck, once the invoice is paid. */
|
||||
schedule: async (
|
||||
id: string,
|
||||
payload: ScheduleEmptyReturnPayload,
|
||||
): Promise<EmptyReturnRequest> => {
|
||||
const { data } = await client.post(E.SCHEDULE(id), payload);
|
||||
return data.data ?? data;
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user