Merge pull request #1369 from Tria-plc/freight_feature/usermanagement

Freight feature/usermanagement
This commit is contained in:
marshal
2026-08-20 21:11:10 +03:00
committed by GitHub
69 changed files with 4958 additions and 1555 deletions

View File

@@ -67,6 +67,7 @@ const TRIGGER_ROUTE_LABELS: Partial<Record<Rate['trigger'], string>> = {
DEMURRAGE: 'Demurrage / wagon detention',
PIL_EXTRA_FEE: 'PIL shipping line extra fee',
CUSTOMS_CLEARANCE: 'Customs clearance service',
ETHIOPIAN_CUSTOMS_CLEARANCE: 'Ethiopian customs clearance service',
FUEL: 'Fuel surcharge',
};

View File

@@ -0,0 +1,29 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Per-schedule wagon yard plan — where THIS departure expects each consist
* wagon to board, independent of where the wagon physically stands today.
*
* `wagons.current_yard_id` is one physical fact shared by every schedule of a
* built train, so a train standing in Mojo could not be sold from Dire for a
* departure next week. The plan is a sparse jsonb map `{ wagonId: yardId }`
* on the schedule: a wagon missing from the map boards from its physical yard.
* Booking capacity, fleet availability and wagon pinning all read the plan;
* dispatch refuses to leave until the plan and the physical yards agree.
*/
export class SchedulePlannedWagonYards3620000000000 implements MigrationInterface {
name = 'SchedulePlannedWagonYards3620000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.train_schedules
ADD COLUMN IF NOT EXISTS planned_wagon_yards jsonb
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.train_schedules DROP COLUMN IF EXISTS planned_wagon_yards
`);
}
}

View File

@@ -0,0 +1,49 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* The customer now approves a clearance charge before it becomes an invoice:
* GL describes the price, SENDs it, the customer ACCEPTs (invoice issued, charge
* locked) or REJECTs with a note (GL revises and re-sends). Charges that were
* already sent as invoices under the old flow are carried over as ACCEPTED so
* their invoices stay payable.
*/
export class ClearanceChargeCustomerDecision3630000000000
implements MigrationInterface
{
name = 'ClearanceChargeCustomerDecision3630000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE "freight"."booking_clearance_charge"
ADD COLUMN IF NOT EXISTS "description" text,
ADD COLUMN IF NOT EXISTS "customer_note" text,
ADD COLUMN IF NOT EXISTS "customer_decided_at" timestamptz,
ADD COLUMN IF NOT EXISTS "customer_decided_by" uuid
`);
await queryRunner.query(`
UPDATE "freight"."booking_clearance_charge"
SET "status" = 'ACCEPTED'
WHERE "status" = 'SENT' AND "invoice_id" IS NOT NULL
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
UPDATE "freight"."booking_clearance_charge"
SET "status" = 'SENT'
WHERE "status" = 'ACCEPTED'
`);
await queryRunner.query(`
UPDATE "freight"."booking_clearance_charge"
SET "status" = 'BILLED'
WHERE "status" = 'REJECTED'
`);
await queryRunner.query(`
ALTER TABLE "freight"."booking_clearance_charge"
DROP COLUMN IF EXISTS "description",
DROP COLUMN IF EXISTS "customer_note",
DROP COLUMN IF EXISTS "customer_decided_at",
DROP COLUMN IF EXISTS "customer_decided_by"
`);
}
}

View File

@@ -0,0 +1,69 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Ethiopian-side-only customs clearance:
*
* - service_types.includes_ethiopian_customs_only marks a customs service that
* EDR clears on the Ethiopian side only. Same clearance flow; only the fee
* differs — pricing looks up the ETHIOPIAN_CUSTOMS_CLEARANCE rate instead of
* CUSTOMS_CLEARANCE.
* - rates.trigger widens to 30 chars to fit the new trigger value.
* - CK_rates_yard_scope gains ETHIOPIAN_CUSTOMS_CLEARANCE in its yard-carrying
* branch: it is priced per origin → destination leg like customs clearance.
*/
export class EthiopianCustomsClearance3640000000000 implements MigrationInterface {
name = 'EthiopianCustomsClearance3640000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.service_types
ADD COLUMN IF NOT EXISTS includes_ethiopian_customs_only boolean NOT NULL DEFAULT false
`);
await queryRunner.query(
`ALTER TABLE freight.rates ALTER COLUMN trigger TYPE varchar(30)`,
);
await queryRunner.query(
`ALTER TABLE freight.rates DROP CONSTRAINT IF EXISTS "CK_rates_yard_scope"`,
);
await queryRunner.query(`
ALTER TABLE freight.rates ADD CONSTRAINT "CK_rates_yard_scope" CHECK (
deleted_at IS NOT NULL OR status = 'SUPERSEDED' OR
CASE
WHEN (trigger = 'ALWAYS' AND applies_to IN ('BULK', 'CONTAINER', 'INTERCITY'))
OR trigger IN ('CUSTOMS_CLEARANCE', 'ETHIOPIAN_CUSTOMS_CLEARANCE', 'WITH_RETURN', 'FUEL')
THEN origin_yard_id IS NOT NULL AND destination_yard_id IS NOT NULL
ELSE origin_yard_id IS NULL AND destination_yard_id IS NULL
END
)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.rates DROP CONSTRAINT IF EXISTS "CK_rates_yard_scope"`,
);
await queryRunner.query(`
ALTER TABLE freight.rates ADD CONSTRAINT "CK_rates_yard_scope" CHECK (
deleted_at IS NOT NULL OR status = 'SUPERSEDED' OR
CASE
WHEN (trigger = 'ALWAYS' AND applies_to IN ('BULK', 'CONTAINER', 'INTERCITY'))
OR trigger IN ('CUSTOMS_CLEARANCE', 'WITH_RETURN', 'FUEL')
THEN origin_yard_id IS NOT NULL AND destination_yard_id IS NOT NULL
ELSE origin_yard_id IS NULL AND destination_yard_id IS NULL
END
)
`);
// Rows on the new trigger would not fit varchar(20) — drop them first.
await queryRunner.query(
`DELETE FROM freight.rates WHERE trigger = 'ETHIOPIAN_CUSTOMS_CLEARANCE'`,
);
await queryRunner.query(
`ALTER TABLE freight.rates ALTER COLUMN trigger TYPE varchar(20)`,
);
await queryRunner.query(
`ALTER TABLE freight.service_types DROP COLUMN IF EXISTS includes_ethiopian_customs_only`,
);
}
}

View File

@@ -14,9 +14,11 @@ import { Invoice } from '../billing/entities/invoice.entity';
import { FilesService } from '../files/files.service';
import { BookingsService } from './bookings.service';
import { BookingsRepository } from './bookings.repository';
import { BookingLifecycleNotifierService } from './booking-lifecycle-notifier.service';
import { Booking } from './entities/booking.entity';
import {
BookingClearanceCharge,
ClearanceChargeStatus,
ClearanceChargeType,
} from './entities/booking-clearance-charge.entity';
import { ClearanceEventService } from './clearance-event.service';
@@ -32,13 +34,22 @@ const CHARGE_LABEL: Record<ClearanceChargeType, string> = {
MISCELLANEOUS: 'Miscellaneous charges',
};
/** Statuses the customer sees — drafts (DOC_UPLOADED / BILLED) stay GL-internal. */
export const CUSTOMER_VISIBLE_CHARGE_STATUSES: ReadonlySet<ClearanceChargeStatus> =
new Set(['SENT', 'REJECTED', 'ACCEPTED', 'PAID']);
/** Once the customer has accepted (invoice issued) or paid, GL cannot touch the charge. */
export const canStaffEditCharge = (status: ClearanceChargeStatus): boolean =>
status !== 'ACCEPTED' && status !== 'PAID';
/**
* Post-finalization clearance charges billed to the customer. Two levels per
* booking: GL Djibouti uploads the port-charges document; GL Ethiopia bills it
* (amount + currency) and sends the invoice; once that invoice is paid GL
* Ethiopia may create and send the miscellaneous charge. ETB invoices are paid
* through the portal gateway, other currencies through Finance's manual
* settlement worklist — both settle via `clearance_charge.invoice.paid`.
* Post-finalization clearance charges billed to the customer: one port charge
* (document from GL Djibouti, priced by GL Ethiopia) and any number of
* miscellaneous charges. GL prices + describes a charge and SENDs it; the
* customer REJECTs with a note (GL revises, re-sends) or ACCEPTs, which issues
* the payable invoice and locks the charge. ETB invoices are paid through the
* portal gateway, other currencies through Finance's manual settlement
* worklist — both settle via `clearance_charge.invoice.paid`.
*/
@Injectable()
export class BookingClearanceChargeService {
@@ -51,6 +62,7 @@ export class BookingClearanceChargeService {
private readonly bookingsService: BookingsService,
private readonly bookingsRepository: BookingsRepository,
private readonly clearanceEvents: ClearanceEventService,
private readonly notifier: BookingLifecycleNotifierService,
) {}
private repo() {
@@ -104,6 +116,11 @@ export class BookingClearanceChargeService {
file: file ? { id: file.id, name: file.name, url: file.url } : null,
amount: c.amount != null ? Number(c.amount) : null,
currency: c.currency ?? null,
description: c.description ?? null,
customerNote: c.customerNote ?? null,
customerDecidedAt: c.customerDecidedAt
? c.customerDecidedAt.toISOString()
: null,
invoiceId: c.invoiceId ?? null,
invoiceNumber: c.invoiceId
? (invoiceById.get(c.invoiceId)?.invoiceNumber ?? null)
@@ -121,6 +138,24 @@ export class BookingClearanceChargeService {
});
}
/** The customer's view: only charges GL has sent them. */
async listForCustomer(bookingId: string): Promise<Freight.ClearanceCharge[]> {
return (await this.list(bookingId)).filter((c) =>
CUSTOMER_VISIBLE_CHARGE_STATUSES.has(c.status),
);
}
private async findCharge(
bookingId: string,
chargeId: string,
): Promise<BookingClearanceCharge> {
const charge = await this.repo().findOne({
where: { id: chargeId, bookingId },
});
if (!charge) throw new NotFoundException('Clearance charge not found');
return charge;
}
/** GL Djibouti uploads (or replaces, until billed) the port-charges document. */
async uploadPortDocument(
bookingId: string,
@@ -180,22 +215,21 @@ export class BookingClearanceChargeService {
}
/**
* GL Ethiopia sets (or, on the customer's request, revises) amount +
* currency. Revising a SENT charge cancels its unpaid invoice; a PAID charge
* is immutable.
* GL Ethiopia sets (or, after a customer rejection, revises) amount +
* currency + description. Allowed until the customer accepts: an ACCEPTED
* charge already carries an invoice and a PAID one is settled.
*/
async billCharge(
bookingId: string,
chargeId: string,
input: { amount: number; currency: string },
input: { amount: number; currency: string; description?: string },
staffId: string,
): Promise<Freight.ClearanceCharge[]> {
const charge = await this.repo().findOne({
where: { id: chargeId, bookingId },
});
if (!charge) throw new NotFoundException('Clearance charge not found');
if (charge.status === 'PAID') {
throw new ConflictException('A paid charge can no longer be changed.');
const charge = await this.findCharge(bookingId, chargeId);
if (!canStaffEditCharge(charge.status)) {
throw new ConflictException(
'The customer has accepted this charge — it can no longer be changed.',
);
}
if (!(input.amount > 0)) {
throw new BadRequestException('Amount must be greater than zero.');
@@ -203,53 +237,117 @@ export class BookingClearanceChargeService {
if (!input.currency?.trim()) {
throw new BadRequestException('Currency is required.');
}
if (charge.status === 'SENT' && charge.invoiceId) {
await this.billing.cancelInvoice(charge.invoiceId);
const description = (input.description ?? charge.description ?? '').trim();
if (charge.type === 'MISCELLANEOUS' && !description) {
throw new BadRequestException('Describe what this charge is for.');
}
const currency = input.currency.trim().toUpperCase();
const revised = charge.status === 'SENT' || charge.status === 'REJECTED';
// Back to draft: the customer's previous decision no longer applies.
await this.repo().update(charge.id, {
amount: input.amount.toFixed(2),
currency: input.currency.trim().toUpperCase(),
currency,
description: description || null,
status: 'BILLED',
invoiceId: null,
customerNote: null,
customerDecidedAt: null,
customerDecidedBy: null,
billedByStaffId: staffId,
billedAt: new Date(),
});
await this.clearanceEvents.record({
bookingId,
action: 'CHARGE_BILLED',
label: `${charge.status === 'SENT' ? 'Revised' : 'Billed'} ${CHARGE_LABEL[
label: `${revised ? 'Revised' : 'Billed'} ${CHARGE_LABEL[
charge.type
].toLowerCase()}: ${input.amount} ${input.currency.trim().toUpperCase()}`,
].toLowerCase()}: ${input.amount} ${currency}${
description ? `${description}` : ''
}`,
actorId: staffId,
metadata: {
chargeType: charge.type,
amount: input.amount,
currency: input.currency.trim().toUpperCase(),
revised: charge.status === 'SENT',
currency,
description: description || null,
revised,
},
});
return this.list(bookingId);
}
/** GL Ethiopia issues the payable invoice to the customer. */
/**
* GL Ethiopia proposes the priced charge to the customer. No invoice yet —
* that is issued when the customer accepts. Re-sending after a rejection
* goes through here too.
*/
async sendCharge(
bookingId: string,
chargeId: string,
staffId?: string,
staffId: string,
): Promise<Freight.ClearanceCharge[]> {
const charge = await this.repo().findOne({
where: { id: chargeId, bookingId },
});
if (!charge) throw new NotFoundException('Clearance charge not found');
if (charge.status !== 'BILLED') {
const charge = await this.findCharge(bookingId, chargeId);
if (charge.status !== 'BILLED' && charge.status !== 'REJECTED') {
throw new ConflictException(
'Set the amount and currency before sending the charge to the customer.',
charge.status === 'DOC_UPLOADED'
? 'Set the amount and currency before sending the charge to the customer.'
: 'This charge has already been sent to the customer.',
);
}
const revised = charge.status === 'REJECTED';
const amount = Number(charge.amount);
const currency = charge.currency ?? 'ETB';
await this.repo().update(charge.id, {
status: 'SENT',
customerNote: null,
customerDecidedAt: null,
customerDecidedBy: null,
});
await this.clearanceEvents.record({
bookingId,
action: 'CHARGE_SENT',
label: `${revised ? 'Re-sent' : 'Sent'} ${CHARGE_LABEL[
charge.type
].toLowerCase()} to the customer for approval: ${amount} ${currency}`,
actorId: staffId ?? null,
metadata: {
chargeType: charge.type,
amount,
currency,
description: charge.description ?? null,
revised,
},
});
const booking = await this.bookingsService.findById(bookingId);
this.notifier.clearanceChargeProposed(booking, {
label: CHARGE_LABEL[charge.type],
amount,
currency,
description: charge.description ?? null,
revised,
});
return this.list(bookingId);
}
/** Customer agrees to the price: the payable invoice is issued and the charge locks. */
async customerAccept(
bookingId: string,
chargeId: string,
userId: string,
): Promise<Freight.ClearanceCharge[]> {
const booking = await this.bookingsService.findById(bookingId);
await this.bookingsService.assertCustomerCanAccessBooking(userId, booking);
const charge = await this.findCharge(bookingId, chargeId);
if (charge.status !== 'SENT' && charge.status !== 'REJECTED') {
throw new ConflictException(
charge.status === 'ACCEPTED' || charge.status === 'PAID'
? 'This charge has already been accepted.'
: 'This charge is not awaiting your decision.',
);
}
const amount = Number(charge.amount);
const currency = charge.currency ?? 'ETB';
const invoice = await this.billing.generateInvoice({
source: Freight.InvoiceSource.ClearanceCharge,
// The charge's own id, NOT the booking id — booking-scoped invoice
@@ -258,46 +356,101 @@ export class BookingClearanceChargeService {
type: charge.type,
companyId: booking.companyId,
companyProfileId: booking.companyProfileId,
currency: charge.currency ?? 'ETB',
currency,
lines: [
{
chargeType: charge.type,
description: `${CHARGE_LABEL[charge.type]}${booking.reference ?? bookingId}`,
amount: Number(charge.amount),
description: `${CHARGE_LABEL[charge.type]}${
booking.reference ?? bookingId
}${charge.description ? `: ${charge.description}` : ''}`,
amount,
},
],
});
await this.repo().update(charge.id, {
status: 'SENT',
status: 'ACCEPTED',
invoiceId: invoice.id,
customerNote: null,
customerDecidedAt: new Date(),
customerDecidedBy: userId,
});
await this.clearanceEvents.record({
bookingId,
action: 'CHARGE_INVOICE_SENT',
label: `Sent ${CHARGE_LABEL[charge.type].toLowerCase()} invoice ${invoice.invoiceNumber} to the customer`,
actorId: staffId ?? null,
action: 'CHARGE_ACCEPTED',
label: `Customer accepted ${CHARGE_LABEL[
charge.type
].toLowerCase()} (${amount} ${currency}) — invoice ${invoice.invoiceNumber} issued`,
actorType: 'CUSTOMER',
actorId: userId,
metadata: {
chargeType: charge.type,
invoiceNumber: invoice.invoiceNumber,
amount: Number(charge.amount),
currency: charge.currency,
amount,
currency,
},
});
this.notifier.clearanceChargeInvoiceIssued(booking, {
label: CHARGE_LABEL[charge.type],
amount,
currency,
invoiceNumber: invoice.invoiceNumber,
});
this.logger.log(
`Clearance charge ${charge.type} on booking ${bookingId} sent as invoice ${invoice.invoiceNumber}`,
`Clearance charge ${charge.type} on booking ${bookingId} accepted; invoice ${invoice.invoiceNumber}`,
);
return this.list(bookingId);
return this.listForCustomer(bookingId);
}
/** Customer declines the price with a reason; GL revises and re-sends. */
async customerReject(
bookingId: string,
chargeId: string,
note: string,
userId: string,
): Promise<Freight.ClearanceCharge[]> {
const booking = await this.bookingsService.findById(bookingId);
await this.bookingsService.assertCustomerCanAccessBooking(userId, booking);
const charge = await this.findCharge(bookingId, chargeId);
if (charge.status !== 'SENT') {
throw new ConflictException(
charge.status === 'ACCEPTED' || charge.status === 'PAID'
? 'This charge has already been accepted.'
: 'This charge is not awaiting your decision.',
);
}
if (!note?.trim()) {
throw new BadRequestException('Say why you are rejecting this charge.');
}
await this.repo().update(charge.id, {
status: 'REJECTED',
customerNote: note.trim(),
customerDecidedAt: new Date(),
customerDecidedBy: userId,
});
await this.clearanceEvents.record({
bookingId,
action: 'CHARGE_REJECTED',
label: `Customer rejected ${CHARGE_LABEL[charge.type].toLowerCase()}: ${note.trim()}`,
actorType: 'CUSTOMER',
actorId: userId,
metadata: { chargeType: charge.type, note: note.trim() },
});
this.notifier.clearanceChargeRejectedToStaff(booking, {
label: CHARGE_LABEL[charge.type],
note: note.trim(),
});
return this.listForCustomer(bookingId);
}
/**
* GL Ethiopia creates the miscellaneous charge whole (document + amount +
* currency). Second payment level: allowed only once the port charge is paid.
* GL Ethiopia creates a miscellaneous charge whole (document + amount +
* currency + what it is for). Lands as a BILLED draft; GL sends it next.
*/
async createMiscellaneous(
bookingId: string,
file: Express.Multer.File,
input: { amount: number; currency: string },
input: { amount: number; currency: string; description?: string },
staffId: string,
): Promise<Freight.ClearanceCharge[]> {
const booking = await this.bookingsService.findById(bookingId);
@@ -311,6 +464,10 @@ export class BookingClearanceChargeService {
if (!input.currency?.trim()) {
throw new BadRequestException('Currency is required.');
}
const description = input.description?.trim() ?? '';
if (!description) {
throw new BadRequestException('Describe what this charge is for.');
}
// Save the row first so its id can key the document. A booking may carry
// several miscellaneous charges, and `upsertByCode` retires whatever sits
@@ -323,6 +480,7 @@ export class BookingClearanceChargeService {
status: 'BILLED',
amount: input.amount.toFixed(2),
currency: input.currency.trim().toUpperCase(),
description,
uploadedByStaffId: staffId,
uploadedAt: new Date(),
billedByStaffId: staffId,
@@ -342,11 +500,12 @@ export class BookingClearanceChargeService {
await this.clearanceEvents.record({
bookingId,
action: 'CHARGE_MISC_CREATED',
label: `Created miscellaneous charge: ${input.amount} ${input.currency.trim().toUpperCase()}`,
label: `Created miscellaneous charge: ${input.amount} ${input.currency.trim().toUpperCase()}${description}`,
actorId: staffId,
metadata: {
amount: input.amount,
currency: input.currency.trim().toUpperCase(),
description,
fileName: file.originalname,
},
});

View File

@@ -0,0 +1,22 @@
import {
CUSTOMER_VISIBLE_CHARGE_STATUSES,
canStaffEditCharge,
} from './booking-clearance-charge.service';
import { CLEARANCE_CHARGE_STATUSES } from './entities/booking-clearance-charge.entity';
describe('clearance charge status guards', () => {
it('locks the charge once the customer has accepted or paid', () => {
expect(canStaffEditCharge('ACCEPTED')).toBe(false);
expect(canStaffEditCharge('PAID')).toBe(false);
for (const s of ['DOC_UPLOADED', 'BILLED', 'SENT', 'REJECTED'] as const) {
expect(canStaffEditCharge(s)).toBe(true);
}
});
it('hides GL drafts from the customer and shows everything sent', () => {
const visible = CLEARANCE_CHARGE_STATUSES.filter((s) =>
CUSTOMER_VISIBLE_CHARGE_STATUSES.has(s),
);
expect(visible).toEqual(['SENT', 'REJECTED', 'ACCEPTED', 'PAID']);
});
});

View File

@@ -421,6 +421,55 @@ export class BookingLifecycleNotifierService {
});
}
// ── Clearance charges (port + miscellaneous) ───────────────────────────────
/** GL proposed (or re-proposed) a clearance charge — the customer accepts or rejects it in the portal. */
clearanceChargeProposed(
b: Booking,
c: {
label: string;
amount: number;
currency: string;
description: string | null;
revised: boolean;
},
): void {
const msg =
`${c.revised ? 'Revised ' + c.label.toLowerCase() : c.label} of ${c.amount} ${c.currency}` +
`${c.description ? ` (${c.description})` : ''} on booking ${b.reference} ` +
`await your approval. Please accept or reject them in the portal.`;
void this.notifyContact(b, msg, c.revised ? 'CLEARANCE CHARGE REVISED' : 'CLEARANCE CHARGE SENT');
this.inApp(b, c.revised ? `${c.label} revised` : `${c.label} need your approval`, msg, {
type: NotificationType.INVOICE_ISSUED,
});
}
/** The customer accepted a clearance charge — its invoice is now payable. */
clearanceChargeInvoiceIssued(
b: Booking,
c: { label: string; amount: number; currency: string; invoiceNumber: string },
): void {
const msg =
`Invoice ${c.invoiceNumber} for ${c.label.toLowerCase()} (${c.amount} ${c.currency}) ` +
`on booking ${b.reference} is ready. Please pay it from the portal.`;
void this.notifyContact(b, msg, 'CLEARANCE CHARGE INVOICE');
this.inApp(b, `${c.label} invoice issued`, msg, {
type: NotificationType.INVOICE_ISSUED,
});
}
/** The customer rejected a clearance charge — GL Ethiopia revises and re-sends. */
clearanceChargeRejectedToStaff(b: Booking, c: { label: string; note: string }): void {
const msg =
`The customer rejected the ${c.label.toLowerCase()} on booking ${this.ref(b)}: ` +
`"${c.note}". Revise and re-send from the clearance page.`;
this.inAppStaff(b, `${c.label} rejected — ${this.ref(b)}`, msg, {
recipients: CLEARANCE_DESK,
type: NotificationType.CLEARANCE_REVIEW,
link: `/dashboard/clearance/${b.id}`,
});
}
/** GL confirmed the final-invoice payment slip. */
finalInvoicePaid(b: Booking): void {
const msg = `Your final invoice payment for booking ${b.reference} has been confirmed. Thank you.`;

View File

@@ -0,0 +1,107 @@
import { Injectable } from '@nestjs/common';
import { DataSource } from 'typeorm';
import { Freight } from '@edr/types';
/** Invoice statuses a customer can still settle (mirrors the portal's PAYABLE_STATUSES). */
const PAYABLE_INVOICE_STATUSES = ['ISSUED', 'PENDING', 'PARTIALLY_PAID', 'OVERDUE'];
/** Booking statuses at which the freight invoice is actually due (mirrors BookingsService). */
const FREIGHT_PAYABLE_BOOKING_STATUSES = [
'FULLY_EXECUTED',
'SELECTED_FOR_BATCH',
'AWAITING_PAYMENT',
];
/**
* One row per outstanding item. `invoices.status` / `bookings.status` are
* Postgres enums, hence the ::text casts. `amount` is NULL for items that only need the
* customer's review (a proposed clearance charge, a draft final invoice) so
* they count but do not inflate "amount due".
*/
const SQL = `
-- Central invoices on the booking: freight (only while the booking is in a
-- payable status), wagon-cancellation fee, GL final invoice (+ its DRAFT,
-- which waits for the customer's approval).
SELECT i.source_id AS "bookingId", i.currency,
CASE WHEN i.status::text = 'DRAFT' THEN NULL ELSE i.balance_amount END AS amount
FROM freight.invoices i
JOIN freight.bookings b ON b.id::text = i.source_id AND b.deleted_at IS NULL
WHERE i.company_id = $1 AND i.deleted_at IS NULL AND i.source = 'booking'
AND (
(i.status::text = ANY($2::text[]) AND i.balance_amount > 0
AND (i.type IN ('WAGON_CANCEL_FEE', 'GL_FINAL') OR b.status::text = ANY($3::text[])))
OR (i.type = 'GL_FINAL' AND i.status::text = 'DRAFT')
)
UNION ALL
-- Accepted clearance charges whose invoice is still unpaid.
SELECT c.booking_id::text, i.currency, i.balance_amount
FROM freight.invoices i
JOIN freight.booking_clearance_charge c ON c.id::text = i.source_id AND c.deleted_at IS NULL
WHERE i.company_id = $1 AND i.deleted_at IS NULL AND i.source = 'clearance_charge'
AND i.status::text = ANY($2::text[]) AND i.balance_amount > 0
UNION ALL
-- Clearance charges waiting for the customer to accept or reject the price.
SELECT c.booking_id::text, c.currency, NULL::numeric
FROM freight.booking_clearance_charge c
JOIN freight.bookings b ON b.id = c.booking_id AND b.deleted_at IS NULL
WHERE b.company_id = $1 AND c.deleted_at IS NULL AND c.status = 'SENT'
UNION ALL
-- Duty / tax advised by customs, payment slip not uploaded yet.
SELECT m.booking_id::text, m.metadata->>'dutyCurrency',
NULLIF(m.metadata->>'dutyAmount', '')::numeric
FROM freight.clearance_milestones m
JOIN freight.bookings b ON b.id = m.booking_id AND b.deleted_at IS NULL
WHERE b.company_id = $1 AND m.deleted_at IS NULL AND m.status = 'COMPLETED'
AND (
(m.milestone_code = 'DUTY_TAXES_ADVISED' AND NOT EXISTS (
SELECT 1 FROM freight.clearance_milestones p
WHERE p.booking_id = m.booking_id AND p.milestone_code = 'DUTY_TAX_PAID'
AND p.status = 'COMPLETED' AND p.deleted_at IS NULL))
OR
(m.milestone_code = 'SECOND_DUTY_ADVISED' AND NOT EXISTS (
SELECT 1 FROM freight.clearance_milestones p
WHERE p.booking_id = m.booking_id AND p.milestone_code = 'SECOND_DUTY_PAID'
AND p.status = 'COMPLETED' AND p.deleted_at IS NULL))
)
`;
/**
* Everything a customer still has to act on, per booking, in one query. Drives
* the "Pay" badge on the home and booking-list rows; the booking's Payments tab
* composes the same items client-side from the per-booking endpoints.
*/
@Injectable()
export class BookingPayablesService {
constructor(private readonly dataSource: DataSource) {}
async summarizeForCompany(
companyId: string,
): Promise<Freight.BookingPayableSummary[]> {
const rows: Array<{
bookingId: string;
currency: string | null;
amount: string | null;
}> = await this.dataSource.query(SQL, [
companyId,
PAYABLE_INVOICE_STATUSES,
FREIGHT_PAYABLE_BOOKING_STATUSES,
]);
const byBooking = new Map<string, Freight.BookingPayableSummary>();
for (const r of rows) {
const s = byBooking.get(r.bookingId) ?? {
bookingId: r.bookingId,
count: 0,
totals: [],
};
s.count += 1;
const amount = Number(r.amount ?? 0);
if (r.currency && amount > 0) {
const t = s.totals.find((x) => x.currency === r.currency);
if (t) t.amount += amount;
else s.totals.push({ currency: r.currency, amount });
}
byBooking.set(r.bookingId, s);
}
return [...byBooking.values()];
}
}

View File

@@ -388,6 +388,27 @@ describe('BookingPricingService — customs clearance fee billed on the booking
expect(line!.amount).toBe(200);
});
it('prices an Ethiopian-customs-only service off ETHIOPIAN_CUSTOMS_CLEARANCE, not the full fee', async () => {
const ethiopianFee = {
...containerFee20,
id: 'rate-et-20',
rateType: 'ETHIOPIAN_CUSTOMS_CLEARANCE',
trigger: 'ETHIOPIAN_CUSTOMS_CLEARANCE',
rateValue: 40,
} as Rate;
const service = makeService({ liveRates: [containerFee20, ethiopianFee] });
const result = await service.computePriceForBooking(
containerBooking({
serviceType: { includesCustoms: true, includesEthiopianCustomsOnly: true },
} as never),
);
const line = result.lineItems.find((l) => l.code === 'ETHIOPIAN_CUSTOMS_CLEARANCE_20FT');
expect(line).toBeDefined();
expect(line!.amount).toBe(160);
expect(result.lineItems.some((l) => l.code === 'CUSTOMS_CLEARANCE_20FT')).toBe(false);
});
it('hard-blocks a container type with no fee configured (never free clearance)', async () => {
const service = makeService({ liveRates: [bulkFeePerTon] });
const result = await service.computePriceForBooking(containerBooking());

View File

@@ -1060,9 +1060,18 @@ export class BookingPricingService {
const usdToEtb = isEtb ? await this.exchangeService.getRate('USD', 'ETB') : 1;
const convert = (usd: number): number => (isEtb ? round2(usd * usdToEtb) : usd);
// An Ethiopian-side-only customs service prices off its own rate; the
// contract froze its snapshots under the matching code prefix.
const customsType = booking.serviceType?.includesEthiopianCustomsOnly
? 'ETHIOPIAN_CUSTOMS_CLEARANCE'
: 'CUSTOMS_CLEARANCE';
const customsLabel =
customsType === 'ETHIOPIAN_CUSTOMS_CLEARANCE'
? 'Ethiopian customs clearance service'
: 'Customs clearance service';
const onLeg = liveRates.filter(
(r) =>
r.rateType === 'CUSTOMS_CLEARANCE' &&
r.rateType === customsType &&
r.currency === 'USD' &&
r.tradeDirection === booking.tradeDirection &&
r.originYardId === booking.originYardId &&
@@ -1070,20 +1079,20 @@ export class BookingPricingService {
);
const missingRateMessage = (scope: string): string =>
`No customs clearance service fee is configured for ${scope} on this ` +
'origin → destination. Ask EDR to configure the CUSTOMS_CLEARANCE rate for this route.';
`origin → destination. Ask EDR to configure the ${customsType} rate for this route.`;
if (booking.freightType === 'CONTAINER') {
// Legacy short-circuit: an old contract froze one flat fee — bill it once.
const hasPerSizeSnapshot =
frozenRates?.has('CUSTOMS_CLEARANCE_20FT') ||
frozenRates?.has('CUSTOMS_CLEARANCE_40FT');
const legacyFlat = this.frozenRateByCode(frozenRates, 'CUSTOMS_CLEARANCE', currency, usdToEtb);
frozenRates?.has(`${customsType}_20FT`) ||
frozenRates?.has(`${customsType}_40FT`);
const legacyFlat = this.frozenRateByCode(frozenRates, customsType, currency, usdToEtb);
if (legacyFlat && !hasPerSizeSnapshot) {
const amount = Number(legacyFlat.unitPrice);
if (amount > 0) {
lineItems.push({
code: 'CUSTOMS_CLEARANCE',
description: 'Customs clearance service',
code: customsType,
description: customsLabel,
amount,
unitAmount: amount,
unit: 'FLAT',
@@ -1106,7 +1115,7 @@ export class BookingPricingService {
// unknown type — falls through to the live per-type lookup below
}
const frozen = sizeFt
? this.frozenRateByCode(frozenRates, `CUSTOMS_CLEARANCE_${sizeFt}FT`, currency, usdToEtb)
? this.frozenRateByCode(frozenRates, `${customsType}_${sizeFt}FT`, currency, usdToEtb)
: null;
const live = onLeg.find((r) => r.containerTypeId === bc.containerTypeId);
if (!frozen && !live) {
@@ -1124,8 +1133,8 @@ export class BookingPricingService {
const amount = unit === 'FLAT' ? unitAmount : unitAmount * billedQty;
if (!(amount > 0)) continue;
lineItems.push({
code: sizeFt ? `CUSTOMS_CLEARANCE_${sizeFt}FT` : 'CUSTOMS_CLEARANCE',
description: `Customs clearance service${sizeFt ? ` (${sizeFt}ft)` : ''}`,
code: sizeFt ? `${customsType}_${sizeFt}FT` : customsType,
description: `${customsLabel}${sizeFt ? ` (${sizeFt}ft)` : ''}`,
amount,
unitAmount,
unit,
@@ -1141,7 +1150,7 @@ export class BookingPricingService {
// flat snapshot share the CUSTOMS_CLEARANCE code; both are the agreed fee.
// Live lookup: the rate scoped to the booking's commodity wins; a
// commodity-less rate (legacy) is the catch-all fallback.
const frozen = this.frozenRateByCode(frozenRates, 'CUSTOMS_CLEARANCE', currency, usdToEtb);
const frozen = this.frozenRateByCode(frozenRates, customsType, currency, usdToEtb);
const live =
(booking.cargoTypeId
? onLeg.find(
@@ -1172,8 +1181,8 @@ export class BookingPricingService {
const amount = unit === 'FLAT' ? unitAmount : unitAmount * billedQty;
if (amount > 0) {
lineItems.push({
code: 'CUSTOMS_CLEARANCE',
description: 'Customs clearance service (bulk)',
code: customsType,
description: `${customsLabel} (bulk)`,
amount,
unitAmount,
unit,

View File

@@ -40,10 +40,18 @@ import {
import type { Response } from "express";
import { BookingClearanceChargeService } from './booking-clearance-charge.service';
import { BookingPayablesService } from './booking-payables.service';
import { ClearanceEventService } from './clearance-event.service';
<<<<<<< HEAD
import {
BillClearanceChargeDto,
RejectClearanceChargeDto,
} from './dto/clearance-charge.dto';
=======
import { BillClearanceChargeDto } from './dto/clearance-charge.dto';
import { AdditionalChargeService } from './additional-charge.service';
import { CancelAdditionalChargeDto, CreateAdditionalChargeDto } from './dto/additional-charge.dto';
>>>>>>> 82c795999efce5e4422dd332551cecab2592764d
import { BookingContractService } from './booking-contract.service';
import { BookingPricingService } from './booking-pricing.service';
import { BookingTransitionService } from './booking-transition.service';
@@ -177,6 +185,7 @@ export class BookingsController {
private readonly wagonCancellationService: BookingWagonCancellationService,
private readonly consolidationApprovalService: ConsolidationApprovalService,
private readonly clearanceChargeService: BookingClearanceChargeService,
private readonly bookingPayablesService: BookingPayablesService,
private readonly clearanceEventService: ClearanceEventService,
private readonly additionalChargeService: AdditionalChargeService,
) {}
@@ -315,6 +324,21 @@ export class BookingsController {
return this.bookingsService.getListSummary(filter);
}
@Get("my-payables")
@PortalCustomer()
@ApiOperation({
summary:
"Outstanding customer payments per booking — invoices to pay, prices to accept, duty slips to upload",
})
async findMyPayables(@CurrentUser() user: AuthUserPayload) {
const companyId = await this.bookingsService.resolveCustomerCompanyId(
resolveAuthUserId(user),
);
return companyId
? this.bookingPayablesService.summarizeForCompany(companyId)
: [];
}
@Get("my")
@PortalCustomer()
@ApiOperation({
@@ -1121,15 +1145,63 @@ export class BookingsController {
// ── Clearance charges (post-finalization customer billing) ────────────────
@Get(":id/clearance/charges")
@BookingStaff([
@MixedAudience([
FREIGHT_PERMS.contracts.clearanceEtActions,
FREIGHT_PERMS.contracts.clearanceDjActions,
])
@ApiOperation({
summary: "Clearance charges billed to the customer (port + miscellaneous)",
summary:
"Clearance charges billed to the customer (port + miscellaneous); customers see only the charges sent to them",
})
getClearanceCharges(@Param("id", ParseUUIDPipe) id: string) {
return this.clearanceChargeService.list(id);
async getClearanceCharges(
@Param("id", ParseUUIDPipe) id: string,
@CurrentUser() user: TCurrentUser,
) {
const isStaff =
hasFreightPermission(user, FREIGHT_PERMS.contracts.clearanceEtActions) ||
hasFreightPermission(user, FREIGHT_PERMS.contracts.clearanceDjActions);
if (isStaff) return this.clearanceChargeService.list(id);
const booking = await this.bookingsService.findById(id);
await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking);
return this.clearanceChargeService.listForCustomer(id);
}
@Post(":id/clearance/charges/:chargeId/accept")
@PortalCustomer()
@ApiOperation({
summary:
"Customer accepts a proposed clearance charge — issues the payable invoice and locks the charge",
})
acceptClearanceCharge(
@Param("id", ParseUUIDPipe) id: string,
@Param("chargeId", ParseUUIDPipe) chargeId: string,
@CurrentUser() user: AuthUserPayload,
) {
return this.clearanceChargeService.customerAccept(
id,
chargeId,
resolveAuthUserId(user),
);
}
@Post(":id/clearance/charges/:chargeId/reject")
@PortalCustomer()
@ApiOperation({
summary:
"Customer rejects a proposed clearance charge with a reason — GL Ethiopia revises and re-sends",
})
rejectClearanceCharge(
@Param("id", ParseUUIDPipe) id: string,
@Param("chargeId", ParseUUIDPipe) chargeId: string,
@Body() dto: RejectClearanceChargeDto,
@CurrentUser() user: AuthUserPayload,
) {
return this.clearanceChargeService.customerReject(
id,
chargeId,
dto.note,
resolveAuthUserId(user),
);
}
@Post(":id/clearance/charges/port-document")
@@ -1156,7 +1228,7 @@ export class BookingsController {
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
@ApiOperation({
summary:
"GL Ethiopia sets or revises the charge's amount + currency (revising a sent charge cancels its unpaid invoice)",
"GL Ethiopia sets or revises the charge's amount, currency and description (locked once the customer accepts)",
})
billClearanceCharge(
@Param("id", ParseUUIDPipe) id: string,
@@ -1176,7 +1248,7 @@ export class BookingsController {
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
@ApiOperation({
summary:
"GL Ethiopia issues the charge's payable invoice to the customer (ETB pays via gateway, other currencies via manual settlement)",
"GL Ethiopia sends the priced charge to the customer for approval (the invoice is issued when they accept)",
})
sendClearanceCharge(
@Param("id", ParseUUIDPipe) id: string,
@@ -1196,7 +1268,7 @@ export class BookingsController {
@ApiConsumes("multipart/form-data")
@ApiOperation({
summary:
"GL Ethiopia creates the miscellaneous charge (document + amount + currency); unlocked once the port charge is paid",
"GL Ethiopia creates a miscellaneous charge (document + amount + currency + description) as a draft to send",
})
createMiscellaneousCharge(
@Param("id", ParseUUIDPipe) id: string,

View File

@@ -42,6 +42,7 @@ import { AdditionalCharge } from './entities/additional-charge.entity';
import { AdditionalChargeRepository } from './additional-charge.repository';
import { AdditionalChargeService } from './additional-charge.service';
import { BookingClearanceChargeService } from './booking-clearance-charge.service';
import { BookingPayablesService } from './booking-payables.service';
import { BookingClearanceEvent } from './entities/booking-clearance-event.entity';
import { ClearanceEventService } from './clearance-event.service';
import { BookingContainer } from './entities/booking-container.entity';
@@ -122,6 +123,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
BookingContractService,
BookingInvoiceService,
BookingClearanceChargeService,
BookingPayablesService,
ClearanceEventService,
AdditionalChargeRepository,
AdditionalChargeService,

View File

@@ -1,6 +1,13 @@
import { ApiProperty } from '@nestjs/swagger';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsNumber, IsPositive, IsString, Length } from 'class-validator';
import {
IsNumber,
IsOptional,
IsPositive,
IsString,
Length,
MaxLength,
} from 'class-validator';
export class BillClearanceChargeDto {
@ApiProperty({ example: 12500.5 })
@@ -13,4 +20,18 @@ export class BillClearanceChargeDto {
@IsString()
@Length(3, 8)
currency!: string;
/** What the price is for. Required for miscellaneous charges (checked in the service). */
@ApiPropertyOptional({ example: 'Container cleaning and weighbridge fee' })
@IsOptional()
@IsString()
@MaxLength(1000)
description?: string;
}
export class RejectClearanceChargeDto {
@ApiProperty({ example: 'The weighbridge fee was already paid at the port.' })
@IsString()
@Length(1, 1000)
note!: string;
}

View File

@@ -9,6 +9,8 @@ export const CLEARANCE_CHARGE_STATUSES = [
'DOC_UPLOADED',
'BILLED',
'SENT',
'REJECTED',
'ACCEPTED',
'PAID',
] as const;
export type ClearanceChargeStatus = (typeof CLEARANCE_CHARGE_STATUSES)[number];
@@ -17,9 +19,11 @@ export type ClearanceChargeStatus = (typeof CLEARANCE_CHARGE_STATUSES)[number];
* Clearance charge billed to the customer. One PORT_CHARGES row per booking
* (enforced by a partial unique index) and any number of MISCELLANEOUS rows.
* GL Djibouti uploads the port-charges document (DOC_UPLOADED); GL Ethiopia
* sets amount + currency (BILLED) and issues the invoice (SENT); the billing
* `clearance_charge.invoice.paid` event marks it PAID. The two levels are
* independent — either may be raised first.
* sets amount + currency + description (BILLED) and proposes it to the
* customer (SENT). The customer either REJECTS with a note (GL revises and
* re-sends) or ACCEPTS, which issues the invoice and locks the charge; the
* billing `clearance_charge.invoice.paid` event marks it PAID. The two levels
* are independent — either may be raised first.
*/
@Entity({ schema: 'freight', name: 'booking_clearance_charge' })
@Index(['bookingId'])
@@ -47,6 +51,20 @@ export class BookingClearanceCharge extends BaseEntity {
@Column({ name: 'currency', type: 'varchar', length: 8, nullable: true })
currency?: string | null;
/** What the price is for, written by GL. */
@Column({ name: 'description', type: 'text', nullable: true })
description?: string | null;
/** Customer's reason when REJECTED; cleared when GL revises. */
@Column({ name: 'customer_note', type: 'text', nullable: true })
customerNote?: string | null;
@Column({ name: 'customer_decided_at', type: 'timestamptz', nullable: true })
customerDecidedAt?: Date | null;
@Column({ name: 'customer_decided_by', type: 'uuid', nullable: true })
customerDecidedBy?: string | null;
/** The payable invoice issued for this charge (null until SENT). */
@Column({ name: 'invoice_id', type: 'uuid', nullable: true })
invoiceId?: string | null;

View File

@@ -376,12 +376,21 @@ export class ContractPricingService {
// own container-type rate), bulk contracts freeze the route's bulk fee.
// A customs contract may not proceed without the fee(s) configured.
if (contract.customsClearingEnabled) {
// An Ethiopian-side-only customs service prices off its own rate; the
// snapshot codes carry the same prefix so booking pricing finds them.
const customsType = contract.serviceType?.includesEthiopianCustomsOnly
? 'ETHIOPIAN_CUSTOMS_CLEARANCE'
: 'CUSTOMS_CLEARANCE';
const customsLabel =
customsType === 'ETHIOPIAN_CUSTOMS_CLEARANCE'
? 'Ethiopian customs clearance service'
: 'Customs clearance service';
// Strict, no route-less fallback.
// ponytail: multi-route contracts bill the first lane's fee; per-lane fees need per-route snapshots.
const onLeg = route
? liveRates.filter(
(r) =>
r.rateType === 'CUSTOMS_CLEARANCE' &&
r.rateType === customsType &&
r.currency === 'USD' &&
r.tradeDirection === contract.tradeDirection &&
r.originYardId === route.originYardId &&
@@ -406,14 +415,14 @@ export class ContractPricingService {
);
if (!rate || Number(rate.rateValue) <= 0) {
throw new UnprocessableEntityException(
`No customs clearance service fee is configured for ${size} containers on this direction and route. Ask the rates team to set a live CUSTOMS_CLEARANCE rate for this container type and origin → destination.`,
`No customs clearance service fee is configured for ${size} containers on this direction and route. Ask the rates team to set a live ${customsType} rate for this container type and origin → destination.`,
);
}
lineItems.push({
// Distinct code per size so the frozen snapshots don't collide —
// booking pricing looks each size up by CUSTOMS_CLEARANCE_<FT>FT.
code: `CUSTOMS_CLEARANCE_${sizeFt}FT`,
label: `Customs clearance service (${size})`,
// booking pricing looks each size up by <customsType>_<FT>FT.
code: `${customsType}_${sizeFt}FT`,
label: `${customsLabel} (${size})`,
unit: toContractUnit(rate.rateUnit),
unitPrice: convert(Number(rate.rateValue)),
containerSize: size,
@@ -432,12 +441,12 @@ export class ContractPricingService {
: undefined) ?? onLeg.find((r) => !r.containerTypeId && !r.cargoTypeId);
if (!rate || Number(rate.rateValue) <= 0) {
throw new UnprocessableEntityException(
'No bulk customs clearance service fee is configured for this cargo type on this direction and route. Ask the rates team to set a live bulk CUSTOMS_CLEARANCE rate for this commodity and origin → destination.',
`No bulk customs clearance service fee is configured for this cargo type on this direction and route. Ask the rates team to set a live bulk ${customsType} rate for this commodity and origin → destination.`,
);
}
lineItems.push({
code: 'CUSTOMS_CLEARANCE',
label: `Customs clearance service (${scope?.cargoType?.cargoTypeName ?? 'bulk'})`,
code: customsType,
label: `${customsLabel} (${scope?.cargoType?.cargoTypeName ?? 'bulk'})`,
unit: toContractUnit(rate.rateUnit),
unitPrice: convert(Number(rate.rateValue)),
cargoTypeCode: scope?.cargoType?.code ?? null,

View File

@@ -124,11 +124,14 @@ export class ContractsService {
return `CTR-${year}-${String(seq + 1).padStart(5, '0')}`;
}
/** The service type a contract is sold under (null when the id is unknown). */
private resolveServiceType(serviceTypeId: string): Promise<ServiceType | null> {
return this.dataSource.getRepository(ServiceType).findOne({ where: { id: serviceTypeId } });
}
/** Whether a service type bundles customs clearance. */
private async resolveIncludesCustoms(serviceTypeId: string): Promise<boolean> {
const serviceType = await this.dataSource
.getRepository(ServiceType)
.findOne({ where: { id: serviceTypeId } });
const serviceType = await this.resolveServiceType(serviceTypeId);
return serviceType?.includesCustoms ?? false;
}
@@ -324,7 +327,8 @@ export class ContractsService {
}
// Customs clearing is owned by the service type, not the customer.
const includesCustoms = await this.resolveIncludesCustoms(dto.serviceTypeId);
const serviceType = await this.resolveServiceType(dto.serviceTypeId);
const includesCustoms = serviceType?.includesCustoms ?? false;
// Intercity never crosses a border, so a customs-including service type is
// a contradiction — the wizard hides them, the API enforces it.
if (dto.tradeDirection === 'DOMESTIC' && includesCustoms) {
@@ -344,6 +348,8 @@ export class ContractsService {
freightType: dto.freightType,
paymentCurrency: 'USD',
customsClearingEnabled: includesCustoms,
// Decides which customs fee the probe looks up (Ethiopian-only vs full).
serviceType,
isHazardous: dto.isHazardous ?? false,
isReefer: dto.isReefer ?? false,
equipmentReturn: dto.equipmentReturn ?? null,

View File

@@ -32,6 +32,14 @@ export class CreateServiceTypeDto {
@IsBoolean()
includesCustoms?: boolean;
@ApiPropertyOptional({
default: false,
description: 'Customs cleared on the Ethiopian side only (alternative to full includesCustoms; implies it). Prices off the Ethiopian customs rate.',
})
@IsOptional()
@IsBoolean()
includesEthiopianCustomsOnly?: boolean;
@ApiPropertyOptional({ default: true })
@IsOptional()
@IsBoolean()

View File

@@ -16,6 +16,7 @@ describe('deriveRateType — surcharge triggers', () => {
['DEMURRAGE', 'DEMURRAGE'],
['PIL_EXTRA_FEE', 'PIL_EXTRA_FEE'],
['CUSTOMS_CLEARANCE', 'CUSTOMS_CLEARANCE'],
['ETHIOPIAN_CUSTOMS_CLEARANCE', 'ETHIOPIAN_CUSTOMS_CLEARANCE'],
] as const)('maps trigger %s to %s', (trigger, expected) => {
expect(deriveRateType({ appliesTo: 'OTHER', trigger })).toBe(expected);
});

View File

@@ -46,6 +46,8 @@ export function deriveRateType(input: {
return 'PIL_EXTRA_FEE';
case 'CUSTOMS_CLEARANCE':
return 'CUSTOMS_CLEARANCE';
case 'ETHIOPIAN_CUSTOMS_CLEARANCE':
return 'ETHIOPIAN_CUSTOMS_CLEARANCE';
case 'FUEL':
return 'FUEL_SURCHARGE';
}

View File

@@ -28,7 +28,7 @@ export const isBulkQuantityUnit = (unit: string): boolean =>
export function allowedRateUnits(input: {
appliesTo: RateAppliesTo;
trigger: RateTrigger;
/** CUSTOMS_CLEARANCE / CANCELLATION only: which cargo kind the fee covers. */
/** Customs clearance / CANCELLATION only: which cargo kind the fee covers. */
cargoKind?: 'CONTAINER' | 'BULK' | null;
/** Unit of measure of the bulk commodity the rate is scoped to, when any. */
cargoUnitOfMeasure?: CargoUom;
@@ -67,6 +67,7 @@ function unitsForShape(input: {
// per wagon is the only unit the wagon-cancel flow can apply.
return ['PER_WAGON'];
case 'CUSTOMS_CLEARANCE':
case 'ETHIOPIAN_CUSTOMS_CLEARANCE':
// Sold per cargo kind: container fees bill per box or per wagon, bulk
// fees per ton or per wagon. Billed on the booking invoice.
return input.cargoKind === 'BULK'

View File

@@ -25,6 +25,7 @@ export const RATE_TYPES = [
'RETURN_SURCHARGE',
'PIL_EXTRA_FEE',
'CUSTOMS_CLEARANCE',
'ETHIOPIAN_CUSTOMS_CLEARANCE',
'FUEL_SURCHARGE',
] as const;
@@ -96,12 +97,22 @@ export const RATE_TRIGGERS = [
// Customs clearance service fee — billed up front via a clearance invoice,
// never auto-applied to booking pricing (matchesTrigger returns false).
'CUSTOMS_CLEARANCE',
// Same shape as CUSTOMS_CLEARANCE; priced instead of it when the booking's
// service type has includesEthiopianCustomsOnly (Ethiopian-side clearance).
'ETHIOPIAN_CUSTOMS_CLEARANCE',
// Fuel surcharge — fires when the booking's cargo type has hasFuel = true,
// billed off the lane-scoped rate (direction + route + cargo type).
'FUEL',
] as const;
export type RateTrigger = typeof RATE_TRIGGERS[number];
/**
* The two customs clearance service fees share one rate shape (per direction +
* route + cargo kind); only which one a booking prices off differs.
*/
export const isCustomsClearanceTrigger = (trigger: string): boolean =>
trigger === 'CUSTOMS_CLEARANCE' || trigger === 'ETHIOPIAN_CUSTOMS_CLEARANCE';
@Entity({ schema: 'freight', name: 'rates' })
@Index(['rateType'])
@Index(['status'])
@@ -117,7 +128,7 @@ export class Rate extends BaseEntity {
@Column({ name: 'applies_to', type: 'varchar', length: 20, default: 'OTHER' })
appliesTo!: RateAppliesTo;
@Column({ name: 'trigger', type: 'varchar', length: 20, default: 'ALWAYS' })
@Column({ name: 'trigger', type: 'varchar', length: 30, default: 'ALWAYS' })
trigger!: RateTrigger;
@Column({ name: 'container_type_id', type: 'uuid', nullable: true })

View File

@@ -27,6 +27,16 @@ export class ServiceType extends BaseEntity {
@Column({ name: 'includes_customs', type: 'boolean', default: false })
includesCustoms!: boolean;
/**
* EDR clears customs on the Ethiopian side only. The admin picks full customs
* OR Ethiopian-only, never both; the API stores includesCustoms = true for
* either so every clearance read (GL review, duty, docs) stays unchanged —
* only the fee differs: pricing looks up ETHIOPIAN_CUSTOMS_CLEARANCE instead
* of CUSTOMS_CLEARANCE.
*/
@Column({ name: 'includes_ethiopian_customs_only', type: 'boolean', default: false })
includesEthiopianCustomsOnly!: boolean;
@Column({ name: 'is_active', type: 'boolean', default: true })
isActive!: boolean;

View File

@@ -13,7 +13,7 @@ import { ShippingLineCompaniesService } from '../../shipping-lines/shipping-line
import { CreateRateDto } from '../dto/create-rate.dto';
import { ListRatesQueryDto } from '../dto/list-rule-engine-query.dto';
import { UpdateRateDto } from '../dto/update-rate.dto';
import { Rate } from '../entities/rate.entity';
import { Rate, isCustomsClearanceTrigger } from '../entities/rate.entity';
import { deriveRateType } from '../entities/rate-type.util';
import { CargoUom, allowedRateUnits, isRateUnitAllowed } from '../entities/rate-unit.util';
import {
@@ -29,10 +29,15 @@ const BASE_FREIGHT_CATEGORIES: readonly Rate['appliesTo'][] = ['BULK', 'CONTAINE
* Surcharges sold per cargo kind: the admin says container or bulk, a
* container fee then names its container type and a bulk fee its commodity.
*/
const CARGO_KIND_TRIGGERS: readonly Rate['trigger'][] = ['CUSTOMS_CLEARANCE', 'CANCELLATION'];
const CARGO_KIND_TRIGGERS: readonly Rate['trigger'][] = [
'CUSTOMS_CLEARANCE',
'ETHIOPIAN_CUSTOMS_CLEARANCE',
'CANCELLATION',
];
/** Surcharges that keep a trade direction (everything else is direction-agnostic). */
const DIRECTED_SURCHARGE_TRIGGERS: readonly Rate['trigger'][] = [
'CUSTOMS_CLEARANCE',
'ETHIOPIAN_CUSTOMS_CLEARANCE',
'CANCELLATION',
'WITH_RETURN',
'LASHING',
@@ -144,7 +149,7 @@ export class RatesService {
private isRouteScoped(appliesTo: Rate['appliesTo'], trigger: Rate['trigger']): boolean {
return (
this.isBaseFreight(appliesTo, trigger) ||
trigger === 'CUSTOMS_CLEARANCE' ||
isCustomsClearanceTrigger(trigger) ||
trigger === 'WITH_RETURN' ||
trigger === 'FUEL'
);
@@ -261,7 +266,7 @@ export class RatesService {
}): void {
const { appliesTo, trigger, tradeDirection, intercityKind, cargoKind } = input;
const { containerTypeId, cargoTypeId } = input;
if (trigger === 'CUSTOMS_CLEARANCE' || trigger === 'CANCELLATION') {
if (isCustomsClearanceTrigger(trigger) || trigger === 'CANCELLATION') {
// Both fees are sold per direction + cargo kind + type: customs clearance
// per lane, the wagon cancellation fee per direction only.
const fee = trigger === 'CANCELLATION' ? 'cancellation fee' : 'customs clearance';

View File

@@ -1,5 +1,11 @@
import { PaginatedResponse } from '@edr/types';
import { ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common';
import {
BadRequestException,
ConflictException,
Inject,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { generateCode } from '../../../common/utils/generate-code.util';
import { CreateServiceTypeDto } from '../dto/create-service-type.dto';
import { ListServiceTypesQueryDto } from '../dto/list-rule-engine-query.dto';
@@ -43,6 +49,10 @@ export class ServiceTypesService {
const existing = await this.repository.findByCode(code);
if (existing) throw new ConflictException(`Service type with name "${dto.serviceName}" conflicts with existing code "${code}"`);
const customs = this.resolveCustomsFlags(
dto.includesCustoms ?? false,
dto.includesEthiopianCustomsOnly ?? false,
);
const displayOrder = await this.displayOrder.resolveCreateOrder(ServiceType, 'displayOrder', {
explicitOrder: dto.displayOrder,
insertAfterId: dto.insertAfterId,
@@ -55,7 +65,7 @@ export class ServiceTypesService {
canBeBookedAlone: dto.canBeBookedAlone ?? true,
includesFirstMile: dto.includesFirstMile ?? false,
includesLastMile: dto.includesLastMile ?? false,
includesCustoms: dto.includesCustoms ?? false,
...customs,
isActive: dto.isActive ?? true,
displayOrder,
});
@@ -63,13 +73,44 @@ export class ServiceTypesService {
/** Update an existing service type. */
async update(id: string, dto: UpdateServiceTypeDto): Promise<ServiceType> {
await this.findById(id);
const { ...patch } = dto;
const existing = await this.findById(id);
const ethiopian = dto.includesEthiopianCustomsOnly ?? existing.includesEthiopianCustomsOnly;
// The form sends both flags whenever either is touched; a payload with only
// one is a plain edit (name, order…) that keeps the stored pair.
const customs =
dto.includesCustoms !== undefined || dto.includesEthiopianCustomsOnly !== undefined
? this.resolveCustomsFlags(
dto.includesCustoms ?? (existing.includesCustoms && !existing.includesEthiopianCustomsOnly),
ethiopian,
)
: {};
const patch = { ...dto, ...customs };
const updated = await this.repository.update(id, patch);
if (!updated) throw new NotFoundException(`Service type ${id} not found`);
return updated;
}
/**
* Full customs and Ethiopian-only customs are alternatives: the admin picks
* one. Ethiopian-only is still a customs service, so it is stored with
* includesCustoms = true — every clearance read keeps working unchanged and
* only pricing looks at the Ethiopian flag.
*/
private resolveCustomsFlags(
includesCustoms: boolean,
ethiopianOnly: boolean,
): Pick<ServiceType, 'includesCustoms' | 'includesEthiopianCustomsOnly'> {
if (includesCustoms && ethiopianOnly) {
throw new BadRequestException(
'Pick either "Includes customs" or "Ethiopian customs only", not both.',
);
}
return {
includesCustoms: includesCustoms || ethiopianOnly,
includesEthiopianCustomsOnly: ethiopianOnly,
};
}
/** Soft-delete a service type. */
async remove(id: string): Promise<void> {
await this.findById(id);

View File

@@ -120,6 +120,16 @@ export class TrainSchedule extends BaseEntity {
@Column({ name: 'max_wagons', type: 'int', default: 53 })
maxWagons!: number;
/**
* Where THIS departure plans to board each consist wagon: `{ wagonId: yardId }`.
* Sparse — a wagon absent from the map boards from its physical
* `wagons.current_yard_id`. Independent of the built train's physical spread
* so a departure can be sold from Dire while the steel still stands in Mojo;
* dispatch requires plan and physical yards to agree.
*/
@Column({ name: 'planned_wagon_yards', type: 'jsonb', nullable: true })
plannedWagonYards?: Record<string, string> | null;
/** OPEN = accepting/holding bookings; FULL = train filled; CLOSED = manually closed. Orthogonal to `status`. */
@Column({ name: 'booking_window_status', type: 'varchar', length: 10, default: 'OPEN' })
bookingWindowStatus!: string;

View File

@@ -48,6 +48,7 @@ import {
} from "../dto/import-djibouti-operation.dto";
import { AvailableLocomotivesQueryDto } from "../dto/available-locomotives-query.dto";
import { AdjustScheduleConsistDto } from "../dto/adjust-schedule-consist.dto";
import { UpdateScheduleWagonYardsDto } from "../dto/update-schedule-wagon-yards.dto";
import { AvailableTrainsQueryDto } from "../dto/available-trains-query.dto";
import { BatchBoardQueryDto } from "../dto/batch-board-query.dto";
import { BookableSchedulesQueryDto } from "../dto/bookable-schedules-query.dto";
@@ -201,6 +202,29 @@ export class TrainSchedulingController {
return this.trainSchedulingService.getScheduleConsist(id);
}
@Get("schedules/:id/wagon-yards")
@TrainSchedulingView()
@ApiOperation({
summary:
"Schedule wagon yard plan: where THIS departure boards each consist wagon vs where it physically stands, per-stop totals, locked wagons",
})
getScheduleWagonYards(@Param("id", ParseUUIDPipe) id: string) {
return this.trainSchedulingService.getScheduleWagonYards(id);
}
@Patch("schedules/:id/wagon-yards")
@TrainSchedulingUpdate()
@ApiOperation({
summary:
"Re-plan the yard this departure boards wagons from (schedule-only; physical yards untouched, dispatch requires alignment)",
})
updateScheduleWagonYards(
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: UpdateScheduleWagonYardsDto,
) {
return this.trainSchedulingService.updateScheduleWagonYards(id, dto.moves);
}
@Post("schedules/:id/adjust-consist")
@TrainSchedulingUpdate()
@ApiOperation({

View File

@@ -0,0 +1,26 @@
import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { ArrayMaxSize, IsArray, IsUUID, ValidateNested } from 'class-validator';
export class ScheduleWagonYardMoveDto {
@ApiProperty({ format: 'uuid', description: "Wagon coupled to the schedule's built train." })
@IsUUID()
wagonId!: string;
@ApiProperty({ format: 'uuid', description: 'Pickup stop of the route this departure boards the wagon from.' })
@IsUUID()
yardId!: string;
}
export class UpdateScheduleWagonYardsDto {
@ApiProperty({
type: [ScheduleWagonYardMoveDto],
description:
'Wagon → planned boarding yard for THIS schedule only. Physical wagon yards are untouched; dispatch requires both to agree.',
})
@IsArray()
@ArrayMaxSize(500)
@ValidateNested({ each: true })
@Type(() => ScheduleWagonYardMoveDto)
moves!: ScheduleWagonYardMoveDto[];
}

View File

@@ -138,6 +138,12 @@ import {
import { CorridorBudget } from '../corridor-capacity.util';
import { deriveScheduleDirection } from '../utils/derive-schedule-direction.util';
import { computeScheduleWagonUsage } from '../utils/schedule-wagon-usage.util';
import {
defaultPlannedWagonYards,
misalignedWagons,
type PlannedWagonYards,
scheduleYardOf,
} from '../utils/planned-wagon-yards.util';
import { pickLowestFreeNumber, pickTrainNumberPool } from '../train-number.util';
import {
bookingCargoTons,
@@ -1557,6 +1563,7 @@ export class TrainSchedulingService {
// and yard follow the schedule.
let builtTrain: Train | null = null;
let locomotiveIds: string[];
let plannedWagonYards: PlannedWagonYards | null = null;
if (dto.trainId) {
builtTrain = await this.dataSource.getRepository(Train).findOne({
where: { id: dto.trainId },
@@ -1589,7 +1596,7 @@ export class TrainSchedulingService {
`Train ${builtTrain.code} is not at the origin yard yet; it must arrive before this departure dispatches`,
);
}
await this.assertRouteCoversWagonYards(builtTrain, route);
plannedWagonYards = await this.defaultPlannedWagonYardsFor(builtTrain, route, scheduleWarnings);
const conflict = await this.findTrainRouteDayConflict(
builtTrain.id,
route.id,
@@ -1844,6 +1851,7 @@ export class TrainSchedulingService {
direction,
trainNumber: pairTrainNumber ?? undefined,
maxWagons,
plannedWagonYards,
reverseWagonOrder: dto.reverseWagonOrder ?? false,
shippingLineCompanyId: dto.shippingLineCompanyId ?? null,
...windowFields,
@@ -2742,6 +2750,9 @@ export class TrainSchedulingService {
);
}
}
// The yard plan this departure was SOLD against must match where the steel
// actually stands: a wagon sold from Dire but still in Mojo cannot board.
await this.assertPlannedYardsAligned(schedule);
await this.dataSource.transaction(async (manager) => {
const trainNumber = await this.assignTrainNumber(manager, schedule);
@@ -5295,15 +5306,30 @@ export class TrainSchedulingService {
return rows[0]?.train_id ?? null;
}
/** `{ wagonId: yardId }` this schedule boards each wagon from; `{}` when unset. */
private async plannedWagonYardsOf(
scheduleId: string | undefined,
manager?: EntityManager,
): Promise<PlannedWagonYards> {
if (!scheduleId) return {};
const runner = manager ?? this.dataSource;
const rows: { planned_wagon_yards: PlannedWagonYards | null }[] = await runner.query(
`SELECT planned_wagon_yards FROM freight.train_schedules WHERE id = $1`,
[scheduleId],
);
return rows[0]?.planned_wagon_yards ?? {};
}
private async countFleetAvailability(
originYardId: string,
targetScheduleId?: string,
): Promise<Array<{ wagonTypeId: string; wagonTypeCode: string; available: number }>> {
const [wagons, wagonTypes, builtTrainId, pinnedToTargetIds] = await Promise.all([
const [wagons, wagonTypes, builtTrainId, pinnedToTargetIds, plan] = await Promise.all([
this.dataSource.getRepository(Wagon).find(),
this.dataSource.getRepository(WagonType).find(),
this.builtTrainIdOfSchedule(targetScheduleId),
this.pinnedPhysicalWagonIdsForSchedule(targetScheduleId),
this.plannedWagonYardsOf(targetScheduleId),
]);
const typeCodeById = new Map(wagonTypes.map((type) => [type.id, type.code]));
const counts = new Map<string, { code: string; available: number }>();
@@ -5311,11 +5337,14 @@ export class TrainSchedulingService {
// A built consist spread across several yards can only offer, at each yard,
// the wagons standing there. A single-yard consist keeps the original
// behaviour: the whole train counts wherever it currently sits.
// Yards are the SCHEDULE's plan (falling back to the physical yard), so a
// departure sold from Dire counts its Dire wagons even while they still
// stand in Mojo — dispatch is what demands the two agree.
const consistYards = builtTrainId
? new Set(
wagons
.filter((w) => w.trainId === builtTrainId && w.currentYardId)
.map((w) => w.currentYardId as string),
.filter((w) => w.trainId === builtTrainId && scheduleYardOf(plan, w))
.map((w) => scheduleYardOf(plan, w) as string),
)
: new Set<string>();
const consistIsSplit = consistYards.size > 1;
@@ -5327,7 +5356,7 @@ export class TrainSchedulingService {
// counted at the yard each wagon actually stands in.
if (builtTrainId) {
if (wagon.trainId !== builtTrainId) continue;
if (consistIsSplit && wagon.currentYardId !== originYardId) continue;
if (consistIsSplit && scheduleYardOf(plan, wagon) !== originYardId) continue;
} else {
// Schedule-scoped availability: pins held by OTHER schedules never
// consume a wagon here — the same physical wagon may serve the July 17
@@ -5498,6 +5527,7 @@ export class TrainSchedulingService {
const pinSchedule = await this.trainSchedulesRepository.findById(scheduleId);
const stops = pinSchedule ? await this.stopYardsForSchedule(pinSchedule) : [];
const plannedYards = pinSchedule?.plannedWagonYards ?? {};
const unpinnable = this.findUnpinnableWagonSlots(
planSlots,
@@ -5507,6 +5537,7 @@ export class TrainSchedulingService {
builtTrainId,
pinnedToScheduleIds,
stops,
plannedYards,
);
if (unpinnable.length) {
throw new BadRequestException({
@@ -5528,6 +5559,7 @@ export class TrainSchedulingService {
builtTrainId,
pinnedToScheduleIds,
reverseWagonOrder,
plannedYards,
);
if (!physical) continue;
@@ -5577,6 +5609,7 @@ export class TrainSchedulingService {
builtTrainId,
pinnedToScheduleIds,
stops,
targetSchedule?.plannedWagonYards ?? {},
);
}
@@ -5609,6 +5642,7 @@ export class TrainSchedulingService {
builtTrainId: string | null = null,
pinnedToScheduleIds: Set<string> = new Set(),
stops: string[] = [],
plannedYards: PlannedWagonYards = {},
): string[] {
const violations: string[] = [];
// One physical wagon may serve several slots whose leg spans don't overlap
@@ -5627,6 +5661,8 @@ export class TrainSchedulingService {
span,
builtTrainId,
pinnedToScheduleIds,
false,
plannedYards,
);
if (!physical) {
violations.push(
@@ -5657,6 +5693,7 @@ export class TrainSchedulingService {
builtTrainId: string | null = null,
pinnedToScheduleIds: Set<string> = new Set(),
reverseWagonOrder = false,
plannedYards: PlannedWagonYards = {},
): Wagon | undefined {
// Free for this slot = no already-assigned span on this wagon overlaps the
// slot's own leg. Disjoint legs (alight before board) share the wagon.
@@ -5689,8 +5726,8 @@ export class TrainSchedulingService {
// takes slot #1). Unsequenced wagons sort after every sequenced one.
const consistYards = new Set(
wagons
.filter((w) => w.trainId === builtTrainId && w.currentYardId)
.map((w) => w.currentYardId as string),
.filter((w) => w.trainId === builtTrainId && scheduleYardOf(plannedYards, w))
.map((w) => scheduleYardOf(plannedYards, w) as string),
);
// Split consist: a slot boarding at a given yard must take a wagon that
// physically stands there — the train cannot load a Mojo wagon at Dire.
@@ -5703,7 +5740,7 @@ export class TrainSchedulingService {
w.trainId === builtTrainId &&
w.wagonTypeId === slot.wagonTypeId &&
spanFree(w.id) &&
(!requiredYardId || w.currentYardId === requiredYardId),
(!requiredYardId || scheduleYardOf(plannedYards, w) === requiredYardId),
)
.sort((a, b) => {
if (a.sequenceNumber == null || b.sequenceNumber == null) {
@@ -5843,7 +5880,7 @@ export class TrainSchedulingService {
preloadedBuiltTrainId !== undefined
? preloadedBuiltTrainId
: await this.builtTrainIdOfSchedule(scheduleId);
if (builtTrainId) return this.builtTrainStock(builtTrainId);
if (builtTrainId) return this.builtTrainStock(builtTrainId, scheduleId);
const boardYardIds = [
...new Set([originYardId, ...boardingYardIds].filter((id): id is string => Boolean(id))),
@@ -5865,11 +5902,17 @@ export class TrainSchedulingService {
return { mode: 'YARD', remainingByTypeId, codesByTypeId };
}
private async builtTrainStock(builtTrainId: string): Promise<WagonStock> {
const wagons = await this.dataSource.getRepository(Wagon).find({
where: { trainId: builtTrainId },
relations: { wagonType: true },
});
private async builtTrainStock(
builtTrainId: string,
scheduleId?: string,
): Promise<WagonStock> {
const [wagons, plan] = await Promise.all([
this.dataSource.getRepository(Wagon).find({
where: { trainId: builtTrainId },
relations: { wagonType: true },
}),
this.plannedWagonYardsOf(scheduleId),
]);
const remainingByTypeId = new Map<string, number>();
const codesByTypeId = new Map<string, string>();
const byYardId = new Map<string, Map<string, number>>();
@@ -5879,10 +5922,12 @@ export class TrainSchedulingService {
(remainingByTypeId.get(wagon.wagonTypeId) ?? 0) + 1,
);
if (wagon.wagonType) codesByTypeId.set(wagon.wagonTypeId, wagon.wagonType.code);
if (wagon.currentYardId) {
const perType = byYardId.get(wagon.currentYardId) ?? new Map<string, number>();
// The schedule's own yard plan, not the physical yard — see plannedWagonYards.
const yardId = scheduleYardOf(plan, wagon);
if (yardId) {
const perType = byYardId.get(yardId) ?? new Map<string, number>();
perType.set(wagon.wagonTypeId, (perType.get(wagon.wagonTypeId) ?? 0) + 1);
byYardId.set(wagon.currentYardId, perType);
byYardId.set(yardId, perType);
}
}
// Single-yard consist (the overwhelming majority): the whole train is
@@ -6222,17 +6267,22 @@ export class TrainSchedulingService {
}
/**
* A built train's wagons may stand in several yards. The route must pass
* through every one of them as origin or an intermediate stop — never only
* as the final destination (the train has to pick the wagons up en route).
* Default yard plan for a schedule created from a built train: every wagon
* keeps the yard it physically stands in when that yard is a pickup stop of
* the route (origin or intermediate — never only the destination, the train
* has to collect it en route); the rest are planned at the origin and
* reported as a warning so staff can redistribute in the schedule-yards tab.
*/
private async assertRouteCoversWagonYards(train: Train, route: Route) {
private async defaultPlannedWagonYardsFor(
train: Train,
route: Route,
warnings: string[],
): Promise<PlannedWagonYards | null> {
const wagons = await this.dataSource.getRepository(Wagon).find({
where: { trainId: train.id },
select: { id: true, currentYardId: true },
select: { id: true, currentYardId: true, wagonNumber: true },
});
const wagonYards = [...new Set(wagons.map((w) => w.currentYardId).filter((y): y is string => !!y))];
if (!wagonYards.length) return;
if (!wagons.length) return null;
const milestones = await this.dataSource
.getRepository(RouteMilestone)
@@ -6243,23 +6293,203 @@ export class TrainSchedulingService {
// Every stop except the last one is a pickup point.
const pickupYards = new Set(stops.slice(0, -1));
const uncovered = wagonYards.filter((y) => !pickupYards.has(y));
if (!uncovered.length) return;
const { plan, rehomed } = defaultPlannedWagonYards(wagons, pickupYards, route.originYardId);
if (rehomed.length) {
const labels = await this.yardLabelMap([
...new Set(rehomed.map((w) => w.currentYardId).filter((y): y is string => !!y)),
]);
const origin = labels.get(route.originYardId) ?? route.originYard?.label ?? 'the origin';
const where = [...new Set(rehomed.map((w) => (w.currentYardId ? labels.get(w.currentYardId) ?? w.currentYardId : 'no yard')))].join(', ');
warnings.push(
`${rehomed.length} wagon(s) of train ${train.code} stand off this route (${where}) and were planned at ${origin}; ` +
'they must be moved there before dispatch — adjust in the schedule yards tab if they should board elsewhere',
);
}
return plan;
}
const labels = await this.yardLabelMap(uncovered);
const destination = stops[stops.length - 1];
const detail = uncovered
.map((y) =>
y === destination
? `${labels.get(y) ?? y} (only as the destination)`
: `${labels.get(y) ?? y} (not on route)`,
)
/** Dispatch gate: every planned wagon must physically stand at its planned yard. */
private async assertPlannedYardsAligned(schedule: TrainSchedule) {
const builtTrainId = schedule.trainSet?.trainId;
if (!builtTrainId) return;
const wagons = await this.dataSource.getRepository(Wagon).find({
where: { trainId: builtTrainId },
select: { id: true, currentYardId: true, wagonNumber: true },
});
const off = misalignedWagons(schedule.plannedWagonYards, wagons);
if (!off.length) return;
const plan = schedule.plannedWagonYards ?? {};
const labels = await this.yardLabelMap([
...new Set(off.flatMap((w) => [plan[w.id], w.currentYardId]).filter((y): y is string => !!y)),
]);
const detail = off
.slice(0, 5)
.map((w) => `${w.wagonNumber} (planned ${labels.get(plan[w.id]) ?? plan[w.id]}, at ${w.currentYardId ? labels.get(w.currentYardId) ?? w.currentYardId : 'no yard'})`)
.join(', ');
throw new BadRequestException(
`Route ${formatRouteLabel(route)} does not pass through every yard where train ${train.code}'s wagons stand: ${detail}`,
throw new ConflictException(
`Cannot dispatch: ${off.length} wagon(s) are not at the yard this schedule planned them for — ${detail}` +
(off.length > 5 ? ', …' : '') +
'. Move them in the train builder or re-plan them in the schedule yards tab.',
);
}
/**
* Schedule-yards tab: where THIS departure boards each consist wagon vs
* where it physically stands, per stop totals, and which wagons are locked
* (already carrying this schedule's cargo).
*/
async getScheduleWagonYards(scheduleId: string) {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!schedule) throw new NotFoundException(`Train schedule ${scheduleId} not found`);
const builtTrain = schedule.trainSet?.train;
if (!builtTrain) {
throw new BadRequestException(
'This schedule was not created from a built train — it has no wagon yard plan',
);
}
const stops = this.mapScheduleStops(schedule);
const pickupYardIds = new Set(stops.slice(0, -1).map((s) => s.yardId));
const plan = schedule.plannedWagonYards ?? {};
const wagons = await this.dataSource.getRepository(Wagon).find({
where: { trainId: builtTrain.id },
relations: { wagonType: true, currentYard: true },
order: { sequenceNumber: 'ASC' },
});
const lockedIds = new Set(
(schedule.trainSet?.wagons ?? [])
.filter((slot) => slot.physicalWagonId && (slot.allocations?.length ?? 0) > 0)
.map((slot) => slot.physicalWagonId as string),
);
const offRouteYardIds = [
...new Set(
wagons
.flatMap((w) => [scheduleYardOf(plan, w), w.currentYardId])
.filter((y): y is string => !!y && !stops.some((s) => s.yardId === y)),
),
];
const labels = new Map([
...stops.map((s) => [s.yardId, s.label] as const),
...(await this.yardLabelMap(offRouteYardIds)),
]);
const editable =
schedule.status === TrainScheduleStatusEnum.Draft ||
schedule.status === TrainScheduleStatusEnum.Scheduled;
const rows = wagons.map((w) => {
const plannedYardId = scheduleYardOf(plan, w);
const locked = lockedIds.has(w.id);
return {
id: w.id,
wagonNumber: w.wagonNumber,
sequenceNumber: w.sequenceNumber,
wagonType: w.wagonType
? { id: w.wagonType.id, code: w.wagonType.code, name: w.wagonType.name }
: { id: w.wagonTypeId, code: w.wagonTypeId, name: w.wagonTypeId },
physicalYardId: w.currentYardId,
physicalYardLabel: w.currentYardId ? labels.get(w.currentYardId) ?? w.currentYardId : null,
plannedYardId,
plannedYardLabel: plannedYardId ? labels.get(plannedYardId) ?? plannedYardId : null,
aligned: plannedYardId === w.currentYardId,
locked,
lockReason: locked ? 'Carries cargo booked on this schedule' : null,
};
});
const perStop = stops.map((s) => ({
yardId: s.yardId,
label: s.label,
pickup: pickupYardIds.has(s.yardId),
planned: rows.filter((r) => r.plannedYardId === s.yardId).length,
physical: rows.filter((r) => r.physicalYardId === s.yardId).length,
}));
return {
scheduleId,
train: { id: builtTrain.id, code: builtTrain.code },
editable,
stops: perStop,
wagons: rows,
misaligned: rows.filter((r) => !r.aligned).length,
};
}
/**
* Re-plan which yard this departure boards wagons from. Only DRAFT/SCHEDULED
* schedules, only the train's own wagons, only pickup stops of the route,
* never a wagon already carrying this schedule's cargo. Physical yards are
* untouched — the train builder owns those.
*/
async updateScheduleWagonYards(
scheduleId: string,
moves: Array<{ wagonId: string; yardId: string }>,
) {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!schedule) throw new NotFoundException(`Train schedule ${scheduleId} not found`);
const builtTrain = schedule.trainSet?.train;
if (!builtTrain) {
throw new BadRequestException(
'This schedule was not created from a built train — it has no wagon yard plan',
);
}
if (
schedule.status !== TrainScheduleStatusEnum.Draft &&
schedule.status !== TrainScheduleStatusEnum.Scheduled
) {
throw new ConflictException(
`Wagon yards can only be re-planned before departure (schedule is ${schedule.status})`,
);
}
const stops = this.mapScheduleStops(schedule);
const pickupYardIds = new Set(stops.slice(0, -1).map((s) => s.yardId));
const wagons = await this.dataSource.getRepository(Wagon).find({
where: { trainId: builtTrain.id },
select: { id: true, currentYardId: true, wagonNumber: true },
});
const wagonById = new Map(wagons.map((w) => [w.id, w]));
const lockedIds = new Set(
(schedule.trainSet?.wagons ?? [])
.filter((slot) => slot.physicalWagonId && (slot.allocations?.length ?? 0) > 0)
.map((slot) => slot.physicalWagonId as string),
);
const plan: PlannedWagonYards = { ...(schedule.plannedWagonYards ?? {}) };
for (const move of moves) {
const wagon = wagonById.get(move.wagonId);
if (!wagon) {
throw new BadRequestException(`Wagon ${move.wagonId} is not coupled to train ${builtTrain.code}`);
}
if (!pickupYardIds.has(move.yardId)) {
throw new BadRequestException(
`Yard ${move.yardId} is not a pickup stop of this schedule's route`,
);
}
if (lockedIds.has(wagon.id) && scheduleYardOf(plan, wagon) !== move.yardId) {
throw new ConflictException(
`Wagon ${wagon.wagonNumber} already carries cargo booked on this schedule and cannot change yard`,
);
}
plan[wagon.id] = move.yardId;
}
await this.dataSource
.getRepository(TrainSchedule)
.update(scheduleId, { plannedWagonYards: plan });
// ponytail: per-stop over-booking check counts bookings boarding at the
// stop against wagons planned there, ignoring leg sharing — a warning, not
// a gate; switch to CorridorBudget per stop if staff need exact numbers.
const warnings: string[] = [];
for (const stop of stops.slice(0, -1)) {
const planned = wagons.filter((w) => scheduleYardOf(plan, w) === stop.yardId).length;
const booked = (schedule.scheduleBookings ?? [])
.filter((sb) => sb.booking?.originYardId === stop.yardId)
.reduce((sum, sb) => sum + (sb.booking ? this.effectiveWagonsRequired(sb.booking) : 0), 0);
if (booked > planned) {
warnings.push(
`${stop.label}: bookings boarding here need ${booked} wagon(s) but only ${planned} are planned at this yard`,
);
}
}
return { ...(await this.getScheduleWagonYards(scheduleId)), warnings };
}
private async getSchedulableRoute(routeId: string) {
const route = await this.dataSource.getRepository(Route).findOne({
where: { id: routeId },

View File

@@ -0,0 +1,30 @@
import {
defaultPlannedWagonYards,
misalignedWagons,
scheduleYardOf,
} from './planned-wagon-yards.util';
const w = (id: string, currentYardId: string | null) => ({ id, currentYardId });
describe('planned-wagon-yards.util', () => {
it('scheduleYardOf prefers the plan and falls back to the physical yard', () => {
expect(scheduleYardOf({ w1: 'B' }, w('w1', 'C'))).toBe('B');
expect(scheduleYardOf({ w1: 'B' }, w('w2', 'C'))).toBe('C');
expect(scheduleYardOf(null, w('w2', null))).toBeNull();
});
it('defaultPlannedWagonYards snapshots on-route yards and rehomes the rest to origin', () => {
const { plan, rehomed } = defaultPlannedWagonYards(
[w('a', 'A'), w('b', 'B'), w('x', 'X'), w('n', null)],
new Set(['A', 'B', 'C']),
'A',
);
expect(plan).toEqual({ a: 'A', b: 'B', x: 'A', n: 'A' });
expect(rehomed.map((r) => r.id)).toEqual(['x', 'n']);
});
it('misalignedWagons lists only planned wagons standing elsewhere', () => {
const out = misalignedWagons({ a: 'A', b: 'B' }, [w('a', 'A'), w('b', 'C'), w('z', 'Z')]);
expect(out.map((r) => r.id)).toEqual(['b']);
});
});

View File

@@ -0,0 +1,51 @@
/**
* Per-schedule wagon yard plan: `{ wagonId: yardId }` — where THIS departure
* boards each consist wagon, independent of where the steel physically stands
* (`wagons.current_yard_id`, one fact shared by every schedule of the train).
*/
export type PlannedWagonYards = Record<string, string>;
type YardedWagon = { id: string; currentYardId: string | null };
/** Yard a schedule boards a wagon from: its own plan first, the physical yard otherwise. */
export function scheduleYardOf(
plan: PlannedWagonYards | null | undefined,
wagon: YardedWagon,
): string | null {
return plan?.[wagon.id] ?? wagon.currentYardId;
}
/**
* Default plan when a schedule is created from a built train: snapshot every
* wagon's physical yard (so later physical moves never shift this departure's
* capacity); a wagon standing off the route's pickup stops — or nowhere — is
* planned at the origin instead, and reported back so staff can redistribute.
*/
export function defaultPlannedWagonYards(
wagons: readonly YardedWagon[],
pickupYardIds: ReadonlySet<string>,
originYardId: string,
): { plan: PlannedWagonYards; rehomed: YardedWagon[] } {
const plan: PlannedWagonYards = {};
const rehomed: YardedWagon[] = [];
for (const wagon of wagons) {
if (wagon.currentYardId && pickupYardIds.has(wagon.currentYardId)) {
plan[wagon.id] = wagon.currentYardId;
} else {
plan[wagon.id] = originYardId;
rehomed.push(wagon);
}
}
return { plan, rehomed };
}
/** Wagons whose planned yard disagrees with where they physically stand. */
export function misalignedWagons<T extends YardedWagon>(
plan: PlannedWagonYards | null | undefined,
wagons: readonly T[],
): T[] {
return wagons.filter((w) => {
const planned = plan?.[w.id];
return planned != null && planned !== w.currentYardId;
});
}

View File

@@ -53,7 +53,7 @@ export const bookingInput = {
export const bookingTable = {
headerCell:
"h-11 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground",
"whitespace-nowrap text-[10px] font-semibold uppercase tracking-[0.08em] text-edr-muted",
rowHover:
"transition-colors hover:bg-muted/25 data-[state=selected]:bg-muted/30",
rowIcon: `flex size-10 shrink-0 items-center justify-center rounded-xl ${bookingGlass.iconWellGreen}`,

View File

@@ -1,10 +1,12 @@
import { useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
ActionIcon,
Alert,
Badge,
Box,
Button,
Collapse,
FileButton,
Group,
Loader,
@@ -20,6 +22,7 @@ import {
import {
AlertCircle,
CheckCircle2,
ChevronDown,
Download,
Eye,
FileCheck2,
@@ -187,73 +190,102 @@ export function ClearanceReviewSection({
return (
<Stack gap="lg">
<SectionCard
icon={FileText}
title="Customer documents"
subtitle="Approve each document, or open a query to tell the customer what to fix."
extra={
<Text size="xs" c="dimmed" fw={600}>
{stats.approved}/{stats.total} approved
</Text>
}
>
<Stack gap={12}>
{!hideSummary && stats.total > 0 && (
<Box>
<Progress
value={stats.pct}
color="edr-green"
radius="xl"
size="sm"
mb={6}
/>
<Group gap="lg">
<StatPill color="edr-green" label="Approved" value={stats.approved} />
<StatPill color="red" label="Queried" value={stats.queried} />
<StatPill color="gray" label="Pending" value={stats.pending} />
</Group>
<Paper radius={13} withBorder style={{ overflow: "hidden" }} p={0}>
<Group
justify="space-between"
wrap="nowrap"
px={18}
py={15}
style={{ borderBottom: "1px solid #EFF3F7" }}
>
<Group gap={9} wrap="nowrap" style={{ minWidth: 0 }}>
<FileText size={16} color="#0A8A5F" />
<Box style={{ minWidth: 0 }}>
<Text fz={14} fw={700} c="edr-text">
Customer documents
</Text>
<Text fz={11.5} c="#93A4B5" truncate>
{stats.approved} of {stats.total} approved
{stats.queried > 0 ? ` · ${stats.queried} queried` : ""} · required
marked *
</Text>
</Box>
)}
{customerDocs.length === 0 ? (
<Text size="sm" c="dimmed">
No customer documents are required for this booking.
</Group>
<Group
gap={5}
wrap="nowrap"
px={8}
py={3}
style={{
flexShrink: 0,
borderRadius: 6,
background: approvalsLocked ? "#F4F7FA" : "#E7F5EF",
}}
>
<Box
style={{
width: 6,
height: 6,
borderRadius: 999,
background: approvalsLocked ? "#93A4B5" : "#0A8A5F",
}}
/>
<Text
fz={10.5}
fw={700}
style={{ color: approvalsLocked ? "#67788A" : "#0A8A5F" }}
>
{approvalsLocked ? "Uploads closed" : "Uploads open"}
</Text>
) : (
customerDocs.map((doc) => (
<DocReviewCard
key={`${doc.settingCode}:${doc.fileKey}`}
doc={doc}
approvalsLocked={effectiveApprovalsLocked}
queriesLocked={queriesLocked}
readOnly={readOnly}
note={queryNotes[doc.fileKey] ?? ""}
queryOpen={openQuery[doc.fileKey] ?? false}
onToggleQuery={(open) =>
setOpenQuery((o) => ({ ...o, [doc.fileKey]: open }))
}
onNote={(v) =>
setQueryNotes((n) => ({ ...n, [doc.fileKey]: v }))
}
onApprove={() =>
reviewMutation.mutate({
fileKey: doc.fileKey,
status: "APPROVED",
})
}
onQuery={() =>
reviewMutation.mutate({
fileKey: doc.fileKey,
status: "QUERIED",
note: queryNotes[doc.fileKey],
})
}
onView={view}
busy={reviewMutation.isPending}
/>
))
)}
</Stack>
</SectionCard>
</Group>
</Group>
{!hideSummary && stats.total > 0 && (
<Box px={18} py={12} style={{ borderBottom: "1px solid #EFF3F7" }}>
<Progress value={stats.pct} color="edr-green" radius="xl" size="sm" mb={8} />
<Group gap="lg">
<StatPill color="edr-green" label="Approved" value={stats.approved} />
<StatPill color="red" label="Queried" value={stats.queried} />
<StatPill color="gray" label="Pending" value={stats.pending} />
</Group>
</Box>
)}
{customerDocs.length === 0 ? (
<Text size="sm" c="dimmed" px={18} py={20}>
No customer documents are required for this booking.
</Text>
) : (
customerDocs.map((doc, i) => (
<DocReviewCard
key={`${doc.settingCode}:${doc.fileKey}`}
doc={doc}
first={i === 0}
approvalsLocked={effectiveApprovalsLocked}
queriesLocked={queriesLocked}
readOnly={readOnly}
note={queryNotes[doc.fileKey] ?? ""}
queryOpen={openQuery[doc.fileKey] ?? false}
onToggleQuery={(open) =>
setOpenQuery((o) => ({ ...o, [doc.fileKey]: open }))
}
onNote={(v) => setQueryNotes((n) => ({ ...n, [doc.fileKey]: v }))}
onApprove={() =>
reviewMutation.mutate({ fileKey: doc.fileKey, status: "APPROVED" })
}
onQuery={() =>
reviewMutation.mutate({
fileKey: doc.fileKey,
status: "QUERIED",
note: queryNotes[doc.fileKey],
})
}
onView={view}
busy={reviewMutation.isPending}
/>
))
)}
</Paper>
{clearance.outputCode && !phasedCustoms && (
<SectionCard
@@ -559,6 +591,23 @@ function StatPill({
);
}
/** Row tints straight from the design tokens. */
const ROW_TONE: Record<
Freight.DocumentReviewStatus,
{ bg: string; chipBg: string; fg: string }
> = {
APPROVED: { bg: "#FFFFFF", chipBg: "#E7F5EF", fg: "#0A8A5F" },
QUERIED: { bg: "#FBECEA", chipBg: "#FBECEA", fg: "#C0392B" },
PENDING: { bg: "#FFFFFF", chipBg: "#FCF2E2", fg: "#A76F08" },
};
/**
* One document as a compact 60px row that expands in place. Collapsed it shows
* name, file line, status chip and the review actions; expanded it reveals the
* per-document history timeline and the query note. Keeping the actions in the
* collapsed row means approving a stack of documents never needs a single
* expand.
*/
function DocReviewCard({
doc,
approvalsLocked,
@@ -572,6 +621,7 @@ function DocReviewCard({
onQuery,
onView,
busy,
first,
}: {
doc: Freight.ClearanceDocument;
approvalsLocked: boolean;
@@ -585,146 +635,177 @@ function DocReviewCard({
onQuery: () => void;
onView: (file: { name: string; url: string }) => void;
busy: boolean;
first: boolean;
}) {
const status = doc.reviewStatus ?? "PENDING";
const meta = STATUS_META[status];
const tone = ROW_TONE[status];
const hasFile = !!doc.file;
const isApproved = status === "APPROVED";
const history = doc.history ?? [];
// A queried document is the one the reviewer must act on, so it opens itself.
const [open, setOpen] = useState(status === "QUERIED");
const expandable = history.length > 0 || Boolean(doc.note);
// Opening the query form has to reveal the body it lives in.
const bodyOpen = open || queryOpen;
// The file line carries the same at-a-glance summary as the design: file
// name, who decided, when.
const last = history[history.length - 1];
const fileLine = hasFile
? [
doc.file!.name,
status === "APPROVED" && last ? `Approved by ${last.byName ?? "staff"}` : null,
status === "PENDING" ? "awaiting review" : null,
status === "QUERIED" ? doc.note : null,
last ? formatDateTime(last.at) : null,
]
.filter(Boolean)
.join(" · ")
: "Not uploaded by customer";
return (
<Paper
withBorder
radius="md"
p="md"
<Box
style={{
borderColor:
status === "QUERIED"
? "var(--mantine-color-red-2)"
: status === "APPROVED"
? "var(--mantine-color-edr-green-2)"
: "var(--mantine-color-edr-border-6)",
background: tone.bg,
borderTop: first ? undefined : "1px solid #EFF3F7",
}}
>
<Group justify="space-between" align="flex-start" wrap="nowrap">
<Group gap={12} wrap="nowrap" style={{ minWidth: 0 }}>
<ThemeIcon
variant="light"
color={hasFile ? "edr-green" : "gray"}
radius="md"
size={40}
>
<FileText size={19} />
</ThemeIcon>
<Box style={{ minWidth: 0 }}>
<Text fz="14px" fw={700} c="edr-text" truncate>
{doc.label}
{doc.required ? " *" : ""}
</Text>
<Text fz="12px" c="edr-muted" truncate>
{hasFile ? doc.file!.name : "Not uploaded by customer"}
</Text>
</Box>
</Group>
<Group gap={12} wrap="nowrap" align="center" px={18} py={13}>
<Box
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
width: 34,
height: 34,
borderRadius: 9,
background: tone.chipBg,
color: tone.fg,
}}
>
<FileText size={16} />
</Box>
<Group gap={8} wrap="nowrap">
<Badge variant="light" color={meta.color} radius="sm">
{meta.label}
</Badge>
{hasFile &&
isViewable({
name: doc.file!.name,
url: "",
}) && (
<Tooltip label="Preview document">
<Button
size="compact-xs"
variant="default"
radius="md"
leftSection={<Eye size={13} />}
onClick={() =>
void fetchViewableFile(doc.file!.id, doc.file!.name).then(
onView,
)
}
>
View
</Button>
</Tooltip>
)}
<Box style={{ flex: 1, minWidth: 0 }}>
<Text fz={12.5} fw={600} c="edr-text" truncate>
{doc.label}
{doc.required ? " *" : ""}
</Text>
<Text fz={11} c="#93A4B5" truncate>
{fileLine}
</Text>
</Box>
<Badge
variant="light"
radius="xl"
color={meta.color}
styles={{ root: { flexShrink: 0 } }}
>
{meta.label}
</Badge>
<Group gap={6} wrap="nowrap" style={{ flexShrink: 0 }}>
{hasFile && !readOnly && !isApproved && !approvalsLocked && (
<Button
size="compact-sm"
radius={7}
color="edr-green"
leftSection={<CheckCircle2 size={12} />}
disabled={busy}
onClick={onApprove}
>
Approve
</Button>
)}
{hasFile && !readOnly && !queriesLocked && !queryOpen && (
<Button
size="compact-sm"
radius={7}
variant="default"
disabled={busy}
onClick={() => {
onToggleQuery(true);
setOpen(true);
}}
>
Query
</Button>
)}
{hasFile && isViewable({ name: doc.file!.name, url: "" }) && (
<Tooltip label="Preview document">
<ActionIcon
variant="default"
radius={7}
size={29}
onClick={() =>
void fetchViewableFile(doc.file!.id, doc.file!.name).then(onView)
}
>
<Eye size={13} />
</ActionIcon>
</Tooltip>
)}
{hasFile && (
<Tooltip label="Download">
<Box
component="button"
type="button"
onClick={() =>
void downloadBookingFile(doc.file!.id, doc.file!.name)
}
c="edr-green"
style={{
display: "flex",
background: "transparent",
border: "none",
cursor: "pointer",
}}
<ActionIcon
variant="default"
radius={7}
size={29}
onClick={() => void downloadBookingFile(doc.file!.id, doc.file!.name)}
>
<Download size={15} />
</Box>
<Download size={13} />
</ActionIcon>
</Tooltip>
)}
{expandable && (
<Tooltip label={bodyOpen ? "Hide history" : "Show history"}>
<ActionIcon
variant="subtle"
color="gray"
radius={7}
size={29}
aria-expanded={bodyOpen}
aria-label={bodyOpen ? "Hide history" : "Show history"}
onClick={() => setOpen((o) => !o)}
>
<ChevronDown
size={14}
style={{
transition: "transform 150ms",
transform: bodyOpen ? "rotate(180deg)" : undefined,
}}
/>
</ActionIcon>
</Tooltip>
)}
</Group>
</Group>
{(doc.history?.length ?? 0) > 0 && (
<DocHistoryTimeline history={doc.history!} />
)}
<Collapse expanded={bodyOpen}>
<Box px={18} pb={14} pl={64}>
{status === "QUERIED" && doc.note ? (
<Alert
color="red"
variant="light"
radius="md"
icon={<MessageSquareWarning size={15} />}
p="xs"
mb="sm"
>
<Text fz={12.5} c="red.9">
{doc.note}
</Text>
</Alert>
) : null}
{status === "QUERIED" && doc.note && (
<Alert
mt="sm"
color="red"
variant="light"
radius="md"
icon={<MessageSquareWarning size={15} />}
p="xs"
>
<Text fz="12.5px" c="red.9">
{doc.note}
</Text>
</Alert>
)}
{history.length > 0 ? <DocHistoryTimeline history={history} /> : null}
{hasFile && !readOnly && (
<Box mt="sm">
{!queryOpen ? (
<Group justify="flex-end" gap={8}>
{!queriesLocked && (
<Button
size="compact-sm"
variant="light"
color="red"
radius="md"
leftSection={<MessageSquareWarning size={14} />}
disabled={busy}
onClick={() => onToggleQuery(true)}
>
Open query
</Button>
)}
{!isApproved && !approvalsLocked && (
<Button
size="compact-sm"
color="edr-green"
radius="md"
leftSection={<CheckCircle2 size={14} />}
disabled={busy}
onClick={onApprove}
>
Approve
</Button>
)}
</Group>
) : (
{queryOpen && !readOnly ? (
<Box
mt="sm"
p="sm"
style={{
borderRadius: 12,
@@ -733,11 +814,8 @@ function DocReviewCard({
}}
>
<Group gap={6} mb={6}>
<MessageSquareWarning
size={14}
color="var(--mantine-color-red-7)"
/>
<Text fz="12.5px" fw={700} c="red.8">
<MessageSquareWarning size={14} color="var(--mantine-color-red-7)" />
<Text fz={12.5} fw={700} c="red.8">
Describe the problem for the customer
</Text>
</Group>
@@ -775,9 +853,9 @@ function DocReviewCard({
</Button>
</Group>
</Box>
)}
) : null}
</Box>
)}
</Paper>
</Collapse>
</Box>
);
}

View File

@@ -1,6 +1,7 @@
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Alert,
Badge,
Box,
Button,
@@ -12,6 +13,7 @@ import {
Select,
Stack,
Text,
TextInput,
Tooltip,
} from "@mantine/core";
import {
@@ -19,9 +21,11 @@ import {
Download,
Eye,
FileText,
Lock,
Receipt,
Send,
Upload,
XCircle,
} from "lucide-react";
import toast from "react-hot-toast";
import type { Freight } from "@edr/types";
@@ -42,11 +46,19 @@ const STATUS_META: Record<
{ label: string; color: string }
> = {
DOC_UPLOADED: { label: "Awaiting billing", color: "yellow" },
BILLED: { label: "Ready to send", color: "blue" },
SENT: { label: "Sent — unpaid", color: "orange" },
BILLED: { label: "Draft — not sent", color: "blue" },
SENT: { label: "Awaiting customer approval", color: "orange" },
REJECTED: { label: "Rejected by customer", color: "red" },
ACCEPTED: { label: "Accepted — invoice unpaid", color: "teal" },
PAID: { label: "Paid", color: "edr-green" },
};
/** Once the customer accepts, the invoice exists and GL can no longer edit. */
const isLocked = (s: Freight.ClearanceChargeStatus) =>
s === "ACCEPTED" || s === "PAID";
type BillInput = { amount: number; currency: string; description: string };
export interface ClearanceChargesTabProps {
bookingId: string;
/** DJ uploads the port document; ET bills, sends and creates miscellaneous. */
@@ -55,11 +67,12 @@ export interface ClearanceChargesTabProps {
}
/**
* Post-finalization charges billed to the customer, two levels: port charges
* (document from GL Djibouti, billed by GL Ethiopia) then miscellaneous
* (created whole by GL Ethiopia once the port charge is paid). Each level
* issues its own payable invoice — ETB settles through the portal gateway
* (CBE), other currencies through Finance's manual settlement.
* Post-finalization charges billed to the customer: port charges (document
* from GL Djibouti, priced by GL Ethiopia) and any number of miscellaneous
* charges. GL prices + describes a charge and sends it; the customer accepts
* (invoice issued, charge locked) or rejects with a note (GL revises and
* re-sends). ETB settles through the portal gateway (CBE), other currencies
* through Finance's manual settlement.
*/
export function ClearanceChargesTab({
bookingId,
@@ -89,8 +102,9 @@ export function ClearanceChargesTab({
onError,
});
const bill = useMutation({
mutationFn: (p: { chargeId: string; amount: number; currency: string }) =>
bookingsService.billClearanceCharge(bookingId, p.chargeId, p),
// Body must be exactly the DTO — the API rejects unknown keys like chargeId.
mutationFn: ({ chargeId, ...payload }: BillInput & { chargeId: string }) =>
bookingsService.billClearanceCharge(bookingId, chargeId, payload),
onSuccess: (next) => {
toast.success("Charge amount saved");
refresh(next);
@@ -101,14 +115,14 @@ export function ClearanceChargesTab({
mutationFn: (chargeId: string) =>
bookingsService.sendClearanceCharge(bookingId, chargeId),
onSuccess: (next) => {
toast.success("Invoice sent to the customer");
toast.success("Sent to the customer for approval");
refresh(next);
},
onError,
});
const createMisc = useMutation({
mutationFn: (p: { file: File; amount: number; currency: string }) =>
bookingsService.createMiscellaneousCharge(bookingId, p.file, p),
mutationFn: ({ file, ...payload }: BillInput & { file: File }) =>
bookingsService.createMiscellaneousCharge(bookingId, file, payload),
onSuccess: (next) => {
toast.success("Miscellaneous charge created");
// Remount the form so the next charge starts from an empty one.
@@ -153,9 +167,7 @@ export function ClearanceChargesTab({
: "Waiting for GL Djibouti to upload the port-charges document."
}
onViewFile={onViewFile}
onBill={(amount, currency) =>
port && bill.mutate({ chargeId: port.id, amount, currency })
}
onBill={(input) => port && bill.mutate({ chargeId: port.id, ...input })}
onSend={() => port && send.mutate(port.id)}
djUpload={
roleMode === "DJ" && (!port || port.status === "DOC_UPLOADED") ? (
@@ -196,9 +208,7 @@ export function ClearanceChargesTab({
busy={busy}
emptyHint=""
onViewFile={onViewFile}
onBill={(amount, currency) =>
bill.mutate({ chargeId: c.id, amount, currency })
}
onBill={(input) => bill.mutate({ chargeId: c.id, ...input })}
onSend={() => send.mutate(c.id)}
/>
))}
@@ -211,15 +221,13 @@ export function ClearanceChargesTab({
: "Add a miscellaneous charge"}
</Text>
<Text fz="12px" c="dimmed" mb="sm">
Upload the supporting document and set the amount. You can raise as
many as the shipment needs, before or after the port charge.
Upload the supporting document, set the amount and say what it is
for. The customer sees it once you send it for approval.
</Text>
<MiscCreateForm
key={miscCreated}
busy={createMisc.isPending}
onCreate={(file, amount, currency) =>
createMisc.mutate({ file, amount, currency })
}
onCreate={(file, input) => createMisc.mutate({ file, ...input })}
/>
</Paper>
)}
@@ -265,7 +273,7 @@ function ChargeCard({
busy: boolean;
emptyHint: string;
onViewFile: (file: { name: string; url: string }) => void;
onBill: (amount: number, currency: string) => void;
onBill: (input: BillInput) => void;
onSend: () => void;
djUpload?: React.ReactNode;
etCreate?: React.ReactNode;
@@ -273,13 +281,17 @@ function ChargeCard({
const [editing, setEditing] = useState(false);
const [amount, setAmount] = useState<number | string>(charge?.amount ?? "");
const [currency, setCurrency] = useState<string>(charge?.currency ?? "ETB");
const [description, setDescription] = useState(charge?.description ?? "");
const status = charge?.status ?? null;
const meta = status ? STATUS_META[status] : null;
// ET enters/revises the amount while the charge is unpaid.
const locked = status != null && isLocked(status);
const needsDescription = charge?.type === "MISCELLANEOUS";
// ET enters/revises the price until the customer accepts it.
const showBillForm =
roleMode === "ET" &&
charge != null &&
!locked &&
(charge.status === "DOC_UPLOADED" || editing);
return (
@@ -304,6 +316,12 @@ function ChargeCard({
{formatDateTime(charge.billedAt)}
</Text>
)}
{charge?.status === "ACCEPTED" && charge.customerDecidedAt && (
<Text fz="11.5px" c="teal.8" fw={600}>
Accepted by the customer · {formatDateTime(charge.customerDecidedAt)}
{charge.invoiceNumber ? ` (invoice ${charge.invoiceNumber})` : ""}
</Text>
)}
{charge?.paidAt && (
<Text fz="11.5px" c="edr-green.8" fw={600}>
Paid · {formatDateTime(charge.paidAt)}
@@ -379,6 +397,32 @@ function ChargeCard({
</Group>
)}
{charge?.description && !showBillForm && (
<Text fz="12.5px" c="edr-text" mt="xs">
{charge.description}
</Text>
)}
{charge?.status === "REJECTED" && charge.customerNote && (
<Alert
color="red"
variant="light"
radius="md"
p="xs"
mt="sm"
icon={<XCircle size={16} />}
title="Rejected by the customer"
>
<Text fz="12.5px">{charge.customerNote}</Text>
{charge.customerDecidedAt && (
<Text fz="11px" c="dimmed" mt={4}>
{formatDateTime(charge.customerDecidedAt)} fix the price or
description and send it again.
</Text>
)}
</Alert>
)}
{!charge && (
<Text fz="12.5px" c="dimmed" mt="xs">
{emptyHint}
@@ -389,6 +433,15 @@ function ChargeCard({
{showBillForm && (
<Group mt="sm" gap={8} align="flex-end" wrap="wrap">
<TextInput
label={needsDescription ? "What is this charge for?" : "Description (optional)"}
size="xs"
radius="md"
value={description}
onChange={(e) => setDescription(e.currentTarget.value)}
maxLength={1000}
w={320}
/>
<NumberInput
label="Amount"
size="xs"
@@ -412,13 +465,21 @@ function ChargeCard({
size="compact-sm"
color="edr-green"
radius="md"
disabled={busy || !(Number(amount) > 0)}
disabled={
busy ||
!(Number(amount) > 0) ||
(needsDescription && !description.trim())
}
onClick={() => {
onBill(Number(amount), currency);
onBill({
amount: Number(amount),
currency,
description: description.trim(),
});
setEditing(false);
}}
>
Save amount
Save
</Button>
{editing && (
<Button
@@ -435,7 +496,7 @@ function ChargeCard({
</Group>
)}
{roleMode === "ET" && charge && !showBillForm && charge.status !== "PAID" && (
{roleMode === "ET" && charge && !showBillForm && !locked && (
<Group mt="sm" gap={8} justify="flex-end">
<Button
size="compact-sm"
@@ -446,13 +507,14 @@ function ChargeCard({
onClick={() => {
setAmount(charge.amount ?? "");
setCurrency(charge.currency ?? "ETB");
setDescription(charge.description ?? "");
setEditing(true);
}}
>
{charge.status === "SENT" ? "Revise (cancels invoice)" : "Edit amount"}
{charge.status === "SENT" ? "Revise" : "Edit"}
</Button>
{charge.status === "BILLED" && (
<Tooltip label="ETB is payable online via CBE; other currencies go to Finance's manual settlement.">
{(charge.status === "BILLED" || charge.status === "REJECTED") && (
<Tooltip label="The customer accepts or rejects the price in the portal; the invoice is issued when they accept.">
<Button
size="compact-sm"
color="edr-green"
@@ -461,15 +523,20 @@ function ChargeCard({
disabled={busy}
onClick={onSend}
>
Send invoice to customer
{charge.status === "REJECTED"
? "Send again for approval"
: "Send to customer for approval"}
</Button>
</Tooltip>
)}
{charge.status === "SENT" && charge.invoiceNumber && (
<Badge variant="light" color="orange" radius="sm">
Invoice {charge.invoiceNumber}
</Badge>
)}
</Group>
)}
{charge?.status === "ACCEPTED" && (
<Group mt="sm" gap={6} justify="flex-end">
<Lock size={14} color="var(--mantine-color-teal-7)" />
<Text fz="12px" c="teal.8" fw={600}>
Locked invoice {charge.invoiceNumber ?? ""} awaiting payment
</Text>
</Group>
)}
{charge?.status === "PAID" && (
@@ -489,14 +556,25 @@ function MiscCreateForm({
onCreate,
}: {
busy: boolean;
onCreate: (file: File, amount: number, currency: string) => void;
onCreate: (file: File, input: BillInput) => void;
}) {
const [file, setFile] = useState<File | null>(null);
const [amount, setAmount] = useState<number | string>("");
const [currency, setCurrency] = useState("ETB");
const [description, setDescription] = useState("");
return (
<Group gap={8} align="flex-end" wrap="wrap">
<TextInput
label="What is this charge for?"
placeholder="e.g. Container cleaning and weighbridge fee"
size="xs"
radius="md"
value={description}
onChange={(e) => setDescription(e.currentTarget.value)}
maxLength={1000}
w={320}
/>
<FileButton onChange={setFile} accept="application/pdf,image/*" disabled={busy}>
{(props) => (
<Button
@@ -534,9 +612,16 @@ function MiscCreateForm({
size="compact-sm"
color="edr-green"
radius="md"
disabled={busy || !file || !(Number(amount) > 0)}
disabled={busy || !file || !(Number(amount) > 0) || !description.trim()}
loading={busy}
onClick={() => file && onCreate(file, Number(amount), currency)}
onClick={() =>
file &&
onCreate(file, {
amount: Number(amount),
currency,
description: description.trim(),
})
}
>
Create charge
</Button>

View File

@@ -2,7 +2,11 @@ import { Check } from "lucide-react";
import { Box, Group, Stack, Text } from "@mantine/core";
import type { Freight } from "@edr/types";
const BRAND_GREEN = "var(--freight-brand, #0A6F4D)";
const GREEN = "#0A8A5F";
const BLUE = "#1D6FD1";
const BORDER = "#E4EBF1";
const MUTED = "#93A4B5";
const INK = "#10202F";
const IMPORT_PHASES = [
"CUSTOMER_INTAKE",
@@ -24,6 +28,18 @@ const PHASE_LABELS: Record<string, string> = {
POST_TRANSIT: "Transit",
};
/** Which desk owns each phase — shown under the label, as in the design. */
const PHASE_ACTOR: Record<string, string> = {
CUSTOMER_INTAKE: "CUSTOMER",
GL_ET_REVIEW: "GL ET",
GL_ET_OUTPUT: "GL ET",
CUSTOMER_DUTY: "CUSTOMER",
GL_ET_POST_CLEARANCE: "GL ET",
GL_DJ_COLLECTION: "GL DJ",
GL_DJ_LOADING: "GL DJ",
POST_TRANSIT: "OPS",
};
const EXPORT_PHASES = [
"CUSTOMER_INTAKE",
"GL_ET_REVIEW",
@@ -38,6 +54,20 @@ function phaseIndex(phases: readonly string[], current?: string | null): number
return idx >= 0 ? idx : 0;
}
/** Half-width connector; only the segment behind a completed dot is green. */
function Line({ done, hidden }: { done: boolean; hidden: boolean }) {
return (
<Box
style={{
flex: 1,
height: 2,
borderRadius: 2,
background: hidden ? "transparent" : done ? GREEN : BORDER,
}}
/>
);
}
export function ClearancePhaseStepper({
clearance,
tradeDirection,
@@ -50,61 +80,69 @@ export function ClearancePhaseStepper({
const phases = tradeDirection === "EXPORT" ? EXPORT_PHASES : IMPORT_PHASES;
const current = clearance?.phase ?? phases[0];
const activeIdx = phaseIndex(phases, current);
const dot = compact ? 26 : 28;
return (
<Group gap={0} wrap="nowrap" align="flex-start" style={{ overflowX: "auto" }}>
{phases.map((phase, index) => {
const isComplete = index < activeIdx;
const isActive = index === activeIdx;
const isLast = index === phases.length - 1;
const actor = PHASE_ACTOR[phase];
return (
<Box key={phase} style={{ flex: isLast ? "0 0 auto" : 1, minWidth: compact ? 72 : 88 }}>
<Group gap={0} wrap="nowrap" align="center">
<Stack gap={4} align="center" style={{ flexShrink: 0 }}>
<Box
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: compact ? 28 : 34,
height: compact ? 28 : 34,
borderRadius: "50%",
background: isComplete ? BRAND_GREEN : isActive ? "white" : "var(--mantine-color-gray-1)",
border: isActive
? `2px solid ${BRAND_GREEN}`
: isComplete
? "2px solid transparent"
: "2px solid var(--mantine-color-gray-3)",
color: isComplete ? "white" : isActive ? BRAND_GREEN : "var(--mantine-color-gray-5)",
}}
>
{isComplete ? <Check size={compact ? 14 : 16} strokeWidth={3} /> : null}
</Box>
<Text
size={compact ? "10px" : "xs"}
fw={isActive ? 600 : 500}
c={isActive ? "edr-green.7" : isComplete ? "dark" : "dimmed"}
ta="center"
style={{ whiteSpace: "nowrap" }}
>
{PHASE_LABELS[phase] ?? phase}
</Text>
</Stack>
{!isLast && (
<Box
style={{
flex: 1,
height: 2,
marginInline: 6,
marginBottom: compact ? 16 : 20,
borderRadius: 2,
background: isComplete ? BRAND_GREEN : "var(--mantine-color-gray-2)",
}}
/>
)}
<Stack
key={phase}
gap={7}
align="center"
style={{ flex: 1, minWidth: compact ? 92 : 112 }}
>
{/* Dot sits centred on its own row so the connectors meet it edge-to-edge. */}
<Group gap={0} wrap="nowrap" align="center" style={{ width: "100%" }}>
<Line done={isComplete || isActive} hidden={index === 0} />
<Box
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
width: dot,
height: dot,
borderRadius: 999,
background: isComplete ? GREEN : "#FFFFFF",
border: `2px solid ${
isComplete ? GREEN : isActive ? BLUE : BORDER
}`,
color: isComplete ? "#FFFFFF" : isActive ? BLUE : MUTED,
fontSize: 12,
fontWeight: 700,
lineHeight: 1,
}}
>
{isComplete ? <Check size={14} strokeWidth={3} /> : index + 1}
</Box>
<Line done={isComplete} hidden={index === phases.length - 1} />
</Group>
</Box>
<Text
fz={10.5}
fw={700}
lh={1.3}
ta="center"
style={{ color: isActive || isComplete ? INK : MUTED }}
>
{PHASE_LABELS[phase] ?? phase}
</Text>
{actor ? (
<Text
fz={9}
fw={700}
lts="0.3px"
style={{ color: isActive ? BLUE : MUTED, marginTop: -3 }}
>
{actor}
</Text>
) : null}
</Stack>
);
})}
</Group>

View File

@@ -122,7 +122,10 @@ function phaseCountdown(w: WindowRow): {
}
/** Badge label + Mantine color per UI state — same state the countdown uses. */
const KIND_BADGE: Record<BookingWindowUiKind, { label: string; color: string }> = {
const KIND_BADGE: Record<
BookingWindowUiKind,
{ label: string; color: string }
> = {
OPEN: { label: "Open now", color: "edr-green" },
FULL: { label: "Train full", color: "red" },
PRE_WINDOW: { label: "Opens soon", color: "yellow" },
@@ -301,8 +304,12 @@ export function GlUpcomingWindowsSection({
// Order by the train's dispatch (departure) date, nearest first. Open-now
// breaks ties on the same departure.
return rows.sort((a, b) => {
const da = a.departureDate ? new Date(a.departureDate).getTime() : Infinity;
const db = b.departureDate ? new Date(b.departureDate).getTime() : Infinity;
const da = a.departureDate
? new Date(a.departureDate).getTime()
: Infinity;
const db = b.departureDate
? new Date(b.departureDate).getTime()
: Infinity;
if (da !== db) return da - db;
return Number(b.isOpenNow) - Number(a.isOpenNow);
});
@@ -319,14 +326,16 @@ export function GlUpcomingWindowsSection({
return (
<Card withBorder shadow="sm" radius="lg" p="lg">
<Group justify="space-between" align="flex-start" mb="md" wrap="nowrap">
<Group gap={8} wrap="nowrap">
<CalendarClock size={18} />
<Group justify="space-between" align="center" mb="md" wrap="nowrap">
<Group gap={10} wrap="nowrap">
<div className="flex size-8 shrink-0 items-center justify-center rounded-[9px] bg-edr-soft text-edr-primary-dark">
<CalendarClock size={16} />
</div>
<Box>
<Text fw={700} fz={16}>
<Text ff="heading" fw={600} fz={15} lh={1.2}>
Booking windows
</Text>
<Text fz={13} c="dimmed">
<Text fz={12} c="edr-muted">
{contractId
? "Booking windows on this contract's routes (EAT)"
: "Import and export booking windows across all lanes (EAT)"}
@@ -386,7 +395,11 @@ export function GlUpcomingWindowsSection({
))}
</SimpleGrid>
) : (
<SimpleGrid key={safePage} cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<SimpleGrid
key={safePage}
cols={{ base: 1, sm: 2, lg: 3 }}
spacing="md"
>
{visible.map((w) => (
<WindowCard key={`${w.scheduleId}-${w.bookingCycleNo}`} w={w} />
))}

View File

@@ -2,10 +2,12 @@ import { useEffect, useMemo, useState } from "react";
import {
Alert,
Badge,
Box,
Button,
Group,
NumberInput,
Paper,
Progress,
SegmentedControl,
Select,
Stack,
@@ -14,14 +16,9 @@ import {
Text,
TextInput,
} from "@mantine/core";
import { PhasedFileDropzone, PhasedMultiFileDropzone } from "@/components/contracts/PhasedFileDropzone";
import { TransitAssigneePanel } from "@/components/contracts/TransitAssigneePanel";
import {
TransitPermitMultiUpload,
type TransitPermitUploadedRow,
} from "@/components/contracts/TransitPermitMultiUpload";
import {
AlertTriangle,
ArrowRight,
CheckCircle2,
Clock,
FileText,
@@ -30,10 +27,17 @@ import {
PackageOpen,
Receipt,
ShieldAlert,
ShieldCheck,
Ship,
Truck,
Upload,
} from "lucide-react";
import { PhasedFileDropzone, PhasedMultiFileDropzone } from "@/components/contracts/PhasedFileDropzone";
import { TransitAssigneePanel } from "@/components/contracts/TransitAssigneePanel";
import {
TransitPermitMultiUpload,
type TransitPermitUploadedRow,
} from "@/components/contracts/TransitPermitMultiUpload";
import {
deliveryOrderFileLabel,
isDeliveryOrderFileCode,
@@ -113,6 +117,9 @@ export function isBookingMilestoneDone(
return m?.status === "COMPLETED" || m?.status === "SKIPPED";
}
/** Number of steps in the import stepper — drives the header progress bar. */
const IMPORT_STEP_COUNT = 12;
function computeImportActiveStep(
clearance: ClearanceViewLike,
bookingCreated: boolean,
@@ -322,19 +329,64 @@ export function PhasedClearanceActionPanel({
</Alert>
) : null}
{clearance.nextAction ? (
<Alert color="blue" variant="light" title="Next step">
<Text size="sm">
<strong>{clearance.nextAction.actor.replace("_", " ")}</strong> {" "}
{clearance.nextAction.action}
</Text>
</Alert>
) : null}
<Paper withBorder radius={13} p={0} style={{ overflow: "hidden" }}>
{/* Header: what this workflow is, and how far along it is. */}
<Group
justify="space-between"
wrap="nowrap"
px={18}
py={15}
style={{ borderBottom: "1px solid #EFF3F7" }}
>
<Group gap={9} wrap="nowrap" style={{ minWidth: 0 }}>
<ShieldCheck size={16} color="#0A8A5F" />
<Box style={{ minWidth: 0 }}>
<Text fz={14} fw={700} c="edr-text">
Import pre-booking clearance
</Text>
<Text fz={11.5} c="#93A4B5" truncate>
Step {Math.min(activeStep + 1, IMPORT_STEP_COUNT)} of{" "}
{IMPORT_STEP_COUNT}
{clearance.nextAction
? ` · ${clearance.nextAction.action}`
: ""}
</Text>
</Box>
</Group>
<Group gap={8} wrap="nowrap" style={{ flexShrink: 0 }}>
<Progress
value={Math.round((activeStep / IMPORT_STEP_COUNT) * 100)}
color="edr-green"
radius="xl"
size={6}
w={110}
/>
<Text fz={11.5} c="#67788A" fw={600}>
{Math.round((activeStep / IMPORT_STEP_COUNT) * 100)}%
</Text>
</Group>
</Group>
<Paper withBorder radius="md" p="md">
<Text fw={600} size="sm" mb="md">
Import pre-booking clearance
</Text>
{/* Whose desk the flow is sitting on right now. */}
{clearance.nextAction ? (
<Group
gap={10}
wrap="nowrap"
px={18}
py={12}
style={{ background: "#E9F1FC", borderBottom: "1px solid #EFF3F7" }}
>
<ArrowRight size={15} color="#1D6FD1" style={{ flexShrink: 0 }} />
<Text fz={10.5} fw={700} lts="0.4px" c="#1D6FD1" style={{ flexShrink: 0 }}>
{clearance.nextAction.actor.replace("_", " ").toUpperCase()}
</Text>
<Text fz={11.5} fw={600} c="edr-text" style={{ minWidth: 0 }}>
{clearance.nextAction.action}
</Text>
</Group>
) : null}
<Box p="md">
<Stepper
active={activeStep}
orientation="vertical"
@@ -826,7 +878,8 @@ export function PhasedClearanceActionPanel({
onDownloadFile={onDownloadFile}
/>
</Stepper.Step>
</Stepper>
</Stepper>
</Box>
</Paper>
</Stack>
);

View File

@@ -199,7 +199,13 @@ export function RequestServiceTypeCard({
if (lastMile)
chips.push({ label: "Last-mile delivery", color: "teal", icon: Warehouse });
if (customs)
chips.push({ label: "Customs clearance (GL)", color: "grape", icon: FileCheck });
chips.push({
label: st.includesEthiopianCustomsOnly
? "Ethiopian customs clearance (GL)"
: "Customs clearance (GL)",
color: "grape",
icon: FileCheck,
});
return (
<SectionCard

View File

@@ -1,4 +1,5 @@
import { Card, Skeleton, Text } from "@mantine/core";
import { ArrowUpRight } from "lucide-react";
import type { LucideIcon } from "lucide-react";
import type { ElementType, ReactNode } from "react";
import { Link } from "react-router-dom";
@@ -8,14 +9,14 @@ import { cn } from "@/lib/utils";
export interface KpiItem {
label: string;
value: ReactNode;
/** Optional leading icon rendered in a tinted chip. */
/** Optional leading icon rendered in a tinted chip beside the label. */
icon?: LucideIcon;
/** Secondary line under the label (e.g. a unit or comparison). */
/** Small tinted pill beside the value (e.g. "+6 today"). */
hint?: string;
/**
* Mantine color name for the icon chip (e.g. "edr-green", "red", "yellow").
* Defaults to the brand green so a strip reads as uniform unless a page opts
* into semantic tints.
* Mantine color name for the icon chip and sparkline (e.g. "edr-green",
* "red", "yellow"). Defaults to the brand green so a strip reads as uniform
* unless a page opts into semantic tints.
*/
color?: string;
/**
@@ -28,6 +29,8 @@ export interface KpiItem {
* becomes clickable (pointer, hover tint); when absent it stays static.
*/
href?: string;
/** Tiny bar sparkline, oldest → newest, scaled to its own max. */
spark?: number[];
}
export interface KpiStripProps {
@@ -36,11 +39,52 @@ export interface KpiStripProps {
loading?: boolean;
}
function Pill({
children,
tone,
}: {
children: ReactNode;
tone: "green" | "red";
}) {
const c = tone === "green" ? "edr-green" : "red";
return (
<span
className="inline-flex shrink-0 items-center gap-1 rounded-full px-[7px] py-[2px] text-[10px] font-medium leading-none"
style={{
background: `var(--mantine-color-${c}-0)`,
color: `var(--mantine-color-${c}-7)`,
}}
>
{children}
</span>
);
}
function Spark({ values, color }: { values: number[]; color: string }) {
const max = Math.max(1, ...values);
return (
<div className="flex h-[26px] shrink-0 items-end gap-[3px]" aria-hidden>
{values.map((v, i) => (
<div
key={i}
className="w-1 rounded-sm"
style={{
height: Math.max(3, Math.round((v / max) * 26)),
background: `var(--mantine-color-${color}-7)`,
opacity: i === values.length - 1 ? 0.9 : 0.28,
}}
/>
))}
</div>
);
}
/**
* A single bordered card divided into up to five KPI cells:
* `[ kpi | kpi | kpi ]`. Hairline dividers separate cells (vertical on wide
* screens, horizontal when they wrap). Surface, border and shadow all come from
* the theme — no per-cell backgrounds, gradients or custom shadows.
* `[ kpi | kpi | kpi ]`. Each cell stacks a tinted icon + label over a large
* display-font value, with an optional hint/delta pill and a sparkline on the
* right. Hairline dividers separate cells (vertical on wide screens,
* horizontal when they wrap).
*/
export function KpiStrip({ items, loading = false }: KpiStripProps) {
// The spec caps a strip at five cells; extra items are dropped rather than
@@ -48,7 +92,7 @@ export function KpiStrip({ items, loading = false }: KpiStripProps) {
const cells = items.slice(0, 5);
return (
<Card withBorder shadow="sm" p={0} className="overflow-hidden">
<Card withBorder shadow="sm" radius="lg" p={0} className="overflow-hidden">
<div className="flex flex-col sm:flex-row">
{cells.map((item, index) => {
const Icon = item.icon;
@@ -66,66 +110,63 @@ export function KpiStrip({ items, loading = false }: KpiStripProps) {
className={cn(
// min-w-0 lets a crowded strip (five cells, long labels)
// truncate its labels instead of overflowing the card.
"flex min-w-0 flex-1 items-center gap-3 px-5 py-4",
"flex min-w-0 flex-1 flex-col justify-center gap-2 px-[18px] py-4",
index > 0 &&
"border-t border-edr-border sm:border-l sm:border-t-0",
item.href &&
"cursor-pointer no-underline transition-colors hover:bg-gray-50 focus-visible:bg-gray-50",
)}
>
{Icon ? (
<div
className="flex size-10 shrink-0 items-center justify-center rounded-lg"
style={{
background: `var(--mantine-color-${color}-1)`,
color: `var(--mantine-color-${color}-7)`,
}}
>
<Icon size={20} strokeWidth={2} />
</div>
) : null}
<div className="flex items-center gap-2">
{Icon ? (
<div
className="flex size-7 shrink-0 items-center justify-center rounded-lg"
style={{
background: `var(--mantine-color-${color}-0)`,
color: `var(--mantine-color-${color}-7)`,
}}
>
<Icon size={14} strokeWidth={2} />
</div>
) : null}
<Text fz={12} fw={500} c="edr-muted" truncate>
{item.label}
</Text>
</div>
<div style={{ minWidth: 0 }}>
{loading ? (
<Skeleton height={26} width={72} radius="sm" my={2} />
) : (
<div className="flex items-baseline gap-2">
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-2">
{loading ? (
<Skeleton height={28} width={64} radius="sm" />
) : (
<Text
fw={800}
fz={24}
lh={1.05}
ff="heading"
fw={600}
fz={27}
lh={1}
c="edr-text"
style={{ letterSpacing: "-0.02em" }}
style={{ letterSpacing: "-0.03em" }}
truncate
>
{item.value}
</Text>
{item.delta != null && item.delta !== 0 ? (
<Text
component="span"
fz="xs"
fw={700}
c={item.delta > 0 ? "edr-green.7" : "red.7"}
style={{
whiteSpace: "nowrap",
background:
item.delta > 0
? "var(--mantine-color-edr-green-0)"
: "var(--mantine-color-red-0)",
borderRadius: 999,
padding: "1px 7px",
}}
>
{item.delta > 0 ? "▲" : "▼"}
{Math.abs(item.delta)}%
</Text>
) : null}
</div>
)}
<Text size="xs" fw={600} c="edr-muted" truncate>
{item.label}
{item.hint ? ` · ${item.hint}` : ""}
</Text>
)}
{!loading && item.hint ? (
<Pill tone="green">
<ArrowUpRight size={10} />
{item.hint}
</Pill>
) : null}
{!loading && item.delta != null && item.delta !== 0 ? (
<Pill tone={item.delta > 0 ? "green" : "red"}>
{item.delta > 0 ? "▲" : "▼"}
{Math.abs(item.delta)}%
</Pill>
) : null}
</div>
{item.spark?.length ? (
<Spark values={item.spark} color={color} />
) : null}
</div>
</Cell>
);

View File

@@ -0,0 +1,75 @@
import { Group, Pagination, Select, Text } from "@mantine/core";
import type { DataTableFooterProps } from "@edr/ui-common";
export interface TablePagerProps<T> extends DataTableFooterProps<T> {
/** Plural noun for the row count — "Showing 110 of 48 shipments". */
noun?: string;
pageSizes?: number[];
}
/**
* DataTable footer: row range on the left, rows-per-page select + numbered
* pager on the right. Pass via `footer={(p) => <TablePager {...p} noun="…" />}`.
*/
export function TablePager<T>({
table,
pagination,
noun = "rows",
pageSizes = [10, 25, 50],
}: TablePagerProps<T>) {
const pageIndex = pagination.pageIndex ?? 0;
const pageSize = pagination.pageSize ?? 10;
const total = pagination.totalCount ?? 0;
const pageCount = Math.max(
1,
pagination.pageCount ?? Math.ceil(total / pageSize),
);
const start = total === 0 ? 0 : pageIndex * pageSize + 1;
const end = Math.min((pageIndex + 1) * pageSize, total);
return (
<Group
justify="space-between"
gap="sm"
wrap="wrap"
px="md"
py={10}
style={{ borderTop: "1px solid var(--mantine-color-edr-divider-6)" }}
>
<Text fz={12} c="edr-muted">
Showing {start}{end} of {total} {noun}
</Text>
<Group gap="sm" wrap="nowrap">
<Group gap={6} wrap="nowrap">
<Text fz={12} c="edr-muted">
Rows
</Text>
<Select
size="xs"
w={70}
radius="md"
value={String(pageSize)}
data={pageSizes.map(String)}
onChange={(v) => v && table.setPageSize(Number(v))}
allowDeselect={false}
comboboxProps={{ withinPortal: true }}
aria-label="Rows per page"
/>
</Group>
<div className="h-5 w-px bg-edr-border" />
<Pagination
size="sm"
radius="md"
color="edr-ink"
total={pageCount}
value={pageIndex + 1}
onChange={(p) => table.setPageIndex(p - 1)}
siblings={1}
boundaries={1}
/>
</Group>
</Group>
);
}
export default TablePager;

View File

@@ -254,6 +254,14 @@ const RuleEngineFormDialog = ({
next.cargoTypeId = "";
next.rateUnit = "";
}
// Full customs and Ethiopian-only customs are alternatives on a service
// type — switching one on drops the other so the API never sees both.
if (name === "includesCustoms" && value === true) {
next.includesEthiopianCustomsOnly = false;
}
if (name === "includesEthiopianCustomsOnly" && value === true) {
next.includesCustoms = false;
}
// Turning the shipping-line toggle on or off swaps the entire form, so
// nothing answered under the other shape may survive into the payload.
if (name === "isShippingLineRate") {
@@ -388,7 +396,11 @@ const RuleEngineFormDialog = ({
// A toggle that re-targets what an existing record means (e.g. who
// a rate is priced for) is create-only — flipping it on a saved row
// would silently change every booking that prices off it.
disabled={field.disabled || (field.disabledOnEdit && !!initialRecord)}
disabled={
field.disabled ||
(field.disabledOnEdit && !!initialRecord) ||
field.disabledIf?.(values) === true
}
size="md"
color="edr-green"
/>

View File

@@ -0,0 +1,312 @@
import {
Alert,
Badge,
Button,
Group,
Loader,
NumberInput,
Paper,
Select,
SimpleGrid,
Stack,
Table,
Text,
Tooltip,
} from "@mantine/core";
import { useMutation, useQuery } from "@tanstack/react-query";
import { isAxiosError } from "axios";
import { AlertTriangle, Lock, MapPin } from "lucide-react";
import { useMemo, useState } from "react";
import { useToast } from "@/hooks/use-toast";
import { api } from "@/services/api";
import type { ScheduleWagonYardRow } from "@/services/trainBuilder.service";
/**
* Schedule yards tab: where THIS departure plans to board each consist wagon,
* side by side with where the wagon physically stands (the train builder's
* truth). Booking capacity per origin reads the plan; dispatch refuses to
* leave until plan and physical yards agree. Edits are queued locally and
* saved in one PATCH.
*/
const parseError = (error: unknown, fallback: string) => {
if (isAxiosError(error)) {
const message = error.response?.data?.message;
if (Array.isArray(message)) return message.join(", ");
if (typeof message === "string") return message;
}
return fallback;
};
interface Props {
scheduleId: string;
canEdit: boolean;
}
export function ScheduleWagonYardPanel({ scheduleId, canEdit }: Props) {
const { toast } = useToast();
const query = useQuery(
api.trainScheduling.scheduleWagonYards.queryOptions({ input: { scheduleId } }),
);
const save = useMutation(api.trainScheduling.updateScheduleWagonYards.mutationOptions());
const data = query.data;
/** wagonId → yardId queued but not yet saved. */
const [pending, setPending] = useState<Record<string, string>>({});
const [bulkType, setBulkType] = useState<string | null>(null);
const [bulkFrom, setBulkFrom] = useState<string | null>(null);
const [bulkTo, setBulkTo] = useState<string | null>(null);
const [bulkCount, setBulkCount] = useState<number | string>(1);
const editable = Boolean(canEdit && data?.editable);
const pickupStops = useMemo(() => (data?.stops ?? []).filter((s) => s.pickup), [data]);
const yardOptions = pickupStops.map((s) => ({ value: s.yardId, label: s.label }));
const yardLabel = (id: string | null) =>
(data?.stops ?? []).find((s) => s.yardId === id)?.label ??
data?.wagons.find((w) => w.plannedYardId === id)?.plannedYardLabel ??
data?.wagons.find((w) => w.physicalYardId === id)?.physicalYardLabel ??
id ??
"—";
const effectiveYard = (w: ScheduleWagonYardRow) => pending[w.id] ?? w.plannedYardId;
const perStop = useMemo(
() =>
(data?.stops ?? []).map((s) => ({
...s,
planned: (data?.wagons ?? []).filter((w) => (pending[w.id] ?? w.plannedYardId) === s.yardId)
.length,
})),
[data, pending],
);
const typeOptions = useMemo(() => {
const seen = new Map<string, string>();
for (const w of data?.wagons ?? []) seen.set(w.wagonType.id, w.wagonType.code);
return [...seen].map(([value, label]) => ({ value, label }));
}, [data]);
const pendingCount = Object.keys(pending).length;
const queueBulk = () => {
if (!data || !bulkFrom || !bulkTo || bulkFrom === bulkTo) return;
const n = Number(bulkCount) || 0;
const picked = data.wagons
.filter(
(w) =>
!w.locked &&
effectiveYard(w) === bulkFrom &&
(!bulkType || w.wagonType.id === bulkType),
)
.slice(0, n);
if (!picked.length) {
toast({ title: "No free wagons match", variant: "destructive" });
return;
}
setPending((prev) => {
const next = { ...prev };
for (const w of picked) {
if (w.plannedYardId === bulkTo) delete next[w.id];
else next[w.id] = bulkTo;
}
return next;
});
};
const handleSave = async () => {
if (!pendingCount) return;
try {
const result = await save.mutateAsync({
scheduleId,
payload: {
moves: Object.entries(pending).map(([wagonId, yardId]) => ({ wagonId, yardId })),
},
});
setPending({});
toast({
title: `Schedule yards updated — ${pendingCount} wagon(s) re-planned`,
description: result.warnings.length ? result.warnings.join(" ") : undefined,
variant: result.warnings.length ? "destructive" : undefined,
});
} catch (err) {
toast({
title: "Update failed",
description: parseError(err, "Could not update the schedule's wagon yards"),
variant: "destructive",
});
}
};
if (query.isLoading) return <Loader size="sm" />;
if (query.isError || !data) {
return (
<Alert color="red" icon={<AlertTriangle size={16} />}>
{parseError(
query.error,
"This schedule has no wagon yard plan (not created from a built train).",
)}
</Alert>
);
}
return (
<Stack gap="md">
<Alert color="blue" icon={<MapPin size={16} />} variant="light">
<b>Planned</b> = where this departure boards the wagon (what customers can book per
origin). <b>Physical</b> = where the wagon stands now (train builder). Dispatch is blocked
until every wagon stands at its planned yard.
{data.misaligned > 0 ? (
<Text component="span" c="orange" fw={600}>
{" "}
{data.misaligned} wagon(s) currently misaligned.
</Text>
) : null}
</Alert>
<SimpleGrid cols={{ base: 2, sm: 3, md: Math.min(5, Math.max(2, perStop.length)) }}>
{perStop.map((s) => (
<Paper key={s.yardId} withBorder p="sm" radius="md">
<Group justify="space-between" mb={4}>
<Text fw={600} size="sm">
{s.label}
</Text>
{!s.pickup ? (
<Badge size="xs" color="gray" variant="light">
destination
</Badge>
) : null}
</Group>
<Group gap="xs">
<Badge color="edr-green" variant="filled">
Planned {s.planned}
</Badge>
<Badge color={s.physical === s.planned ? "gray" : "orange"} variant="light">
Physical {s.physical}
</Badge>
</Group>
</Paper>
))}
</SimpleGrid>
{editable ? (
<Paper withBorder p="sm" radius="md">
<Group align="end" gap="sm" wrap="wrap">
<NumberInput
label="Move"
min={1}
max={data.wagons.length}
value={bulkCount}
onChange={setBulkCount}
w={90}
/>
<Select
label="Wagon type"
placeholder="Any"
clearable
data={typeOptions}
value={bulkType}
onChange={setBulkType}
w={140}
/>
<Select label="From" data={yardOptions} value={bulkFrom} onChange={setBulkFrom} w={170} />
<Select label="To" data={yardOptions} value={bulkTo} onChange={setBulkTo} w={170} />
<Button
variant="light"
onClick={queueBulk}
disabled={!bulkFrom || !bulkTo || bulkFrom === bulkTo}
>
Queue
</Button>
</Group>
</Paper>
) : null}
<Table striped highlightOnHover withTableBorder>
<Table.Thead>
<Table.Tr>
<Table.Th>#</Table.Th>
<Table.Th>Wagon</Table.Th>
<Table.Th>Type</Table.Th>
<Table.Th>Physical yard</Table.Th>
<Table.Th>Planned yard (this schedule)</Table.Th>
<Table.Th>Status</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{data.wagons.map((w) => {
const planned = effectiveYard(w);
const changed = w.id in pending;
return (
<Table.Tr key={w.id} bg={changed ? "var(--mantine-color-yellow-light)" : undefined}>
<Table.Td>{w.sequenceNumber ?? "—"}</Table.Td>
<Table.Td>
<Text fw={600} size="sm">
{w.wagonNumber}
</Text>
</Table.Td>
<Table.Td>{w.wagonType.code}</Table.Td>
<Table.Td>{w.physicalYardLabel ?? "No yard"}</Table.Td>
<Table.Td>
{editable && !w.locked ? (
<Select
size="xs"
data={yardOptions}
value={planned}
onChange={(v) =>
setPending((prev) => {
const next = { ...prev };
if (!v || v === w.plannedYardId) delete next[w.id];
else next[w.id] = v;
return next;
})
}
w={180}
/>
) : (
<Group gap={4}>
<Text size="sm">{yardLabel(planned)}</Text>
{w.locked ? (
<Tooltip label={w.lockReason ?? "Locked"}>
<Lock size={14} />
</Tooltip>
) : null}
</Group>
)}
</Table.Td>
<Table.Td>
{planned === w.physicalYardId ? (
<Badge color="teal" variant="light" size="sm">
Aligned
</Badge>
) : (
<Badge color="orange" variant="light" size="sm">
Needs move
</Badge>
)}
</Table.Td>
</Table.Tr>
);
})}
</Table.Tbody>
</Table>
{editable ? (
<Group justify="flex-end">
<Text size="sm" c="dimmed">
{pendingCount} pending change(s)
</Text>
<Button variant="default" onClick={() => setPending({})} disabled={!pendingCount}>
Discard
</Button>
<Button
onClick={() => void handleSave()}
loading={save.isPending}
disabled={!pendingCount}
>
Save plan
</Button>
</Group>
) : null}
</Stack>
);
}

View File

@@ -8,15 +8,20 @@ import {
Card,
Group,
Menu,
Select,
Stack,
Text,
TextInput,
ThemeIcon,
Tooltip,
UnstyledButton,
} from "@mantine/core";
import { useInterval } from "@mantine/hooks";
import type { LucideIcon } from "lucide-react";
import {
ArrowRight,
Calendar,
Building2,
CalendarClock,
ExternalLink,
Eye,
FileText,
@@ -27,26 +32,27 @@ import {
RefreshCw,
Search,
ShieldCheck,
User,
ShipWheel,
TriangleAlert,
Truck,
X,
} from "lucide-react";
import {
DataTable,
usePagination,
type ColumnDef,
} from "@edr/ui-common";
import { DataTable, usePagination, type ColumnDef } from "@edr/ui-common";
import type { Freight } from "@edr/types";
import { useQuery } from "@tanstack/react-query";
import { PageContainer } from "@/components/page/PageContainer";
import { PageHeader } from "@/components/page/PageHeader";
import { KpiStrip } from "@/components/page/KpiStrip";
import { TablePager } from "@/components/page/TablePager";
import { bookingTable } from "@/components/bookings/booking-ui.styles";
import { useAuth } from "@/auth/useAuth";
import { useBookingEtClearanceQueue } from "@/hooks/bookings/useBookings";
import type { BookingDetail } from "@/types/booking";
import { FREIGHT_PERMS, hasPermission, isDjiboutiGl } from "@/lib/permissions";
import { formatDate } from "@/lib/format";
import { GlUpcomingWindowsSection } from "@/components/contracts/GlUpcomingWindowsSection";
import { CLEARANCE_TABS } from "@/features/clearance/clearance-tabs.config";
import {
RequestedCargoChips,
summarizeRequestedCargo,
@@ -62,53 +68,134 @@ function yardLabel(
return yard.label ?? yard.name ?? yard.code ?? "—";
}
/**
* "Origin → Destination", wrapping past 120px as "Addis Ababa" /
* "→ Djibouti": the arrow is glued to the destination with an nbsp, and
* text wraps normally (the table's cells are otherwise nowrap) so a long
* lane never spills into the next column.
*/
function RouteLabel({
origin,
destination,
}: {
origin: string;
destination: string;
}) {
const prettyStatus = (s: string) =>
s
.toLowerCase()
.replace(/_/g, " ")
.replace(/^\w/, (c) => c.toUpperCase());
const shipmentStatusColor = (s: string) => {
if (s === "AWAITING_DOCUMENTS") return "yellow";
if (s === "DOCUMENTS_UNDER_REVIEW") return "blue";
if (s === "CLEARANCE_READY") return "edr-green";
if (
[
"SELECTED_FOR_BATCH",
"PNR_GENERATED",
"AWAITING_PAYMENT",
"PAYMENT_VERIFICATION_IN_PROGRESS",
].includes(s)
)
return "violet";
if (s === "EXPIRED") return "orange";
if (s === "CANCELLED" || s === "REJECTED") return "red";
return "gray";
};
/** Rows created per day over the last `days` days, oldest → newest. */
function perDay(rows: { createdAt: string | null }[], days = 8): number[] {
const today = new Date().setHours(0, 0, 0, 0);
const out = new Array<number>(days).fill(0);
for (const r of rows) {
if (!r.createdAt) continue;
const age = Math.floor(
(today - new Date(r.createdAt).setHours(0, 0, 0, 0)) / 86_400_000,
);
if (age >= 0 && age < days) out[days - 1 - age] += 1;
}
return out;
}
// ── Tabs ─────────────────────────────────────────────────────────────────────
type TabKey = "all" | "import" | "export" | "review";
const TABS: { key: TabKey; label: string; icon: LucideIcon }[] = [
...CLEARANCE_TABS,
{ key: "review", label: "Needs approval", icon: TriangleAlert },
];
// ── Small pieces ─────────────────────────────────────────────────────────────
function LivePill({ updatedAt }: { updatedAt: number }) {
// Re-render every 30s so "Xm ago" keeps ticking between refetches.
const [, setTick] = useState(0);
useInterval(() => setTick((t) => t + 1), 30_000, { autoInvoke: true });
const mins = Math.max(0, Math.round((Date.now() - updatedAt) / 60_000));
const label = !updatedAt
? "Connecting…"
: mins < 1
? "Live · updated just now"
: `Live · updated ${mins}m ago`;
return (
<Text
size="sm"
maw={120}
lh={1.35}
style={{ whiteSpace: "normal", overflowWrap: "anywhere" }}
>
{origin}{" "}
<ArrowRight
size={13}
className="text-muted-foreground"
style={{ display: "inline-block", verticalAlign: "-2px" }}
/>
{"\u00A0"}
{destination}
</Text>
<span className="inline-flex shrink-0 items-center gap-1.5 whitespace-nowrap rounded-full bg-edr-soft px-2.5 py-1 text-[11px] font-medium text-edr-primary-dark">
<span className="size-1.5 rounded-full bg-edr-primary-dark" />
{label}
</span>
);
}
function CustomsBadge({ customs }: { customs: boolean }) {
return customs ? (
<Badge
size="xs"
variant="light"
color="edr-green"
radius="sm"
leftSection={<ShieldCheck size={11} />}
function DirectionPill({ direction }: { direction: string }) {
const isImport = direction === "IMPORT";
const Icon = isImport ? Truck : ShipWheel;
const color = isImport ? "blue" : "teal";
return (
<span
className="inline-flex items-center gap-1 rounded-[5px] px-1.5 py-[2px] text-[10px] font-medium leading-none"
style={{
background: `var(--mantine-color-${color}-0)`,
color: `var(--mantine-color-${color}-7)`,
}}
>
Customs
</Badge>
) : (
<Badge size="xs" variant="light" color="gray" radius="sm">
No customs
</Badge>
<Icon size={10} />
{prettyStatus(direction)}
</span>
);
}
function OutlinePill({ children }: { children: React.ReactNode }) {
return (
<span className="inline-flex items-center rounded-[5px] border border-edr-border px-1.5 py-[2px] text-[10px] leading-none text-edr-muted">
{children}
</span>
);
}
function RouteCell({
origin,
destination,
direction,
freightType,
customs,
}: {
origin: string;
destination: string;
direction: string;
freightType: string;
customs: boolean;
}) {
return (
<Stack gap={5} py={2}>
<Group gap={6} wrap="nowrap">
<Text fz={12.5} fw={500} c="edr-text">
{origin}
</Text>
<ArrowRight size={12} className="shrink-0 text-edr-muted" />
<Text fz={12.5} fw={500} c="edr-text">
{destination}
</Text>
</Group>
<Group gap={6} wrap="nowrap">
<DirectionPill direction={direction} />
<OutlinePill>{prettyStatus(freightType)}</OutlinePill>
{customs ? (
<span className="inline-flex items-center gap-1 rounded-[5px] bg-edr-soft px-1.5 py-[2px] text-[10px] font-medium leading-none text-edr-primary-dark">
<ShieldCheck size={10} />
Customs
</span>
) : null}
</Group>
</Stack>
);
}
@@ -129,13 +216,21 @@ export default function ContractClearanceListPage() {
!isDjiboutiGl(user);
const [query, setQuery] = useState("");
const [tab, setTab] = useState<TabKey>("all");
const [freight, setFreight] = useState<string | null>(null);
const [status, setStatus] = useState<string | null>(null);
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const resetPage = useCallback(
() => setPagination({ pageIndex: 0, pageSize: pagination.pageSize }),
[setPagination, pagination.pageSize],
);
const {
data: bookingQueue,
isLoading,
isError,
isFetching,
dataUpdatedAt,
refetch,
} = useBookingEtClearanceQueue(true);
@@ -149,7 +244,8 @@ export default function ContractClearanceListPage() {
const requestedByBooking = useMemo(() => {
const map = new Map<string, Freight.RequestedShipmentLines>();
for (const req of requestQueue ?? []) {
if (req.createdBookingId) map.set(req.createdBookingId, req.requestedLines);
if (req.createdBookingId)
map.set(req.createdBookingId, req.requestedLines);
}
return map;
}, [requestQueue]);
@@ -170,7 +266,8 @@ export default function ContractClearanceListPage() {
contractId: b.contractId ?? null,
contractReference: b.contractReference ?? null,
contractKind: b.contractKind ?? null,
customs: b.serviceType?.includesCustoms ?? Boolean(b.customsClearingEnabled),
customs:
b.serviceType?.includesCustoms ?? Boolean(b.customsClearingEnabled),
createdAt: b.createdAt ?? null,
// A bare initiated instance has no cargo/price yet — GL still has to
// create (complete) the booking.
@@ -178,23 +275,9 @@ export default function ContractClearanceListPage() {
})) as ShipmentBookingRow[];
}, [bookingQueue, requestedByBooking]);
const rows = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return allRows;
return allRows.filter(
(r) =>
r.reference.toLowerCase().includes(q) ||
r.customerLabel.toLowerCase().includes(q) ||
(r.contractReference ?? "").toLowerCase().includes(q) ||
r.originLabel.toLowerCase().includes(q) ||
r.destinationLabel.toLowerCase().includes(q) ||
summarizeRequestedCargo(r.requested).toLowerCase().includes(q),
);
}, [allRows, query]);
const counts = useMemo(
// KPI groups span the whole queue, regardless of tab/filters.
const groups = useMemo(
() => ({
all: allRows.length,
// Counts anything actually waiting on GL, including a document added
// after clearance was finalized (the status stays CLEARANCE_READY).
review: allRows.filter(
@@ -202,13 +285,72 @@ export default function ContractClearanceListPage() {
r.status === "AWAITING_DOCUMENTS" ||
r.status === "DOCUMENTS_UNDER_REVIEW" ||
r.hasDocumentsAwaitingReview,
).length,
ready: allRows.filter((r) => r.status === "CLEARANCE_READY" || r.bookingCreated)
.length,
),
approval: allRows.filter((r) => r.hasDocumentsAwaitingReview),
ready: allRows.filter(
(r) => r.status === "CLEARANCE_READY" || r.bookingCreated,
),
}),
[allRows],
);
const newToday = perDay(allRows, 1)[0];
const tabCounts = useMemo<Record<TabKey, number>>(
() => ({
all: allRows.length,
import: allRows.filter((r) => r.tradeDirection === "IMPORT").length,
export: allRows.filter((r) => r.tradeDirection === "EXPORT").length,
review: groups.approval.length,
}),
[allRows, groups.approval.length],
);
const statusOptions = useMemo(
() =>
[...new Set(allRows.map((r) => r.status))].sort().map((s) => ({
value: s,
label: prettyStatus(s),
})),
[allRows],
);
const rows = useMemo(() => {
const q = query.trim().toLowerCase();
return allRows.filter((r) => {
if (tab === "review" && !r.hasDocumentsAwaitingReview) return false;
if (
(tab === "import" || tab === "export") &&
r.tradeDirection !== tab.toUpperCase()
)
return false;
if (freight && r.freightType !== freight) return false;
if (status && r.status !== status) return false;
if (!q) return true;
return [
r.reference,
r.customerLabel,
r.contractReference ?? "",
r.originLabel,
r.destinationLabel,
summarizeRequestedCargo(r.requested),
].some((v) => v.toLowerCase().includes(q));
});
}, [allRows, tab, freight, status, query]);
const total = rows.length;
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
const pagedRows = useMemo(() => {
const start = pagination.pageIndex * pagination.pageSize;
return rows.slice(start, start + pagination.pageSize);
}, [rows, pagination.pageIndex, pagination.pageSize]);
const hasFilters = Boolean(query || freight || status);
const clearFilters = useCallback(() => {
setQuery("");
setFreight(null);
setStatus(null);
resetPage();
}, [resetPage]);
const openBooking = useCallback(
// `from` so the detail page's Back returns to this hub.
@@ -223,31 +365,20 @@ export default function ContractClearanceListPage() {
<PageContainer>
<Stack gap="lg">
<PageHeader
title="Document Clearance"
title="Clearance queue"
subtitle="Every customs shipment in phased clearance — the documents live on the shipment, not on the contract."
meta={
<Badge
variant="light"
color="edr-green"
radius="sm"
leftSection={<ShieldCheck size={13} />}
>
{counts.all} in clearance
</Badge>
}
meta={<LivePill updatedAt={dataUpdatedAt} />}
action={
<Group gap="sm" wrap="nowrap">
<ActionIcon
variant="default"
size="lg"
radius="md"
onClick={() => void refetch()}
loading={isFetching}
aria-label="Refresh"
>
<RefreshCw size={16} />
</ActionIcon>
</Group>
<Button
variant="default"
radius="md"
size="sm"
leftSection={<RefreshCw size={14} />}
loading={isFetching}
onClick={() => void refetch()}
>
Refresh
</Button>
}
/>
@@ -256,65 +387,220 @@ export default function ContractClearanceListPage() {
items={[
{
label: "In clearance",
value: counts.all,
value: allRows.length,
icon: Inbox,
color: "edr-green",
color: "blue",
hint: newToday ? `+${newToday} today` : undefined,
spark: perDay(allRows),
},
{
label: "Awaiting review",
value: counts.review,
value: groups.review.length,
icon: ShieldCheck,
color: "yellow",
spark: perDay(groups.review),
},
{
label: "Needs approval",
value: groups.approval.length,
icon: TriangleAlert,
color: "red",
spark: perDay(groups.approval),
},
{
label: "Ready / booked",
value: counts.ready,
value: groups.ready.length,
icon: PackageCheck,
color: "edr-green",
spark: perDay(groups.ready),
},
]}
/>
<GlUpcomingWindowsSection />
<Card p={0} withBorder shadow="sm" radius="lg">
<Card
p={0}
withBorder
shadow="sm"
radius="lg"
style={{ overflow: "hidden" }}
>
<Stack gap={0}>
<Box px="md" pt="md" pb="sm">
<Group justify="space-between" gap="md" wrap="wrap">
<TextInput
placeholder="Search shipment, contract, customer or route…"
leftSection={<Search size={18} />}
value={query}
onChange={(e) => {
setQuery(e.currentTarget.value);
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
}}
rightSection={
query ? (
<ActionIcon
size="sm"
color="gray"
radius="md"
variant="transparent"
onClick={() => setQuery("")}
{/* ── Tabs ─────────────────────────────────────────────── */}
<Group
justify="space-between"
align="stretch"
px="md"
h={46}
wrap="nowrap"
style={{
borderBottom: "1px solid var(--mantine-color-edr-border-6)",
}}
>
<Group gap={2} wrap="nowrap" align="stretch">
{TABS.map((t) => {
const active = tab === t.key;
const Icon = t.icon;
return (
<UnstyledButton
key={t.key}
onClick={() => {
setTab(t.key);
resetPage();
}}
px={13}
className="flex items-center gap-2 transition-colors"
style={{
borderBottom: `2px solid ${
active
? "var(--mantine-color-edr-green-6)"
: "transparent"
}`,
marginBottom: -1,
}}
aria-pressed={active}
>
<Icon
size={14}
style={{
color: active
? "var(--mantine-color-edr-green-6)"
: "var(--mantine-color-gray-5)",
}}
/>
<Text
fz={13}
fw={active ? 600 : 500}
c={active ? "edr-text" : "edr-muted"}
>
<X size={16} />
</ActionIcon>
) : null
}
radius="lg"
style={{ flex: 1, minWidth: 220 }}
/>
<Text size="sm" c="dimmed">
{rows.length} record{rows.length !== 1 ? "s" : ""}
</Text>
{t.label}
</Text>
<span
className="rounded-full px-1.5 py-px text-[10.5px] font-semibold leading-[1.4]"
style={{
background: active
? "var(--mantine-color-edr-green-0)"
: "var(--mantine-color-gray-1)",
color: active
? "var(--mantine-color-edr-green-7)"
: "var(--mantine-color-edr-muted-6)",
}}
>
{tabCounts[t.key]}
</span>
</UnstyledButton>
);
})}
</Group>
</Box>
<Text
fz={12}
c="edr-muted"
className="self-center whitespace-nowrap"
>
{total} record{total !== 1 ? "s" : ""}
</Text>
</Group>
{/* ── Filter bar ───────────────────────────────────────── */}
<Group
gap={9}
px="md"
py={12}
wrap="wrap"
style={{
borderBottom: "1px solid var(--mantine-color-edr-divider-6)",
}}
>
<TextInput
placeholder="Search reference, customer, contract, or route…"
leftSection={<Search size={15} />}
value={query}
onChange={(e) => {
setQuery(e.currentTarget.value);
resetPage();
}}
rightSection={
query ? (
<ActionIcon
size="sm"
color="gray"
radius="md"
variant="transparent"
onClick={() => {
setQuery("");
resetPage();
}}
aria-label="Clear search"
>
<X size={14} />
</ActionIcon>
) : null
}
radius="md"
size="sm"
styles={{
input: { background: "var(--mantine-color-gray-0)" },
}}
style={{ flex: 1, minWidth: 220 }}
/>
<Select
placeholder="Freight"
data={[
{ value: "CONTAINER", label: "Container" },
{ value: "BULK", label: "Bulk" },
]}
value={freight}
onChange={(v) => {
setFreight(v);
resetPage();
}}
clearable
radius="md"
size="sm"
w={124}
comboboxProps={{ withinPortal: true }}
aria-label="Filter by freight type"
/>
<Select
placeholder="Status"
data={statusOptions}
value={status}
onChange={(v) => {
setStatus(v);
resetPage();
}}
clearable
radius="md"
size="sm"
w={180}
comboboxProps={{ withinPortal: true }}
aria-label="Filter by status"
/>
{hasFilters ? (
<Button
variant="subtle"
color="gray"
size="compact-sm"
radius="md"
leftSection={<X size={14} />}
onClick={clearFilters}
>
Clear
</Button>
) : null}
</Group>
<ShipmentBookingsTable
rows={rows}
rows={pagedRows}
total={total}
pageCount={pageCount}
pagination={pagination}
setPagination={setPagination}
loading={isLoading}
error={isError}
hasFilters={hasFilters}
onClearFilters={clearFilters}
canCreateBooking={canCreateBooking}
onOpen={openBooking}
onCreateBooking={(row) =>
@@ -337,7 +623,6 @@ export default function ContractClearanceListPage() {
</Stack>
</Card>
</Stack>
</PageContainer>
);
}
@@ -367,43 +652,19 @@ interface ShipmentBookingRow {
bookingCreated: boolean;
}
const formatDate = (iso: string | null) => {
if (!iso) return "—";
const d = new Date(iso);
return Number.isNaN(d.getTime())
? "—"
: d.toLocaleDateString(undefined, { day: "2-digit", month: "short", year: "numeric" });
};
const prettyStatus = (s: string) =>
s
.toLowerCase()
.replace(/_/g, " ")
.replace(/^\w/, (c) => c.toUpperCase());
const shipmentStatusColor = (s: string) => {
if (s === "AWAITING_DOCUMENTS") return "yellow";
if (s === "DOCUMENTS_UNDER_REVIEW") return "blue";
if (s === "CLEARANCE_READY") return "edr-green";
if (
[
"SELECTED_FOR_BATCH",
"PNR_GENERATED",
"AWAITING_PAYMENT",
"PAYMENT_VERIFICATION_IN_PROGRESS",
].includes(s)
)
return "violet";
if (s === "EXPIRED") return "orange";
if (s === "CANCELLED" || s === "REJECTED") return "red";
return "gray";
};
type PaginationState = ReturnType<typeof usePagination>["pagination"];
/** GENERAL-contract shipment bookings currently in per-booking clearance. */
function ShipmentBookingsTable({
rows,
total,
pageCount,
pagination,
setPagination,
loading,
error,
hasFilters,
onClearFilters,
canCreateBooking,
onOpen,
onCreateBooking,
@@ -411,8 +672,14 @@ function ShipmentBookingsTable({
onViewContract,
}: {
rows: ShipmentBookingRow[];
total: number;
pageCount: number;
pagination: PaginationState;
setPagination: ReturnType<typeof usePagination>["setPagination"];
loading: boolean;
error: boolean;
hasFilters: boolean;
onClearFilters: () => void;
canCreateBooking: boolean;
onOpen: (id: string) => void;
onCreateBooking: (row: ShipmentBookingRow) => void;
@@ -440,18 +707,23 @@ function ShipmentBookingsTable({
id: "booking",
header: () => <span className={bookingTable.headerCell}>Booking</span>,
cell: ({ row }) => (
<div className="flex items-center gap-3 py-1.5">
<div className={bookingTable.rowIcon}>
<PackageCheck className="size-4" strokeWidth={1.75} />
<div className="flex items-center gap-2.5 py-1">
<div className="flex size-[30px] shrink-0 items-center justify-center rounded-[9px] bg-edr-divider text-edr-muted">
<PackageCheck size={15} strokeWidth={1.75} />
</div>
<div className="min-w-0">
<p className="font-medium text-foreground">
<Text fz={13} fw={600} c="edr-text">
{row.original.reference}
</p>
<p className="mt-0.5 flex items-center gap-1 text-xs text-muted-foreground">
<User className="size-3 shrink-0 opacity-70" />
{row.original.customerLabel}
</p>
</Text>
<Group gap={4} wrap="nowrap" align="flex-start">
<Building2
size={10}
className="mt-[3px] shrink-0 text-edr-muted opacity-70"
/>
<Text fz={11} c="edr-muted" className="cell-wrap">
{row.original.customerLabel}
</Text>
</Group>
</div>
</div>
),
@@ -462,17 +734,19 @@ function ShipmentBookingsTable({
cell: ({ row }) => {
const r = row.original;
return (
<Stack gap={4} py={2}>
<Group gap={6} wrap="nowrap">
<FileText size={13} className="shrink-0 text-muted-foreground" />
<Text size="sm" fw={500}>
<Stack gap={3} py={2}>
<Group gap={5} wrap="nowrap">
<FileText size={12} className="shrink-0 text-edr-muted" />
<Text fz={12.5} c="edr-text">
{r.contractReference ?? "—"}
</Text>
</Group>
{r.contractKind ? (
<Badge size="xs" variant="default" radius="sm" tt="uppercase">
{r.contractKind === "GENERAL" ? "General" : "One-time"}
</Badge>
<Text fz={10.5} c="edr-muted">
{r.contractKind === "GENERAL"
? "General contract"
: "One-time"}
</Text>
) : null}
</Stack>
);
@@ -481,44 +755,33 @@ function ShipmentBookingsTable({
{
id: "route",
header: () => <span className={bookingTable.headerCell}>Route</span>,
cell: ({ row }) => (
<RouteLabel
origin={row.original.originLabel}
destination={row.original.destinationLabel}
/>
),
},
{
id: "kind",
header: () => <span className={bookingTable.headerCell}>Type</span>,
cell: ({ row }) => (
<Group gap={6} wrap="nowrap">
<Badge variant="light" color="gray" radius="sm">
{prettyStatus(row.original.tradeDirection)}
</Badge>
<Badge variant="outline" color="gray" radius="sm">
{prettyStatus(row.original.freightType)}
</Badge>
<CustomsBadge customs={row.original.customs} />
</Group>
),
cell: ({ row }) => {
const r = row.original;
return (
<RouteCell
origin={r.originLabel}
destination={r.destinationLabel}
direction={r.tradeDirection}
freightType={r.freightType}
customs={r.customs}
/>
);
},
},
{
id: "requested",
header: () => (
<span className={bookingTable.headerCell}>Requested cargo</span>
),
header: () => <span className={bookingTable.headerCell}>Cargo</span>,
cell: ({ row }) => (
<RequestedCargoChips lines={row.original.requested} size="sm" />
<RequestedCargoChips lines={row.original.requested} size="xs" />
),
},
{
id: "created",
header: () => <span className={bookingTable.headerCell}>Created</span>,
cell: ({ row }) => (
<Group gap={6} wrap="nowrap">
<Calendar size={13} className="shrink-0 text-muted-foreground" />
<Text size="sm" c="dimmed">
<Group gap={5} wrap="nowrap">
<CalendarClock size={12} className="shrink-0 text-edr-muted" />
<Text fz={11.5} c="edr-muted">
{formatDate(row.original.createdAt)}
</Text>
</Group>
@@ -533,14 +796,14 @@ function ShipmentBookingsTable({
a file added after clearance was finalized leaves the status at
CLEARANCE_READY, and the row must still call for the review. */}
{row.original.hasDocumentsAwaitingReview ? (
<Badge variant="filled" color="orange" radius="sm">
<Badge variant="filled" color="orange" radius="sm" size="sm">
Needs approval
</Badge>
) : /* All docs approved but not yet finalized: the booking status is
still DOCUMENTS_UNDER_REVIEW — show the real review state. */
row.original.status === "DOCUMENTS_UNDER_REVIEW" &&
row.original.allDocsApproved ? (
<Badge variant="light" color="edr-green" radius="sm">
<Badge variant="light" color="edr-green" radius="sm" size="sm">
Documents approved
</Badge>
) : (
@@ -548,6 +811,7 @@ function ShipmentBookingsTable({
variant="light"
color={shipmentStatusColor(row.original.status)}
radius="sm"
size="sm"
>
{prettyStatus(row.original.status)}
</Badge>
@@ -558,6 +822,7 @@ function ShipmentBookingsTable({
variant="light"
color="blue"
radius="sm"
size="sm"
leftSection={<PackagePlus size={11} />}
>
Booked
@@ -617,7 +882,10 @@ function ShipmentBookingsTable({
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item leftSection={<Eye size={14} />} onClick={() => onOpen(r.id)}>
<Menu.Item
leftSection={<Eye size={14} />}
onClick={() => onOpen(r.id)}
>
Open booking
</Menu.Item>
{bookable ? (
@@ -655,25 +923,53 @@ function ShipmentBookingsTable({
[canCreateBooking, onOpen, onCreateBooking, onViewContract],
);
if (!loading && !error && rows.length === 0) {
if (!loading && !error && total === 0) {
return (
<Stack align="center" gap={8} py={48}>
<ThemeIcon variant="light" color="gray" radius="xl" size={48}>
<Inbox size={22} />
</ThemeIcon>
<Text c="dimmed">No shipment bookings in clearance.</Text>
<Text c="dimmed">
{hasFilters
? "No shipments match these filters."
: "No shipment bookings in clearance."}
</Text>
{hasFilters ? (
<Button
variant="light"
color="gray"
size="compact-sm"
radius="md"
onClick={onClearFilters}
>
Clear filters
</Button>
) : null}
</Stack>
);
}
return (
<Box w="100%" miw={0} style={{ overflowX: "auto" }}>
<Box w="100%" miw={0}>
<DataTable<ShipmentBookingRow, unknown>
columns={columns}
data={rows}
status={loading ? "loading" : error ? "error" : "success"}
onRowClick={(row) => onOpen(row.id)}
pagination={{
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
pageCount,
totalCount: total,
}}
tableOptions={{
state: { pagination },
onPaginationChange: setPagination,
manualPagination: true,
pageCount,
}}
containerClassName="edr-clearance-table border-0 shadow-none rounded-none bg-transparent"
footer={(p) => <TablePager {...p} noun="shipments" />}
/>
</Box>
);

View File

@@ -15,33 +15,33 @@ import {
TextInput,
ThemeIcon,
Tooltip,
UnstyledButton,
} from "@mantine/core";
import { useInterval } from "@mantine/hooks";
import type { LucideIcon } from "lucide-react";
import {
AlertTriangle,
ArrowRight,
Building2,
CalendarClock,
ChevronRight,
FileText,
Inbox,
Layers,
PackageCheck,
RefreshCw,
Search,
ShipWheel,
Truck,
User,
Weight,
X,
} from "lucide-react";
import {
DataTable,
DataTableFooter,
usePagination,
type ColumnDef,
} from "@edr/ui-common";
import { DataTable, usePagination, type ColumnDef } from "@edr/ui-common";
import { PageContainer } from "@/components/page/PageContainer";
import { PageHeader } from "@/components/page/PageHeader";
import { KpiStrip } from "@/components/page/KpiStrip";
import { TablePager } from "@/components/page/TablePager";
import { bookingTable } from "@/components/bookings/booking-ui.styles";
import { useBookingDjClearanceQueue } from "@/hooks/bookings/useBookings";
import type { BookingDetail } from "@/types/booking";
@@ -106,6 +106,20 @@ function statusColor(status: string): string {
}
}
/** Rows created/scheduled per day over the last `days` days, oldest → newest. */
function perDay(rows: { scheduledDate: string | null }[], days = 8): number[] {
const today = new Date().setHours(0, 0, 0, 0);
const out = new Array<number>(days).fill(0);
for (const r of rows) {
if (!r.scheduledDate) continue;
const age = Math.floor(
(today - new Date(r.scheduledDate).setHours(0, 0, 0, 0)) / 86_400_000,
);
if (age >= 0 && age < days) out[days - 1 - age] += 1;
}
return out;
}
// ── DJ next action (shipments) ───────────────────────────────────────────────
type DjActionKey = "RO_HOLD" | "COLLECT_DO" | "ISSUE_RO" | "LOADING" | "REVIEW";
@@ -180,28 +194,65 @@ function toShipmentRow(b: BookingDetail): ShipmentRow {
};
}
// ── Tabs ─────────────────────────────────────────────────────────────────────
type TabKey = "all" | "import" | "export" | "hold";
const TABS: { key: TabKey; label: string; icon: LucideIcon }[] = [
{ key: "all", label: "All", icon: Layers },
{ key: "import", label: "Import", icon: Truck },
{ key: "export", label: "Export", icon: ShipWheel },
{ key: "hold", label: "On hold", icon: AlertTriangle },
];
// ── Shared cell pieces ───────────────────────────────────────────────────────
function DirectionIcon({ direction }: { direction: string }) {
function LivePill({ updatedAt }: { updatedAt: number }) {
// Re-render every 30s so "Xm ago" keeps ticking between refetches.
const [, setTick] = useState(0);
useInterval(() => setTick((t) => t + 1), 30_000, { autoInvoke: true });
const mins = Math.max(0, Math.round((Date.now() - updatedAt) / 60_000));
const label = !updatedAt
? "Connecting…"
: mins < 1
? "Live · updated just now"
: `Live · updated ${mins}m ago`;
return (
<span className="inline-flex shrink-0 items-center gap-1.5 whitespace-nowrap rounded-full bg-edr-soft px-2.5 py-1 text-[11px] font-medium text-edr-primary-dark">
<span className="size-1.5 rounded-full bg-edr-primary-dark" />
{label}
</span>
);
}
function DirectionPill({ direction }: { direction: string }) {
const isImport = direction === "IMPORT";
const Icon = isImport ? Truck : ShipWheel;
const label = directionLabel(direction);
const color = isImport ? "blue" : "teal";
return (
<Tooltip label={label} withArrow>
<ThemeIcon
variant="light"
color={isImport ? "edr-green" : "gray"}
radius="md"
size={26}
aria-label={label}
<Tooltip label={directionLabel(direction)} withArrow>
<span
className="inline-flex items-center gap-1 rounded-[5px] px-1.5 py-[2px] text-[10px] font-medium leading-none"
style={{
background: `var(--mantine-color-${color}-0)`,
color: `var(--mantine-color-${color}-7)`,
}}
>
<Icon size={14} strokeWidth={1.9} />
</ThemeIcon>
<Icon size={10} />
{prettyStatus(direction)}
</span>
</Tooltip>
);
}
function OutlinePill({ children }: { children: React.ReactNode }) {
return (
<span className="inline-flex items-center rounded-[5px] border border-edr-border px-1.5 py-[2px] text-[10px] leading-none text-edr-muted">
{children}
</span>
);
}
function RouteCell({
origin,
destination,
@@ -214,30 +265,19 @@ function RouteCell({
freightType: string;
}) {
return (
<Stack gap={4} py={2}>
{/* Wraps past 120px as "Addis Ababa" / "→ Djibouti"; text wraps
normally (cells are otherwise nowrap) so it never spills over. */}
<Text
size="sm"
fw={500}
maw={120}
lh={1.35}
style={{ whiteSpace: "normal", overflowWrap: "anywhere" }}
>
{origin}{" "}
<ArrowRight
size={13}
className="text-muted-foreground"
style={{ display: "inline-block", verticalAlign: "-2px" }}
/>
{"\u00A0"}
{destination}
</Text>
<Group gap={8} align="center">
<DirectionIcon direction={direction} />
<Badge size="xs" variant="default" radius="sm">
{freightType}
</Badge>
<Stack gap={5} py={2}>
<Group gap={6} wrap="nowrap">
<Text fz={12.5} fw={500} c="edr-text">
{origin}
</Text>
<ArrowRight size={12} className="shrink-0 text-edr-muted" />
<Text fz={12.5} fw={500} c="edr-text">
{destination}
</Text>
</Group>
<Group gap={6} wrap="nowrap">
<DirectionPill direction={direction} />
<OutlinePill>{prettyStatus(freightType)}</OutlinePill>
</Group>
</Stack>
);
@@ -254,7 +294,7 @@ function RouteCell({
export default function GlDjiboutiClearanceListPage() {
const navigate = useNavigate();
const [query, setQuery] = useState("");
const [direction, setDirection] = useState<string | null>(null);
const [tab, setTab] = useState<TabKey>("all");
const [freight, setFreight] = useState<string | null>(null);
const [status, setStatus] = useState<string | null>(null);
const [action, setAction] = useState<string | null>(null);
@@ -265,6 +305,7 @@ export default function GlDjiboutiClearanceListPage() {
isLoading: bookingsLoading,
isError: bookingsError,
isFetching: bookingsFetching,
dataUpdatedAt,
refetch: refetchBookings,
} = useBookingDjClearanceQueue();
@@ -277,18 +318,30 @@ export default function GlDjiboutiClearanceListPage() {
() => (bookingQueue ?? []).map(toShipmentRow),
[bookingQueue],
);
// KPI metrics span the whole queue, regardless of filters.
const metrics = useMemo(
() => ({
shipments: allShipmentRows.length,
collectDo: allShipmentRows.filter((r) => r.action.key === "COLLECT_DO")
.length,
issueRo: allShipmentRows.filter((r) => r.action.key === "ISSUE_RO").length,
roHolds: allShipmentRows.filter((r) => r.action.key === "RO_HOLD").length,
shipments: allShipmentRows,
collectDo: allShipmentRows.filter((r) => r.action.key === "COLLECT_DO"),
issueRo: allShipmentRows.filter((r) => r.action.key === "ISSUE_RO"),
roHolds: allShipmentRows.filter((r) => r.action.key === "RO_HOLD"),
}),
[allShipmentRows],
);
const tabCounts = useMemo<Record<TabKey, number>>(
() => ({
all: allShipmentRows.length,
import: allShipmentRows.filter((r) => r.tradeDirection === "IMPORT")
.length,
export: allShipmentRows.filter((r) => r.tradeDirection === "EXPORT")
.length,
hold: metrics.roHolds.length,
}),
[allShipmentRows, metrics.roHolds.length],
);
const statusOptions = useMemo(
() =>
[...new Set(allShipmentRows.map((r) => r.status))].sort().map((s) => ({
@@ -298,64 +351,45 @@ export default function GlDjiboutiClearanceListPage() {
[allShipmentRows],
);
const matchesShared = useCallback(
(
r: {
reference: string;
customerLabel: string;
originLabel: string;
destinationLabel: string;
tradeDirection: string;
freightType: string;
status: string;
},
extraSearchFields: string[] = [],
) => {
if (direction && r.tradeDirection !== direction) return false;
const shipmentRows = useMemo(() => {
const q = query.trim().toLowerCase();
return allShipmentRows.filter((r) => {
if (tab === "hold" && r.action.key !== "RO_HOLD") return false;
if (
(tab === "import" || tab === "export") &&
r.tradeDirection !== tab.toUpperCase()
)
return false;
if (freight && r.freightType !== freight) return false;
if (status && r.status !== status) return false;
const q = query.trim().toLowerCase();
if (action && r.action.key !== action) return false;
if (!q) return true;
return [
r.reference,
r.customerLabel,
r.contractReference,
r.originLabel,
r.destinationLabel,
prettyStatus(r.status),
...extraSearchFields,
].some((v) => v.toLowerCase().includes(q));
},
[direction, freight, status, query],
);
const shipmentRows = useMemo(
() =>
allShipmentRows.filter(
(r) =>
(!action || r.action.key === action) &&
// Shipments also match the parent contract reference in search.
matchesShared(r, [r.contractReference]),
),
[allShipmentRows, action, matchesShared],
);
});
}, [allShipmentRows, tab, freight, status, action, query]);
const isLoading = bookingsLoading;
const isError = bookingsError;
const isFetching = bookingsFetching;
const total = shipmentRows.length;
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
const showEmpty = !isLoading && !isError && total === 0;
const pagedShipmentRows = useMemo(() => {
const start = pagination.pageIndex * pagination.pageSize;
return shipmentRows.slice(start, start + pagination.pageSize);
}, [shipmentRows, pagination.pageIndex, pagination.pageSize]);
const hasFilters = Boolean(query || direction || freight || status || action);
const hasFilters = Boolean(query || freight || status || action);
const clearFilters = useCallback(() => {
setQuery("");
setDirection(null);
setFreight(null);
setStatus(null);
setAction(null);
@@ -379,18 +413,23 @@ export default function GlDjiboutiClearanceListPage() {
cell: ({ row }) => {
const r = row.original;
return (
<div className="flex items-center gap-3 py-1.5">
<div className={bookingTable.rowIcon}>
<PackageCheck className="size-4" strokeWidth={1.75} />
<div className="flex items-center gap-2.5 py-1">
<div className="flex size-[30px] shrink-0 items-center justify-center rounded-[9px] bg-edr-divider text-edr-muted">
<PackageCheck size={15} strokeWidth={1.75} />
</div>
<div className="min-w-0">
<p className="font-medium text-foreground">
<Text fz={13} fw={600} c="edr-text">
{r.reference}
</p>
<p className="mt-0.5 flex items-center gap-1 text-xs text-muted-foreground">
<User className="size-3 shrink-0 opacity-70" />
{r.customerLabel}
</p>
</Text>
<Group gap={4} wrap="nowrap" align="flex-start">
<Building2
size={10}
className="mt-[3px] shrink-0 text-edr-muted opacity-70"
/>
<Text fz={11} c="edr-muted" className="cell-wrap">
{r.customerLabel}
</Text>
</Group>
</div>
</div>
);
@@ -400,9 +439,11 @@ export default function GlDjiboutiClearanceListPage() {
id: "contract",
header: () => <span className={bookingTable.headerCell}>Contract</span>,
cell: ({ row }) => (
<Group gap={6} wrap="nowrap">
<FileText size={13} className="shrink-0 text-muted-foreground" />
<Text size="sm">{row.original.contractReference}</Text>
<Group gap={5} wrap="nowrap">
<FileText size={12} className="shrink-0 text-edr-muted" />
<Text fz={12.5} c="edr-text">
{row.original.contractReference}
</Text>
</Group>
),
},
@@ -428,9 +469,11 @@ export default function GlDjiboutiClearanceListPage() {
const r = row.original;
return (
<Stack gap={4} py={2}>
<Group gap={6} wrap="nowrap">
<Weight size={13} className="shrink-0 text-muted-foreground" />
<Text size="sm">{r.weightTons} t</Text>
<Group gap={5} wrap="nowrap">
<Weight size={12} className="shrink-0 text-edr-muted" />
<Text fz={12} fw={500} c="edr-text">
{r.weightTons} t
</Text>
</Group>
{r.isHazardous ? (
<Badge
@@ -449,7 +492,9 @@ export default function GlDjiboutiClearanceListPage() {
},
{
id: "action",
header: () => <span className={bookingTable.headerCell}>DJ action</span>,
header: () => (
<span className={bookingTable.headerCell}>DJ action</span>
),
cell: ({ row }) => {
const r = row.original;
const badge = (
@@ -466,7 +511,7 @@ export default function GlDjiboutiClearanceListPage() {
) : (
badge
)}
<Text size="xs" c="dimmed">
<Text fz={10.5} c="edr-muted">
{phaseLabel(r.phase)}
</Text>
</Stack>
@@ -489,11 +534,13 @@ export default function GlDjiboutiClearanceListPage() {
},
{
id: "scheduled",
header: () => <span className={bookingTable.headerCell}>Scheduled</span>,
header: () => (
<span className={bookingTable.headerCell}>Scheduled</span>
),
cell: ({ row }) => (
<Group gap={6} wrap="nowrap">
<CalendarClock size={13} className="shrink-0 text-muted-foreground" />
<Text size="sm" c="dimmed">
<Group gap={5} wrap="nowrap">
<CalendarClock size={12} className="shrink-0 text-edr-muted" />
<Text fz={11.5} c="edr-muted">
{formatDate(row.original.scheduledDate)}
</Text>
</Group>
@@ -505,7 +552,7 @@ export default function GlDjiboutiClearanceListPage() {
header: "",
cell: () => (
<Group justify="flex-end" pr="xs">
<ChevronRight size={16} className="text-muted-foreground" />
<ChevronRight size={16} className="text-edr-muted" />
</Group>
),
},
@@ -519,17 +566,18 @@ export default function GlDjiboutiClearanceListPage() {
<PageHeader
title="GL Djibouti — Clearance"
subtitle="Customs shipments handed off to Djibouti GL — every clearance step lives on the shipment."
meta={<LivePill updatedAt={dataUpdatedAt} />}
action={
<ActionIcon
<Button
variant="default"
size="lg"
radius="md"
size="sm"
leftSection={<RefreshCw size={14} />}
loading={isFetching}
onClick={handleRefresh}
aria-label="Refresh"
>
<RefreshCw size={16} />
</ActionIcon>
Refresh
</Button>
}
/>
@@ -538,139 +586,230 @@ export default function GlDjiboutiClearanceListPage() {
items={[
{
label: "Shipments in queue",
value: metrics.shipments,
value: metrics.shipments.length,
icon: PackageCheck,
color: "blue",
spark: perDay(metrics.shipments),
},
{
label: "Imports — collect DO",
value: metrics.collectDo,
value: metrics.collectDo.length,
icon: Truck,
color: "yellow",
spark: perDay(metrics.collectDo),
},
{
label: "Exports — issue RO",
value: metrics.issueRo,
value: metrics.issueRo.length,
icon: ShipWheel,
color: "blue",
spark: perDay(metrics.issueRo),
},
{
label: "RO amendment holds",
value: metrics.roHolds,
value: metrics.roHolds.length,
icon: AlertTriangle,
color: "red",
spark: perDay(metrics.roHolds),
},
]}
/>
<Card p={0} withBorder shadow="sm" radius="lg">
<Card
p={0}
withBorder
shadow="sm"
radius="lg"
style={{ overflow: "hidden" }}
>
<Stack gap={0}>
<Box px="md" pt="md" pb="sm">
<Group gap="sm" wrap="wrap">
<TextInput
placeholder="Search reference, customer, route, or status…"
leftSection={<Search size={18} />}
value={query}
onChange={(e) => {
setQuery(e.currentTarget.value);
resetPage();
}}
rightSection={
query ? (
<ActionIcon
size="sm"
color="gray"
radius="md"
variant="transparent"
onClick={() => {
setQuery("");
resetPage();
{/* ── Tabs ─────────────────────────────────────────────── */}
<Group
justify="space-between"
align="stretch"
px="md"
h={46}
wrap="nowrap"
style={{
borderBottom: "1px solid var(--mantine-color-edr-border-6)",
}}
>
<Group gap={2} wrap="nowrap" align="stretch">
{TABS.map((t) => {
const active = tab === t.key;
const Icon = t.icon;
return (
<UnstyledButton
key={t.key}
onClick={() => {
setTab(t.key);
resetPage();
}}
px={13}
className="flex items-center gap-2 transition-colors"
style={{
borderBottom: `2px solid ${
active
? "var(--mantine-color-edr-green-6)"
: "transparent"
}`,
marginBottom: -1,
}}
aria-pressed={active}
>
<Icon
size={14}
style={{
color: active
? "var(--mantine-color-edr-green-6)"
: "var(--mantine-color-gray-5)",
}}
/>
<Text
fz={13}
fw={active ? 600 : 500}
c={active ? "edr-text" : "edr-muted"}
>
{t.label}
</Text>
<span
className="rounded-full px-1.5 py-px text-[10.5px] font-semibold leading-[1.4]"
style={{
background: active
? "var(--mantine-color-edr-green-0)"
: "var(--mantine-color-gray-1)",
color: active
? "var(--mantine-color-edr-green-7)"
: "var(--mantine-color-edr-muted-6)",
}}
>
<X size={16} />
</ActionIcon>
) : null
}
radius="lg"
style={{ flex: 1, minWidth: 220 }}
/>
<Select
placeholder="Direction"
data={[
{ value: "IMPORT", label: "Import" },
{ value: "EXPORT", label: "Export" },
]}
value={direction}
onChange={(v) => {
setDirection(v);
resetPage();
}}
clearable
radius="lg"
w={130}
/>
<Select
placeholder="Freight"
data={[
{ value: "CONTAINER", label: "Container" },
{ value: "BULK", label: "Bulk" },
]}
value={freight}
onChange={(v) => {
setFreight(v);
resetPage();
}}
clearable
radius="lg"
w={130}
/>
<Select
placeholder="Status"
data={statusOptions}
value={status}
onChange={(v) => {
setStatus(v);
resetPage();
}}
clearable
radius="lg"
w={190}
/>
<Select
placeholder="DJ action"
data={DJ_ACTION_OPTIONS}
value={action}
onChange={(v) => {
setAction(v);
resetPage();
}}
clearable
radius="lg"
w={180}
/>
{hasFilters ? (
<Button
variant="subtle"
color="gray"
size="compact-sm"
radius="md"
leftSection={<X size={14} />}
onClick={clearFilters}
>
Clear
</Button>
) : null}
{tabCounts[t.key]}
</span>
</UnstyledButton>
);
})}
</Group>
</Box>
<Text
fz={12}
c="edr-muted"
className="self-center whitespace-nowrap"
>
{total} record{total !== 1 ? "s" : ""}
</Text>
</Group>
{showEmpty ? (
{/* ── Filter bar ───────────────────────────────────────── */}
<Group
gap={9}
px="md"
py={12}
wrap="wrap"
style={{
borderBottom: "1px solid var(--mantine-color-edr-divider-6)",
}}
>
<TextInput
placeholder="Search reference, customer, route, or status…"
leftSection={<Search size={15} />}
value={query}
onChange={(e) => {
setQuery(e.currentTarget.value);
resetPage();
}}
rightSection={
query ? (
<ActionIcon
size="sm"
color="gray"
radius="md"
variant="transparent"
onClick={() => {
setQuery("");
resetPage();
}}
aria-label="Clear search"
>
<X size={14} />
</ActionIcon>
) : null
}
radius="md"
size="sm"
styles={{
input: { background: "var(--mantine-color-gray-0)" },
}}
style={{ flex: 1, minWidth: 220 }}
/>
<Select
placeholder="Freight"
data={[
{ value: "CONTAINER", label: "Container" },
{ value: "BULK", label: "Bulk" },
]}
value={freight}
onChange={(v) => {
setFreight(v);
resetPage();
}}
clearable
radius="md"
size="sm"
w={124}
comboboxProps={{ withinPortal: true }}
aria-label="Filter by freight type"
/>
<Select
placeholder="Status"
data={statusOptions}
value={status}
onChange={(v) => {
setStatus(v);
resetPage();
}}
clearable
radius="md"
size="sm"
w={180}
comboboxProps={{ withinPortal: true }}
aria-label="Filter by status"
/>
<Select
placeholder="DJ action"
data={DJ_ACTION_OPTIONS}
value={action}
onChange={(v) => {
setAction(v);
resetPage();
}}
clearable
radius="md"
size="sm"
w={170}
comboboxProps={{ withinPortal: true }}
aria-label="Filter by DJ action"
/>
{hasFilters ? (
<Button
variant="subtle"
color="gray"
size="compact-sm"
radius="md"
leftSection={<X size={14} />}
onClick={clearFilters}
>
Clear
</Button>
) : null}
</Group>
{!isLoading && !isError && total === 0 ? (
<Stack align="center" gap={8} py={48}>
<ThemeIcon variant="light" color="gray" radius="xl" size={48}>
<Inbox size={22} />
</ThemeIcon>
<Text c="dimmed">
{hasFilters
? "No records match these filters."
? "No shipments match these filters."
: "No shipments awaiting a Djibouti action."}
</Text>
{hasFilters ? (
@@ -686,7 +825,7 @@ export default function GlDjiboutiClearanceListPage() {
) : null}
</Stack>
) : (
<Box w="100%" miw={0} style={{ overflowX: "auto" }}>
<Box w="100%" miw={0}>
<DataTable<ShipmentRow, unknown>
columns={shipmentColumns}
data={pagedShipmentRows}
@@ -705,7 +844,7 @@ export default function GlDjiboutiClearanceListPage() {
pageCount,
}}
containerClassName="edr-clearance-table border-0 shadow-none rounded-none bg-transparent"
footer={DataTableFooter}
footer={(p) => <TablePager {...p} noun="shipments" />}
/>
</Box>
)}

View File

@@ -32,6 +32,22 @@
white-space: nowrap;
}
/*
* Booking (col 1) and Contract (col 2) carry free-text company/contract names.
* Cap those two columns and let their content wrap onto 2+ lines so a very long
* name (e.g. "SHAFICI PHARMACEUTICAL MEDICAL SUPPLIES WHOLESALER PARTINERSHIP")
* stacks inside its own cell instead of shoving the next column off-screen.
* Everything below the header row so the header labels still sit on one line.
*/
.edr-clearance-table tbody td:not([colspan]):nth-child(1) {
max-width: 240px;
white-space: normal;
}
.edr-clearance-table tbody td:not([colspan]):nth-child(2) {
max-width: 200px;
white-space: normal;
}
/*
* Mantine Badge caps itself at max-width: 100%; inside an auto-layout table
* cell that resolves against min-content and clips the label. Let badges size
@@ -41,6 +57,22 @@
max-width: none;
}
/*
* Opt-out for long free text (company/customer names). The blanket nowrap rule
* above keeps every cell on one line so columns size to content; a very long
* name would otherwise force the column absurdly wide. Mark such text with
* `cell-wrap` to cap it and wrap onto 2+ lines instead of pushing the layout.
*/
.edr-clearance-table .cell-wrap,
.edr-clearance-table .mantine-Group-root > .cell-wrap {
white-space: normal;
overflow-wrap: anywhere;
word-break: break-word;
min-width: 0;
max-width: 100%;
line-height: 1.3;
}
/*
* Mantine Group's preventGrowOverflow caps every child at 100%/N of the cell.
* In an auto-width table cell that resolves against min-content and collapses
@@ -70,7 +102,7 @@
min-width: 0;
position: sticky;
right: 0;
box-shadow: -12px 0 16px -6px rgba(16, 32, 47, 0.3);
box-shadow: -10px 0 14px -8px rgba(16, 32, 47, 0.12);
}
/*
@@ -78,17 +110,41 @@
* background or the columns underneath show through.
*/
.edr-clearance-table td:last-child:not([colspan]) {
background: #f5f8fb;
background: var(--mantine-color-body);
z-index: 2;
}
/* Row hover uses the tailwind `hover:bg-accent` class on the <tr>. */
.edr-clearance-table tbody tr:hover td:last-child:not([colspan]) {
background: var(--accent, #f4fbf8);
background: #f7fbf9;
}
/* Header cell is sticky on both axes — it must outrank the body's sticky column. */
.edr-clearance-table th:last-child {
background: #f4f7fa;
background: var(--mantine-color-gray-0);
z-index: 3;
}
/* ── Design pass: flat head band, 64px rows, hairline dividers ─────────── */
.edr-clearance-table thead th {
height: 38px;
padding-top: 0;
padding-bottom: 0;
background: var(--mantine-color-gray-0);
border-bottom: 1px solid var(--mantine-color-edr-divider-6);
}
.edr-clearance-table tbody td:not([colspan]) {
height: 64px;
padding-top: 8px;
padding-bottom: 8px;
border-bottom: 1px solid var(--mantine-color-edr-divider-6);
}
.edr-clearance-table tbody tr:last-child td:not([colspan]) {
border-bottom: 0;
}
.edr-clearance-table tbody tr:hover td {
background: #f7fbf9;
}

View File

@@ -59,6 +59,7 @@ import {
import {
DEFAULT_CONFIGURATION_SLUG,
DEFAULT_RULES_SLUG,
ROUTE_SCOPED_TRIGGERS,
RULE_ENGINE_CATEGORY_BASE_PATH,
RULE_ENGINE_SELECT_NONE,
getRuleEngineResource,
@@ -120,9 +121,7 @@ const yardOptionsForLegEnd = (
// direction + route, so their yard dropdowns narrow exactly like base
// freight.
(appliesTo === "OTHER" &&
["CUSTOMS_CLEARANCE", "WITH_RETURN", "FUEL"].includes(
String(values.trigger ?? ""),
))
ROUTE_SCOPED_TRIGGERS.includes(String(values.trigger ?? "")))
) {
const direction = String(values.tradeDirection ?? "");
// Direction is what decides the countries, so offer nothing until it is set

View File

@@ -41,6 +41,8 @@ export interface FormFieldDef {
disabled?: boolean;
/** Editable on create, locked when editing an existing record. */
disabledOnEdit?: boolean;
/** Lock the field while the predicate accepts the live form values. */
disabledIf?: (values: Record<string, unknown>) => boolean;
/** Trailing unit label shown inside the input (e.g. "USD" on a rate value). */
suffix?: string;
/** Hide this field when another field currently equals one of these values. */
@@ -221,6 +223,10 @@ const RATE_TRIGGERS = [
{ label: "Cancellation (per wagon, per direction + cargo type)", value: "CANCELLATION" },
{ label: "Shipping line extra fee (PIL)", value: "PIL_EXTRA_FEE" },
{ label: "Customs clearance service fee (billed with the booking)", value: "CUSTOMS_CLEARANCE" },
{
label: "Ethiopian customs clearance service fee (Ethiopian-side-only services)",
value: "ETHIOPIAN_CUSTOMS_CLEARANCE",
},
{ label: "Fuel (per lane + cargo type)", value: "FUEL" },
];
@@ -279,8 +285,16 @@ const SHIPPING_LINE_CARGO_KINDS = [
const isBaseFreightRate = (values: Record<string, unknown>) =>
["BULK", "CONTAINER", "INTERCITY"].includes(String(values.appliesTo ?? ""));
/** Surcharges sold per origin → destination leg (mirrors RatesService.isRouteScoped). */
export const ROUTE_SCOPED_TRIGGERS = [
"CUSTOMS_CLEARANCE",
"ETHIOPIAN_CUSTOMS_CLEARANCE",
"WITH_RETURN",
"FUEL",
];
/**
* Rates priced per leg: base rail freight, plus the customs clearance fee and
* Rates priced per leg: base rail freight, plus the customs clearance fees and
* the empty-container return surcharge (sold per route + container type).
*/
const isRouteScopedRate = (values: Record<string, unknown>) =>
@@ -289,19 +303,19 @@ const isRouteScopedRate = (values: Record<string, unknown>) =>
(isShippingLineRate(values)
? hasShippingLine(values) &&
(values.shippingLineRateKind === "BASE" ||
["CUSTOMS_CLEARANCE", "WITH_RETURN", "FUEL"].includes(
String(values.trigger ?? ""),
))
ROUTE_SCOPED_TRIGGERS.includes(String(values.trigger ?? "")))
: isBaseFreightRate(values)) ||
(String(values.appliesTo ?? "") === "OTHER" &&
["CUSTOMS_CLEARANCE", "WITH_RETURN", "FUEL"].includes(String(values.trigger ?? "")));
ROUTE_SCOPED_TRIGGERS.includes(String(values.trigger ?? "")));
/**
* Surcharges sold per cargo kind: the admin says container or bulk, then names
* the container type or bulk commodity the fee covers.
*/
const isCargoKindTrigger = (values: Record<string, unknown>) =>
["CUSTOMS_CLEARANCE", "CANCELLATION"].includes(String(values.trigger ?? ""));
["CUSTOMS_CLEARANCE", "ETHIOPIAN_CUSTOMS_CLEARANCE", "CANCELLATION"].includes(
String(values.trigger ?? ""),
);
const unitOption = (value: string) => ({ label: value.replace(/_/g, " "), value });
@@ -345,6 +359,7 @@ const unitsForShape = (
// Wagon cancellation fee — scales with the cancelled wagons only.
return ["PER_WAGON"];
case "CUSTOMS_CLEARANCE":
case "ETHIOPIAN_CUSTOMS_CLEARANCE":
// Per cargo kind: container fees per box/wagon, bulk per ton/wagon.
return cargoKind === "BULK"
? ["PER_TON", "PER_WAGON"]
@@ -875,7 +890,28 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
{ name: "canBeBookedAlone", label: "Can be booked alone", type: "boolean" },
{ name: "includesFirstMile", label: "Includes first mile", type: "boolean" },
{ name: "includesLastMile", label: "Includes last mile", type: "boolean" },
{ name: "includesCustoms", label: "Includes customs", type: "boolean" },
// Full customs and Ethiopian-only customs are alternatives — turning one
// on clears and locks the other (see RuleEngineFormDialog.setField). The
// API stores includesCustoms = true for both; the toggle shown here is
// "full customs", so an Ethiopian-only record reads it back as off.
{
name: "includesCustoms",
label: "Includes customs",
type: "boolean",
description:
"Full customs clearance bundled with the service. Cannot be combined with Ethiopian customs only.",
getInitialValue: (record) =>
record.includesCustoms === true && record.includesEthiopianCustomsOnly !== true,
disabledIf: (v) => v.includesEthiopianCustomsOnly === true,
},
{
name: "includesEthiopianCustomsOnly",
label: "Ethiopian customs only",
type: "boolean",
description:
"EDR clears customs on the Ethiopian side only. Same clearance flow; contracts and bookings price off the Ethiopian customs clearance rate. Cannot be combined with Includes customs.",
disabledIf: (v) => v.includesCustoms === true,
},
{ name: "isActive", label: "Active", type: "boolean" },
],
},
@@ -1081,6 +1117,11 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
label: "Customs clearance",
filters: { trigger: "CUSTOMS_CLEARANCE", isShippingLineRate: "false" },
},
{
key: "ethiopian-customs",
label: "Ethiopian customs",
filters: { trigger: "ETHIOPIAN_CUSTOMS_CLEARANCE", isShippingLineRate: "false" },
},
{
key: "return",
label: "Container return",
@@ -1235,6 +1276,7 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
(String(v.appliesTo ?? "") === "OTHER" &&
[
"CUSTOMS_CLEARANCE",
"ETHIOPIAN_CUSTOMS_CLEARANCE",
"CANCELLATION",
"WITH_RETURN",
"LASHING",

View File

@@ -41,6 +41,7 @@ import {
Train,
Weight,
Workflow as WorkflowIcon,
Warehouse,
} from "lucide-react";
import { DateTimePicker } from "@mantine/dates";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
@@ -59,6 +60,7 @@ import { ContainerPlacementGrid } from "@/components/trainScheduling/ContainerPl
import { FleetAvailabilitySummary } from "@/components/trainScheduling/FleetAvailabilitySummary";
import { IntercityRideAlongPanel } from "@/components/trainScheduling/IntercityRideAlongPanel";
import { LegCapacityPanel } from "@/components/trainScheduling/LegCapacityPanel";
import { ScheduleWagonYardPanel } from "@/components/trainScheduling/ScheduleWagonYardPanel";
import { LegLoadBoardPanel } from "@/components/trainScheduling/LegLoadBoardPanel";
import { LoadEmptyContainersModal } from "@/components/trainScheduling/LoadEmptyContainersModal";
import MergeScheduleTrainModal from "@/components/trainScheduling/MergeScheduleTrainModal";
@@ -1287,6 +1289,9 @@ export default function TrainScheduleV2DetailPage() {
<Tabs.Tab value="leg-board" leftSection={<Grid3x3 size={16} />}>
Leg board
</Tabs.Tab>
<Tabs.Tab value="wagon-yards" leftSection={<Warehouse size={16} />}>
Schedule yards
</Tabs.Tab>
<Tabs.Tab value="history" leftSection={<HistoryIcon size={16} />}>
History
</Tabs.Tab>
@@ -1382,6 +1387,15 @@ export default function TrainScheduleV2DetailPage() {
/>
</Tabs.Panel>
<Tabs.Panel value="wagon-yards">
{scheduleId ? (
<ScheduleWagonYardPanel
scheduleId={scheduleId}
canEdit={hasPermission(authUser, FREIGHT_PERMS.trainScheduling.update)}
/>
) : null}
</Tabs.Panel>
<Tabs.Panel value="history">
{scheduleId ? <ScheduleHistoryPanel scheduleId={scheduleId} /> : null}
</Tabs.Panel>

View File

@@ -232,6 +232,9 @@ import {
type BuiltTrainListFilters,
type BuiltTrainListResponse,
type ScheduleConsist,
type ScheduleWagonYards,
type UpdateScheduleWagonYardsPayload,
type UpdateScheduleWagonYardsResult,
type ScheduleHistoryEntry,
type TrainComposition,
type UpdateTrainDetailsPayload,
@@ -416,6 +419,30 @@ export const api = {
],
),
scheduleWagonYards: endpoint<{ scheduleId: string }, ScheduleWagonYards>(
"train-scheduling",
"schedule-wagon-yards",
({ scheduleId }) =>
trainBuilderService.scheduleWagonYards(scheduleId).then((r) => r.data),
({ scheduleId }) => [
...QUERY_KEYS.TRAIN_SCHEDULING.ROOT,
"wagon-yards",
scheduleId,
],
),
updateScheduleWagonYards: endpoint<
{ scheduleId: string; payload: UpdateScheduleWagonYardsPayload },
UpdateScheduleWagonYardsResult
>(
"train-scheduling",
"update-schedule-wagon-yards",
({ scheduleId, payload }) =>
trainBuilderService.updateScheduleWagonYards(scheduleId, payload).then((r) => r.data),
undefined,
() => TRAIN_SCHEDULING_INVALIDATIONS,
),
adjustConsist: endpoint<
{ scheduleId: string; payload: AdjustConsistPayload },
AdjustConsistResult

View File

@@ -466,11 +466,11 @@ export const bookingsService = {
return unwrap(response.data) as Freight.ClearanceCharge[];
},
/** GL Ethiopia sets or revises a charge's amount + currency. */
/** GL Ethiopia sets or revises a charge's amount, currency and description. */
billClearanceCharge: async (
id: string,
chargeId: string,
payload: { amount: number; currency: string },
payload: { amount: number; currency: string; description?: string },
): Promise<Freight.ClearanceCharge[]> => {
const response = await client.patch(
`/bookings/${id}/clearance/charges/${chargeId}/bill`,
@@ -479,7 +479,7 @@ export const bookingsService = {
return unwrap(response.data) as Freight.ClearanceCharge[];
},
/** GL Ethiopia issues the charge's payable invoice to the customer. */
/** GL Ethiopia sends the priced charge to the customer for approval. */
sendClearanceCharge: async (
id: string,
chargeId: string,
@@ -490,16 +490,17 @@ export const bookingsService = {
return unwrap(response.data) as Freight.ClearanceCharge[];
},
/** GL Ethiopia creates the miscellaneous charge (document + amount + currency). */
/** GL Ethiopia creates a miscellaneous charge (document + amount + currency + description). */
createMiscellaneousCharge: async (
id: string,
file: File,
payload: { amount: number; currency: string },
payload: { amount: number; currency: string; description: string },
): Promise<Freight.ClearanceCharge[]> => {
const form = new FormData();
form.append("file", file);
form.append("amount", String(payload.amount));
form.append("currency", payload.currency);
form.append("description", payload.description);
const response = await client.post(
`/bookings/${id}/clearance/charges/miscellaneous`,
form,

View File

@@ -300,6 +300,45 @@ export interface ScheduleHistoryEntry {
/** Adjust response: fresh consist + schedule-impact warnings to surface. */
export type AdjustConsistResult = ScheduleConsist & { warnings: string[] };
/** One consist wagon in the schedule-yards tab: where this departure plans it vs where it stands. */
export interface ScheduleWagonYardRow {
id: string;
wagonNumber: string;
sequenceNumber: number | null;
wagonType: { id: string; code: string; name: string };
physicalYardId: string | null;
physicalYardLabel: string | null;
plannedYardId: string | null;
plannedYardLabel: string | null;
aligned: boolean;
locked: boolean;
lockReason: string | null;
}
export interface ScheduleWagonYardStop {
yardId: string;
label: string;
/** Origin or intermediate stop — wagons can board here. The destination cannot. */
pickup: boolean;
planned: number;
physical: number;
}
export interface ScheduleWagonYards {
scheduleId: string;
train: { id: string; code: string };
editable: boolean;
stops: ScheduleWagonYardStop[];
wagons: ScheduleWagonYardRow[];
misaligned: number;
}
export interface UpdateScheduleWagonYardsPayload {
moves: Array<{ wagonId: string; yardId: string }>;
}
export type UpdateScheduleWagonYardsResult = ScheduleWagonYards & { warnings: string[] };
export const trainBuilderService = {
list: (filters: BuiltTrainListFilters = {}) =>
apiClient.get<BuiltTrainListResponse>(`${BASE}${toQuery(filters)}`),
@@ -350,6 +389,15 @@ export const trainBuilderService = {
`/train-scheduling/schedules/${scheduleId}/adjust-consist`,
payload,
),
/** Schedule-only wagon yard plan (where THIS departure boards each wagon). */
scheduleWagonYards: (scheduleId: string) =>
apiClient.get<ScheduleWagonYards>(`/train-scheduling/schedules/${scheduleId}/wagon-yards`),
/** Re-plan boarding yards for this schedule; physical wagon yards untouched. */
updateScheduleWagonYards: (scheduleId: string, payload: UpdateScheduleWagonYardsPayload) =>
apiClient.patch<UpdateScheduleWagonYardsResult>(
`/train-scheduling/schedules/${scheduleId}/wagon-yards`,
payload,
),
/** Unified wagon/booking change history for the schedule's History tab. */
scheduleHistory: (scheduleId: string) =>
apiClient.get<ScheduleHistoryEntry[]>(

View File

@@ -2,8 +2,8 @@ import { Box, Group, Stack, Text } from "@mantine/core";
import { memo } from "react";
import { ACTION_PROPS, STATUS_CONFIG, cv } from "../constants";
import { Stepper } from "./Stepper";
import { PayNowButton } from "@/pages/bookings/payments/PayNowButton";
import { payWindowState } from "@/pages/bookings/payments/payment-drain";
import { PayButton } from "@/pages/bookings/payments/PayButton";
import { useMyPayables } from "@/pages/bookings/payments/useMyPayables";
import { BookingActionButton } from "@/pages/bookings/clearance/BookingActionButton";
import { bookingHasInlineAction } from "@/pages/bookings/clearance/bookingNextAction";
import {
@@ -28,21 +28,9 @@ export const BookingRow = memo(function BookingRow({
const Icon = cfg.icon;
const AIcon = cfg.action.icon;
const ap = ACTION_PROPS[cfg.action.kind];
// Payable bookings get an inline "Pay now" that opens the payment modal
// instead of navigating to the detail page. A general contract is payable as
// soon as it's FULLY_EXECUTED (signed); a one-time booking only after it's
// SELECTED_FOR_BATCH — same rule as the bookings list's PrimaryAction.
const payableStatus =
booking.bookingType === "GENERAL_CONTRACT"
? "FULLY_EXECUTED"
: "SELECTED_FOR_BATCH";
// A fully-closed pay window (deadline + drain both elapsed) has nothing to pay
// against, so the row falls back to its normal action instead of an empty slot.
// The drain itself still routes here — PayNowButton renders the wait notice.
const canPay =
booking.status === payableStatus &&
booking.paymentStatus !== "PAID" &&
payWindowState(booking).phase !== "closed";
// Anything outstanding (freight, clearance charge, duty slip, cancellation
// fee) → "Pay" jumps to the booking's Payments tab. One shared query.
const payable = useMyPayables().get(booking.id);
// Clearance/operation steps + changes-requested resubmit can be done in place
// via a modal on the row.
const hasInlineAction = bookingHasInlineAction(booking);
@@ -113,8 +101,8 @@ export const BookingRow = memo(function BookingRow({
{cfg.badgeLabel}
</Text>
</Group>
{canPay ? (
<PayNowButton booking={booking} size="sm" />
{payable ? (
<PayButton bookingId={booking.id} summary={payable} size="sm" />
) : canSign ? (
<ContractSignButton booking={booking} size="sm" />
) : canApproveDelivery ? (

View File

@@ -1,11 +1,11 @@
import { useState } from "react";
import {
useState,
} from "react";
import {
Alert,
Anchor,
Badge,
Box,
Button,
FileInput,
Group,
Paper,
Stack,
@@ -15,19 +15,20 @@ import {
import {
AlertTriangle,
Check,
Download,
Eye,
FileBadge,
MessageSquareWarning,
Receipt,
Upload,
} from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import {
useQuery,
} from "@tanstack/react-query";
import toast from "react-hot-toast";
import type { Freight } from "@edr/types";
import { bookingsService } from "@/services/bookings.service";
import { contractsService } from "@/services/contracts.service";
import {
bookingsService,
} from "@/services/bookings.service";
import { downloadStoredFile } from "@/services/files.service";
import { ClearancePhaseStepper } from "../contracts/ClearancePhaseStepper";
import { ClearanceUploadedDocumentsPanel } from "@/components/contracts/ClearanceUploadedDocumentsPanel";
@@ -36,29 +37,6 @@ import { GREEN, INK } from "../contracts/contract-ui";
const BORDER = "#E6ECF2";
// Customer-facing labels for an invoice status (Freight.InvoiceStatus).
const INVOICE_STATUS_LABELS: Record<string, string> = {
DRAFT: "Draft",
ISSUED: "Issued",
PENDING: "Due",
PAYMENT_PROCESSING: "Payment processing",
PARTIALLY_PAID: "Partially paid",
PAID: "Paid",
OVERDUE: "Overdue",
CANCELLED: "Cancelled",
REFUNDED: "Refunded",
EXPIRED: "Expired",
};
function invoiceStatusLabel(status: string): string {
return (
INVOICE_STATUS_LABELS[status] ??
status
.replace(/_/g, " ")
.toLowerCase()
.replace(/\b\w/g, (m) => m.toUpperCase())
);
}
@@ -81,13 +59,9 @@ export function BookingClearanceWorkflowBanner({
if (!isPhased || !clearance) return null;
const dutyPaid = clearance.milestones?.some(
(m) => m.milestoneCode === "DUTY_TAX_PAID" && m.status === "COMPLETED",
);
const dutyPending =
clearance.dutyRequired &&
clearance.dutyAdvice &&
!dutyPaid;
// Duty / tax, additional duty and the final invoice are paid from the
// booking's Payments tab (CustomsPaymentsCard); this banner keeps the
// progress, the draft declaration and the documents.
// A change request clears the draft while it's open — show the "waiting on
// GL" state instead of the review panel until GL sends a corrected draft.
const draftDeclarationChangeRequestPending = Boolean(
@@ -131,14 +105,6 @@ export function BookingClearanceWorkflowBanner({
/>
) : null}
{dutyPending && clearance.dutyAdvice ? (
<DutyAdvicePanel
dutyAdvice={clearance.dutyAdvice}
bookingId={booking.id}
onChanged={() => void refetch()}
/>
) : null}
{clearance.riskLevel ? (
<Group gap={10} align="center">
<Text fw={700} fz={14} c={INK}>
@@ -160,24 +126,6 @@ export function BookingClearanceWorkflowBanner({
</Group>
) : null}
{clearance.secondDuty?.advised ? (
<SecondDutyDueCard
duty={clearance.secondDuty}
bookingId={booking.id}
onView={(f) => view(f)}
onChanged={() => void refetch()}
/>
) : null}
{clearance.finalInvoice ? (
<FinalInvoiceDueCard
invoice={clearance.finalInvoice}
bookingId={booking.id}
onView={(f) => view(f)}
onChanged={() => void refetch()}
/>
) : null}
{clearance.operationReady ? (
<Alert color="green" variant="light">
Clearance is complete. You may proceed to request your operation date.
@@ -197,76 +145,6 @@ export function BookingClearanceWorkflowBanner({
);
}
function DutyAdvicePanel({
dutyAdvice,
bookingId,
onChanged,
}: {
dutyAdvice: NonNullable<Freight.ClearanceView["dutyAdvice"]>;
bookingId: string;
onChanged: () => void;
}) {
const [file, setFile] = useState<File | null>(null);
const [loading, setLoading] = useState(false);
const noticeFile = dutyAdvice.noticeFile;
return (
<Paper withBorder radius="md" p="md" bg="#FFFBF0">
<Stack gap="sm">
<GroupLabel icon={Receipt} text="Duty / tax payment" />
<Text size="sm">
Amount due:{" "}
<strong>
{dutyAdvice.amount.toLocaleString()} {dutyAdvice.currency}
</strong>
{dutyAdvice.declarationSerial
? ` · Payment code: ${dutyAdvice.declarationSerial}`
: null}
</Text>
{noticeFile ? (
<Anchor
component="button"
type="button"
onClick={() => void downloadStoredFile(noticeFile.id, noticeFile.name)}
size="sm"
>
<Group gap={6} wrap="nowrap">
<Download size={14} />
Download duty notice ({noticeFile.name})
</Group>
</Anchor>
) : null}
<Text size="sm" c="dimmed">
Pay the amount above, then upload your payment slip so clearance can continue.
</Text>
<FileInput label="Payment slip" value={file} onChange={setFile} size="sm" />
<Button
color="orange"
loading={loading}
disabled={!file}
leftSection={<Upload size={16} />}
onClick={async () => {
if (!file) return;
setLoading(true);
try {
await bookingsService.uploadBookingClearanceDutySlip(bookingId, file);
toast.success("Payment slip uploaded");
onChanged();
} catch (e) {
toast.error(e instanceof Error ? e.message : "Upload failed");
} finally {
setLoading(false);
}
}}
>
Submit payment slip
</Button>
</Stack>
</Paper>
);
}
/**
* GL Ethiopia sent a draft customs declaration — an estimated price + files
* the customer must accept before the real declaration is filed, or send back
@@ -446,328 +324,8 @@ function GroupLabel({ icon: Icon, text }: { icon: typeof Receipt; text: string }
);
}
/**
* Post-offload final invoice from GL Djibouti (export): shows the due amount +
* invoice document; the customer pays offline and attaches the payment slip
* here, then GL confirms and the badge flips to PAID.
*/
function FinalInvoiceDueCard({
invoice,
bookingId,
onView,
onChanged,
}: {
invoice: NonNullable<Freight.ClearanceView["finalInvoice"]>;
bookingId: string;
onView: (file: { name: string; url: string }) => void;
onChanged: () => void;
}) {
const [slip, setSlip] = useState<File | null>(null);
const [uploading, setUploading] = useState(false);
const [approving, setApproving] = useState(false);
const paid = invoice.status === "PAID";
// GL Djibouti raises it as a draft: nothing is payable until the customer
// reviews the attached invoice and approves it.
const approved = Boolean(invoice.approvedAt);
return (
<Paper
withBorder
radius="lg"
p="lg"
style={{
borderColor: paid ? "#CDEBDD" : "#F2D9A6",
background: paid ? "#F6FBF8" : "#FFFBF2",
position: "relative",
overflow: "hidden",
}}
>
<Box
style={{
position: "absolute",
left: 0,
top: 0,
bottom: 0,
width: 3,
background: paid ? GREEN : "#E3A93C",
}}
/>
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
<div>
<Group gap={8} align="center">
<FileBadge size={16} color={paid ? GREEN : "#B07C1F"} />
<Text fw={700} fz={15} c={INK}>
{paid
? "Final invoice paid"
: approved
? "Final invoice due"
: "Final invoice — your approval needed"}{" "}
{invoice.invoiceNumber}
</Text>
<Badge color={paid ? "edr-green" : "yellow"} variant="light" radius="sm">
{approved
? invoiceStatusLabel(invoice.status)
: "Awaiting your approval"}
</Badge>
</Group>
<Text fz={20} fw={800} mt={6} c={INK}>
{invoice.totalAmount.toLocaleString()} {invoice.currency}
</Text>
{invoice.description ? (
<Text fz={13} c="dimmed" mt={2}>
{invoice.description}
</Text>
) : null}
{!paid ? (
<Text fz={13} c="#9A6B1F" mt={6}>
{approved
? "Pay the amount above and attach your payment slip — Global Logistics will confirm the payment."
: "Review the invoice document from Global Logistics Djibouti and approve it to proceed with payment."}
</Text>
) : null}
</div>
<Stack gap="xs" miw={260}>
{invoice.invoiceFile ? (
<Button
variant="default"
radius="md"
size="sm"
leftSection={<Eye size={15} />}
onClick={() =>
onView({
name: invoice.invoiceFile!.name,
url: invoice.invoiceFile!.url,
})
}
>
View invoice
</Button>
) : null}
{invoice.slipFile ? (
<Button
variant="default"
radius="md"
size="sm"
leftSection={<Eye size={15} />}
onClick={() =>
onView({
name: invoice.slipFile!.name,
url: invoice.slipFile!.url,
})
}
>
View payment slip
</Button>
) : null}
{!paid && !approved ? (
<Button
color="edr-green"
radius="md"
size="sm"
loading={approving}
leftSection={<Check size={15} />}
onClick={async () => {
setApproving(true);
try {
await contractsService.approveFinalInvoice(bookingId);
toast.success("Invoice approved — you can now pay");
onChanged();
} catch (e) {
toast.error(e instanceof Error ? e.message : "Approval failed");
} finally {
setApproving(false);
}
}}
>
Approve invoice
</Button>
) : null}
{!paid && approved ? (
<>
<FileInput
placeholder={
invoice.slipFile ? "Replace payment slip" : "Attach payment slip"
}
value={slip}
onChange={setSlip}
size="sm"
radius="md"
/>
<Button
color="edr-green"
radius="md"
size="sm"
loading={uploading}
disabled={!slip}
leftSection={<Upload size={15} />}
onClick={async () => {
if (!slip) return;
setUploading(true);
try {
await contractsService.uploadFinalInvoiceSlip(bookingId, slip);
setSlip(null);
toast.success("Payment slip attached");
onChanged();
} catch (e) {
toast.error(
e instanceof Error ? e.message : "Upload failed",
);
} finally {
setUploading(false);
}
}}
>
{invoice.slipFile ? "Replace slip" : "Submit payment slip"}
</Button>
</>
) : null}
</Stack>
</Group>
</Paper>
);
}
const CUSTOMS_RISK_COLOR: Record<string, string> = {
GREEN: "green",
YELLOW: "yellow",
RED: "red",
};
/**
* Post-arrival additional duty/tax round (import): GL advises an extra amount
* with a notice; the customer pays offline and attaches another slip here.
*/
function SecondDutyDueCard({
duty,
bookingId,
onView,
onChanged,
}: {
duty: NonNullable<Freight.ClearanceView["secondDuty"]>;
bookingId: string;
onView: (file: { name: string; url: string }) => void;
onChanged: () => void;
}) {
const [slip, setSlip] = useState<File | null>(null);
const [uploading, setUploading] = useState(false);
const paid = duty.paid;
return (
<Paper
withBorder
radius="lg"
p="lg"
style={{
borderColor: paid ? "#CDEBDD" : "#F2D9A6",
background: paid ? "#F6FBF8" : "#FFFBF2",
position: "relative",
overflow: "hidden",
}}
>
<Box
style={{
position: "absolute",
left: 0,
top: 0,
bottom: 0,
width: 3,
background: paid ? GREEN : "#E3A93C",
}}
/>
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
<div>
<Group gap={8} align="center">
<FileBadge size={16} color={paid ? GREEN : "#B07C1F"} />
<Text fw={700} fz={15} c={INK}>
{paid ? "Additional duty & tax paid" : "Additional duty & tax due"}
</Text>
<Badge color={paid ? "edr-green" : "yellow"} variant="light" radius="sm">
{paid ? "PAID" : "DUE"}
</Badge>
</Group>
<Text fz={20} fw={800} mt={6} c={INK}>
{(duty.amount ?? 0).toLocaleString()} {duty.currency ?? ""}
</Text>
{duty.declarationSerial ? (
<Text fz={13} c="dimmed" mt={2}>
Payment code: {duty.declarationSerial}
</Text>
) : null}
{!paid ? (
<Text fz={13} c="#9A6B1F" mt={6}>
Customs advised additional duty/tax after arrival. Pay the amount
above and attach your payment slip.
</Text>
) : null}
</div>
<Stack gap="xs" miw={260}>
{duty.noticeFile ? (
<Button
variant="default"
radius="md"
size="sm"
leftSection={<Eye size={15} />}
onClick={() =>
onView({ name: duty.noticeFile!.name, url: duty.noticeFile!.url })
}
>
View duty notice
</Button>
) : null}
{duty.slipFile ? (
<Button
variant="default"
radius="md"
size="sm"
leftSection={<Eye size={15} />}
onClick={() =>
onView({ name: duty.slipFile!.name, url: duty.slipFile!.url })
}
>
View payment slip
</Button>
) : null}
{!paid ? (
<>
<FileInput
placeholder={duty.slipFile ? "Replace payment slip" : "Attach payment slip"}
value={slip}
onChange={setSlip}
size="sm"
radius="md"
/>
<Button
color="edr-green"
radius="md"
size="sm"
loading={uploading}
disabled={!slip}
leftSection={<Upload size={15} />}
onClick={async () => {
if (!slip) return;
setUploading(true);
try {
await contractsService.uploadSecondDutySlip(bookingId, slip);
setSlip(null);
toast.success("Payment slip attached");
onChanged();
} catch (e) {
toast.error(e instanceof Error ? e.message : "Upload failed");
} finally {
setUploading(false);
}
}}
>
{duty.slipFile ? "Replace slip" : "Submit payment slip"}
</Button>
</>
) : null}
</Stack>
</Group>
</Paper>
);
}

View File

@@ -8,11 +8,12 @@ import {
Package,
TrainFront,
Truck,
Wallet,
XCircle,
} from "lucide-react";
import { useState } from "react";
import toast from "react-hot-toast";
import { useNavigate } from "react-router-dom";
import { useNavigate, useSearchParams } from "react-router-dom";
import { useFileViewer } from "@/hooks/useFileViewer";
import { bookingsService } from "@/services/bookings.service";
@@ -44,17 +45,17 @@ import { HeaderButton, PageHeader } from "./components/PageHeader";
import { PaymentMethodModal } from "./components/PaymentMethodModal";
import { ScheduleCard } from "./components/ScheduleCard";
import { WarehousePaymentsSection } from "./components/WarehousePaymentsSection";
import { PaymentsDueStrip, PaymentsTab } from "./components/PaymentsTab";
import { WarehouseLocationCard } from "./components/WarehouseLocationCard";
import { ShipmentDetailsCard } from "./components/ShipmentDetailsCard";
import { ShipmentTrackingCard } from "./components/ShipmentTrackingCard";
import { StatusHero } from "./components/StatusHero";
import { SupportCard } from "./components/SupportCard";
import { WagonCancellationCard } from "./components/WagonCancellationCard";
import { WagonsTab } from "./components/WagonsTab";
import { fmtDate, isNegative, priceTotal } from "./utils";
import { useScrollToHash } from "@/hooks/useScrollToHash";
import { useBookingPayment } from "@/pages/bookings/payments/useBookingPayment";
import { isUsdOfflineBooking } from "@/pages/bookings/payments/offline-payment";
import { useBookingPayables } from "@/pages/bookings/payments/useBookingPayables";
// Pre-payment statuses the customer may self-cancel from this view (free of
// charge). DRAFT / CHANGES_REQUESTED render their own views and drafts can
@@ -87,6 +88,22 @@ export function ReadonlyBookingView({
const navigate = useNavigate();
// Deep-link support: e.g. /bookings/:id#warehouse-payments from an invoice.
useScrollToHash();
// Active tab lives in the URL (?tab=payments) so list/home "Pay" buttons and
// notifications can land straight on the Payments tab.
const [searchParams, setSearchParams] = useSearchParams();
const tab = searchParams.get("tab") ?? "overview";
const setTab = (next: string | null) =>
setSearchParams(
(prev) => {
if (!next || next === "overview") prev.delete("tab");
else prev.set("tab", next);
return prev;
},
{ replace: true },
);
// Everything the customer owes or must decide on — drives the header "Pay"
// button, the tab badge and the overview strip.
const payables = useBookingPayables(booking);
const status = booking.status as string;
const { viewer } = useFileViewer();
@@ -187,17 +204,19 @@ export function ReadonlyBookingView({
<PageHeader
booking={booking}
actions={
(canApproveDelivery || (canPay && !showCountdown) || canCancel) && (
(canApproveDelivery ||
(payables.items.length > 0 && tab !== "payments") ||
canCancel) && (
<Group gap={8} wrap="nowrap">
{canApproveDelivery && (
<ApproveDeliveryButton bookingId={booking.id} />
)}
{canPay && !showCountdown && !isUsdOfflineBooking(booking) && (
{payables.items.length > 0 && tab !== "payments" && (
<HeaderButton
green
icon={<CreditCard size={16} />}
label="Pay now"
onClick={pay.open}
label="Pay"
onClick={() => setTab("payments")}
/>
)}
{canCancel && (
@@ -260,7 +279,8 @@ export function ReadonlyBookingView({
<KeyFactsStrip booking={booking} />
<Tabs
defaultValue="overview"
value={tab}
onChange={setTab}
keepMounted={false}
color="edr-green"
styles={{
@@ -277,6 +297,32 @@ export function ReadonlyBookingView({
<Tabs.Tab value="overview" leftSection={<LayoutGrid size={15} />}>
Overview
</Tabs.Tab>
<Tabs.Tab
value="payments"
leftSection={<Wallet size={15} />}
rightSection={
payables.items.length > 0 ? (
<Text
component="span"
fz={11}
fw={800}
c="white"
px={6}
style={{
borderRadius: 999,
background: "#B45309",
lineHeight: "18px",
minWidth: 18,
textAlign: "center",
}}
>
{payables.items.length}
</Text>
) : undefined
}
>
Payments
</Tabs.Tab>
<Tabs.Tab value="cargo" leftSection={<Package size={15} />}>
Cargo
</Tabs.Tab>
@@ -298,12 +344,16 @@ export function ReadonlyBookingView({
<Tabs.Panel value="overview">
<div className="flex flex-col gap-6">
{/* The only payment surface on Overview — everything payable lives
on the Payments tab. Hidden when nothing is outstanding. */}
<PaymentsDueStrip booking={booking} onOpen={() => setTab("payments")} />
<ContractCard booking={booking} />
{isClearance && <ClearanceCard booking={booking} />}
{/* Customs (Path B) shipments: GL's phased progress, the duty /
additional-duty payments and the final invoice — all per booking. */}
{/* Customs (Path B) shipments: GL's phased progress, draft
declaration and documents. Its payments moved to the Payments tab. */}
<BookingClearanceWorkflowBanner booking={booking} />
<BodyGrid
@@ -333,11 +383,6 @@ export function ReadonlyBookingView({
title="Consignment & Schedule"
consignment
/>
{/* Renders only on PAID + paid + contract-backed bookings. */}
<WagonCancellationCard
booking={booking}
onBookingUpdated={onBookingUpdated}
/>
<CompanyInfoCard booking={booking} />
<SupportCard />
</>
@@ -346,6 +391,15 @@ export function ReadonlyBookingView({
</div>
</Tabs.Panel>
<Tabs.Panel value="payments">
<PaymentsTab
booking={booking}
pay={pay}
showCountdown={showCountdown}
onBookingUpdated={onBookingUpdated}
/>
</Tabs.Panel>
<Tabs.Panel value="cargo">
<CargoTab booking={booking} />
</Tabs.Panel>

View File

@@ -252,9 +252,9 @@ export function BookingPaymentPanel({
};
return (
<SectionCard p={22}>
<SectionCard id="freight-payment" p={22}>
<Group justify="space-between" align="center">
<CardTitle>Payment</CardTitle>
<CardTitle>Freight payment</CardTitle>
<Group
component="span"
gap={6}

View File

@@ -0,0 +1,289 @@
import { useState } from "react";
import { Alert, Box, Button, Group, Stack, Text, Textarea } from "@mantine/core";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Check, CreditCard, Download, Eye, FileText, Receipt, X } from "lucide-react";
import { Link } from "react-router-dom";
import toast from "react-hot-toast";
import type { Freight } from "@edr/types";
import { isViewable } from "@edr/ui-common";
import { useFileViewer } from "@/hooks/useFileViewer";
import { useInvoicePayment } from "@/hooks/useInvoicePayment";
import { bookingsService } from "@/services/bookings.service";
import { downloadStoredFile, fetchViewableFile } from "@/services/files.service";
import { formatAmount } from "../utils";
import { IconSquare } from "./Documents";
import { PaymentMethodModal } from "./PaymentMethodModal";
import { CardTitle, SectionCard } from "./layout";
const LABEL: Record<Freight.ClearanceChargeType, string> = {
PORT_CHARGES: "Port charges",
MISCELLANEOUS: "Miscellaneous charge",
};
const STATUS: Record<
Freight.ClearanceChargeStatus,
{ label: string; bg: string; fg: string }
> = {
DOC_UPLOADED: { label: "DRAFT", bg: "#EEF2F6", fg: "#64748B" },
BILLED: { label: "DRAFT", bg: "#EEF2F6", fg: "#64748B" },
SENT: { label: "NEEDS YOUR APPROVAL", bg: "#FEF3E2", fg: "#B45309" },
REJECTED: { label: "REJECTED", bg: "#FEE2E2", fg: "#B91C1C" },
ACCEPTED: { label: "ACCEPTED — UNPAID", bg: "#E0F2FE", fg: "#0369A1" },
PAID: { label: "PAID", bg: "#E6F7EF", fg: "#0A6F4D" },
};
const money = (c: Freight.ClearanceCharge) =>
`${formatAmount(c.amount)} ${c.currency ?? ""}`;
/**
* Clearance charges Global Logistics proposed for this shipment. The customer
* accepts a price (its invoice is then issued and payable here) or rejects it
* with a note so GL can revise. Renders nothing until GL sends a charge.
*/
export function ClearanceChargesSection({ bookingId }: { bookingId: string }) {
const qc = useQueryClient();
const key = ["booking-clearance-charges", bookingId];
const { data: charges = [] } = useQuery({
queryKey: key,
queryFn: () => bookingsService.getClearanceCharges(bookingId),
});
const [rejecting, setRejecting] = useState<string | null>(null);
const [note, setNote] = useState("");
const [payCharge, setPayCharge] = useState<Freight.ClearanceCharge | null>(null);
const pay = useInvoicePayment();
const { view, viewer } = useFileViewer();
const onError = (e: unknown) =>
toast.error(e instanceof Error ? e.message : "Could not update the charge");
const accept = useMutation({
mutationFn: (chargeId: string) =>
bookingsService.acceptClearanceCharge(bookingId, chargeId),
onSuccess: (next) => {
qc.setQueryData(key, next);
toast.success("Accepted — your invoice is ready to pay");
},
onError,
});
const reject = useMutation({
mutationFn: (p: { chargeId: string; note: string }) =>
bookingsService.rejectClearanceCharge(bookingId, p.chargeId, p.note),
onSuccess: (next) => {
qc.setQueryData(key, next);
setRejecting(null);
setNote("");
toast.success("Sent back to Global Logistics");
},
onError,
});
const busy = accept.isPending || reject.isPending;
if (charges.length === 0) return null;
return (
<SectionCard id="clearance-charges">
<Group justify="space-between" align="center" mb="md">
<CardTitle>Clearance charges</CardTitle>
<Text fz="12.5px" fw={600} c="#9AA8B5">
{charges.length} {charges.length === 1 ? "charge" : "charges"}
</Text>
</Group>
<Stack gap={12}>
{charges.map((c) => {
const st = STATUS[c.status];
return (
<Box
key={c.id}
style={{ border: "1px solid #EEF2F6", borderRadius: 12, padding: "12px 14px" }}
>
<Group justify="space-between" align="flex-start" wrap="nowrap">
<Box style={{ minWidth: 0 }}>
<Group gap={8} wrap="nowrap">
<Text fz="13.5px" fw={700} c="#10202F">
{LABEL[c.type]}
</Text>
<Box
style={{
padding: "3px 9px",
borderRadius: 999,
background: st.bg,
color: st.fg,
fontSize: 11,
fontWeight: 700,
whiteSpace: "nowrap",
}}
>
{st.label}
</Box>
</Group>
{c.description && (
<Text fz="12.5px" c="#6B7C8E" mt={4}>
{c.description}
</Text>
)}
{c.invoiceNumber && c.invoiceId && (
<Text fz="12px" c="#9AA8B5" mt={4}>
Invoice{" "}
<Link to={`/billing/${c.invoiceId}`} style={{ color: "#2E5B96" }}>
{c.invoiceNumber}
</Link>
</Text>
)}
</Box>
<Text fz="14px" fw={800} c="#10202F" style={{ whiteSpace: "nowrap" }}>
{money(c)}
</Text>
</Group>
{/* GL's supporting document (port bill, receipt…) — what the
price is based on, so the customer can check before deciding. */}
{c.file && (
<Group
justify="space-between"
wrap="nowrap"
mt="sm"
style={{ border: "1px solid #EEF2F6", borderRadius: 10, padding: "8px 10px" }}
>
<Group gap={8} wrap="nowrap" style={{ minWidth: 0 }}>
<FileText size={16} color="#2E5B96" />
<Text fz="12.5px" c="#10202F" truncate>
{c.file.name}
</Text>
</Group>
<Group gap={6} wrap="nowrap">
{isViewable({ name: c.file.name, url: "" }) && (
<IconSquare
icon={<Eye size={15} />}
onClick={() =>
void fetchViewableFile(c.file!.id, c.file!.name).then(view)
}
/>
)}
<IconSquare
icon={<Download size={15} />}
onClick={() => void downloadStoredFile(c.file!.id, c.file!.name)}
/>
</Group>
</Group>
)}
{c.status === "REJECTED" && c.customerNote && (
<Alert color="red" variant="light" radius="md" p="xs" mt="sm">
<Text fz="12.5px">
You rejected this price: {c.customerNote}. Global Logistics
will revise it and send it again.
</Text>
</Alert>
)}
{c.status === "SENT" &&
(rejecting === c.id ? (
<Stack gap={6} mt="sm">
<Textarea
label="Why are you rejecting this charge?"
placeholder="Tell Global Logistics what is wrong with the price…"
minRows={2}
autosize
maxLength={1000}
value={note}
onChange={(e) => setNote(e.currentTarget.value)}
/>
<Group gap="xs" justify="flex-end">
<Button
variant="default"
size="xs"
disabled={busy}
onClick={() => {
setRejecting(null);
setNote("");
}}
>
Cancel
</Button>
<Button
color="red"
size="xs"
loading={reject.isPending}
disabled={!note.trim()}
onClick={() => reject.mutate({ chargeId: c.id, note: note.trim() })}
>
Submit rejection
</Button>
</Group>
</Stack>
) : (
<Group gap="xs" justify="flex-end" mt="sm">
<Button
variant="default"
size="xs"
radius={10}
leftSection={<X size={14} />}
disabled={busy}
onClick={() => setRejecting(c.id)}
>
Reject
</Button>
<Button
color="edr-green"
size="xs"
radius={10}
leftSection={<Check size={14} />}
loading={accept.isPending}
disabled={busy}
onClick={() => accept.mutate(c.id)}
>
Accept price
</Button>
</Group>
))}
{c.status === "ACCEPTED" && c.invoiceId && (
<Group justify="flex-end" mt="sm">
<Button
size="xs"
radius={10}
color="edr-green"
leftSection={<CreditCard size={14} />}
onClick={() => setPayCharge(c)}
>
Pay
</Button>
</Group>
)}
{c.status === "PAID" && (
<Group gap={6} justify="flex-end" mt="sm">
<Receipt size={14} color="#0A6F4D" />
<Text fz="12px" c="#0A6F4D" fw={600}>
Paid{c.paidAt ? ` · ${new Date(c.paidAt).toLocaleString()}` : ""}
</Text>
</Group>
)}
</Box>
);
})}
</Stack>
<PaymentMethodModal
opened={payCharge !== null}
onClose={() => {
if (!pay.processing) {
setPayCharge(null);
pay.reset();
}
}}
amountLabel={payCharge ? money(payCharge) : undefined}
currency={payCharge?.currency}
onConfirm={(method, payerAccount) =>
payCharge?.invoiceId && pay.pay(payCharge.invoiceId, method, payerAccount)
}
processing={pay.processing}
error={pay.error}
otp={pay.otp}
bill={pay.bill}
/>
{viewer}
</SectionCard>
);
}

View File

@@ -0,0 +1,577 @@
import {
useQuery,
} from "@tanstack/react-query";
import {
Anchor,
Badge,
Box,
Button,
FileInput,
Group,
Paper,
Stack,
Text,
} from "@mantine/core";
import {
Check,
CheckCircle2,
Download,
Eye,
FileBadge,
Receipt,
Upload,
} from "lucide-react";
import {
useState,
} from "react";
import toast from "react-hot-toast";
import type { Freight } from "@edr/types";
import {
useFileViewer,
} from "@/hooks/useFileViewer";
import {
GREEN,
INK,
} from "@/pages/contracts/contract-ui";
import { bookingsService } from "@/services/bookings.service";
import { contractsService } from "@/services/contracts.service";
import { downloadStoredFile } from "@/services/files.service";
import { CardTitle, SectionCard } from "./layout";
// Customer-facing labels for an invoice status (Freight.InvoiceStatus).
const INVOICE_STATUS_LABELS: Record<string, string> = {
DRAFT: "Draft",
ISSUED: "Issued",
PENDING: "Due",
PAYMENT_PROCESSING: "Payment processing",
PARTIALLY_PAID: "Partially paid",
PAID: "Paid",
OVERDUE: "Overdue",
CANCELLED: "Cancelled",
REFUNDED: "Refunded",
EXPIRED: "Expired",
};
function invoiceStatusLabel(status: string): string {
return (
INVOICE_STATUS_LABELS[status] ??
status
.replace(/_/g, " ")
.toLowerCase()
.replace(/\b\w/g, (m) => m.toUpperCase())
);
}
/**
* Customs payments on a phased (customs) booking — duty / tax, the post-arrival
* additional duty and GL Djibouti's final invoice. All are paid by bank
* transfer; the customer attaches the slip here and Global Logistics confirms.
* Renders nothing until customs has advised something.
*/
export function CustomsPaymentsCard({ booking }: { booking: Freight.IBooking }) {
const isPhased = Boolean(booking.customsClearingEnabled && booking.contractId);
const { view, viewer } = useFileViewer();
const { data: clearance, refetch } = useQuery({
queryKey: ["booking-clearance", booking.id],
queryFn: () => bookingsService.getClearance(booking.id),
enabled: isPhased,
});
if (!isPhased || !clearance) return null;
const dutyPaid = Boolean(
clearance.milestones?.some(
(m) => m.milestoneCode === "DUTY_TAX_PAID" && m.status === "COMPLETED",
),
);
const showDuty = Boolean(clearance.dutyRequired && clearance.dutyAdvice);
const showSecond = Boolean(
clearance.secondDuty?.advised || clearance.secondDuty?.paid,
);
const showFinal = Boolean(clearance.finalInvoice);
if (!showDuty && !showSecond && !showFinal) return null;
const onChanged = () => void refetch();
return (
<SectionCard id="customs-payments">
<Group justify="space-between" align="center" mb="md">
<CardTitle>Customs payments</CardTitle>
<Text fz="12px" c="#9AA8B5">
Bank transfer · upload the slip here
</Text>
</Group>
<Stack gap="md">
{showDuty && clearance.dutyAdvice && (
dutyPaid ? (
<PaidRow
label="Customs duty & tax"
amount={clearance.dutyAdvice.amount}
currency={clearance.dutyAdvice.currency}
/>
) : (
<DutyAdvicePanel
dutyAdvice={clearance.dutyAdvice}
bookingId={booking.id}
onChanged={onChanged}
/>
)
)}
{showSecond && clearance.secondDuty && (
<SecondDutyDueCard
duty={clearance.secondDuty}
bookingId={booking.id}
onView={view}
onChanged={onChanged}
/>
)}
{showFinal && clearance.finalInvoice && (
<FinalInvoiceDueCard
invoice={clearance.finalInvoice}
bookingId={booking.id}
onView={view}
onChanged={onChanged}
/>
)}
</Stack>
{viewer}
</SectionCard>
);
}
/** A settled customs payment — slip uploaded, nothing left to do. */
function PaidRow({
label,
amount,
currency,
}: {
label: string;
amount: number;
currency: string;
}) {
return (
<Group
justify="space-between"
wrap="nowrap"
style={{
border: "1px solid #CDEBDD",
background: "#F6FBF8",
borderRadius: 12,
padding: "12px 14px",
}}
>
<Group gap={8} wrap="nowrap">
<CheckCircle2 size={16} color={GREEN} />
<Text fz="13.5px" fw={700} c={INK}>
{label}
</Text>
<Badge color="edr-green" variant="light" radius="sm">
Slip uploaded
</Badge>
</Group>
<Text fz="14px" fw={800} c={INK} style={{ whiteSpace: "nowrap" }}>
{amount.toLocaleString()} {currency}
</Text>
</Group>
);
}
function DutyAdvicePanel({
dutyAdvice,
bookingId,
onChanged,
}: {
dutyAdvice: NonNullable<Freight.ClearanceView["dutyAdvice"]>;
bookingId: string;
onChanged: () => void;
}) {
const [file, setFile] = useState<File | null>(null);
const [loading, setLoading] = useState(false);
const noticeFile = dutyAdvice.noticeFile;
return (
<Paper withBorder radius="md" p="md" bg="#FFFBF0">
<Stack gap="sm">
<GroupLabel icon={Receipt} text="Duty / tax payment" />
<Text size="sm">
Amount due:{" "}
<strong>
{dutyAdvice.amount.toLocaleString()} {dutyAdvice.currency}
</strong>
{dutyAdvice.declarationSerial
? ` · Payment code: ${dutyAdvice.declarationSerial}`
: null}
</Text>
{noticeFile ? (
<Anchor
component="button"
type="button"
onClick={() => void downloadStoredFile(noticeFile.id, noticeFile.name)}
size="sm"
>
<Group gap={6} wrap="nowrap">
<Download size={14} />
Download duty notice ({noticeFile.name})
</Group>
</Anchor>
) : null}
<Text size="sm" c="dimmed">
Pay the amount above, then upload your payment slip so clearance can continue.
</Text>
<FileInput label="Payment slip" value={file} onChange={setFile} size="sm" />
<Button
color="orange"
loading={loading}
disabled={!file}
leftSection={<Upload size={16} />}
onClick={async () => {
if (!file) return;
setLoading(true);
try {
await bookingsService.uploadBookingClearanceDutySlip(bookingId, file);
toast.success("Payment slip uploaded");
onChanged();
} catch (e) {
toast.error(e instanceof Error ? e.message : "Upload failed");
} finally {
setLoading(false);
}
}}
>
Submit payment slip
</Button>
</Stack>
</Paper>
);
}
function GroupLabel({ icon: Icon, text }: { icon: typeof Receipt; text: string }) {
return (
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<Icon size={16} />
<Text fw={600} size="sm">
{text}
</Text>
</div>
);
}
/**
* Post-offload final invoice from GL Djibouti (export): shows the due amount +
* invoice document; the customer pays offline and attaches the payment slip
* here, then GL confirms and the badge flips to PAID.
*/
function FinalInvoiceDueCard({
invoice,
bookingId,
onView,
onChanged,
}: {
invoice: NonNullable<Freight.ClearanceView["finalInvoice"]>;
bookingId: string;
onView: (file: { name: string; url: string }) => void;
onChanged: () => void;
}) {
const [slip, setSlip] = useState<File | null>(null);
const [uploading, setUploading] = useState(false);
const [approving, setApproving] = useState(false);
const paid = invoice.status === "PAID";
// GL Djibouti raises it as a draft: nothing is payable until the customer
// reviews the attached invoice and approves it.
const approved = Boolean(invoice.approvedAt);
return (
<Paper
withBorder
radius="lg"
p="lg"
style={{
borderColor: paid ? "#CDEBDD" : "#F2D9A6",
background: paid ? "#F6FBF8" : "#FFFBF2",
position: "relative",
overflow: "hidden",
}}
>
<Box
style={{
position: "absolute",
left: 0,
top: 0,
bottom: 0,
width: 3,
background: paid ? GREEN : "#E3A93C",
}}
/>
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
<div>
<Group gap={8} align="center">
<FileBadge size={16} color={paid ? GREEN : "#B07C1F"} />
<Text fw={700} fz={15} c={INK}>
{paid
? "Final invoice paid"
: approved
? "Final invoice due"
: "Final invoice — your approval needed"}{" "}
{invoice.invoiceNumber}
</Text>
<Badge color={paid ? "edr-green" : "yellow"} variant="light" radius="sm">
{approved
? invoiceStatusLabel(invoice.status)
: "Awaiting your approval"}
</Badge>
</Group>
<Text fz={20} fw={800} mt={6} c={INK}>
{invoice.totalAmount.toLocaleString()} {invoice.currency}
</Text>
{invoice.description ? (
<Text fz={13} c="dimmed" mt={2}>
{invoice.description}
</Text>
) : null}
{!paid ? (
<Text fz={13} c="#9A6B1F" mt={6}>
{approved
? "Pay the amount above and attach your payment slip — Global Logistics will confirm the payment."
: "Review the invoice document from Global Logistics Djibouti and approve it to proceed with payment."}
</Text>
) : null}
</div>
<Stack gap="xs" miw={260}>
{invoice.invoiceFile ? (
<Button
variant="default"
radius="md"
size="sm"
leftSection={<Eye size={15} />}
onClick={() =>
onView({
name: invoice.invoiceFile!.name,
url: invoice.invoiceFile!.url,
})
}
>
View invoice
</Button>
) : null}
{invoice.slipFile ? (
<Button
variant="default"
radius="md"
size="sm"
leftSection={<Eye size={15} />}
onClick={() =>
onView({
name: invoice.slipFile!.name,
url: invoice.slipFile!.url,
})
}
>
View payment slip
</Button>
) : null}
{!paid && !approved ? (
<Button
color="edr-green"
radius="md"
size="sm"
loading={approving}
leftSection={<Check size={15} />}
onClick={async () => {
setApproving(true);
try {
await contractsService.approveFinalInvoice(bookingId);
toast.success("Invoice approved — you can now pay");
onChanged();
} catch (e) {
toast.error(e instanceof Error ? e.message : "Approval failed");
} finally {
setApproving(false);
}
}}
>
Approve invoice
</Button>
) : null}
{!paid && approved ? (
<>
<FileInput
placeholder={
invoice.slipFile ? "Replace payment slip" : "Attach payment slip"
}
value={slip}
onChange={setSlip}
size="sm"
radius="md"
/>
<Button
color="edr-green"
radius="md"
size="sm"
loading={uploading}
disabled={!slip}
leftSection={<Upload size={15} />}
onClick={async () => {
if (!slip) return;
setUploading(true);
try {
await contractsService.uploadFinalInvoiceSlip(bookingId, slip);
setSlip(null);
toast.success("Payment slip attached");
onChanged();
} catch (e) {
toast.error(
e instanceof Error ? e.message : "Upload failed",
);
} finally {
setUploading(false);
}
}}
>
{invoice.slipFile ? "Replace slip" : "Submit payment slip"}
</Button>
</>
) : null}
</Stack>
</Group>
</Paper>
);
}
/**
* Post-arrival additional duty/tax round (import): GL advises an extra amount
* with a notice; the customer pays offline and attaches another slip here.
*/
function SecondDutyDueCard({
duty,
bookingId,
onView,
onChanged,
}: {
duty: NonNullable<Freight.ClearanceView["secondDuty"]>;
bookingId: string;
onView: (file: { name: string; url: string }) => void;
onChanged: () => void;
}) {
const [slip, setSlip] = useState<File | null>(null);
const [uploading, setUploading] = useState(false);
const paid = duty.paid;
return (
<Paper
withBorder
radius="lg"
p="lg"
style={{
borderColor: paid ? "#CDEBDD" : "#F2D9A6",
background: paid ? "#F6FBF8" : "#FFFBF2",
position: "relative",
overflow: "hidden",
}}
>
<Box
style={{
position: "absolute",
left: 0,
top: 0,
bottom: 0,
width: 3,
background: paid ? GREEN : "#E3A93C",
}}
/>
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
<div>
<Group gap={8} align="center">
<FileBadge size={16} color={paid ? GREEN : "#B07C1F"} />
<Text fw={700} fz={15} c={INK}>
{paid ? "Additional duty & tax paid" : "Additional duty & tax due"}
</Text>
<Badge color={paid ? "edr-green" : "yellow"} variant="light" radius="sm">
{paid ? "PAID" : "DUE"}
</Badge>
</Group>
<Text fz={20} fw={800} mt={6} c={INK}>
{(duty.amount ?? 0).toLocaleString()} {duty.currency ?? ""}
</Text>
{duty.declarationSerial ? (
<Text fz={13} c="dimmed" mt={2}>
Payment code: {duty.declarationSerial}
</Text>
) : null}
{!paid ? (
<Text fz={13} c="#9A6B1F" mt={6}>
Customs advised additional duty/tax after arrival. Pay the amount
above and attach your payment slip.
</Text>
) : null}
</div>
<Stack gap="xs" miw={260}>
{duty.noticeFile ? (
<Button
variant="default"
radius="md"
size="sm"
leftSection={<Eye size={15} />}
onClick={() =>
onView({ name: duty.noticeFile!.name, url: duty.noticeFile!.url })
}
>
View duty notice
</Button>
) : null}
{duty.slipFile ? (
<Button
variant="default"
radius="md"
size="sm"
leftSection={<Eye size={15} />}
onClick={() =>
onView({ name: duty.slipFile!.name, url: duty.slipFile!.url })
}
>
View payment slip
</Button>
) : null}
{!paid ? (
<>
<FileInput
placeholder={duty.slipFile ? "Replace payment slip" : "Attach payment slip"}
value={slip}
onChange={setSlip}
size="sm"
radius="md"
/>
<Button
color="edr-green"
radius="md"
size="sm"
loading={uploading}
disabled={!slip}
leftSection={<Upload size={15} />}
onClick={async () => {
if (!slip) return;
setUploading(true);
try {
await contractsService.uploadSecondDutySlip(bookingId, slip);
setSlip(null);
toast.success("Payment slip attached");
onChanged();
} catch (e) {
toast.error(e instanceof Error ? e.message : "Upload failed");
} finally {
setUploading(false);
}
}}
>
{duty.slipFile ? "Replace slip" : "Submit payment slip"}
</Button>
</>
) : null}
</Stack>
</Group>
</Paper>
);
}

View File

@@ -0,0 +1,269 @@
import { Box, Button, Group, Stack, Text, UnstyledButton } from "@mantine/core";
import {
ArrowRight,
CheckCircle2,
CreditCard,
FileCheck2,
Landmark,
Scale,
Upload,
type LucideIcon,
} from "lucide-react";
import type { Freight } from "@edr/types";
import {
isReviewAction,
useBookingPayables,
type PayableAction,
type PayableItem,
} from "@/pages/bookings/payments/useBookingPayables";
import type { useBookingPayment } from "@/pages/bookings/payments/useBookingPayment";
import { formatAmount } from "../utils";
import { BookingPaymentPanel } from "./BookingPaymentPanel";
import { ClearanceChargesSection } from "./ClearanceChargesSection";
import { CustomsPaymentsCard } from "./CustomsPaymentsCard";
import { BodyGrid, CardTitle, SectionCard } from "./layout";
import { WagonCancellationCard } from "./WagonCancellationCard";
const ACTION_META: Record<
PayableAction,
{ verb: string; icon: LucideIcon; color: string }
> = {
PAY: { verb: "Pay now", icon: CreditCard, color: "#0A6F4D" },
BANK_TRANSFER: { verb: "Bank transfer", icon: Landmark, color: "#B07D14" },
UPLOAD_SLIP: { verb: "Upload slip", icon: Upload, color: "#B07D14" },
APPROVE: { verb: "Approve", icon: FileCheck2, color: "#2E5B96" },
DECIDE: { verb: "Accept or reject", icon: Scale, color: "#2E5B96" },
};
const money = (amount: number, currency: string) =>
`${formatAmount(amount)} ${currency}`.trim();
const scrollTo = (id: string) =>
document.getElementById(id)?.scrollIntoView({ behavior: "smooth", block: "start" });
/**
* The booking's Payments tab — every amount the customer owes or must decide
* on, in one place: freight (with its pay window), clearance charges to
* accept and pay, customs duty / final invoice slips, wagon-cancellation fees.
* The summary strip at the top lists what is outstanding and jumps to the card
* that settles it.
*/
export function PaymentsTab({
booking,
pay,
showCountdown,
onBookingUpdated,
}: {
booking: Freight.IBooking;
pay: ReturnType<typeof useBookingPayment>;
showCountdown: boolean;
onBookingUpdated?: () => void;
}) {
const { items, dueTotals, loading } = useBookingPayables(booking);
return (
<Stack gap="lg">
<PaymentsSummary items={items} dueTotals={dueTotals} loading={loading} />
<BodyGrid
left={
<>
<ClearanceChargesSection bookingId={booking.id} />
<CustomsPaymentsCard booking={booking} />
<WagonCancellationCard
booking={booking}
onBookingUpdated={onBookingUpdated}
/>
</>
}
right={
<BookingPaymentPanel
booking={booking}
pricing={booking.pricingBreakdown}
onPay={pay.open}
paying={pay.processing}
showCountdown={showCountdown}
/>
}
/>
</Stack>
);
}
function PaymentsSummary({
items,
dueTotals,
loading,
}: {
items: PayableItem[];
dueTotals: Array<{ currency: string; amount: number }>;
loading: boolean;
}) {
const reviews = items.filter((i) => isReviewAction(i.action)).length;
const settled = !loading && items.length === 0;
return (
<SectionCard
p={22}
style={{
background: settled ? "#F6FBF8" : "#FFFDF7",
borderColor: settled ? "#CDEBDD" : "#F3E2B8",
}}
>
<Group justify="space-between" align="flex-start" wrap="wrap" gap="lg">
<Box style={{ minWidth: 220 }}>
<CardTitle>{settled ? "All settled" : "Amount due"}</CardTitle>
{loading ? (
<Text fz="14px" c="#9AA8B5" mt={8}>
Checking your payments
</Text>
) : settled ? (
<Group gap={8} mt={8} wrap="nowrap">
<CheckCircle2 size={20} color="#0A6F4D" />
<Text fz="18px" fw={800} c="#10202F">
Nothing to pay right now
</Text>
</Group>
) : (
<>
<Group gap={18} mt={6} align="baseline">
{dueTotals.length > 0 ? (
dueTotals.map((t) => (
<Text key={t.currency} fz="28px" fw={800} c="#10202F" lh={1.1}>
{money(t.amount, t.currency)}
</Text>
))
) : (
<Text fz="20px" fw={800} c="#10202F">
Your review is needed
</Text>
)}
</Group>
<Text fz="12.5px" c="#6B7C8E" mt={6}>
{items.length} {items.length === 1 ? "item needs" : "items need"} your
attention
{reviews > 0 ? ` · ${reviews} awaiting your review` : ""}
</Text>
</>
)}
</Box>
{!settled && !loading && (
<Stack gap={6} style={{ flex: 1, minWidth: 280, maxWidth: 480 }}>
{items.map((it) => {
const m = ACTION_META[it.action];
const Icon = m.icon;
return (
<UnstyledButton
key={`${it.anchor}-${it.id}`}
onClick={() => scrollTo(it.anchor)}
style={{
border: "1px solid #EEF2F6",
borderRadius: 10,
padding: "8px 12px",
background: "white",
}}
>
<Group justify="space-between" wrap="nowrap" gap={10}>
<Group gap={8} wrap="nowrap" style={{ minWidth: 0 }}>
<Icon size={14} color={m.color} />
<Box style={{ minWidth: 0 }}>
<Text fz="13px" fw={700} c="#10202F" truncate>
{it.label}
</Text>
{it.detail && (
<Text fz="11.5px" c="#9AA8B5" truncate>
{it.detail}
</Text>
)}
</Box>
</Group>
<Group gap={8} wrap="nowrap">
<Text
fz="13px"
fw={800}
c="#10202F"
style={{ whiteSpace: "nowrap" }}
>
{money(it.amount, it.currency)}
</Text>
<Text
fz="11.5px"
fw={700}
c={m.color}
style={{ whiteSpace: "nowrap" }}
>
{m.verb}
</Text>
<ArrowRight size={13} color="#9AA8B5" />
</Group>
</Group>
</UnstyledButton>
);
})}
</Stack>
)}
</Group>
</SectionCard>
);
}
/**
* Compact "amount due" strip on the Overview tab — the only payment surface
* left there. Renders nothing when the booking has nothing outstanding.
*/
export function PaymentsDueStrip({
booking,
onOpen,
}: {
booking: Freight.IBooking;
onOpen: () => void;
}) {
const { items, dueTotals } = useBookingPayables(booking);
if (items.length === 0) return null;
const labels = [...new Set(items.map((i) => i.label))].join(", ");
return (
<SectionCard
p="md"
style={{ background: "#FFFDF7", borderColor: "#F3E2B8" }}
>
<Group justify="space-between" wrap="wrap" gap="md">
<Group gap={12} wrap="nowrap" style={{ minWidth: 0 }}>
<Box
style={{
width: 40,
height: 40,
borderRadius: 12,
flexShrink: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "#FEF3E2",
color: "#B45309",
}}
>
<CreditCard size={18} />
</Box>
<Box style={{ minWidth: 0 }}>
<Text fz="14px" fw={800} c="#10202F">
{dueTotals.length > 0
? `${dueTotals.map((t) => money(t.amount, t.currency)).join(" + ")} due`
: "A payment needs your review"}
</Text>
<Text fz="12.5px" c="#6B7C8E" truncate>
{items.length} {items.length === 1 ? "item" : "items"}: {labels}
</Text>
</Box>
</Group>
<Button
color="edr-green"
radius={10}
rightSection={<ArrowRight size={15} />}
onClick={onOpen}
>
Go to payments
</Button>
</Group>
</SectionCard>
);
}

View File

@@ -232,7 +232,7 @@ export function WagonCancellationCard({
if (!canRequest && !ownRows.length) return null;
return (
<SectionCard>
<SectionCard id="wagon-cancellation">
<Group justify="space-between" align="center" mb="sm">
<CardTitle>Wagon Cancellation</CardTitle>
{/* {canRequest && !openRow && !creditRow && (

View File

@@ -34,8 +34,8 @@ import {
} from "lucide-react";
import { ShipmentTrackingModal } from "./tracking/ShipmentTrackingModal";
import { PayNowButton } from "./payments/PayNowButton";
import { payWindowState } from "./payments/payment-drain";
import { PayButton } from "./payments/PayButton";
import { useMyPayables } from "./payments/useMyPayables";
import { BookingActionButton } from "./clearance/BookingActionButton";
import { bookingHasInlineAction } from "./clearance/bookingNextAction";
import {
@@ -181,11 +181,14 @@ const STAT_CARDS: Array<{
function PrimaryAction({
booking,
credit,
payable,
onNavigate,
}: {
booking: Freight.IBooking;
/** CREDIT_AVAILABLE wagon cancellation opened by this booking, if any. */
credit?: WagonCancellation;
/** Outstanding payments on this booking (from `my-payables`), if any. */
payable?: Freight.BookingPayableSummary;
onNavigate: (path: string) => void;
}) {
const { status, id } = booking;
@@ -199,9 +202,6 @@ function PrimaryAction({
/>
);
}
// A general contract is payable as soon as it's FULLY_EXECUTED (signed); a
// one-time booking only after it's SELECTED_FOR_BATCH.
const isGeneralContract = booking.bookingType === "GENERAL_CONTRACT";
if (status === "DRAFT") {
return (
<Button
@@ -220,24 +220,16 @@ function PrimaryAction({
</Button>
);
}
// Anything outstanding (freight, clearance charge, duty slip, cancellation
// fee) → "Pay" jumps to the booking's Payments tab.
if (payable) {
return <PayButton bookingId={id} summary={payable} />;
}
// CHANGES_REQUESTED + clearance/operation steps are handled in place by a
// modal (update & resubmit, upload clearance docs, schedule & proceed).
if (bookingHasInlineAction(booking)) {
return <BookingActionButton booking={booking} size="xs" />;
}
const payableStatus = isGeneralContract
? "FULLY_EXECUTED"
: "SELECTED_FOR_BATCH";
// A fully-closed pay window (deadline + drain both elapsed) falls through to
// the default action. The drain itself still routes here — PayNowButton
// renders the "payment processing" wait notice instead of a pay action.
if (
status === payableStatus &&
booking.paymentStatus !== "PAID" &&
payWindowState(booking).phase !== "closed"
) {
return <PayNowButton booking={booking} />;
}
// Contract ready for the customer's signature → full-page contract viewer.
if (bookingIsSignable(booking)) {
return <ContractSignButton booking={booking} size="xs" />;
@@ -460,6 +452,8 @@ export default function BookingsListPage() {
input: { pageSize: 100 },
}),
);
// Outstanding payments per booking → row "Pay" button (one shared query).
const payables = useMyPayables();
const creditByBooking = useMemo(() => {
const m = new Map<string, WagonCancellation>();
for (const r of myCancellations?.items ?? []) {
@@ -702,6 +696,7 @@ export default function BookingsListPage() {
<PrimaryAction
booking={booking}
credit={creditByBooking.get(booking.id)}
payable={payables.get(booking.id)}
onNavigate={navigate}
/>
<Menu position="bottom-end" withinPortal shadow="md" radius="md">

View File

@@ -39,7 +39,7 @@ interface BookingActionButtonProps {
* when the booking has no customer-actionable clearance/operation step;
* otherwise shows a button that opens the in-place {@link BookingActionModal}.
*
* Drop it into a list row exactly like {@link PayNowButton} — it stops click
* Drop it into a list row exactly like {@link PayButton} — it stops click
* propagation so it never triggers the row's navigation handler.
*/
export function BookingActionButton({

View File

@@ -28,7 +28,7 @@ interface ContractSignButtonProps {
* that navigates to the full-page contract viewer ({@link BookingContractPage})
* where the signature flow lives.
*
* Drop it into a list row exactly like {@link PayNowButton} — it stops click
* Drop it into a list row exactly like {@link PayButton} — it stops click
* propagation so it never triggers the row's navigation handler.
*/
export function ContractSignButton({

View File

@@ -0,0 +1,55 @@
import { Button, type ButtonProps } from "@mantine/core";
import { CreditCard } from "lucide-react";
import { useNavigate } from "react-router-dom";
import type { Freight } from "@edr/types";
import { formatAmount } from "../BookingDetailPage/utils";
/** The booking detail page opened on its Payments tab. */
export const paymentsTabPath = (bookingId: string) =>
`/bookings/${bookingId}?tab=payments`;
/**
* "Pay" on a list / home row. Every payable item (freight, clearance charges,
* customs duty, cancellation fees) lives on the booking's Payments tab, so the
* row only needs to get the customer there — no per-row payment modal.
*/
export function PayButton({
bookingId,
summary,
size = "xs",
fullWidth,
}: {
bookingId: string;
summary?: Freight.BookingPayableSummary;
size?: ButtonProps["size"];
fullWidth?: boolean;
}) {
const navigate = useNavigate();
const single = summary?.totals.length === 1 ? summary.totals[0] : null;
// Only items awaiting the customer's review (a proposed price, a draft
// final invoice): nothing to pay yet, but still theirs to act on.
const reviewOnly = summary != null && summary.totals.length === 0;
return (
<Button
size={size}
radius="md"
fw={700}
fz={13}
color="edr-green"
fullWidth={fullWidth}
leftSection={<CreditCard size={14} />}
onClick={(e) => {
// Don't let a surrounding row-click handler fire.
e.stopPropagation();
navigate(paymentsTabPath(bookingId));
}}
>
{reviewOnly
? "Review payment"
: single
? `Pay ${formatAmount(single.amount)} ${single.currency}`
: "Pay"}
</Button>
);
}

View File

@@ -1,102 +0,0 @@
import { Badge, Button, type ButtonProps } from "@mantine/core";
import { CreditCard, Landmark } from "lucide-react";
import { Freight } from "@edr/types";
import { ModalSafeWrapper } from "@/components/customer-actions/ModalSafeWrapper";
import { PaymentMethodModal } from "../BookingDetailPage/components/PaymentMethodModal";
import { priceTotal } from "../BookingDetailPage/utils";
import { isUsdOfflineBooking } from "./offline-payment";
import { payWindowState } from "./payment-drain";
import { PaymentProcessingNotice } from "./PaymentProcessingNotice";
import { useBookingPayment } from "./useBookingPayment";
interface PayNowButtonProps {
booking: Freight.IBooking;
label?: string;
size?: ButtonProps["size"];
fullWidth?: boolean;
}
/**
* Self-contained "Pay now" action: shows the payment-method modal in place
* instead of navigating to the booking detail page. Drop it into list rows,
* cards, or anywhere a payable booking surfaces.
*/
export function PayNowButton({
booking,
label = "Pay now",
size = "xs",
fullWidth,
}: PayNowButtonProps) {
const pay = useBookingPayment(booking.id);
const pricing = booking.pricingBreakdown;
const payWindow = payWindowState(booking);
// Pay deadline passed but in-flight payments are still settling: show the
// drain countdown instead of any pay action, so nobody pays a second time.
// Checked before the USD branch — a bank transfer is just as double-payable.
if (payWindow.phase === "draining" && payWindow.drainEndsAt) {
return (
<PaymentProcessingNotice
drainEndsAt={payWindow.drainEndsAt}
variant="inline"
/>
);
}
// Window fully over (drain included) — nothing to pay against anymore.
if (payWindow.phase === "closed") {
return null;
}
// USD is paid by bank transfer and confirmed by Finance — no online payment.
if (isUsdOfflineBooking(booking)) {
return (
<Badge
size={size === "xs" ? "md" : "lg"}
radius="md"
variant="light"
color="yellow"
fullWidth={fullWidth}
leftSection={<Landmark size={12} />}
styles={{ label: { textTransform: "none", fontWeight: 700 } }}
>
Pay by bank transfer
</Badge>
);
}
return (
<ModalSafeWrapper>
<Button
size={size}
radius="md"
fw={700}
fz={13}
color="edr-green"
fullWidth={fullWidth}
leftSection={<CreditCard size={14} />}
onClick={(e) => {
// Don't let a surrounding row-click handler fire.
e.stopPropagation();
pay.open();
}}
>
{label}
</Button>
<PaymentMethodModal
opened={pay.modalOpen}
onClose={pay.close}
amountLabel={pricing ? priceTotal(pricing) : undefined}
currency={pricing?.currency ?? booking.paymentCurrency}
processing={pay.processing}
error={pay.error}
otp={pay.otp}
bill={pay.bill}
onConfirm={pay.confirm}
/>
</ModalSafeWrapper>
);
}

View File

@@ -0,0 +1,205 @@
import { useQuery } from "@tanstack/react-query";
import { useMemo } from "react";
import { Freight } from "@edr/types";
import { isPayable } from "@/pages/billing/invoice-ui";
import { bookingsService } from "@/services/bookings.service";
import { invoicesService } from "@/services/invoices.service";
import { isUsdOfflineBooking } from "./offline-payment";
import { WAGON_CANCEL_FEE_INVOICE_TYPE } from "./useBookingPayment";
export type PayableAction =
| "PAY"
| "BANK_TRANSFER"
| "UPLOAD_SLIP"
| "APPROVE"
| "DECIDE";
export interface PayableItem {
id: string;
label: string;
detail?: string | null;
amount: number;
currency: string;
/** What the customer must do with it. */
action: PayableAction;
/** DOM id of the Payments-tab card that handles it. */
anchor: string;
}
/** Card ids on the Payments tab — the summary strip scrolls to these. */
export const PAYABLE_ANCHORS = {
freight: "freight-payment",
charges: "clearance-charges",
customs: "customs-payments",
wagons: "wagon-cancellation",
} as const;
/** Booking statuses at which the freight invoice is actually due (mirrors the API). */
const FREIGHT_PAYABLE_STATUSES = new Set([
"FULLY_EXECUTED",
"SELECTED_FOR_BATCH",
"AWAITING_PAYMENT",
]);
const CHARGE_LABEL: Record<Freight.ClearanceChargeType, string> = {
PORT_CHARGES: "Port charges",
MISCELLANEOUS: "Miscellaneous charge",
};
/** Items the customer still has to review before anything is payable. */
export const isReviewAction = (a: PayableAction) =>
a === "DECIDE" || a === "APPROVE";
/**
* Everything the customer still owes or must decide on for one booking,
* assembled from the same queries the Payments-tab cards use (shared keys, so
* no extra requests): freight + wagon-fee + final invoices, clearance charges,
* customs duty advices. Mirrors the server's `my-payables` rule set.
*/
export function useBookingPayables(booking: Freight.IBooking) {
const isPhased = Boolean(booking.customsClearingEnabled && booking.contractId);
const invoicesQ = useQuery({
queryKey: ["booking-invoices", booking.id],
queryFn: () => invoicesService.listForSource("booking", booking.id),
});
const chargesQ = useQuery({
queryKey: ["booking-clearance-charges", booking.id],
queryFn: () => bookingsService.getClearanceCharges(booking.id),
});
const clearanceQ = useQuery({
queryKey: ["booking-clearance", booking.id],
queryFn: () => bookingsService.getClearance(booking.id),
enabled: isPhased,
});
const items = useMemo(() => {
const out: PayableItem[] = [];
const offline = isUsdOfflineBooking(booking);
for (const inv of invoicesQ.data ?? []) {
const balance = Number(inv.balanceAmount ?? 0);
if (inv.type === Freight.GL_FINAL_INVOICE_TYPE) {
// Raised as DRAFT; issuing IS the customer's approval, then slip-paid.
if (inv.status === Freight.InvoiceStatus.Draft) {
out.push({
id: inv.id,
label: "Final invoice",
detail: `${inv.invoiceNumber} · approve to proceed`,
amount: Number(inv.totalAmount),
currency: inv.currency,
action: "APPROVE",
anchor: PAYABLE_ANCHORS.customs,
});
} else if (isPayable(inv.status) && balance > 0) {
out.push({
id: inv.id,
label: "Final invoice",
detail: inv.invoiceNumber,
amount: balance,
currency: inv.currency,
action: "UPLOAD_SLIP",
anchor: PAYABLE_ANCHORS.customs,
});
}
continue;
}
if (!isPayable(inv.status) || balance <= 0) continue;
if (inv.type === WAGON_CANCEL_FEE_INVOICE_TYPE) {
out.push({
id: inv.id,
label: "Wagon cancellation fee",
detail: inv.invoiceNumber,
amount: balance,
currency: inv.currency,
action: "PAY",
anchor: PAYABLE_ANCHORS.wagons,
});
continue;
}
if (
booking.paymentStatus !== "PAID" &&
FREIGHT_PAYABLE_STATUSES.has(booking.status as string)
) {
out.push({
id: inv.id,
label: "Freight",
detail: inv.invoiceNumber,
amount: balance,
currency: inv.currency,
action: offline ? "BANK_TRANSFER" : "PAY",
anchor: PAYABLE_ANCHORS.freight,
});
}
}
for (const c of chargesQ.data ?? []) {
if (c.status !== "SENT" && c.status !== "ACCEPTED") continue;
out.push({
id: c.id,
label: CHARGE_LABEL[c.type],
detail: c.status === "SENT" ? c.description : c.invoiceNumber,
amount: c.amount ?? 0,
currency: c.currency ?? "",
action: c.status === "SENT" ? "DECIDE" : "PAY",
anchor: PAYABLE_ANCHORS.charges,
});
}
const cl = clearanceQ.data;
if (cl) {
const dutyPaid = cl.milestones?.some(
(m) => m.milestoneCode === "DUTY_TAX_PAID" && m.status === "COMPLETED",
);
if (cl.dutyRequired && cl.dutyAdvice && !dutyPaid) {
out.push({
id: "duty",
label: "Customs duty & tax",
detail: cl.dutyAdvice.declarationSerial
? `Payment code ${cl.dutyAdvice.declarationSerial}`
: null,
amount: cl.dutyAdvice.amount,
currency: cl.dutyAdvice.currency,
action: "UPLOAD_SLIP",
anchor: PAYABLE_ANCHORS.customs,
});
}
if (cl.secondDuty?.advised && !cl.secondDuty.paid) {
out.push({
id: "second-duty",
label: "Additional duty & tax",
detail: cl.secondDuty.declarationSerial
? `Payment code ${cl.secondDuty.declarationSerial}`
: null,
amount: cl.secondDuty.amount ?? 0,
currency: cl.secondDuty.currency ?? "",
action: "UPLOAD_SLIP",
anchor: PAYABLE_ANCHORS.customs,
});
}
}
return out;
}, [booking, invoicesQ.data, chargesQ.data, clearanceQ.data]);
// Payable now, per currency. Items still under review are not "due" yet.
const dueTotals = useMemo(() => {
const m = new Map<string, number>();
for (const it of items) {
if (isReviewAction(it.action) || !it.currency) continue;
m.set(it.currency, (m.get(it.currency) ?? 0) + it.amount);
}
return [...m.entries()].map(([currency, amount]) => ({ currency, amount }));
}, [items]);
return {
items,
dueTotals,
reviewCount: items.filter((i) => isReviewAction(i.action)).length,
loading:
invoicesQ.isPending ||
chargesQ.isPending ||
(isPhased && clearanceQ.isPending),
};
}

View File

@@ -0,0 +1,26 @@
import { useQuery } from "@tanstack/react-query";
import { useMemo } from "react";
import type { Freight } from "@edr/types";
import { bookingsService } from "@/services/bookings.service";
export const MY_PAYABLES_KEY = ["my-payables"] as const;
/**
* Outstanding payments for every booking of the signed-in company, keyed by
* booking id. One request shared by every row on the home page and the
* bookings list (react-query dedupes by key), so rows can show "Pay" without
* each resolving their own invoices.
*/
export function useMyPayables(): Map<string, Freight.BookingPayableSummary> {
const { data } = useQuery({
queryKey: MY_PAYABLES_KEY,
queryFn: bookingsService.getMyPayables,
staleTime: 30_000,
refetchOnWindowFocus: true,
});
return useMemo(
() => new Map((data ?? []).map((p) => [p.bookingId, p] as const)),
[data],
);
}

View File

@@ -48,7 +48,9 @@ function serviceFeatures(s: ServiceItem) {
{
key: "customs",
icon: ShieldCheck,
label: "Customs clearance",
label: s.includesEthiopianCustomsOnly
? "Ethiopian customs clearance"
: "Customs clearance",
on: s.includesCustoms,
},
];

View File

@@ -533,6 +533,40 @@ export const bookingsService = {
return data.data ?? data;
},
/** Outstanding payments per booking — drives the "Pay" badge on list/home rows. */
getMyPayables: async (): Promise<Freight.BookingPayableSummary[]> => {
const { data } = await client.get(`/api/bookings/my-payables`);
return data.data ?? data;
},
// ── Clearance charges (port + miscellaneous) the customer approves, then pays ──
getClearanceCharges: async (id: string): Promise<Freight.ClearanceCharge[]> => {
const { data } = await client.get(`/api/bookings/${id}/clearance/charges`);
return data.data ?? data;
},
acceptClearanceCharge: async (
id: string,
chargeId: string,
): Promise<Freight.ClearanceCharge[]> => {
const { data } = await client.post(
`/api/bookings/${id}/clearance/charges/${chargeId}/accept`,
);
return data.data ?? data;
},
rejectClearanceCharge: async (
id: string,
chargeId: string,
note: string,
): Promise<Freight.ClearanceCharge[]> => {
const { data } = await client.post(
`/api/bookings/${id}/clearance/charges/${chargeId}/reject`,
{ note },
);
return data.data ?? data;
},
acceptDraftDeclaration: async (id: string): Promise<Freight.IBooking> => {
const { data } = await client.post(
`/api/bookings/${id}/clearance/draft-declaration/accept`,

View File

@@ -769,6 +769,7 @@ export interface IContract extends BaseEntity {
includesFirstMile?: boolean;
includesLastMile?: boolean;
includesCustoms: boolean;
includesEthiopianCustomsOnly?: boolean;
} | null;
paymentCurrency: string;
customsClearingEnabled: boolean;

View File

@@ -833,17 +833,22 @@ export type ClearanceChargeType = "PORT_CHARGES" | "MISCELLANEOUS";
/**
* DOC_UPLOADED: GL Djibouti uploaded the supporting document (port charges).
* BILLED: GL Ethiopia set amount + currency. SENT: invoice issued to the
* customer (ETB pays via gateway, other currencies via Finance's manual
* settlement). PAID: the invoice settled.
* BILLED: GL Ethiopia set amount + currency (draft, customer does not see it).
* SENT: price proposed to the customer, awaiting their decision.
* REJECTED: customer declined with a note; GL revises and re-sends.
* ACCEPTED: customer agreed — invoice issued (ETB pays via gateway, other
* currencies via Finance's manual settlement); GL can no longer edit.
* PAID: the invoice settled.
*/
export type ClearanceChargeStatus =
| "DOC_UPLOADED"
| "BILLED"
| "SENT"
| "REJECTED"
| "ACCEPTED"
| "PAID";
/** One clearance charge level on a booking — at most one per type. */
/** One clearance charge on a booking — one port charge, any number of miscellaneous. */
export interface ClearanceCharge {
id: string;
bookingId: string;
@@ -852,6 +857,11 @@ export interface ClearanceCharge {
file: { id: string; name: string; url: string } | null;
amount: number | null;
currency: string | null;
/** What the price is for, written by GL (required for miscellaneous). */
description: string | null;
/** Customer's reason when REJECTED; cleared when GL revises. */
customerNote: string | null;
customerDecidedAt: string | null;
invoiceId: string | null;
invoiceNumber: string | null;
uploadedByName: string | null;
@@ -861,6 +871,18 @@ export interface ClearanceCharge {
paidAt: string | null;
}
/**
* One booking's outstanding customer payments — invoices to pay, prices to
* accept, slips to upload — for the "Pay" badge on list/home rows.
*/
export interface BookingPayableSummary {
bookingId: string;
/** Items waiting on the customer (payable + needing review). */
count: number;
/** Amounts actually payable now, per currency (items under review excluded). */
totals: Array<{ currency: string; amount: number }>;
}
/** A GL→customer request for additional clearance document(s). */
export interface ClearanceDocRequest {
id: string;
@@ -1179,6 +1201,8 @@ export interface BookingReferenceService {
includesFirstMile: boolean;
includesLastMile: boolean;
includesCustoms: boolean;
/** Customs cleared on the Ethiopian side only (prices off the Ethiopian customs rate). */
includesEthiopianCustomsOnly?: boolean;
isActive: boolean;
displayOrder: number;
createdAt: string;