Merge pull request #1378 from Tria-plc/eims-bulk-register

Eims bulk register
This commit is contained in:
Hagernesh Tadesse
2026-08-21 16:04:37 +03:00
committed by GitHub
16 changed files with 559 additions and 104 deletions

BIN
EDR-Freight-User-Guide.pdf Normal file

Binary file not shown.

View 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"
`);
}
}

View File

@@ -1,6 +1,7 @@
import { ConflictException, Injectable, Logger, NotFoundException } from '@nestjs/common'; import { ConflictException, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter'; import { OnEvent } from '@nestjs/event-emitter';
import { DataSource, EntityManager } from 'typeorm'; import { DataSource, EntityManager } from 'typeorm';
import { ExchangeService } from '@edr/api-common';
import { Freight, NotificationAudience, NotificationType } from '@edr/types'; import { Freight, NotificationAudience, NotificationType } from '@edr/types';
import { BillingService, InvoiceEventPayload } from '../billing/billing.service'; import { BillingService, InvoiceEventPayload } from '../billing/billing.service';
@@ -35,6 +36,7 @@ export class AdditionalChargeService {
private readonly repository: AdditionalChargeRepository, private readonly repository: AdditionalChargeRepository,
private readonly bookingsRepository: BookingsRepository, private readonly bookingsRepository: BookingsRepository,
private readonly filesService: FilesService, private readonly filesService: FilesService,
private readonly exchangeService: ExchangeService,
private readonly billing: BillingService, private readonly billing: BillingService,
private readonly bookingsService: BookingsService, private readonly bookingsService: BookingsService,
private readonly notifications: NotificationsService, private readonly notifications: NotificationsService,
@@ -74,6 +76,7 @@ export class AdditionalChargeService {
reason: dto.reason.trim(), reason: dto.reason.trim(),
amount: dto.amount.toFixed(2), amount: dto.amount.toFixed(2),
currency: dto.currency.trim().toUpperCase(), currency: dto.currency.trim().toUpperCase(),
dueAt: dto.dueDate ? new Date(dto.dueDate) : null,
status: 'DRAFT', status: 'DRAFT',
createdByStaffId: staffId, createdByStaffId: staffId,
}), }),
@@ -132,6 +135,8 @@ export class AdditionalChargeService {
companyId: booking.companyId, companyId: booking.companyId,
companyProfileId: booking.companyProfileId, companyProfileId: booking.companyProfileId,
currency: charge.currency, currency: charge.currency,
// Unset falls through to BillingService's own DEFAULT_DUE_DAYS (14).
dueAt: charge.dueAt ?? undefined,
lines: [ lines: [
{ {
chargeType: 'ADDITIONAL_CHARGE', chargeType: 'ADDITIONAL_CHARGE',
@@ -254,9 +259,12 @@ export class AdditionalChargeService {
? await this.dataSource.getRepository(Invoice).find({ where: invoiceIds.map((id) => ({ id })) }) ? await this.dataSource.getRepository(Invoice).find({ where: invoiceIds.map((id) => ({ id })) })
: []; : [];
const invoiceById = new Map(invoices.map((i) => [i.id, i])); 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) => { return rows.map((r) => {
const file = filesByCharge.get(r.id)?.[0]; const file = filesByCharge.get(r.id)?.[0];
const fx = convertedById.get(r.id) ?? null;
return { return {
id: r.id, id: r.id,
bookingId: r.bookingId, bookingId: r.bookingId,
@@ -264,6 +272,9 @@ export class AdditionalChargeService {
status: r.status, status: r.status,
amount: Number(r.amount), amount: Number(r.amount),
currency: r.currency, 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, file: file ? { id: file.id, name: file.name, url: file.url } : null,
invoiceId: r.invoiceId ?? null, invoiceId: r.invoiceId ?? null,
invoiceNumber: r.invoiceId ? (invoiceById.get(r.invoiceId)?.invoiceNumber ?? null) : 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;
}
}
} }

View File

@@ -1,6 +1,14 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer'; 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 { export class CreateAdditionalChargeDto {
@ApiProperty({ example: 'Re-weighing fee at Mojo dry port' }) @ApiProperty({ example: 'Re-weighing fee at Mojo dry port' })
@@ -24,6 +32,12 @@ export class CreateAdditionalChargeDto {
@IsOptional() @IsOptional()
@IsIn(['draft', 'send']) @IsIn(['draft', 'send'])
action?: '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 { export class CancelAdditionalChargeDto {

View File

@@ -39,6 +39,10 @@ export class AdditionalCharge extends BaseEntity {
@Column({ name: 'currency', type: 'varchar', length: 8 }) @Column({ name: 'currency', type: 'varchar', length: 8 })
currency!: string; 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. */ /** The supporting attachment (FileRecord), if any. */
@Column({ name: 'file_record_id', type: 'uuid', nullable: true }) @Column({ name: 'file_record_id', type: 'uuid', nullable: true })
fileRecordId?: string | null; fileRecordId?: string | null;

View File

@@ -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 { 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 { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { BookingsService } from '../bookings/bookings.service';
import { import {
AssignCustomsRiskDto, AssignCustomsRiskDto,
CreateDjiboutiIncidentDto, CreateDjiboutiIncidentDto,
@@ -19,30 +24,38 @@ import { ImportOperationsService } from './import-operations.service';
@ApiBearerAuth() @ApiBearerAuth()
@Controller('import-operations') @Controller('import-operations')
// Post-booking customs / import-operations actions are GL/Ops work, mirroring the // Post-booking customs / import-operations actions are GL/Ops work, mirroring the
// contracts controller's GL operational endpoints (risk, duty, milestones). // contracts controller's GL operational endpoints (risk, duty, milestones). No
@BookingStaff(FREIGHT_PERMS.bookings.operations) // 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 { export class ImportOperationsController {
constructor(private readonly service: ImportOperationsService) {} constructor(
private readonly service: ImportOperationsService,
private readonly bookingsService: BookingsService,
) {}
@Get('djibouti-incidents') @Get('djibouti-incidents')
@BookingStaff(FREIGHT_PERMS.bookings.operations)
@ApiOperation({ summary: 'Batch 8: list Djibouti import incidents' }) @ApiOperation({ summary: 'Batch 8: list Djibouti import incidents' })
listIncidents(@Query('bookingId') bookingId?: string) { listIncidents(@Query('bookingId') bookingId?: string) {
return this.service.listIncidents(bookingId); return this.service.listIncidents(bookingId);
} }
@Post('djibouti-incidents') @Post('djibouti-incidents')
@BookingStaff(FREIGHT_PERMS.bookings.operations)
@ApiOperation({ summary: 'Batch 8: report a Djibouti import incident / exception' }) @ApiOperation({ summary: 'Batch 8: report a Djibouti import incident / exception' })
createIncident(@Body() dto: CreateDjiboutiIncidentDto) { createIncident(@Body() dto: CreateDjiboutiIncidentDto) {
return this.service.createIncident(dto); return this.service.createIncident(dto);
} }
@Get('customs/:bookingId') @Get('customs/:bookingId')
@BookingStaff(FREIGHT_PERMS.bookings.operations)
@ApiOperation({ summary: 'Batch 12: import customs finalization state' }) @ApiOperation({ summary: 'Batch 12: import customs finalization state' })
getCustoms(@Param('bookingId', ParseUUIDPipe) bookingId: string) { getCustoms(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
return this.service.getCustoms(bookingId); return this.service.getCustoms(bookingId);
} }
@Post('customs/:bookingId/documents') @Post('customs/:bookingId/documents')
@BookingStaff(FREIGHT_PERMS.bookings.operations)
@ApiOperation({ summary: 'Batch 12: upload IM4/IM5/T1/permit/payment-slip documents' }) @ApiOperation({ summary: 'Batch 12: upload IM4/IM5/T1/permit/payment-slip documents' })
uploadCustomsDocument( uploadCustomsDocument(
@Param('bookingId', ParseUUIDPipe) bookingId: string, @Param('bookingId', ParseUUIDPipe) bookingId: string,
@@ -52,6 +65,7 @@ export class ImportOperationsController {
} }
@Post('customs/:bookingId/declaration') @Post('customs/:bookingId/declaration')
@BookingStaff(FREIGHT_PERMS.bookings.operations)
@ApiOperation({ summary: 'Batch 12: record declaration serial number' }) @ApiOperation({ summary: 'Batch 12: record declaration serial number' })
recordDeclaration( recordDeclaration(
@Param('bookingId', ParseUUIDPipe) bookingId: string, @Param('bookingId', ParseUUIDPipe) bookingId: string,
@@ -61,6 +75,7 @@ export class ImportOperationsController {
} }
@Post('customs/:bookingId/notify-duties-taxes') @Post('customs/:bookingId/notify-duties-taxes')
@BookingStaff(FREIGHT_PERMS.bookings.operations)
@ApiOperation({ summary: 'Batch 12: notify duties and taxes' }) @ApiOperation({ summary: 'Batch 12: notify duties and taxes' })
notifyDutiesTaxes( notifyDutiesTaxes(
@Param('bookingId', ParseUUIDPipe) bookingId: string, @Param('bookingId', ParseUUIDPipe) bookingId: string,
@@ -70,6 +85,7 @@ export class ImportOperationsController {
} }
@Post('customs/:bookingId/duties-taxes-paid') @Post('customs/:bookingId/duties-taxes-paid')
@BookingStaff(FREIGHT_PERMS.bookings.operations)
@ApiOperation({ summary: 'Batch 12: mark duties and taxes paid' }) @ApiOperation({ summary: 'Batch 12: mark duties and taxes paid' })
markDutiesTaxesPaid( markDutiesTaxesPaid(
@Param('bookingId', ParseUUIDPipe) bookingId: string, @Param('bookingId', ParseUUIDPipe) bookingId: string,
@@ -79,12 +95,14 @@ export class ImportOperationsController {
} }
@Post('customs/:bookingId/risk') @Post('customs/:bookingId/risk')
@BookingStaff(FREIGHT_PERMS.bookings.operations)
@ApiOperation({ summary: 'Batch 12: assign customs risk' }) @ApiOperation({ summary: 'Batch 12: assign customs risk' })
assignRisk(@Param('bookingId', ParseUUIDPipe) bookingId: string, @Body() dto: AssignCustomsRiskDto) { assignRisk(@Param('bookingId', ParseUUIDPipe) bookingId: string, @Body() dto: AssignCustomsRiskDto) {
return this.service.assignRisk(bookingId, dto); return this.service.assignRisk(bookingId, dto);
} }
@Post('customs/:bookingId/release-permitted') @Post('customs/:bookingId/release-permitted')
@BookingStaff(FREIGHT_PERMS.bookings.operations)
@ApiOperation({ summary: 'Batch 12: mark import release permitted' }) @ApiOperation({ summary: 'Batch 12: mark import release permitted' })
markReleasePermitted( markReleasePermitted(
@Param('bookingId', ParseUUIDPipe) bookingId: string, @Param('bookingId', ParseUUIDPipe) bookingId: string,
@@ -94,18 +112,21 @@ export class ImportOperationsController {
} }
@Get('empty-container-returns') @Get('empty-container-returns')
@BookingStaff(FREIGHT_PERMS.bookings.operations)
@ApiOperation({ summary: 'Batch 16: list empty container returns' }) @ApiOperation({ summary: 'Batch 16: list empty container returns' })
listEmptyReturns() { listEmptyReturns() {
return this.service.listEmptyReturns(); return this.service.listEmptyReturns();
} }
@Post('empty-container-returns') @Post('empty-container-returns')
@BookingStaff(FREIGHT_PERMS.bookings.operations)
@ApiOperation({ summary: 'Batch 16: create an empty container return record' }) @ApiOperation({ summary: 'Batch 16: create an empty container return record' })
createEmptyReturn(@Body() dto: CreateEmptyContainerReturnDto) { createEmptyReturn(@Body() dto: CreateEmptyContainerReturnDto) {
return this.service.createEmptyReturn(dto); return this.service.createEmptyReturn(dto);
} }
@Post('empty-container-returns/load-on-train') @Post('empty-container-returns/load-on-train')
@BookingStaff(FREIGHT_PERMS.bookings.operations)
@ApiOperation({ @ApiOperation({
summary: 'Load returned empties onto an export train (1×40ft or 2×20ft per wagon)', 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') @Post('empty-container-returns/:id/status')
@BookingStaff(FREIGHT_PERMS.bookings.operations)
@ApiOperation({ summary: 'Batch 16: advance empty container return workflow' }) @ApiOperation({ summary: 'Batch 16: advance empty container return workflow' })
updateEmptyReturnStatus( updateEmptyReturnStatus(
@Param('id', ParseUUIDPipe) id: string, @Param('id', ParseUUIDPipe) id: string,
@@ -121,4 +143,53 @@ export class ImportOperationsController {
) { ) {
return this.service.updateEmptyReturnStatus(id, dto); 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);
}
} }

View File

@@ -1,6 +1,8 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm'; 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 { DjiboutiIncident } from './entities/djibouti-incident.entity';
import { EmptyContainerReturn } from './entities/empty-container-return.entity'; import { EmptyContainerReturn } from './entities/empty-container-return.entity';
import { ImportCustomsFinalization } from './entities/import-customs-finalization.entity'; import { ImportCustomsFinalization } from './entities/import-customs-finalization.entity';
@@ -14,6 +16,11 @@ import { ImportOperationsService } from './import-operations.service';
ImportCustomsFinalization, ImportCustomsFinalization,
EmptyContainerReturn, 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], controllers: [ImportOperationsController],
providers: [ImportOperationsService], providers: [ImportOperationsService],

View File

@@ -2,6 +2,9 @@ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/comm
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { In, Repository } from '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 { import {
CreateDjiboutiIncidentDto, CreateDjiboutiIncidentDto,
CreateEmptyContainerReturnDto, CreateEmptyContainerReturnDto,
@@ -39,6 +42,8 @@ export class ImportOperationsService {
private readonly customs: Repository<ImportCustomsFinalization>, private readonly customs: Repository<ImportCustomsFinalization>,
@InjectRepository(EmptyContainerReturn) @InjectRepository(EmptyContainerReturn)
private readonly emptyReturns: Repository<EmptyContainerReturn>, private readonly emptyReturns: Repository<EmptyContainerReturn>,
private readonly pdfDocuments: WarehouseReleaseDocumentService,
private readonly logoSettings: LogoSettingsService,
) {} ) {}
listIncidents(bookingId?: string) { listIncidents(bookingId?: string) {
@@ -150,6 +155,10 @@ export class ImportOperationsService {
return this.emptyReturns.find({ order: { createdAt: 'DESC' } as never }); 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) { async createEmptyReturn(dto: CreateEmptyContainerReturnDto) {
const returnDate = dto.returnDate ? new Date(dto.returnDate) : new Date(); const returnDate = dto.returnDate ? new Date(dto.returnDate) : new Date();
return this.emptyReturns.save( return this.emptyReturns.save(
@@ -248,6 +257,142 @@ export class ImportOperationsService {
return this.emptyReturns.findOneOrFail({ where: { id } }); 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
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) { private async getOrCreateCustoms(bookingId: string) {
const existing = await this.customs.findOne({ where: { bookingId } }); const existing = await this.customs.findOne({ where: { bookingId } });
if (existing) return existing; if (existing) return existing;

View File

@@ -16,6 +16,7 @@ import {
Textarea, Textarea,
Tooltip, Tooltip,
} from "@mantine/core"; } from "@mantine/core";
import { DateInput } from "@mantine/dates";
import { import {
Ban, Ban,
Download, Download,
@@ -32,7 +33,7 @@ import { isViewable } from "@edr/ui-common";
import { bookingsService } from "@/services/bookings.service"; import { bookingsService } from "@/services/bookings.service";
import { downloadBookingFile, fetchViewableFile } from "@/services/files.service"; import { downloadBookingFile, fetchViewableFile } from "@/services/files.service";
import { formatDateTime } from "@/lib/format"; import { formatDate, formatDateTime } from "@/lib/format";
import { extractErrorMessage } from "@/utils/errorExtractor"; import { extractErrorMessage } from "@/utils/errorExtractor";
const CURRENCIES = ["ETB", "USD"]; const CURRENCIES = ["ETB", "USD"];
@@ -75,6 +76,7 @@ export function AdditionalPaymentsTab({ bookingId, onViewFile }: AdditionalPayme
currency: string; currency: string;
action: "draft" | "send"; action: "draft" | "send";
file?: File | null; file?: File | null;
dueDate?: string | null;
}) => bookingsService.createAdditionalCharge(bookingId, p), }) => bookingsService.createAdditionalCharge(bookingId, p),
onSuccess: (next, p) => { onSuccess: (next, p) => {
toast.success(p.action === "send" ? "Charge sent to the customer" : "Draft saved"); toast.success(p.action === "send" ? "Charge sent to the customer" : "Draft saved");
@@ -203,16 +205,29 @@ function ChargeCard({
{charge.cancelReason ? `${charge.cancelReason}` : ""} {charge.cancelReason ? `${charge.cancelReason}` : ""}
</Text> </Text>
)} )}
{charge.dueAt && charge.status !== "PAID" && charge.status !== "CANCELLED" && (
<Text fz="11.5px" c="dimmed">
Due {formatDate(charge.dueAt)}
</Text>
)}
</Box> </Box>
</Group> </Group>
<Group gap={8} wrap="nowrap"> <Group gap={8} wrap="nowrap" align="flex-end" style={{ flexDirection: "column" }}>
<Text fz="14px" fw={800} c="edr-text"> <Group gap={8} wrap="nowrap">
{charge.amount.toLocaleString(undefined, { minimumFractionDigits: 2 })}{" "} <Text fz="14px" fw={800} c="edr-text">
{charge.currency} {charge.amount.toLocaleString(undefined, { minimumFractionDigits: 2 })}{" "}
</Text> {charge.currency}
<Badge variant="light" color={meta.color} radius="sm"> </Text>
{meta.label} <Badge variant="light" color={meta.color} radius="sm">
</Badge> {meta.label}
</Badge>
</Group>
{charge.convertedAmount != null && (
<Text fz="11.5px" c="dimmed">
{charge.convertedAmount.toLocaleString(undefined, { minimumFractionDigits: 2 })}{" "}
{charge.convertedCurrency}
</Text>
)}
</Group> </Group>
</Group> </Group>
@@ -297,12 +312,14 @@ function AddChargeModal({
currency: string; currency: string;
action: "draft" | "send"; action: "draft" | "send";
file?: File | null; file?: File | null;
dueDate?: string | null;
}) => void; }) => void;
}) { }) {
const [reason, setReason] = useState(""); const [reason, setReason] = useState("");
const [amount, setAmount] = useState<number | string>(""); const [amount, setAmount] = useState<number | string>("");
const [currency, setCurrency] = useState("ETB"); const [currency, setCurrency] = useState("ETB");
const [file, setFile] = useState<File | null>(null); const [file, setFile] = useState<File | null>(null);
const [dueDate, setDueDate] = useState<Date | null>(null);
const valid = reason.trim().length > 0 && Number(amount) > 0; const valid = reason.trim().length > 0 && Number(amount) > 0;
@@ -311,11 +328,23 @@ function AddChargeModal({
setAmount(""); setAmount("");
setCurrency("ETB"); setCurrency("ETB");
setFile(null); setFile(null);
setDueDate(null);
}; };
const submit = (action: "draft" | "send") => { const submit = (action: "draft" | "send") => {
if (!valid) return; 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 ( return (
@@ -355,6 +384,14 @@ function AddChargeModal({
w={100} w={100}
/> />
</Group> </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/*"> <FileButton onChange={setFile} accept="application/pdf,image/*">
{(props) => ( {(props) => (
<Button <Button

View File

@@ -1,9 +1,10 @@
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { Badge, Card, Center, Divider, Group, Loader, Select, SimpleGrid, Stack, Text, ThemeIcon } from '@mantine/core'; import { Button, Card, Center, Group, Loader, Popover, Select, SimpleGrid, Stack, Text, ThemeIcon } from '@mantine/core';
import { DatePickerInput } from '@mantine/dates'; import { DatePickerInput } from '@mantine/dates';
import { import {
ClipboardList, ClipboardList,
Filter,
PackageCheck, PackageCheck,
PackageOpen, PackageOpen,
PackagePlus, PackagePlus,
@@ -17,7 +18,6 @@ import {
} from 'lucide-react'; } from 'lucide-react';
import { PageContainer, PageHeader } from '@/components/page'; import { PageContainer, PageHeader } from '@/components/page';
import { getDateRangePresets } from '@/components/common/dateRangePresets';
import { import {
AccrualDashboard, AccrualDashboard,
CycleTimeCard, CycleTimeCard,
@@ -44,44 +44,42 @@ interface Metric {
icon: React.ReactNode; icon: React.ReactNode;
/** Route to navigate to when the card is clicked. */ /** Route to navigate to when the card is clicked. */
to: string; to: string;
theme: string;
} }
const ORANGE = 'rgb(241, 147, 23)';
const GREEN = '#084b21';
const METRICS: Metric[] = [ const METRICS: Metric[] = [
{ key: 'totalWarehouses', label: 'Total Warehouses', icon: <WarehouseIcon size={22} />, to: '/dashboard/warehouses', theme: ORANGE }, { key: 'totalWarehouses', label: 'Total Warehouses', icon: <WarehouseIcon size={18} />, to: '/dashboard/warehouses' },
{ key: 'totalInventory', label: 'Total Inventory', icon: <Boxes size={22} />, to: '/dashboard/warehouse-inventory', theme: GREEN }, { key: 'totalInventory', label: 'Total Inventory', icon: <Boxes size={18} />, to: '/dashboard/warehouse-inventory' },
{ key: 'received', label: 'Received Today', icon: <PackagePlus size={22} />, to: '/dashboard/warehouse-inventory?status=RECEIVED', theme: ORANGE }, { key: 'received', label: 'Received Today', icon: <PackagePlus size={18} />, to: '/dashboard/warehouse-inventory?status=RECEIVED' },
{ key: 'awaitingInspection', label: 'Awaiting Inspection', icon: <ClipboardList size={22} />, to: '/dashboard/warehouse-inventory?status=RECEIVED', theme: GREEN }, { key: 'awaitingInspection', label: 'Awaiting Inspection', icon: <ClipboardList size={18} />, to: '/dashboard/warehouse-inventory?status=RECEIVED' },
{ key: 'emptyContainers', label: 'Empty Containers', icon: <PackageOpen size={22} />, to: '/dashboard/containers', theme: ORANGE }, { key: 'emptyContainers', label: 'Empty Containers', icon: <PackageOpen size={18} />, to: '/dashboard/containers' },
{ key: 'importTrains', label: 'Import Trains', icon: <Train size={22} />, to: '/dashboard/import-warehouse', theme: GREEN }, { key: 'importTrains', label: 'Import Trains', icon: <Train size={18} />, to: '/dashboard/import-warehouse' },
{ key: 'exportTrains', label: 'Export Trains', icon: <Train size={22} />, to: '/dashboard/export-warehouse', theme: ORANGE }, { key: 'exportTrains', label: 'Export Trains', icon: <Train size={18} />, to: '/dashboard/export-warehouse' },
{ key: 'loaded', label: 'Loaded', icon: <Truck size={22} />, to: '/dashboard/loaded-inventory', theme: GREEN }, { key: 'loaded', label: 'Loaded', icon: <Truck size={18} />, to: '/dashboard/loaded-inventory' },
{ key: 'dispatched', label: 'Dispatched', icon: <Send size={22} />, to: '/dashboard/dispatch-queue', theme: ORANGE }, { key: 'dispatched', label: 'Dispatched', icon: <Send size={18} />, to: '/dashboard/dispatch-queue' },
{ key: 'readyForPickup', label: 'Ready For Pickup', icon: <PackageSearch size={22} />, to: '/dashboard/warehouse-inventory?status=READY_FOR_PICKUP', theme: GREEN }, { key: 'readyForPickup', label: 'Ready For Pickup', icon: <PackageSearch size={18} />, to: '/dashboard/warehouse-inventory?status=READY_FOR_PICKUP' },
{ key: 'readyForLoading', label: 'Ready For Loading', icon: <PackageCheck size={22} />, to: '/dashboard/loading-queue', theme: ORANGE }, { key: 'readyForLoading', label: 'Ready For Loading', icon: <PackageCheck size={18} />, to: '/dashboard/loading-queue' },
{ key: 'delivered', label: 'Delivered', icon: <CircleCheck size={22} />, to: '/dashboard/warehouse-inventory?status=DELIVERED', theme: GREEN }, { key: 'delivered', label: 'Delivered', icon: <CircleCheck size={18} />, to: '/dashboard/warehouse-inventory?status=DELIVERED' },
]; ];
export default function WarehouseDashboardPage() { export default function WarehouseDashboardPage() {
const navigate = useNavigate(); const navigate = useNavigate();
// Both null → the API defaults `received` to "today", matching the page's original behaviour. // null → the API defaults `received` to "today", matching the page's original behaviour.
const [dateRange, setDateRange] = useState<[string | null, string | null]>([null, null]); const [receivedDate, setReceivedDate] = useState<string | null>(null);
const [warehouseId, setWarehouseId] = useState<string | null>(null); const [warehouseId, setWarehouseId] = useState<string | null>(null);
const [dateFrom, dateTo] = dateRange; const [filtersOpen, setFiltersOpen] = useState(false);
const hasCustomRange = Boolean(dateFrom || dateTo); const hasCustomDate = Boolean(receivedDate);
const warehousesQuery = useWarehouses(); const warehousesQuery = useWarehouses();
const warehouseOptions = useMemo( const warehouseOptions = useMemo(
() => (warehousesQuery.data ?? []).map((w) => ({ value: w.id, label: `${w.name} (${w.code})` })), () => (warehousesQuery.data ?? []).map((w) => ({ value: w.id, label: `${w.name} (${w.code})` })),
[warehousesQuery.data], [warehousesQuery.data],
); );
const activeFilterCount = (warehouseId ? 1 : 0) + (hasCustomDate ? 1 : 0);
const { data, isError, isLoading } = useWarehouseDashboard({ const { data, isError, isLoading } = useWarehouseDashboard({
dateFrom: dateFrom ?? undefined, // Same date both ends → the one day the picker selected, inclusive.
dateTo: dateTo ?? undefined, dateFrom: receivedDate ?? undefined,
dateTo: receivedDate ?? undefined,
warehouseId: warehouseId ?? undefined, warehouseId: warehouseId ?? undefined,
}); });
@@ -89,57 +87,64 @@ export default function WarehouseDashboardPage() {
<PageContainer> <PageContainer>
<PageHeader <PageHeader
title="Warehouse Dashboard" title="Warehouse Dashboard"
subtitle="Live overview of warehouse capacity and inventory lifecycle." subtitle="Freight import/export logistics operations overview"
action={ action={
<Group gap="sm" wrap="wrap" justify="flex-end"> <Group gap="sm" wrap="wrap" justify="flex-end">
<Select
placeholder="All warehouses"
clearable
searchable
data={warehouseOptions}
value={warehouseId}
onChange={setWarehouseId}
w={220}
/>
<DatePickerInput <DatePickerInput
type="range"
placeholder="Received: today" placeholder="Received: today"
value={dateRange} value={receivedDate}
onChange={setDateRange} onChange={setReceivedDate}
presets={getDateRangePresets()}
clearable clearable
w={230} w={180}
/> />
<Badge <Popover opened={filtersOpen} onChange={setFiltersOpen} position="bottom-end" withArrow shadow="md">
color="edr-green" <Popover.Target>
variant="light" <Button
size="lg" variant="default"
leftSection={ leftSection={<Filter size={16} />}
<span rightSection={activeFilterCount > 0 ? <Text size="xs" fw={700} c="edr-green">{activeFilterCount}</Text> : null}
style={{ onClick={() => setFiltersOpen((o) => !o)}
display: 'inline-block', >
width: 8, Filters
height: 8, </Button>
borderRadius: '50%', </Popover.Target>
background: 'var(--mantine-color-edr-green-6)', <Popover.Dropdown>
}} <Stack gap="sm" w={240}>
/> <Select
} label="Warehouse"
> placeholder="All warehouses"
Live · updates every 60s clearable
</Badge> searchable
data={warehouseOptions}
value={warehouseId}
onChange={setWarehouseId}
/>
{activeFilterCount > 0 && (
<Button
variant="subtle"
color="gray"
size="xs"
onClick={() => {
setWarehouseId(null);
setReceivedDate(null);
}}
>
Clear filters
</Button>
)}
</Stack>
</Popover.Dropdown>
</Popover>
</Group> </Group>
} }
/> />
{(warehouseId || hasCustomRange) && ( {(warehouseId || hasCustomDate) && (
<Text size="xs" c="dimmed" mt={-8}> <Text size="xs" c="dimmed" mt={-8}>
Scoped to{' '} Scoped to{' '}
{warehouseId ? warehouseOptions.find((o) => o.value === warehouseId)?.label ?? 'selected warehouse' : 'all warehouses'} {warehouseId ? warehouseOptions.find((o) => o.value === warehouseId)?.label ?? 'selected warehouse' : 'all warehouses'}
{hasCustomRange {hasCustomDate ? ` · Received counts for ${receivedDate}` : ' · Received counts: today'}
? ` · Received counts ${dateFrom ?? '…'} to ${dateTo ?? '…'}` . Status-backlog and fleet counters are always current regardless of the date filter.
: ' · Received counts: today'}
. Status-backlog and fleet counters are always current regardless of the date range.
</Text> </Text>
)} )}
@@ -152,41 +157,33 @@ export default function WarehouseDashboardPage() {
<Text c="red">Failed to load warehouse dashboard.</Text> <Text c="red">Failed to load warehouse dashboard.</Text>
</Center> </Center>
) : ( ) : (
<Stack gap="xl"> <Stack gap="lg">
{/* Needs attention — live ops counters (received today, pending {/* Needs attention — live ops counters (received today, pending
inspection, trucks on-site, items aging > 7 days). */} inspection, trucks on-site, items aging > 7 days). */}
<Stack gap="sm"> <WarehouseOpsKpiStrip />
<SectionTitle>Needs attention</SectionTitle>
<WarehouseOpsKpiStrip />
</Stack>
<Divider />
<SimpleGrid cols={{ base: 1, xs: 2, md: 4 }} spacing="md"> <SimpleGrid cols={{ base: 1, xs: 2, md: 4 }} spacing="md">
{METRICS.map((metric) => ( {METRICS.map((metric) => (
<Card <Card
key={metric.key} key={metric.key}
padding="lg" padding="md"
withBorder
radius="md"
onClick={() => navigate(metric.to)} onClick={() => navigate(metric.to)}
className="cursor-pointer transition-[transform,border-color] duration-150 hover:-translate-y-0.5 hover:border-edr-primary!" className="cursor-pointer transition-[transform,border-color] duration-150 hover:-translate-y-0.5 hover:border-edr-primary!"
> >
<Group justify="space-between" align="flex-start" wrap="nowrap"> <Group gap="sm" wrap="nowrap">
<div> <ThemeIcon color="edr-green" variant="light" size={40} radius="md">
<Text size="xs" c="edr-muted" tt="uppercase" fw={700} style={{ letterSpacing: 0.4 }}>
{metric.key === 'received' && hasCustomRange ? 'Received' : metric.label}
</Text>
<Text fw={800} fz={32} mt={8} c="edr-text" lh={1.1}>
{data ? data[metric.key] : 0}
</Text>
</div>
<ThemeIcon
variant="light"
size={46}
radius="md"
style={{ backgroundColor: `${metric.theme}1a`, color: metric.theme }}
>
{metric.icon} {metric.icon}
</ThemeIcon> </ThemeIcon>
<Stack gap={0} style={{ minWidth: 0 }}>
<Text size="xs" c="edr-muted" fw={600}>
{metric.key === 'received' && hasCustomDate ? 'Received' : metric.label}
</Text>
<Text fw={700} fz={20} c="edr-text" lh={1.2}>
{data ? data[metric.key] : 0}
</Text>
</Stack>
</Group> </Group>
</Card> </Card>
))} ))}

View File

@@ -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. */ /** Finance raises a new charge — 'draft' just saves it, 'send' also issues the invoice and notifies the customer. */
createAdditionalCharge: async ( createAdditionalCharge: async (
id: string, 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[]> => { ): Promise<Freight.AdditionalCharge[]> => {
const form = new FormData(); const form = new FormData();
form.append("reason", payload.reason); form.append("reason", payload.reason);
form.append("amount", String(payload.amount)); form.append("amount", String(payload.amount));
form.append("currency", payload.currency); form.append("currency", payload.currency);
form.append("action", payload.action); form.append("action", payload.action);
if (payload.dueDate) form.append("dueDate", payload.dueDate);
if (payload.file) form.append("file", payload.file); if (payload.file) form.append("file", payload.file);
const response = await client.post(`/bookings/${id}/additional-charges`, form, { const response = await client.post(`/bookings/${id}/additional-charges`, form, {
headers: { "Content-Type": "multipart/form-data" }, headers: { "Content-Type": "multipart/form-data" },

View File

@@ -128,6 +128,8 @@ export const URL_CONSTANTS = {
CONTRACT_DOCUMENT: (id: string) => `/api/bookings/${id}/contract/document`, CONTRACT_DOCUMENT: (id: string) => `/api/bookings/${id}/contract/document`,
CONTRACT_SIGN: (id: string) => `/api/bookings/${id}/contract/sign`, CONTRACT_SIGN: (id: string) => `/api/bookings/${id}/contract/sign`,
CONTRACT_DOWNLOAD: (id: string) => `/api/bookings/${id}/contract`, 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`, CANCEL: (id: string | number) => `/api/bookings/${id}/cancel`,
CONFIRM: (id: string | number) => `/api/bookings/${id}/confirm`, CONFIRM: (id: string | number) => `/api/bookings/${id}/confirm`,
CUSTOMER_TRUCKS: (id: string) => `/api/bookings/${id}/customer-trucks`, CUSTOMER_TRUCKS: (id: string) => `/api/bookings/${id}/customer-trucks`,

View File

@@ -59,8 +59,16 @@ function ChargeRow({ charge }: { charge: Freight.AdditionalCharge }) {
<Text fz="12px" c="#9AA8B5" mt={2}> <Text fz="12px" c="#9AA8B5" mt={2}>
{charge.amount.toLocaleString(undefined, { minimumFractionDigits: 2 })}{" "} {charge.amount.toLocaleString(undefined, { minimumFractionDigits: 2 })}{" "}
{charge.currency} {charge.currency}
{charge.convertedAmount != null
? ` (≈ ${charge.convertedAmount.toLocaleString(undefined, { minimumFractionDigits: 2 })} ${charge.convertedCurrency})`
: ""}
{charge.paymentReference ? ` · ref ${charge.paymentReference}` : ""} {charge.paymentReference ? ` · ref ${charge.paymentReference}` : ""}
</Text> </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> </Box>
<Badge <Badge
radius="sm" radius="sm"

View File

@@ -28,6 +28,7 @@ import { ClearanceUploadedDocumentsPanel } from "@/components/contracts/Clearanc
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { bookingsService } from "@/services/bookings.service"; import { bookingsService } from "@/services/bookings.service";
import type { EmptyContainerReturn } from "@/services/bookings.service";
import { saveBlob } from "@/utils/download"; import { saveBlob } from "@/utils/download";
import { IconSquare } from "./Documents"; import { IconSquare } from "./Documents";
import { CardTitle, SectionCard } from "./layout"; import { CardTitle, SectionCard } from "./layout";
@@ -251,6 +252,24 @@ export function DocumentsTab({ booking }: { booking: Freight.IBooking }) {
queryFn: () => warehouseService.bookingHandovers(booking.id), 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( const customerDocs = useMemo(
() => (clearance?.documents ?? []).filter((d) => d.uploadedBy === "customer"), () => (clearance?.documents ?? []).filter((d) => d.uploadedBy === "customer"),
[clearance], [clearance],
@@ -303,6 +322,14 @@ export function DocumentsTab({ booking }: { booking: Freight.IBooking }) {
fn: () => bookingsService.downloadBookingHandoverDocument(booking.id), 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; let saved = 0;
for (const job of jobs) { for (const job of jobs) {
try { try {
@@ -584,12 +611,56 @@ export function DocumentsTab({ booking }: { booking: Freight.IBooking }) {
</SectionCard> </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) ──────────────────────── */} {/* ── Warehouse documents (one-click bundle) ──────────────────────── */}
<SectionCard> <SectionCard>
<CardTitle>Warehouse documents</CardTitle> <CardTitle>Warehouse documents</CardTitle>
<Text fz="12.5px" c="dimmed" mt={4} mb="sm"> <Text fz="12.5px" c="dimmed" mt={4} mb="sm">
Goods Received Note, gate clearance / release order and handover download all Goods Received Note, gate clearance / release order, handover, and for export
available documents for this booking in one click. bookings the carriage acceptance sheet: download all available documents for
this booking in one click.
</Text> </Text>
<Button <Button
leftSection={<Download size={16} />} leftSection={<Download size={16} />}

View File

@@ -7,6 +7,19 @@ import { client } from "../utils/api";
const B = URL_CONSTANTS.BOOKINGS; 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 { export interface MileVehicleSummary {
plate: string | null; plate: string | null;
code: string | null; code: string | null;
@@ -383,6 +396,19 @@ export const bookingsService = {
); );
return data.data ?? data; 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> => { downloadBookingGrnDocument: async (bookingId: string): Promise<Blob> => {
const { data } = await client.get( const { data } = await client.get(
`/api/warehouse-inventory/bookings/${bookingId}/grn-document`, `/api/warehouse-inventory/bookings/${bookingId}/grn-document`,
@@ -597,6 +623,13 @@ export const bookingsService = {
return data; 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 }> => { checkPayment: async (orderId: string): Promise<{ status: string }> => {
const { data } = await client.post(`/api/payments/bookings/check-payment/${orderId}`); const { data } = await client.post(`/api/payments/bookings/check-payment/${orderId}`);
return data.data ?? data; return data.data ?? data;

View File

@@ -909,6 +909,11 @@ export interface AdditionalCharge {
status: AdditionalChargeStatus; status: AdditionalChargeStatus;
amount: number; amount: number;
currency: string; 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; file: { id: string; name: string; url: string } | null;
invoiceId: string | null; invoiceId: string | null;
invoiceNumber: string | null; invoiceNumber: string | null;