mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge pull request #1378 from Tria-plc/eims-bulk-register
Eims bulk register
This commit is contained in:
BIN
EDR-Freight-User-Guide.pdf
Normal file
BIN
EDR-Freight-User-Guide.pdf
Normal file
Binary file not shown.
@@ -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
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
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 {
|
||||
ClipboardList,
|
||||
Filter,
|
||||
PackageCheck,
|
||||
PackageOpen,
|
||||
PackagePlus,
|
||||
@@ -17,7 +18,6 @@ import {
|
||||
} from 'lucide-react';
|
||||
|
||||
import { PageContainer, PageHeader } from '@/components/page';
|
||||
import { getDateRangePresets } from '@/components/common/dateRangePresets';
|
||||
import {
|
||||
AccrualDashboard,
|
||||
CycleTimeCard,
|
||||
@@ -44,44 +44,42 @@ interface Metric {
|
||||
icon: React.ReactNode;
|
||||
/** Route to navigate to when the card is clicked. */
|
||||
to: string;
|
||||
theme: string;
|
||||
}
|
||||
|
||||
const ORANGE = 'rgb(241, 147, 23)';
|
||||
const GREEN = '#084b21';
|
||||
|
||||
const METRICS: Metric[] = [
|
||||
{ key: 'totalWarehouses', label: 'Total Warehouses', icon: <WarehouseIcon size={22} />, to: '/dashboard/warehouses', theme: ORANGE },
|
||||
{ key: 'totalInventory', label: 'Total Inventory', icon: <Boxes size={22} />, to: '/dashboard/warehouse-inventory', theme: GREEN },
|
||||
{ key: 'received', label: 'Received Today', icon: <PackagePlus size={22} />, to: '/dashboard/warehouse-inventory?status=RECEIVED', theme: ORANGE },
|
||||
{ key: 'awaitingInspection', label: 'Awaiting Inspection', icon: <ClipboardList size={22} />, to: '/dashboard/warehouse-inventory?status=RECEIVED', theme: GREEN },
|
||||
{ key: 'emptyContainers', label: 'Empty Containers', icon: <PackageOpen size={22} />, to: '/dashboard/containers', theme: ORANGE },
|
||||
{ key: 'importTrains', label: 'Import Trains', icon: <Train size={22} />, to: '/dashboard/import-warehouse', theme: GREEN },
|
||||
{ key: 'exportTrains', label: 'Export Trains', icon: <Train size={22} />, to: '/dashboard/export-warehouse', theme: ORANGE },
|
||||
{ key: 'loaded', label: 'Loaded', icon: <Truck size={22} />, to: '/dashboard/loaded-inventory', theme: GREEN },
|
||||
{ key: 'dispatched', label: 'Dispatched', icon: <Send size={22} />, to: '/dashboard/dispatch-queue', theme: ORANGE },
|
||||
{ key: 'readyForPickup', label: 'Ready For Pickup', icon: <PackageSearch size={22} />, to: '/dashboard/warehouse-inventory?status=READY_FOR_PICKUP', theme: GREEN },
|
||||
{ key: 'readyForLoading', label: 'Ready For Loading', icon: <PackageCheck size={22} />, to: '/dashboard/loading-queue', theme: ORANGE },
|
||||
{ key: 'delivered', label: 'Delivered', icon: <CircleCheck size={22} />, to: '/dashboard/warehouse-inventory?status=DELIVERED', theme: GREEN },
|
||||
{ key: 'totalWarehouses', label: 'Total Warehouses', icon: <WarehouseIcon size={18} />, to: '/dashboard/warehouses' },
|
||||
{ key: 'totalInventory', label: 'Total Inventory', icon: <Boxes size={18} />, to: '/dashboard/warehouse-inventory' },
|
||||
{ key: 'received', label: 'Received Today', icon: <PackagePlus size={18} />, to: '/dashboard/warehouse-inventory?status=RECEIVED' },
|
||||
{ key: 'awaitingInspection', label: 'Awaiting Inspection', icon: <ClipboardList size={18} />, to: '/dashboard/warehouse-inventory?status=RECEIVED' },
|
||||
{ key: 'emptyContainers', label: 'Empty Containers', icon: <PackageOpen size={18} />, to: '/dashboard/containers' },
|
||||
{ key: 'importTrains', label: 'Import Trains', icon: <Train size={18} />, to: '/dashboard/import-warehouse' },
|
||||
{ key: 'exportTrains', label: 'Export Trains', icon: <Train size={18} />, to: '/dashboard/export-warehouse' },
|
||||
{ key: 'loaded', label: 'Loaded', icon: <Truck size={18} />, to: '/dashboard/loaded-inventory' },
|
||||
{ key: 'dispatched', label: 'Dispatched', icon: <Send size={18} />, to: '/dashboard/dispatch-queue' },
|
||||
{ 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={18} />, to: '/dashboard/loading-queue' },
|
||||
{ key: 'delivered', label: 'Delivered', icon: <CircleCheck size={18} />, to: '/dashboard/warehouse-inventory?status=DELIVERED' },
|
||||
];
|
||||
|
||||
export default function WarehouseDashboardPage() {
|
||||
const navigate = useNavigate();
|
||||
// Both null → the API defaults `received` to "today", matching the page's original behaviour.
|
||||
const [dateRange, setDateRange] = useState<[string | null, string | null]>([null, null]);
|
||||
// null → the API defaults `received` to "today", matching the page's original behaviour.
|
||||
const [receivedDate, setReceivedDate] = useState<string | null>(null);
|
||||
const [warehouseId, setWarehouseId] = useState<string | null>(null);
|
||||
const [dateFrom, dateTo] = dateRange;
|
||||
const hasCustomRange = Boolean(dateFrom || dateTo);
|
||||
const [filtersOpen, setFiltersOpen] = useState(false);
|
||||
const hasCustomDate = Boolean(receivedDate);
|
||||
|
||||
const warehousesQuery = useWarehouses();
|
||||
const warehouseOptions = useMemo(
|
||||
() => (warehousesQuery.data ?? []).map((w) => ({ value: w.id, label: `${w.name} (${w.code})` })),
|
||||
[warehousesQuery.data],
|
||||
);
|
||||
const activeFilterCount = (warehouseId ? 1 : 0) + (hasCustomDate ? 1 : 0);
|
||||
|
||||
const { data, isError, isLoading } = useWarehouseDashboard({
|
||||
dateFrom: dateFrom ?? undefined,
|
||||
dateTo: dateTo ?? undefined,
|
||||
// Same date both ends → the one day the picker selected, inclusive.
|
||||
dateFrom: receivedDate ?? undefined,
|
||||
dateTo: receivedDate ?? undefined,
|
||||
warehouseId: warehouseId ?? undefined,
|
||||
});
|
||||
|
||||
@@ -89,57 +87,64 @@ export default function WarehouseDashboardPage() {
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
title="Warehouse Dashboard"
|
||||
subtitle="Live overview of warehouse capacity and inventory lifecycle."
|
||||
subtitle="Freight import/export logistics operations overview"
|
||||
action={
|
||||
<Group gap="sm" wrap="wrap" justify="flex-end">
|
||||
<Select
|
||||
placeholder="All warehouses"
|
||||
clearable
|
||||
searchable
|
||||
data={warehouseOptions}
|
||||
value={warehouseId}
|
||||
onChange={setWarehouseId}
|
||||
w={220}
|
||||
/>
|
||||
<DatePickerInput
|
||||
type="range"
|
||||
placeholder="Received: today"
|
||||
value={dateRange}
|
||||
onChange={setDateRange}
|
||||
presets={getDateRangePresets()}
|
||||
value={receivedDate}
|
||||
onChange={setReceivedDate}
|
||||
clearable
|
||||
w={230}
|
||||
w={180}
|
||||
/>
|
||||
<Badge
|
||||
color="edr-green"
|
||||
variant="light"
|
||||
size="lg"
|
||||
leftSection={
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: '50%',
|
||||
background: 'var(--mantine-color-edr-green-6)',
|
||||
}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
Live · updates every 60s
|
||||
</Badge>
|
||||
<Popover opened={filtersOpen} onChange={setFiltersOpen} position="bottom-end" withArrow shadow="md">
|
||||
<Popover.Target>
|
||||
<Button
|
||||
variant="default"
|
||||
leftSection={<Filter size={16} />}
|
||||
rightSection={activeFilterCount > 0 ? <Text size="xs" fw={700} c="edr-green">{activeFilterCount}</Text> : null}
|
||||
onClick={() => setFiltersOpen((o) => !o)}
|
||||
>
|
||||
Filters
|
||||
</Button>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<Stack gap="sm" w={240}>
|
||||
<Select
|
||||
label="Warehouse"
|
||||
placeholder="All warehouses"
|
||||
clearable
|
||||
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>
|
||||
}
|
||||
/>
|
||||
|
||||
{(warehouseId || hasCustomRange) && (
|
||||
{(warehouseId || hasCustomDate) && (
|
||||
<Text size="xs" c="dimmed" mt={-8}>
|
||||
Scoped to{' '}
|
||||
{warehouseId ? warehouseOptions.find((o) => o.value === warehouseId)?.label ?? 'selected warehouse' : 'all warehouses'}
|
||||
{hasCustomRange
|
||||
? ` · Received counts ${dateFrom ?? '…'} to ${dateTo ?? '…'}`
|
||||
: ' · Received counts: today'}
|
||||
. Status-backlog and fleet counters are always current regardless of the date range.
|
||||
{hasCustomDate ? ` · Received counts for ${receivedDate}` : ' · Received counts: today'}
|
||||
. Status-backlog and fleet counters are always current regardless of the date filter.
|
||||
</Text>
|
||||
)}
|
||||
|
||||
@@ -152,41 +157,33 @@ export default function WarehouseDashboardPage() {
|
||||
<Text c="red">Failed to load warehouse dashboard.</Text>
|
||||
</Center>
|
||||
) : (
|
||||
<Stack gap="xl">
|
||||
<Stack gap="lg">
|
||||
{/* Needs attention — live ops counters (received today, pending
|
||||
inspection, trucks on-site, items aging > 7 days). */}
|
||||
<Stack gap="sm">
|
||||
<SectionTitle>Needs attention</SectionTitle>
|
||||
<WarehouseOpsKpiStrip />
|
||||
</Stack>
|
||||
|
||||
<Divider />
|
||||
<WarehouseOpsKpiStrip />
|
||||
|
||||
<SimpleGrid cols={{ base: 1, xs: 2, md: 4 }} spacing="md">
|
||||
{METRICS.map((metric) => (
|
||||
<Card
|
||||
key={metric.key}
|
||||
padding="lg"
|
||||
padding="md"
|
||||
withBorder
|
||||
radius="md"
|
||||
onClick={() => navigate(metric.to)}
|
||||
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">
|
||||
<div>
|
||||
<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 }}
|
||||
>
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<ThemeIcon color="edr-green" variant="light" size={40} radius="md">
|
||||
{metric.icon}
|
||||
</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>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user