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 { 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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],
|
||||||
|
|||||||
@@ -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, '&')
|
||||||
|
.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) {
|
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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -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" },
|
||||||
|
|||||||
@@ -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`,
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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} />}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user