diff --git a/EDR-Freight-User-Guide.pdf b/EDR-Freight-User-Guide.pdf new file mode 100644 index 000000000..66512875f Binary files /dev/null and b/EDR-Freight-User-Guide.pdf differ diff --git a/apps/edr-freight-api/src/migrations/3650000000000-AdditionalChargeDueAt.ts b/apps/edr-freight-api/src/migrations/3650000000000-AdditionalChargeDueAt.ts new file mode 100644 index 000000000..8dcda8181 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3650000000000-AdditionalChargeDueAt.ts @@ -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 { + await queryRunner.query(` + ALTER TABLE "freight"."additional_charge" + ADD COLUMN IF NOT EXISTS "due_at" timestamptz + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "freight"."additional_charge" DROP COLUMN IF EXISTS "due_at" + `); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/additional-charge.service.ts b/apps/edr-freight-api/src/modules/bookings/additional-charge.service.ts index bb5836d1a..012d5f8db 100644 --- a/apps/edr-freight-api/src/modules/bookings/additional-charge.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/additional-charge.service.ts @@ -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; + } + } } diff --git a/apps/edr-freight-api/src/modules/bookings/dto/additional-charge.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/additional-charge.dto.ts index eb4d095bf..baf135876 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/additional-charge.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/additional-charge.dto.ts @@ -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 { diff --git a/apps/edr-freight-api/src/modules/bookings/entities/additional-charge.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/additional-charge.entity.ts index 11ea0bdb1..ba29f0118 100644 --- a/apps/edr-freight-api/src/modules/bookings/entities/additional-charge.entity.ts +++ b/apps/edr-freight-api/src/modules/bookings/entities/additional-charge.entity.ts @@ -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; diff --git a/apps/edr-freight-api/src/modules/import-operations/import-operations.controller.ts b/apps/edr-freight-api/src/modules/import-operations/import-operations.controller.ts index c631bef92..ae80ea9c8 100644 --- a/apps/edr-freight-api/src/modules/import-operations/import-operations.controller.ts +++ b/apps/edr-freight-api/src/modules/import-operations/import-operations.controller.ts @@ -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 { + 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); + } } diff --git a/apps/edr-freight-api/src/modules/import-operations/import-operations.module.ts b/apps/edr-freight-api/src/modules/import-operations/import-operations.module.ts index fb4c6e896..21e0dd9a0 100644 --- a/apps/edr-freight-api/src/modules/import-operations/import-operations.module.ts +++ b/apps/edr-freight-api/src/modules/import-operations/import-operations.module.ts @@ -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], diff --git a/apps/edr-freight-api/src/modules/import-operations/import-operations.service.ts b/apps/edr-freight-api/src/modules/import-operations/import-operations.service.ts index 28eb4e44f..ccec40139 100644 --- a/apps/edr-freight-api/src/modules/import-operations/import-operations.service.ts +++ b/apps/edr-freight-api/src/modules/import-operations/import-operations.service.ts @@ -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, @InjectRepository(EmptyContainerReturn) private readonly emptyReturns: Repository, + 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 { + 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, '''); + 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]) => + `${esc(label)}${esc(value)}`, + ) + .join(''); + + return ` + + + + Equipment Interchange Receipt + + + +
+
+ ${logoMarkup(opts.logoImageUrl)} +
Ethio-Djibouti Railway S.C.
+

Equipment Interchange Receipt

+
+
+ Receipt No. + ${esc(`EIR-${row.id.slice(0, 8).toUpperCase()}`)} + Generated: ${esc(new Date().toLocaleString('en-GB'))} +
+
+ + + + ${rowsHtml} + +
+ +
+ 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. +
+ +
+
Depot officer name / signature / date
+
Customer or driver name / signature / date
+
+ +`; + } + private async getOrCreateCustoms(bookingId: string) { const existing = await this.customs.findOne({ where: { bookingId } }); if (existing) return existing; diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/AdditionalPaymentsTab.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/AdditionalPaymentsTab.tsx index 38fda81e1..e6eb81a02 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/AdditionalPaymentsTab.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/AdditionalPaymentsTab.tsx @@ -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}` : ""} )} + {charge.dueAt && charge.status !== "PAID" && charge.status !== "CANCELLED" && ( + + Due {formatDate(charge.dueAt)} + + )} - - - {charge.amount.toLocaleString(undefined, { minimumFractionDigits: 2 })}{" "} - {charge.currency} - - - {meta.label} - + + + + {charge.amount.toLocaleString(undefined, { minimumFractionDigits: 2 })}{" "} + {charge.currency} + + + {meta.label} + + + {charge.convertedAmount != null && ( + + ≈ {charge.convertedAmount.toLocaleString(undefined, { minimumFractionDigits: 2 })}{" "} + {charge.convertedCurrency} + + )} @@ -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(""); const [currency, setCurrency] = useState("ETB"); const [file, setFile] = useState(null); + const [dueDate, setDueDate] = useState(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} /> + setDueDate(v ? new Date(v) : null)} + minDate={new Date()} + clearable + /> {(props) => ( + )} + + + } /> - {(warehouseId || hasCustomRange) && ( + {(warehouseId || hasCustomDate) && ( 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. )} @@ -152,41 +157,33 @@ export default function WarehouseDashboardPage() { Failed to load warehouse dashboard. ) : ( - + {/* Needs attention — live ops counters (received today, pending inspection, trucks on-site, items aging > 7 days). */} - - Needs attention - - - - + {METRICS.map((metric) => ( navigate(metric.to)} className="cursor-pointer transition-[transform,border-color] duration-150 hover:-translate-y-0.5 hover:border-edr-primary!" > - -
- - {metric.key === 'received' && hasCustomRange ? 'Received' : metric.label} - - - {data ? data[metric.key] : 0} - -
- + + {metric.icon} + + + {metric.key === 'received' && hasCustomDate ? 'Received' : metric.label} + + + {data ? data[metric.key] : 0} + +
))} diff --git a/apps/edr-freight-web/backoffice/src/services/bookings.service.ts b/apps/edr-freight-web/backoffice/src/services/bookings.service.ts index 1a52d21c4..883c9e76e 100644 --- a/apps/edr-freight-web/backoffice/src/services/bookings.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/bookings.service.ts @@ -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 => { 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" }, diff --git a/apps/edr-freight-web/portal/src/constants/URLS.ts b/apps/edr-freight-web/portal/src/constants/URLS.ts index cefef5ddc..1b0a32c28 100644 --- a/apps/edr-freight-web/portal/src/constants/URLS.ts +++ b/apps/edr-freight-web/portal/src/constants/URLS.ts @@ -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`, diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/AdditionalChargesPanel.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/AdditionalChargesPanel.tsx index 399285ca0..d49a8883d 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/AdditionalChargesPanel.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/AdditionalChargesPanel.tsx @@ -59,8 +59,16 @@ function ChargeRow({ charge }: { charge: Freight.AdditionalCharge }) { {charge.amount.toLocaleString(undefined, { minimumFractionDigits: 2 })}{" "} {charge.currency} + {charge.convertedAmount != null + ? ` (≈ ${charge.convertedAmount.toLocaleString(undefined, { minimumFractionDigits: 2 })} ${charge.convertedCurrency})` + : ""} {charge.paymentReference ? ` · ref ${charge.paymentReference}` : ""} + {charge.dueAt && charge.status === "SENT" && ( + + Due {new Date(charge.dueAt).toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" })} + + )} warehouseService.bookingHandovers(booking.id), }); + const { data: emptyReturns = [] } = useQuery({ + queryKey: ["emptyContainerReturns", booking.id], + queryFn: () => + bookingsService.listEmptyContainerReturns(booking.id).catch(() => []), + }); + const [downloadingReturnId, setDownloadingReturnId] = useState(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 }) { )} + {/* ── 4b. Equipment interchange receipts (empty container returns) ─── */} + {emptyReturns.length > 0 && ( + + Equipment interchange receipts + + Container number, size, return time, depot, and condition for each empty + container returned on this booking. + + + {emptyReturns.map((ret, i) => ( + + + + + {ret.containerNumber} + {ret.containerSize ? ` · ${ret.containerSize}ft` : ""} + + + {ret.returnDate ? new Date(ret.returnDate).toLocaleString() : "—"} + {ret.facility ? ` · ${ret.facility}` : ""} + {ret.condition ? ` · ${ret.condition}` : ""} + + + } + onClick={ + downloadingReturnId === ret.id ? undefined : () => void downloadEir(ret) + } + /> + + + ))} + + + )} + {/* ── Warehouse documents (one-click bundle) ──────────────────────── */} Warehouse documents - 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.