Ticket
Empty container returns now generate a downloadable Equipment Interchange Receipt: container number & size, exact return timestamp, depot/location, condition, and the carrier/booking reference. Customers can download it from their booking's Documents tab (own-booking returns only); staff can pull any via the same endpoint.
BIN
.guide-shots2/backoffice-01-overview.png
Normal file
|
After Width: | Height: | Size: 124 KiB |
BIN
.guide-shots2/backoffice-02-customers-list.png
Normal file
|
After Width: | Height: | Size: 65 KiB |
BIN
.guide-shots2/backoffice-03-booking-requests.png
Normal file
|
After Width: | Height: | Size: 72 KiB |
BIN
.guide-shots2/backoffice-04-booking-detail.png
Normal file
|
After Width: | Height: | Size: 70 KiB |
BIN
.guide-shots2/backoffice-05-contract-requests.png
Normal file
|
After Width: | Height: | Size: 69 KiB |
BIN
.guide-shots2/backoffice-06-clearance-documents.png
Normal file
|
After Width: | Height: | Size: 69 KiB |
BIN
.guide-shots2/backoffice-07-transactions.png
Normal file
|
After Width: | Height: | Size: 69 KiB |
BIN
.guide-shots2/backoffice-08-train-schedules.png
Normal file
|
After Width: | Height: | Size: 65 KiB |
BIN
.guide-shots2/backoffice-09-first-mile.png
Normal file
|
After Width: | Height: | Size: 65 KiB |
BIN
.guide-shots2/backoffice-10-wagons.png
Normal file
|
After Width: | Height: | Size: 68 KiB |
BIN
.guide-shots2/backoffice-11-arrival-queue.png
Normal file
|
After Width: | Height: | Size: 66 KiB |
BIN
.guide-shots2/backoffice-12-loading-queue.png
Normal file
|
After Width: | Height: | Size: 63 KiB |
BIN
.guide-shots2/backoffice-13-warehouses.png
Normal file
|
After Width: | Height: | Size: 57 KiB |
BIN
.guide-shots2/backoffice-14-reports.png
Normal file
|
After Width: | Height: | Size: 78 KiB |
BIN
.guide-shots2/backoffice-15-staff.png
Normal file
|
After Width: | Height: | Size: 44 KiB |
BIN
.guide-shots2/backoffice-16-shipping-lines.png
Normal file
|
After Width: | Height: | Size: 77 KiB |
BIN
.guide-shots2/backoffice-17-configuration.png
Normal file
|
After Width: | Height: | Size: 62 KiB |
BIN
.guide-shots2/backoffice-18-fleet-dashboard.png
Normal file
|
After Width: | Height: | Size: 60 KiB |
BIN
.guide-shots2/chat.png
Normal file
|
After Width: | Height: | Size: 49 KiB |
BIN
.guide-shots2/config-audit-logs.png
Normal file
|
After Width: | Height: | Size: 58 KiB |
BIN
.guide-shots2/config-contract-templates.png
Normal file
|
After Width: | Height: | Size: 82 KiB |
BIN
.guide-shots2/config-dropdown-settings.png
Normal file
|
After Width: | Height: | Size: 70 KiB |
BIN
.guide-shots2/config-exchange-rate.png
Normal file
|
After Width: | Height: | Size: 73 KiB |
BIN
.guide-shots2/config-file-settings.png
Normal file
|
After Width: | Height: | Size: 68 KiB |
BIN
.guide-shots2/config-logo-settings.png
Normal file
|
After Width: | Height: | Size: 70 KiB |
BIN
.guide-shots2/config-manual-payments.png
Normal file
|
After Width: | Height: | Size: 87 KiB |
BIN
.guide-shots2/config-operations-standards.png
Normal file
|
After Width: | Height: | Size: 77 KiB |
BIN
.guide-shots2/config-portal-content.png
Normal file
|
After Width: | Height: | Size: 71 KiB |
BIN
.guide-shots2/config-rules.png
Normal file
|
After Width: | Height: | Size: 59 KiB |
BIN
.guide-shots2/config-stamp-settings.png
Normal file
|
After Width: | Height: | Size: 77 KiB |
BIN
.guide-shots2/config-trade-access.png
Normal file
|
After Width: | Height: | Size: 79 KiB |
BIN
.guide-shots2/config-train-scheduling-rules.png
Normal file
|
After Width: | Height: | Size: 72 KiB |
BIN
.guide-shots2/consolidation-approvals.png
Normal file
|
After Width: | Height: | Size: 53 KiB |
BIN
.guide-shots2/contract-detail.png
Normal file
|
After Width: | Height: | Size: 71 KiB |
BIN
.guide-shots2/export-dispatch-queue.png
Normal file
|
After Width: | Height: | Size: 55 KiB |
BIN
.guide-shots2/export-djibouti-unloading.png
Normal file
|
After Width: | Height: | Size: 74 KiB |
BIN
.guide-shots2/export-interchange-documents.png
Normal file
|
After Width: | Height: | Size: 56 KiB |
BIN
.guide-shots2/export-loaded-inventory.png
Normal file
|
After Width: | Height: | Size: 53 KiB |
BIN
.guide-shots2/export-overview.png
Normal file
|
After Width: | Height: | Size: 69 KiB |
BIN
.guide-shots2/fleet-compliance.png
Normal file
|
After Width: | Height: | Size: 65 KiB |
BIN
.guide-shots2/fleet-drivers.png
Normal file
|
After Width: | Height: | Size: 60 KiB |
BIN
.guide-shots2/fleet-financial-reports.png
Normal file
|
After Width: | Height: | Size: 58 KiB |
BIN
.guide-shots2/fleet-fuel-purchases.png
Normal file
|
After Width: | Height: | Size: 64 KiB |
BIN
.guide-shots2/fleet-fuel-stats.png
Normal file
|
After Width: | Height: | Size: 61 KiB |
BIN
.guide-shots2/fleet-incidents.png
Normal file
|
After Width: | Height: | Size: 56 KiB |
BIN
.guide-shots2/fleet-locomotives.png
Normal file
|
After Width: | Height: | Size: 66 KiB |
BIN
.guide-shots2/fleet-maintenance.png
Normal file
|
After Width: | Height: | Size: 63 KiB |
BIN
.guide-shots2/fleet-procurement.png
Normal file
|
After Width: | Height: | Size: 60 KiB |
BIN
.guide-shots2/fleet-routes.png
Normal file
|
After Width: | Height: | Size: 61 KiB |
BIN
.guide-shots2/fleet-tracking.png
Normal file
|
After Width: | Height: | Size: 54 KiB |
BIN
.guide-shots2/fleet-train-builder.png
Normal file
|
After Width: | Height: | Size: 64 KiB |
BIN
.guide-shots2/fleet-vehicles.png
Normal file
|
After Width: | Height: | Size: 61 KiB |
BIN
.guide-shots2/fleet-wagon-transfers.png
Normal file
|
After Width: | Height: | Size: 65 KiB |
BIN
.guide-shots2/fleet-work-orders.png
Normal file
|
After Width: | Height: | Size: 59 KiB |
BIN
.guide-shots2/import-container-returns.png
Normal file
|
After Width: | Height: | Size: 60 KiB |
BIN
.guide-shots2/import-inventory-inquiry.png
Normal file
|
After Width: | Height: | Size: 60 KiB |
BIN
.guide-shots2/import-overview.png
Normal file
|
After Width: | Height: | Size: 66 KiB |
BIN
.guide-shots2/import-terminal-inventory.png
Normal file
|
After Width: | Height: | Size: 60 KiB |
BIN
.guide-shots2/import-trucks.png
Normal file
|
After Width: | Height: | Size: 65 KiB |
BIN
.guide-shots2/intercity.png
Normal file
|
After Width: | Height: | Size: 58 KiB |
BIN
.guide-shots2/ops-batch-board.png
Normal file
|
After Width: | Height: | Size: 65 KiB |
BIN
.guide-shots2/ops-clearance.png
Normal file
|
After Width: | Height: | Size: 80 KiB |
BIN
.guide-shots2/ops-gl-djibouti.png
Normal file
|
After Width: | Height: | Size: 66 KiB |
BIN
.guide-shots2/ops-last-mile.png
Normal file
|
After Width: | Height: | Size: 62 KiB |
BIN
.guide-shots2/portal-01-signup.png
Normal file
|
After Width: | Height: | Size: 45 KiB |
BIN
.guide-shots2/portal-02-otp.png
Normal file
|
After Width: | Height: | Size: 50 KiB |
BIN
.guide-shots2/portal-03-onboarding-company-type-coop.png
Normal file
|
After Width: | Height: | Size: 63 KiB |
BIN
.guide-shots2/portal-05-onboarding-company-info.png
Normal file
|
After Width: | Height: | Size: 55 KiB |
BIN
.guide-shots2/portal-06-onboarding-owner.png
Normal file
|
After Width: | Height: | Size: 64 KiB |
BIN
.guide-shots2/portal-07-onboarding-poa.png
Normal file
|
After Width: | Height: | Size: 66 KiB |
BIN
.guide-shots2/portal-08-onboarding-fayda.png
Normal file
|
After Width: | Height: | Size: 67 KiB |
BIN
.guide-shots2/shipping-line-credits.png
Normal file
|
After Width: | Height: | Size: 64 KiB |
BIN
.guide-shots2/support.png
Normal file
|
After Width: | Height: | Size: 59 KiB |
BIN
.guide-shots2/trucks-on-site.png
Normal file
|
After Width: | Height: | Size: 64 KiB |
BIN
.guide-shots2/wagon-cancellations.png
Normal file
|
After Width: | Height: | Size: 67 KiB |
BIN
.guide-shots2/warehouse-allocation-fees.png
Normal file
|
After Width: | Height: | Size: 69 KiB |
BIN
.guide-shots2/warehouse-dashboard.png
Normal file
|
After Width: | Height: | Size: 65 KiB |
BIN
.guide-shots2/warehouse-detail.png
Normal file
|
After Width: | Height: | Size: 61 KiB |
BIN
.guide-shots2/warehouse-fee-invoices.png
Normal file
|
After Width: | Height: | Size: 66 KiB |
BIN
EDR-Freight-User-Guide.pdf
Normal file
@@ -0,0 +1,19 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/** Optional payment due date finance can set on an additional charge. */
|
||||
export class AdditionalChargeDueAt3650000000000 implements MigrationInterface {
|
||||
name = 'AdditionalChargeDueAt3650000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE "freight"."additional_charge"
|
||||
ADD COLUMN IF NOT EXISTS "due_at" timestamptz
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE "freight"."additional_charge" DROP COLUMN IF EXISTS "due_at"
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ConflictException, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { OnEvent } from '@nestjs/event-emitter';
|
||||
import { DataSource, EntityManager } from 'typeorm';
|
||||
import { ExchangeService } from '@edr/api-common';
|
||||
import { Freight, NotificationAudience, NotificationType } from '@edr/types';
|
||||
|
||||
import { BillingService, InvoiceEventPayload } from '../billing/billing.service';
|
||||
@@ -35,6 +36,7 @@ export class AdditionalChargeService {
|
||||
private readonly repository: AdditionalChargeRepository,
|
||||
private readonly bookingsRepository: BookingsRepository,
|
||||
private readonly filesService: FilesService,
|
||||
private readonly exchangeService: ExchangeService,
|
||||
private readonly billing: BillingService,
|
||||
private readonly bookingsService: BookingsService,
|
||||
private readonly notifications: NotificationsService,
|
||||
@@ -74,6 +76,7 @@ export class AdditionalChargeService {
|
||||
reason: dto.reason.trim(),
|
||||
amount: dto.amount.toFixed(2),
|
||||
currency: dto.currency.trim().toUpperCase(),
|
||||
dueAt: dto.dueDate ? new Date(dto.dueDate) : null,
|
||||
status: 'DRAFT',
|
||||
createdByStaffId: staffId,
|
||||
}),
|
||||
@@ -132,6 +135,8 @@ export class AdditionalChargeService {
|
||||
companyId: booking.companyId,
|
||||
companyProfileId: booking.companyProfileId,
|
||||
currency: charge.currency,
|
||||
// Unset falls through to BillingService's own DEFAULT_DUE_DAYS (14).
|
||||
dueAt: charge.dueAt ?? undefined,
|
||||
lines: [
|
||||
{
|
||||
chargeType: 'ADDITIONAL_CHARGE',
|
||||
@@ -254,9 +259,12 @@ export class AdditionalChargeService {
|
||||
? await this.dataSource.getRepository(Invoice).find({ where: invoiceIds.map((id) => ({ id })) })
|
||||
: [];
|
||||
const invoiceById = new Map(invoices.map((i) => [i.id, i]));
|
||||
const converted = await Promise.all(rows.map((r) => this.convertAmount(r)));
|
||||
const convertedById = new Map(rows.map((r, i) => [r.id, converted[i]]));
|
||||
|
||||
return rows.map((r) => {
|
||||
const file = filesByCharge.get(r.id)?.[0];
|
||||
const fx = convertedById.get(r.id) ?? null;
|
||||
return {
|
||||
id: r.id,
|
||||
bookingId: r.bookingId,
|
||||
@@ -264,6 +272,9 @@ export class AdditionalChargeService {
|
||||
status: r.status,
|
||||
amount: Number(r.amount),
|
||||
currency: r.currency,
|
||||
convertedAmount: fx?.amount ?? null,
|
||||
convertedCurrency: fx?.currency ?? null,
|
||||
dueAt: r.dueAt?.toISOString() ?? null,
|
||||
file: file ? { id: file.id, name: file.name, url: file.url } : null,
|
||||
invoiceId: r.invoiceId ?? null,
|
||||
invoiceNumber: r.invoiceId ? (invoiceById.get(r.invoiceId)?.invoiceNumber ?? null) : null,
|
||||
@@ -278,4 +289,26 @@ export class AdditionalChargeService {
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Amount converted to the other of ETB/USD, via the existing shared
|
||||
* `ExchangeService` (CBE rate, falls back to the stored `exchange_settings`
|
||||
* rate) — same mechanism `booking-wagon-cancellation.service.ts` and
|
||||
* warehouse fee pricing already use. Null on anything but ETB/USD, or if
|
||||
* the rate feed is down — this is a display convenience, not the payable
|
||||
* amount, so a failure here must never break the charge list.
|
||||
*/
|
||||
private async convertAmount(
|
||||
charge: AdditionalCharge,
|
||||
): Promise<{ amount: number; currency: string } | null> {
|
||||
if (charge.currency !== 'ETB' && charge.currency !== 'USD') return null;
|
||||
const target = charge.currency === 'ETB' ? 'USD' : 'ETB';
|
||||
try {
|
||||
const amount = await this.exchangeService.convert(Number(charge.amount), charge.currency, target);
|
||||
return { amount: Math.round(amount * 100) / 100, currency: target };
|
||||
} catch (err) {
|
||||
this.logger.warn(`Rate conversion failed for charge ${charge.id}: ${(err as Error).message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsIn, IsNumber, IsOptional, IsPositive, IsString, Length } from 'class-validator';
|
||||
import {
|
||||
IsDateString,
|
||||
IsIn,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsPositive,
|
||||
IsString,
|
||||
Length,
|
||||
} from 'class-validator';
|
||||
|
||||
export class CreateAdditionalChargeDto {
|
||||
@ApiProperty({ example: 'Re-weighing fee at Mojo dry port' })
|
||||
@@ -24,6 +32,12 @@ export class CreateAdditionalChargeDto {
|
||||
@IsOptional()
|
||||
@IsIn(['draft', 'send'])
|
||||
action?: 'draft' | 'send';
|
||||
|
||||
/** Payment due date; omit to fall back to the invoice's own default term (14 days) on send. */
|
||||
@ApiPropertyOptional({ example: '2026-09-01' })
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
dueDate?: string;
|
||||
}
|
||||
|
||||
export class CancelAdditionalChargeDto {
|
||||
|
||||
@@ -39,6 +39,10 @@ export class AdditionalCharge extends BaseEntity {
|
||||
@Column({ name: 'currency', type: 'varchar', length: 8 })
|
||||
currency!: string;
|
||||
|
||||
/** Optional payment due date; unset falls back to the invoice's own default term on send. */
|
||||
@Column({ name: 'due_at', type: 'timestamptz', nullable: true })
|
||||
dueAt?: Date | null;
|
||||
|
||||
/** The supporting attachment (FileRecord), if any. */
|
||||
@Column({ name: 'file_record_id', type: 'uuid', nullable: true })
|
||||
fileRecordId?: string | null;
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import { Body, Controller, Get, Param, ParseUUIDPipe, Post, Query } from '@nestjs/common';
|
||||
import { Body, Controller, Get, NotFoundException, Param, ParseUUIDPipe, Post, Query, Res } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import type { Response } from 'express';
|
||||
import { CurrentUser } from '@edr/api-common';
|
||||
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
||||
|
||||
import { BookingStaff } from '../../common/booking-guards';
|
||||
import { BookingStaff, MixedAudience } from '../../common/booking-guards';
|
||||
import { hasFreightPermission } from '../../common/freight-permission.util';
|
||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||
import { BookingsService } from '../bookings/bookings.service';
|
||||
import {
|
||||
AssignCustomsRiskDto,
|
||||
CreateDjiboutiIncidentDto,
|
||||
@@ -19,30 +24,38 @@ import { ImportOperationsService } from './import-operations.service';
|
||||
@ApiBearerAuth()
|
||||
@Controller('import-operations')
|
||||
// Post-booking customs / import-operations actions are GL/Ops work, mirroring the
|
||||
// contracts controller's GL operational endpoints (risk, duty, milestones).
|
||||
@BookingStaff(FREIGHT_PERMS.bookings.operations)
|
||||
// contracts controller's GL operational endpoints (risk, duty, milestones). No
|
||||
// class-level guard: the equipment interchange receipt below is customer-reachable,
|
||||
// every other route here stays staff-only via its own @BookingStaff.
|
||||
export class ImportOperationsController {
|
||||
constructor(private readonly service: ImportOperationsService) {}
|
||||
constructor(
|
||||
private readonly service: ImportOperationsService,
|
||||
private readonly bookingsService: BookingsService,
|
||||
) {}
|
||||
|
||||
@Get('djibouti-incidents')
|
||||
@BookingStaff(FREIGHT_PERMS.bookings.operations)
|
||||
@ApiOperation({ summary: 'Batch 8: list Djibouti import incidents' })
|
||||
listIncidents(@Query('bookingId') bookingId?: string) {
|
||||
return this.service.listIncidents(bookingId);
|
||||
}
|
||||
|
||||
@Post('djibouti-incidents')
|
||||
@BookingStaff(FREIGHT_PERMS.bookings.operations)
|
||||
@ApiOperation({ summary: 'Batch 8: report a Djibouti import incident / exception' })
|
||||
createIncident(@Body() dto: CreateDjiboutiIncidentDto) {
|
||||
return this.service.createIncident(dto);
|
||||
}
|
||||
|
||||
@Get('customs/:bookingId')
|
||||
@BookingStaff(FREIGHT_PERMS.bookings.operations)
|
||||
@ApiOperation({ summary: 'Batch 12: import customs finalization state' })
|
||||
getCustoms(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
|
||||
return this.service.getCustoms(bookingId);
|
||||
}
|
||||
|
||||
@Post('customs/:bookingId/documents')
|
||||
@BookingStaff(FREIGHT_PERMS.bookings.operations)
|
||||
@ApiOperation({ summary: 'Batch 12: upload IM4/IM5/T1/permit/payment-slip documents' })
|
||||
uploadCustomsDocument(
|
||||
@Param('bookingId', ParseUUIDPipe) bookingId: string,
|
||||
@@ -52,6 +65,7 @@ export class ImportOperationsController {
|
||||
}
|
||||
|
||||
@Post('customs/:bookingId/declaration')
|
||||
@BookingStaff(FREIGHT_PERMS.bookings.operations)
|
||||
@ApiOperation({ summary: 'Batch 12: record declaration serial number' })
|
||||
recordDeclaration(
|
||||
@Param('bookingId', ParseUUIDPipe) bookingId: string,
|
||||
@@ -61,6 +75,7 @@ export class ImportOperationsController {
|
||||
}
|
||||
|
||||
@Post('customs/:bookingId/notify-duties-taxes')
|
||||
@BookingStaff(FREIGHT_PERMS.bookings.operations)
|
||||
@ApiOperation({ summary: 'Batch 12: notify duties and taxes' })
|
||||
notifyDutiesTaxes(
|
||||
@Param('bookingId', ParseUUIDPipe) bookingId: string,
|
||||
@@ -70,6 +85,7 @@ export class ImportOperationsController {
|
||||
}
|
||||
|
||||
@Post('customs/:bookingId/duties-taxes-paid')
|
||||
@BookingStaff(FREIGHT_PERMS.bookings.operations)
|
||||
@ApiOperation({ summary: 'Batch 12: mark duties and taxes paid' })
|
||||
markDutiesTaxesPaid(
|
||||
@Param('bookingId', ParseUUIDPipe) bookingId: string,
|
||||
@@ -79,12 +95,14 @@ export class ImportOperationsController {
|
||||
}
|
||||
|
||||
@Post('customs/:bookingId/risk')
|
||||
@BookingStaff(FREIGHT_PERMS.bookings.operations)
|
||||
@ApiOperation({ summary: 'Batch 12: assign customs risk' })
|
||||
assignRisk(@Param('bookingId', ParseUUIDPipe) bookingId: string, @Body() dto: AssignCustomsRiskDto) {
|
||||
return this.service.assignRisk(bookingId, dto);
|
||||
}
|
||||
|
||||
@Post('customs/:bookingId/release-permitted')
|
||||
@BookingStaff(FREIGHT_PERMS.bookings.operations)
|
||||
@ApiOperation({ summary: 'Batch 12: mark import release permitted' })
|
||||
markReleasePermitted(
|
||||
@Param('bookingId', ParseUUIDPipe) bookingId: string,
|
||||
@@ -94,18 +112,21 @@ export class ImportOperationsController {
|
||||
}
|
||||
|
||||
@Get('empty-container-returns')
|
||||
@BookingStaff(FREIGHT_PERMS.bookings.operations)
|
||||
@ApiOperation({ summary: 'Batch 16: list empty container returns' })
|
||||
listEmptyReturns() {
|
||||
return this.service.listEmptyReturns();
|
||||
}
|
||||
|
||||
@Post('empty-container-returns')
|
||||
@BookingStaff(FREIGHT_PERMS.bookings.operations)
|
||||
@ApiOperation({ summary: 'Batch 16: create an empty container return record' })
|
||||
createEmptyReturn(@Body() dto: CreateEmptyContainerReturnDto) {
|
||||
return this.service.createEmptyReturn(dto);
|
||||
}
|
||||
|
||||
@Post('empty-container-returns/load-on-train')
|
||||
@BookingStaff(FREIGHT_PERMS.bookings.operations)
|
||||
@ApiOperation({
|
||||
summary: 'Load returned empties onto an export train (1×40ft or 2×20ft per wagon)',
|
||||
})
|
||||
@@ -114,6 +135,7 @@ export class ImportOperationsController {
|
||||
}
|
||||
|
||||
@Post('empty-container-returns/:id/status')
|
||||
@BookingStaff(FREIGHT_PERMS.bookings.operations)
|
||||
@ApiOperation({ summary: 'Batch 16: advance empty container return workflow' })
|
||||
updateEmptyReturnStatus(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@@ -121,4 +143,53 @@ export class ImportOperationsController {
|
||||
) {
|
||||
return this.service.updateEmptyReturnStatus(id, dto);
|
||||
}
|
||||
|
||||
@Get('bookings/:bookingId/empty-container-returns')
|
||||
@MixedAudience(FREIGHT_PERMS.bookings.operations)
|
||||
@ApiOperation({ summary: 'List empty container returns for a booking (customer portal)' })
|
||||
async listEmptyReturnsForBooking(
|
||||
@Param('bookingId', ParseUUIDPipe) bookingId: string,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
await this.assertCanAccessBooking(user, bookingId);
|
||||
return this.service.listEmptyReturnsForBooking(bookingId);
|
||||
}
|
||||
|
||||
@Get('empty-container-returns/:id/document')
|
||||
@MixedAudience(FREIGHT_PERMS.bookings.operations)
|
||||
@ApiOperation({ summary: 'Download the equipment interchange receipt PDF (customer portal)' })
|
||||
async equipmentInterchangeDocument(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
@Res() res: Response,
|
||||
) {
|
||||
const row = await this.service.getEmptyReturnOrThrow(id);
|
||||
// A standalone (no-booking) return has no owner to check against, so it
|
||||
// stays staff-only.
|
||||
if (!row.bookingId) {
|
||||
await this.assertCanAccessBooking(user, null);
|
||||
} else {
|
||||
await this.assertCanAccessBooking(user, row.bookingId);
|
||||
}
|
||||
|
||||
const { filename, buffer } = await this.service.equipmentInterchangeDocument(row);
|
||||
res.setHeader('Content-Type', 'application/pdf');
|
||||
res.setHeader('Content-Disposition', `inline; filename="${filename}"`);
|
||||
res.setHeader('Content-Length', buffer.length);
|
||||
return res.send(buffer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Staff pass on permission alone. A customer must own the booking; `null`
|
||||
* (a standalone, booking-less return) has no owner for a customer to match,
|
||||
* so it 404s them the same way a foreign booking would.
|
||||
*/
|
||||
private async assertCanAccessBooking(user: TCurrentUser, bookingId: string | null): Promise<void> {
|
||||
if (hasFreightPermission(user, FREIGHT_PERMS.bookings.operations)) return;
|
||||
if (!bookingId) {
|
||||
throw new NotFoundException('Not found');
|
||||
}
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { BookingsModule } from '../bookings/bookings.module';
|
||||
import { WarehousesModule } from '../warehouses/warehouses.module';
|
||||
import { DjiboutiIncident } from './entities/djibouti-incident.entity';
|
||||
import { EmptyContainerReturn } from './entities/empty-container-return.entity';
|
||||
import { ImportCustomsFinalization } from './entities/import-customs-finalization.entity';
|
||||
@@ -14,6 +16,11 @@ import { ImportOperationsService } from './import-operations.service';
|
||||
ImportCustomsFinalization,
|
||||
EmptyContainerReturn,
|
||||
]),
|
||||
// WarehouseReleaseDocumentService (the shared PDF renderer) for the
|
||||
// equipment interchange receipt; BookingsModule for the customer
|
||||
// ownership check on that same route.
|
||||
WarehousesModule,
|
||||
BookingsModule,
|
||||
],
|
||||
controllers: [ImportOperationsController],
|
||||
providers: [ImportOperationsService],
|
||||
|
||||
@@ -2,6 +2,9 @@ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/comm
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { In, Repository } from 'typeorm';
|
||||
|
||||
import { LogoSettingsService } from '../logo-settings/logo-settings.service';
|
||||
import { logoImageCss, logoMarkup } from '../billing/documents/logo-markup.util';
|
||||
import { WarehouseReleaseDocumentService } from '../warehouses/warehouse-release-document.service';
|
||||
import {
|
||||
CreateDjiboutiIncidentDto,
|
||||
CreateEmptyContainerReturnDto,
|
||||
@@ -39,6 +42,8 @@ export class ImportOperationsService {
|
||||
private readonly customs: Repository<ImportCustomsFinalization>,
|
||||
@InjectRepository(EmptyContainerReturn)
|
||||
private readonly emptyReturns: Repository<EmptyContainerReturn>,
|
||||
private readonly pdfDocuments: WarehouseReleaseDocumentService,
|
||||
private readonly logoSettings: LogoSettingsService,
|
||||
) {}
|
||||
|
||||
listIncidents(bookingId?: string) {
|
||||
@@ -150,6 +155,10 @@ export class ImportOperationsService {
|
||||
return this.emptyReturns.find({ order: { createdAt: 'DESC' } as never });
|
||||
}
|
||||
|
||||
listEmptyReturnsForBooking(bookingId: string) {
|
||||
return this.emptyReturns.find({ where: { bookingId }, order: { createdAt: 'DESC' } as never });
|
||||
}
|
||||
|
||||
async createEmptyReturn(dto: CreateEmptyContainerReturnDto) {
|
||||
const returnDate = dto.returnDate ? new Date(dto.returnDate) : new Date();
|
||||
return this.emptyReturns.save(
|
||||
@@ -248,6 +257,142 @@ export class ImportOperationsService {
|
||||
return this.emptyReturns.findOneOrFail({ where: { id } });
|
||||
}
|
||||
|
||||
async getEmptyReturnOrThrow(id: string): Promise<EmptyContainerReturn> {
|
||||
const row = await this.emptyReturns.findOne({ where: { id } });
|
||||
if (!row) {
|
||||
throw new NotFoundException(`Empty container return ${id} not found`);
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
/**
|
||||
* Equipment Interchange Receipt — container number/size, exact return
|
||||
* timestamp, depot, condition, and the carrier/booking reference that ties
|
||||
* the box back to its bill of lading. Handed to the customer to download.
|
||||
*/
|
||||
async equipmentInterchangeDocument(
|
||||
row: EmptyContainerReturn,
|
||||
): Promise<{ filename: string; buffer: Buffer }> {
|
||||
const booking = row.bookingId
|
||||
? ((
|
||||
await this.emptyReturns.manager.query(
|
||||
`SELECT b.reference, c.name AS company_name
|
||||
FROM freight.bookings b
|
||||
LEFT JOIN freight.companies c ON c.id = b.company_id
|
||||
WHERE b.id = $1`,
|
||||
[row.bookingId],
|
||||
)
|
||||
)[0] as { reference: string; company_name: string | null } | undefined)
|
||||
: undefined;
|
||||
|
||||
const html = this.buildEquipmentInterchangeHtml(row, booking, {
|
||||
logoImageUrl: await this.logoSettings.getLogoImageUrl(),
|
||||
});
|
||||
const buffer = await this.pdfDocuments.renderDocumentHtml(html, 'Equipment interchange receipt');
|
||||
return {
|
||||
filename: `equipment-interchange-${row.containerNumber || row.id.slice(0, 8)}.pdf`,
|
||||
buffer,
|
||||
};
|
||||
}
|
||||
|
||||
private buildEquipmentInterchangeHtml(
|
||||
row: EmptyContainerReturn,
|
||||
booking: { reference: string; company_name: string | null } | undefined,
|
||||
opts: { logoImageUrl?: string | null },
|
||||
): string {
|
||||
const esc = (value: unknown) =>
|
||||
String(value ?? '-')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
const dateTime = (value: unknown) =>
|
||||
value ? new Date(value as string | Date).toLocaleString('en-GB', { dateStyle: 'medium', timeStyle: 'short' }) : '-';
|
||||
const carrier =
|
||||
row.returnedBy === 'EDR'
|
||||
? 'EDR Last Mile'
|
||||
: row.returnedBy === 'CUSTOMER'
|
||||
? 'Customer Self-Haul'
|
||||
: '-';
|
||||
|
||||
const rows: Array<[string, string]> = [
|
||||
['Container Number', row.containerNumber],
|
||||
['Container Size', row.containerSize ? `${row.containerSize}ft` : 'Not recorded'],
|
||||
['Date & Time of Return', dateTime(row.returnDate)],
|
||||
['Depot / Location', [row.facility, row.yard, row.zone].filter(Boolean).join(' — ') || '-'],
|
||||
['Condition Status', row.condition || 'Good — no exceptions noted'],
|
||||
['Carrier', carrier],
|
||||
['Booking / BOL Reference', booking?.reference || 'Standalone — no booking'],
|
||||
['Shipping Line / Customer', booking?.company_name || '-'],
|
||||
['Current Status', row.status.replace(/_/g, ' ')],
|
||||
['Handover Note', row.handoverNote || '-'],
|
||||
];
|
||||
|
||||
const rowsHtml = rows
|
||||
.map(
|
||||
([label, value]) =>
|
||||
`<tr><th>${esc(label)}</th><td>${esc(value)}</td></tr>`,
|
||||
)
|
||||
.join('');
|
||||
|
||||
return `<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Equipment Interchange Receipt</title>
|
||||
<style>
|
||||
@page { size: A4; margin: 14mm; }
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; color: #0f172a; font-family: Arial, sans-serif; }
|
||||
.top { display: flex; justify-content: space-between; align-items: flex-start; border-bottom: 3px solid #0f766e; padding-bottom: 12px; gap: 24px; }
|
||||
.brand { font-size: 11px; color: #475569; text-transform: uppercase; letter-spacing: .08em; font-weight: 700; }
|
||||
h1 { margin: 6px 0 0; font-size: 22px; line-height: 1.1; }
|
||||
.meta { text-align: right; font-size: 11px; color: #475569; }
|
||||
.meta strong { display: block; margin-top: 4px; color: #0f172a; font-size: 15px; }
|
||||
${logoImageCss()}
|
||||
table { width: 100%; border-collapse: collapse; margin-top: 20px; }
|
||||
th, td { border: 1px solid #cbd5e1; padding: 8px 10px; font-size: 11.5px; text-align: left; vertical-align: top; }
|
||||
th { width: 220px; background: #f8fafc; color: #475569; font-weight: 700; }
|
||||
.notice { margin-top: 16px; border-left: 4px solid #0f766e; background: #f0fdfa; padding: 10px 12px; font-size: 10.5px; color: #134e4a; }
|
||||
.signatures { display: grid; grid-template-columns: repeat(2, 1fr); gap: 24px; margin-top: 40px; }
|
||||
.line { border-top: 1px solid #334155; padding-top: 8px; font-size: 10px; color: #475569; min-height: 40px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="top">
|
||||
<div>
|
||||
${logoMarkup(opts.logoImageUrl)}
|
||||
<div class="brand">Ethio-Djibouti Railway S.C.</div>
|
||||
<h1>Equipment Interchange Receipt</h1>
|
||||
</div>
|
||||
<div class="meta">
|
||||
Receipt No.
|
||||
<strong>${esc(`EIR-${row.id.slice(0, 8).toUpperCase()}`)}</strong>
|
||||
Generated: ${esc(new Date().toLocaleString('en-GB'))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table>
|
||||
<tbody>
|
||||
${rowsHtml}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="notice">
|
||||
This receipt confirms the physical interchange of the equipment described above at the
|
||||
depot/location and time stated. Both parties should verify the container number, size,
|
||||
and condition recorded here before signing.
|
||||
</div>
|
||||
|
||||
<div class="signatures">
|
||||
<div class="line">Depot officer name / signature / date</div>
|
||||
<div class="line">Customer or driver name / signature / date</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
private async getOrCreateCustoms(bookingId: string) {
|
||||
const existing = await this.customs.findOne({ where: { bookingId } });
|
||||
if (existing) return existing;
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
Textarea,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { DateInput } from "@mantine/dates";
|
||||
import {
|
||||
Ban,
|
||||
Download,
|
||||
@@ -32,7 +33,7 @@ import { isViewable } from "@edr/ui-common";
|
||||
|
||||
import { bookingsService } from "@/services/bookings.service";
|
||||
import { downloadBookingFile, fetchViewableFile } from "@/services/files.service";
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
import { formatDate, formatDateTime } from "@/lib/format";
|
||||
import { extractErrorMessage } from "@/utils/errorExtractor";
|
||||
|
||||
const CURRENCIES = ["ETB", "USD"];
|
||||
@@ -75,6 +76,7 @@ export function AdditionalPaymentsTab({ bookingId, onViewFile }: AdditionalPayme
|
||||
currency: string;
|
||||
action: "draft" | "send";
|
||||
file?: File | null;
|
||||
dueDate?: string | null;
|
||||
}) => bookingsService.createAdditionalCharge(bookingId, p),
|
||||
onSuccess: (next, p) => {
|
||||
toast.success(p.action === "send" ? "Charge sent to the customer" : "Draft saved");
|
||||
@@ -203,16 +205,29 @@ function ChargeCard({
|
||||
{charge.cancelReason ? ` — ${charge.cancelReason}` : ""}
|
||||
</Text>
|
||||
)}
|
||||
{charge.dueAt && charge.status !== "PAID" && charge.status !== "CANCELLED" && (
|
||||
<Text fz="11.5px" c="dimmed">
|
||||
Due {formatDate(charge.dueAt)}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Group>
|
||||
<Group gap={8} wrap="nowrap">
|
||||
<Text fz="14px" fw={800} c="edr-text">
|
||||
{charge.amount.toLocaleString(undefined, { minimumFractionDigits: 2 })}{" "}
|
||||
{charge.currency}
|
||||
</Text>
|
||||
<Badge variant="light" color={meta.color} radius="sm">
|
||||
{meta.label}
|
||||
</Badge>
|
||||
<Group gap={8} wrap="nowrap" align="flex-end" style={{ flexDirection: "column" }}>
|
||||
<Group gap={8} wrap="nowrap">
|
||||
<Text fz="14px" fw={800} c="edr-text">
|
||||
{charge.amount.toLocaleString(undefined, { minimumFractionDigits: 2 })}{" "}
|
||||
{charge.currency}
|
||||
</Text>
|
||||
<Badge variant="light" color={meta.color} radius="sm">
|
||||
{meta.label}
|
||||
</Badge>
|
||||
</Group>
|
||||
{charge.convertedAmount != null && (
|
||||
<Text fz="11.5px" c="dimmed">
|
||||
≈ {charge.convertedAmount.toLocaleString(undefined, { minimumFractionDigits: 2 })}{" "}
|
||||
{charge.convertedCurrency}
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
@@ -297,12 +312,14 @@ function AddChargeModal({
|
||||
currency: string;
|
||||
action: "draft" | "send";
|
||||
file?: File | null;
|
||||
dueDate?: string | null;
|
||||
}) => void;
|
||||
}) {
|
||||
const [reason, setReason] = useState("");
|
||||
const [amount, setAmount] = useState<number | string>("");
|
||||
const [currency, setCurrency] = useState("ETB");
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [dueDate, setDueDate] = useState<Date | null>(null);
|
||||
|
||||
const valid = reason.trim().length > 0 && Number(amount) > 0;
|
||||
|
||||
@@ -311,11 +328,23 @@ function AddChargeModal({
|
||||
setAmount("");
|
||||
setCurrency("ETB");
|
||||
setFile(null);
|
||||
setDueDate(null);
|
||||
};
|
||||
|
||||
const submit = (action: "draft" | "send") => {
|
||||
if (!valid) return;
|
||||
onSubmit({ reason: reason.trim(), amount: Number(amount), currency, action, file });
|
||||
onSubmit({
|
||||
reason: reason.trim(),
|
||||
amount: Number(amount),
|
||||
currency,
|
||||
action,
|
||||
file,
|
||||
// Local calendar date, not a UTC-shifted ISO timestamp — toISOString() can
|
||||
// roll the date back a day for evening local time in a positive-offset zone.
|
||||
dueDate: dueDate
|
||||
? `${dueDate.getFullYear()}-${String(dueDate.getMonth() + 1).padStart(2, "0")}-${String(dueDate.getDate()).padStart(2, "0")}`
|
||||
: null,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -355,6 +384,14 @@ function AddChargeModal({
|
||||
w={100}
|
||||
/>
|
||||
</Group>
|
||||
<DateInput
|
||||
label="Due date"
|
||||
placeholder="Defaults to 14 days after sending"
|
||||
value={dueDate}
|
||||
onChange={(v) => setDueDate(v ? new Date(v) : null)}
|
||||
minDate={new Date()}
|
||||
clearable
|
||||
/>
|
||||
<FileButton onChange={setFile} accept="application/pdf,image/*">
|
||||
{(props) => (
|
||||
<Button
|
||||
|
||||
@@ -518,13 +518,22 @@ export const bookingsService = {
|
||||
/** Finance raises a new charge — 'draft' just saves it, 'send' also issues the invoice and notifies the customer. */
|
||||
createAdditionalCharge: async (
|
||||
id: string,
|
||||
payload: { reason: string; amount: number; currency: string; action: "draft" | "send"; file?: File | null },
|
||||
payload: {
|
||||
reason: string;
|
||||
amount: number;
|
||||
currency: string;
|
||||
action: "draft" | "send";
|
||||
file?: File | null;
|
||||
/** ISO date (YYYY-MM-DD); omit to fall back to the invoice's default 14-day term. */
|
||||
dueDate?: string | null;
|
||||
},
|
||||
): Promise<Freight.AdditionalCharge[]> => {
|
||||
const form = new FormData();
|
||||
form.append("reason", payload.reason);
|
||||
form.append("amount", String(payload.amount));
|
||||
form.append("currency", payload.currency);
|
||||
form.append("action", payload.action);
|
||||
if (payload.dueDate) form.append("dueDate", payload.dueDate);
|
||||
if (payload.file) form.append("file", payload.file);
|
||||
const response = await client.post(`/bookings/${id}/additional-charges`, form, {
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
|
||||
@@ -128,6 +128,8 @@ export const URL_CONSTANTS = {
|
||||
CONTRACT_DOCUMENT: (id: string) => `/api/bookings/${id}/contract/document`,
|
||||
CONTRACT_SIGN: (id: string) => `/api/bookings/${id}/contract/sign`,
|
||||
CONTRACT_DOWNLOAD: (id: string) => `/api/bookings/${id}/contract`,
|
||||
CARRIAGE_ACCEPTANCE_SHEET: (id: string) =>
|
||||
`/api/bookings/${id}/carriage-acceptance-sheet`,
|
||||
CANCEL: (id: string | number) => `/api/bookings/${id}/cancel`,
|
||||
CONFIRM: (id: string | number) => `/api/bookings/${id}/confirm`,
|
||||
CUSTOMER_TRUCKS: (id: string) => `/api/bookings/${id}/customer-trucks`,
|
||||
|
||||
@@ -59,8 +59,16 @@ function ChargeRow({ charge }: { charge: Freight.AdditionalCharge }) {
|
||||
<Text fz="12px" c="#9AA8B5" mt={2}>
|
||||
{charge.amount.toLocaleString(undefined, { minimumFractionDigits: 2 })}{" "}
|
||||
{charge.currency}
|
||||
{charge.convertedAmount != null
|
||||
? ` (≈ ${charge.convertedAmount.toLocaleString(undefined, { minimumFractionDigits: 2 })} ${charge.convertedCurrency})`
|
||||
: ""}
|
||||
{charge.paymentReference ? ` · ref ${charge.paymentReference}` : ""}
|
||||
</Text>
|
||||
{charge.dueAt && charge.status === "SENT" && (
|
||||
<Text fz="12px" c="#9AA8B5">
|
||||
Due {new Date(charge.dueAt).toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" })}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
<Badge
|
||||
radius="sm"
|
||||
|
||||
@@ -28,6 +28,7 @@ import { ClearanceUploadedDocumentsPanel } from "@/components/contracts/Clearanc
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import { bookingsService } from "@/services/bookings.service";
|
||||
import type { EmptyContainerReturn } from "@/services/bookings.service";
|
||||
import { saveBlob } from "@/utils/download";
|
||||
import { IconSquare } from "./Documents";
|
||||
import { CardTitle, SectionCard } from "./layout";
|
||||
@@ -251,6 +252,24 @@ export function DocumentsTab({ booking }: { booking: Freight.IBooking }) {
|
||||
queryFn: () => warehouseService.bookingHandovers(booking.id),
|
||||
});
|
||||
|
||||
const { data: emptyReturns = [] } = useQuery({
|
||||
queryKey: ["emptyContainerReturns", booking.id],
|
||||
queryFn: () =>
|
||||
bookingsService.listEmptyContainerReturns(booking.id).catch(() => []),
|
||||
});
|
||||
const [downloadingReturnId, setDownloadingReturnId] = useState<string | null>(null);
|
||||
const downloadEir = async (ret: EmptyContainerReturn) => {
|
||||
setDownloadingReturnId(ret.id);
|
||||
try {
|
||||
const blob = await bookingsService.downloadEquipmentInterchangeDocument(ret.id);
|
||||
saveBlob(blob, `equipment-interchange-${ret.containerNumber}.pdf`);
|
||||
} catch {
|
||||
toast.error("Could not download the interchange receipt.");
|
||||
} finally {
|
||||
setDownloadingReturnId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const customerDocs = useMemo(
|
||||
() => (clearance?.documents ?? []).filter((d) => d.uploadedBy === "customer"),
|
||||
[clearance],
|
||||
@@ -303,6 +322,14 @@ export function DocumentsTab({ booking }: { booking: Freight.IBooking }) {
|
||||
fn: () => bookingsService.downloadBookingHandoverDocument(booking.id),
|
||||
},
|
||||
];
|
||||
// Carriage acceptance sheet only exists for export bookings — 404s
|
||||
// (skipped below) for import/domestic, so this is safe unconditionally.
|
||||
if (booking.tradeDirection === "EXPORT") {
|
||||
jobs.push({
|
||||
name: `carriage-acceptance-${ref}.pdf`,
|
||||
fn: () => bookingsService.downloadCarriageAcceptanceSheet(booking.id),
|
||||
});
|
||||
}
|
||||
let saved = 0;
|
||||
for (const job of jobs) {
|
||||
try {
|
||||
@@ -584,12 +611,56 @@ export function DocumentsTab({ booking }: { booking: Freight.IBooking }) {
|
||||
</SectionCard>
|
||||
)}
|
||||
|
||||
{/* ── 4b. Equipment interchange receipts (empty container returns) ─── */}
|
||||
{emptyReturns.length > 0 && (
|
||||
<SectionCard>
|
||||
<CardTitle>Equipment interchange receipts</CardTitle>
|
||||
<Text fz="12.5px" c="dimmed" mt={4} mb="sm">
|
||||
Container number, size, return time, depot, and condition for each empty
|
||||
container returned on this booking.
|
||||
</Text>
|
||||
<Stack gap={0}>
|
||||
{emptyReturns.map((ret, i) => (
|
||||
<Box
|
||||
key={ret.id}
|
||||
py={12}
|
||||
style={{
|
||||
borderBottom:
|
||||
i === emptyReturns.length - 1 ? undefined : "1px solid #F2F5F8",
|
||||
}}
|
||||
>
|
||||
<Group justify="space-between" align="flex-start" wrap="nowrap">
|
||||
<Box miw={0} flex={1}>
|
||||
<Text fz="13.5px" fw={700} c="#10202F">
|
||||
{ret.containerNumber}
|
||||
{ret.containerSize ? ` · ${ret.containerSize}ft` : ""}
|
||||
</Text>
|
||||
<Text fz="12px" c="#9AA8B5">
|
||||
{ret.returnDate ? new Date(ret.returnDate).toLocaleString() : "—"}
|
||||
{ret.facility ? ` · ${ret.facility}` : ""}
|
||||
{ret.condition ? ` · ${ret.condition}` : ""}
|
||||
</Text>
|
||||
</Box>
|
||||
<IconSquare
|
||||
icon={<Download size={15} />}
|
||||
onClick={
|
||||
downloadingReturnId === ret.id ? undefined : () => void downloadEir(ret)
|
||||
}
|
||||
/>
|
||||
</Group>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
</SectionCard>
|
||||
)}
|
||||
|
||||
{/* ── Warehouse documents (one-click bundle) ──────────────────────── */}
|
||||
<SectionCard>
|
||||
<CardTitle>Warehouse documents</CardTitle>
|
||||
<Text fz="12.5px" c="dimmed" mt={4} mb="sm">
|
||||
Goods Received Note, gate clearance / release order and handover — download all
|
||||
available documents for this booking in one click.
|
||||
Goods Received Note, gate clearance / release order, handover, and — for export
|
||||
bookings — the carriage acceptance sheet: download all available documents for
|
||||
this booking in one click.
|
||||
</Text>
|
||||
<Button
|
||||
leftSection={<Download size={16} />}
|
||||
|
||||
@@ -7,6 +7,19 @@ import { client } from "../utils/api";
|
||||
|
||||
const B = URL_CONSTANTS.BOOKINGS;
|
||||
|
||||
export interface EmptyContainerReturn {
|
||||
id: string;
|
||||
containerNumber: string;
|
||||
containerSize: "20" | "40" | null;
|
||||
returnDate: string;
|
||||
facility: string | null;
|
||||
yard: string | null;
|
||||
zone: string | null;
|
||||
condition: string | null;
|
||||
status: string;
|
||||
returnedBy: "EDR" | "CUSTOMER" | null;
|
||||
}
|
||||
|
||||
export interface MileVehicleSummary {
|
||||
plate: string | null;
|
||||
code: string | null;
|
||||
@@ -383,6 +396,19 @@ export const bookingsService = {
|
||||
);
|
||||
return data.data ?? data;
|
||||
},
|
||||
listEmptyContainerReturns: async (bookingId: string): Promise<EmptyContainerReturn[]> => {
|
||||
const { data } = await client.get(
|
||||
`/api/import-operations/bookings/${bookingId}/empty-container-returns`,
|
||||
);
|
||||
return data.data ?? data;
|
||||
},
|
||||
downloadEquipmentInterchangeDocument: async (returnId: string): Promise<Blob> => {
|
||||
const { data } = await client.get(
|
||||
`/api/import-operations/empty-container-returns/${returnId}/document`,
|
||||
{ responseType: "blob" },
|
||||
);
|
||||
return data;
|
||||
},
|
||||
downloadBookingGrnDocument: async (bookingId: string): Promise<Blob> => {
|
||||
const { data } = await client.get(
|
||||
`/api/warehouse-inventory/bookings/${bookingId}/grn-document`,
|
||||
@@ -597,6 +623,13 @@ export const bookingsService = {
|
||||
return data;
|
||||
},
|
||||
|
||||
downloadCarriageAcceptanceSheet: async (id: string): Promise<Blob> => {
|
||||
const { data } = await client.get(B.CARRIAGE_ACCEPTANCE_SHEET(id), {
|
||||
responseType: "blob",
|
||||
});
|
||||
return data;
|
||||
},
|
||||
|
||||
checkPayment: async (orderId: string): Promise<{ status: string }> => {
|
||||
const { data } = await client.post(`/api/payments/bookings/check-payment/${orderId}`);
|
||||
return data.data ?? data;
|
||||
|
||||
@@ -909,6 +909,11 @@ export interface AdditionalCharge {
|
||||
status: AdditionalChargeStatus;
|
||||
amount: number;
|
||||
currency: string;
|
||||
/** Amount converted to the other of ETB/USD at the current exchange rate; null if the rate feed is down. */
|
||||
convertedAmount: number | null;
|
||||
convertedCurrency: string | null;
|
||||
/** Optional payment due date finance sets on the charge; falls back to the invoice's own default term when unset. */
|
||||
dueAt: string | null;
|
||||
file: { id: string; name: string; url: string } | null;
|
||||
invoiceId: string | null;
|
||||
invoiceNumber: string | null;
|
||||
|
||||