[200~feat: add CustomsPaymentsCard and PaymentsTab components for handling customs payments and payment summaries

This commit is contained in:
Marshal
2026-08-20 15:45:42 +00:00
committed by Hagernesh
parent 2e28b10610
commit c5909b8ec7
57 changed files with 3255 additions and 787 deletions

View File

@@ -67,6 +67,7 @@ const TRIGGER_ROUTE_LABELS: Partial<Record<Rate['trigger'], string>> = {
DEMURRAGE: 'Demurrage / wagon detention', DEMURRAGE: 'Demurrage / wagon detention',
PIL_EXTRA_FEE: 'PIL shipping line extra fee', PIL_EXTRA_FEE: 'PIL shipping line extra fee',
CUSTOMS_CLEARANCE: 'Customs clearance service', CUSTOMS_CLEARANCE: 'Customs clearance service',
ETHIOPIAN_CUSTOMS_CLEARANCE: 'Ethiopian customs clearance service',
FUEL: 'Fuel surcharge', 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 { FilesService } from '../files/files.service';
import { BookingsService } from './bookings.service'; import { BookingsService } from './bookings.service';
import { BookingsRepository } from './bookings.repository'; import { BookingsRepository } from './bookings.repository';
import { BookingLifecycleNotifierService } from './booking-lifecycle-notifier.service';
import { Booking } from './entities/booking.entity'; import { Booking } from './entities/booking.entity';
import { import {
BookingClearanceCharge, BookingClearanceCharge,
ClearanceChargeStatus,
ClearanceChargeType, ClearanceChargeType,
} from './entities/booking-clearance-charge.entity'; } from './entities/booking-clearance-charge.entity';
import { ClearanceEventService } from './clearance-event.service'; import { ClearanceEventService } from './clearance-event.service';
@@ -32,13 +34,22 @@ const CHARGE_LABEL: Record<ClearanceChargeType, string> = {
MISCELLANEOUS: 'Miscellaneous charges', 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 * Post-finalization clearance charges billed to the customer: one port charge
* booking: GL Djibouti uploads the port-charges document; GL Ethiopia bills it * (document from GL Djibouti, priced by GL Ethiopia) and any number of
* (amount + currency) and sends the invoice; once that invoice is paid GL * miscellaneous charges. GL prices + describes a charge and SENDs it; the
* Ethiopia may create and send the miscellaneous charge. ETB invoices are paid * customer REJECTs with a note (GL revises, re-sends) or ACCEPTs, which issues
* through the portal gateway, other currencies through Finance's manual * the payable invoice and locks the charge. ETB invoices are paid through the
* settlement worklist — both settle via `clearance_charge.invoice.paid`. * portal gateway, other currencies through Finance's manual settlement
* worklist — both settle via `clearance_charge.invoice.paid`.
*/ */
@Injectable() @Injectable()
export class BookingClearanceChargeService { export class BookingClearanceChargeService {
@@ -51,6 +62,7 @@ export class BookingClearanceChargeService {
private readonly bookingsService: BookingsService, private readonly bookingsService: BookingsService,
private readonly bookingsRepository: BookingsRepository, private readonly bookingsRepository: BookingsRepository,
private readonly clearanceEvents: ClearanceEventService, private readonly clearanceEvents: ClearanceEventService,
private readonly notifier: BookingLifecycleNotifierService,
) {} ) {}
private repo() { private repo() {
@@ -104,6 +116,11 @@ export class BookingClearanceChargeService {
file: file ? { id: file.id, name: file.name, url: file.url } : null, file: file ? { id: file.id, name: file.name, url: file.url } : null,
amount: c.amount != null ? Number(c.amount) : null, amount: c.amount != null ? Number(c.amount) : null,
currency: c.currency ?? null, currency: c.currency ?? null,
description: c.description ?? null,
customerNote: c.customerNote ?? null,
customerDecidedAt: c.customerDecidedAt
? c.customerDecidedAt.toISOString()
: null,
invoiceId: c.invoiceId ?? null, invoiceId: c.invoiceId ?? null,
invoiceNumber: c.invoiceId invoiceNumber: c.invoiceId
? (invoiceById.get(c.invoiceId)?.invoiceNumber ?? null) ? (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. */ /** GL Djibouti uploads (or replaces, until billed) the port-charges document. */
async uploadPortDocument( async uploadPortDocument(
bookingId: string, bookingId: string,
@@ -180,22 +215,21 @@ export class BookingClearanceChargeService {
} }
/** /**
* GL Ethiopia sets (or, on the customer's request, revises) amount + * GL Ethiopia sets (or, after a customer rejection, revises) amount +
* currency. Revising a SENT charge cancels its unpaid invoice; a PAID charge * currency + description. Allowed until the customer accepts: an ACCEPTED
* is immutable. * charge already carries an invoice and a PAID one is settled.
*/ */
async billCharge( async billCharge(
bookingId: string, bookingId: string,
chargeId: string, chargeId: string,
input: { amount: number; currency: string }, input: { amount: number; currency: string; description?: string },
staffId: string, staffId: string,
): Promise<Freight.ClearanceCharge[]> { ): Promise<Freight.ClearanceCharge[]> {
const charge = await this.repo().findOne({ const charge = await this.findCharge(bookingId, chargeId);
where: { id: chargeId, bookingId }, if (!canStaffEditCharge(charge.status)) {
}); throw new ConflictException(
if (!charge) throw new NotFoundException('Clearance charge not found'); 'The customer has accepted this charge — it can no longer be changed.',
if (charge.status === 'PAID') { );
throw new ConflictException('A paid charge can no longer be changed.');
} }
if (!(input.amount > 0)) { if (!(input.amount > 0)) {
throw new BadRequestException('Amount must be greater than zero.'); throw new BadRequestException('Amount must be greater than zero.');
@@ -203,53 +237,117 @@ export class BookingClearanceChargeService {
if (!input.currency?.trim()) { if (!input.currency?.trim()) {
throw new BadRequestException('Currency is required.'); throw new BadRequestException('Currency is required.');
} }
const description = (input.description ?? charge.description ?? '').trim();
if (charge.status === 'SENT' && charge.invoiceId) { if (charge.type === 'MISCELLANEOUS' && !description) {
await this.billing.cancelInvoice(charge.invoiceId); 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, { await this.repo().update(charge.id, {
amount: input.amount.toFixed(2), amount: input.amount.toFixed(2),
currency: input.currency.trim().toUpperCase(), currency,
description: description || null,
status: 'BILLED', status: 'BILLED',
invoiceId: null, customerNote: null,
customerDecidedAt: null,
customerDecidedBy: null,
billedByStaffId: staffId, billedByStaffId: staffId,
billedAt: new Date(), billedAt: new Date(),
}); });
await this.clearanceEvents.record({ await this.clearanceEvents.record({
bookingId, bookingId,
action: 'CHARGE_BILLED', action: 'CHARGE_BILLED',
label: `${charge.status === 'SENT' ? 'Revised' : 'Billed'} ${CHARGE_LABEL[ label: `${revised ? 'Revised' : 'Billed'} ${CHARGE_LABEL[
charge.type charge.type
].toLowerCase()}: ${input.amount} ${input.currency.trim().toUpperCase()}`, ].toLowerCase()}: ${input.amount} ${currency}${
description ? `${description}` : ''
}`,
actorId: staffId, actorId: staffId,
metadata: { metadata: {
chargeType: charge.type, chargeType: charge.type,
amount: input.amount, amount: input.amount,
currency: input.currency.trim().toUpperCase(), currency,
revised: charge.status === 'SENT', description: description || null,
revised,
}, },
}); });
return this.list(bookingId); 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( async sendCharge(
bookingId: string, bookingId: string,
chargeId: string, chargeId: string,
staffId?: string, staffId: string,
): Promise<Freight.ClearanceCharge[]> { ): Promise<Freight.ClearanceCharge[]> {
const charge = await this.repo().findOne({ const charge = await this.findCharge(bookingId, chargeId);
where: { id: chargeId, bookingId }, if (charge.status !== 'BILLED' && charge.status !== 'REJECTED') {
});
if (!charge) throw new NotFoundException('Clearance charge not found');
if (charge.status !== 'BILLED') {
throw new ConflictException( 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); 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({ const invoice = await this.billing.generateInvoice({
source: Freight.InvoiceSource.ClearanceCharge, source: Freight.InvoiceSource.ClearanceCharge,
// The charge's own id, NOT the booking id — booking-scoped invoice // The charge's own id, NOT the booking id — booking-scoped invoice
@@ -258,46 +356,101 @@ export class BookingClearanceChargeService {
type: charge.type, type: charge.type,
companyId: booking.companyId, companyId: booking.companyId,
companyProfileId: booking.companyProfileId, companyProfileId: booking.companyProfileId,
currency: charge.currency ?? 'ETB', currency,
lines: [ lines: [
{ {
chargeType: charge.type, chargeType: charge.type,
description: `${CHARGE_LABEL[charge.type]}${booking.reference ?? bookingId}`, description: `${CHARGE_LABEL[charge.type]}${
amount: Number(charge.amount), booking.reference ?? bookingId
}${charge.description ? `: ${charge.description}` : ''}`,
amount,
}, },
], ],
}); });
await this.repo().update(charge.id, { await this.repo().update(charge.id, {
status: 'SENT', status: 'ACCEPTED',
invoiceId: invoice.id, invoiceId: invoice.id,
customerNote: null,
customerDecidedAt: new Date(),
customerDecidedBy: userId,
}); });
await this.clearanceEvents.record({ await this.clearanceEvents.record({
bookingId, bookingId,
action: 'CHARGE_INVOICE_SENT', action: 'CHARGE_ACCEPTED',
label: `Sent ${CHARGE_LABEL[charge.type].toLowerCase()} invoice ${invoice.invoiceNumber} to the customer`, label: `Customer accepted ${CHARGE_LABEL[
actorId: staffId ?? null, charge.type
].toLowerCase()} (${amount} ${currency}) — invoice ${invoice.invoiceNumber} issued`,
actorType: 'CUSTOMER',
actorId: userId,
metadata: { metadata: {
chargeType: charge.type, chargeType: charge.type,
invoiceNumber: invoice.invoiceNumber, invoiceNumber: invoice.invoiceNumber,
amount: Number(charge.amount), amount,
currency: charge.currency, currency,
}, },
}); });
this.notifier.clearanceChargeInvoiceIssued(booking, {
label: CHARGE_LABEL[charge.type],
amount,
currency,
invoiceNumber: invoice.invoiceNumber,
});
this.logger.log( 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 + * GL Ethiopia creates a miscellaneous charge whole (document + amount +
* currency). Second payment level: allowed only once the port charge is paid. * currency + what it is for). Lands as a BILLED draft; GL sends it next.
*/ */
async createMiscellaneous( async createMiscellaneous(
bookingId: string, bookingId: string,
file: Express.Multer.File, file: Express.Multer.File,
input: { amount: number; currency: string }, input: { amount: number; currency: string; description?: string },
staffId: string, staffId: string,
): Promise<Freight.ClearanceCharge[]> { ): Promise<Freight.ClearanceCharge[]> {
const booking = await this.bookingsService.findById(bookingId); const booking = await this.bookingsService.findById(bookingId);
@@ -311,6 +464,10 @@ export class BookingClearanceChargeService {
if (!input.currency?.trim()) { if (!input.currency?.trim()) {
throw new BadRequestException('Currency is required.'); 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 // Save the row first so its id can key the document. A booking may carry
// several miscellaneous charges, and `upsertByCode` retires whatever sits // several miscellaneous charges, and `upsertByCode` retires whatever sits
@@ -323,6 +480,7 @@ export class BookingClearanceChargeService {
status: 'BILLED', status: 'BILLED',
amount: input.amount.toFixed(2), amount: input.amount.toFixed(2),
currency: input.currency.trim().toUpperCase(), currency: input.currency.trim().toUpperCase(),
description,
uploadedByStaffId: staffId, uploadedByStaffId: staffId,
uploadedAt: new Date(), uploadedAt: new Date(),
billedByStaffId: staffId, billedByStaffId: staffId,
@@ -342,11 +500,12 @@ export class BookingClearanceChargeService {
await this.clearanceEvents.record({ await this.clearanceEvents.record({
bookingId, bookingId,
action: 'CHARGE_MISC_CREATED', 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, actorId: staffId,
metadata: { metadata: {
amount: input.amount, amount: input.amount,
currency: input.currency.trim().toUpperCase(), currency: input.currency.trim().toUpperCase(),
description,
fileName: file.originalname, 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. */ /** GL confirmed the final-invoice payment slip. */
finalInvoicePaid(b: Booking): void { finalInvoicePaid(b: Booking): void {
const msg = `Your final invoice payment for booking ${b.reference} has been confirmed. Thank you.`; 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); 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 () => { it('hard-blocks a container type with no fee configured (never free clearance)', async () => {
const service = makeService({ liveRates: [bulkFeePerTon] }); const service = makeService({ liveRates: [bulkFeePerTon] });
const result = await service.computePriceForBooking(containerBooking()); 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 usdToEtb = isEtb ? await this.exchangeService.getRate('USD', 'ETB') : 1;
const convert = (usd: number): number => (isEtb ? round2(usd * usdToEtb) : usd); 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( const onLeg = liveRates.filter(
(r) => (r) =>
r.rateType === 'CUSTOMS_CLEARANCE' && r.rateType === customsType &&
r.currency === 'USD' && r.currency === 'USD' &&
r.tradeDirection === booking.tradeDirection && r.tradeDirection === booking.tradeDirection &&
r.originYardId === booking.originYardId && r.originYardId === booking.originYardId &&
@@ -1070,20 +1079,20 @@ export class BookingPricingService {
); );
const missingRateMessage = (scope: string): string => const missingRateMessage = (scope: string): string =>
`No customs clearance service fee is configured for ${scope} on this ` + `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') { if (booking.freightType === 'CONTAINER') {
// Legacy short-circuit: an old contract froze one flat fee — bill it once. // Legacy short-circuit: an old contract froze one flat fee — bill it once.
const hasPerSizeSnapshot = const hasPerSizeSnapshot =
frozenRates?.has('CUSTOMS_CLEARANCE_20FT') || frozenRates?.has(`${customsType}_20FT`) ||
frozenRates?.has('CUSTOMS_CLEARANCE_40FT'); frozenRates?.has(`${customsType}_40FT`);
const legacyFlat = this.frozenRateByCode(frozenRates, 'CUSTOMS_CLEARANCE', currency, usdToEtb); const legacyFlat = this.frozenRateByCode(frozenRates, customsType, currency, usdToEtb);
if (legacyFlat && !hasPerSizeSnapshot) { if (legacyFlat && !hasPerSizeSnapshot) {
const amount = Number(legacyFlat.unitPrice); const amount = Number(legacyFlat.unitPrice);
if (amount > 0) { if (amount > 0) {
lineItems.push({ lineItems.push({
code: 'CUSTOMS_CLEARANCE', code: customsType,
description: 'Customs clearance service', description: customsLabel,
amount, amount,
unitAmount: amount, unitAmount: amount,
unit: 'FLAT', unit: 'FLAT',
@@ -1106,7 +1115,7 @@ export class BookingPricingService {
// unknown type — falls through to the live per-type lookup below // unknown type — falls through to the live per-type lookup below
} }
const frozen = sizeFt const frozen = sizeFt
? this.frozenRateByCode(frozenRates, `CUSTOMS_CLEARANCE_${sizeFt}FT`, currency, usdToEtb) ? this.frozenRateByCode(frozenRates, `${customsType}_${sizeFt}FT`, currency, usdToEtb)
: null; : null;
const live = onLeg.find((r) => r.containerTypeId === bc.containerTypeId); const live = onLeg.find((r) => r.containerTypeId === bc.containerTypeId);
if (!frozen && !live) { if (!frozen && !live) {
@@ -1124,8 +1133,8 @@ export class BookingPricingService {
const amount = unit === 'FLAT' ? unitAmount : unitAmount * billedQty; const amount = unit === 'FLAT' ? unitAmount : unitAmount * billedQty;
if (!(amount > 0)) continue; if (!(amount > 0)) continue;
lineItems.push({ lineItems.push({
code: sizeFt ? `CUSTOMS_CLEARANCE_${sizeFt}FT` : 'CUSTOMS_CLEARANCE', code: sizeFt ? `${customsType}_${sizeFt}FT` : customsType,
description: `Customs clearance service${sizeFt ? ` (${sizeFt}ft)` : ''}`, description: `${customsLabel}${sizeFt ? ` (${sizeFt}ft)` : ''}`,
amount, amount,
unitAmount, unitAmount,
unit, unit,
@@ -1141,7 +1150,7 @@ export class BookingPricingService {
// flat snapshot share the CUSTOMS_CLEARANCE code; both are the agreed fee. // flat snapshot share the CUSTOMS_CLEARANCE code; both are the agreed fee.
// Live lookup: the rate scoped to the booking's commodity wins; a // Live lookup: the rate scoped to the booking's commodity wins; a
// commodity-less rate (legacy) is the catch-all fallback. // 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 = const live =
(booking.cargoTypeId (booking.cargoTypeId
? onLeg.find( ? onLeg.find(
@@ -1172,8 +1181,8 @@ export class BookingPricingService {
const amount = unit === 'FLAT' ? unitAmount : unitAmount * billedQty; const amount = unit === 'FLAT' ? unitAmount : unitAmount * billedQty;
if (amount > 0) { if (amount > 0) {
lineItems.push({ lineItems.push({
code: 'CUSTOMS_CLEARANCE', code: customsType,
description: 'Customs clearance service (bulk)', description: `${customsLabel} (bulk)`,
amount, amount,
unitAmount, unitAmount,
unit, unit,

View File

@@ -40,8 +40,12 @@ import {
import type { Response } from "express"; import type { Response } from "express";
import { BookingClearanceChargeService } from './booking-clearance-charge.service'; import { BookingClearanceChargeService } from './booking-clearance-charge.service';
import { BookingPayablesService } from './booking-payables.service';
import { ClearanceEventService } from './clearance-event.service'; import { ClearanceEventService } from './clearance-event.service';
import { BillClearanceChargeDto } from './dto/clearance-charge.dto'; import {
BillClearanceChargeDto,
RejectClearanceChargeDto,
} from './dto/clearance-charge.dto';
import { AdditionalChargeService } from './additional-charge.service'; import { AdditionalChargeService } from './additional-charge.service';
import { CancelAdditionalChargeDto, CreateAdditionalChargeDto } from './dto/additional-charge.dto'; import { CancelAdditionalChargeDto, CreateAdditionalChargeDto } from './dto/additional-charge.dto';
import { BookingContractService } from './booking-contract.service'; import { BookingContractService } from './booking-contract.service';
@@ -177,6 +181,7 @@ export class BookingsController {
private readonly wagonCancellationService: BookingWagonCancellationService, private readonly wagonCancellationService: BookingWagonCancellationService,
private readonly consolidationApprovalService: ConsolidationApprovalService, private readonly consolidationApprovalService: ConsolidationApprovalService,
private readonly clearanceChargeService: BookingClearanceChargeService, private readonly clearanceChargeService: BookingClearanceChargeService,
private readonly bookingPayablesService: BookingPayablesService,
private readonly clearanceEventService: ClearanceEventService, private readonly clearanceEventService: ClearanceEventService,
private readonly additionalChargeService: AdditionalChargeService, private readonly additionalChargeService: AdditionalChargeService,
) {} ) {}
@@ -315,6 +320,21 @@ export class BookingsController {
return this.bookingsService.getListSummary(filter); 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") @Get("my")
@PortalCustomer() @PortalCustomer()
@ApiOperation({ @ApiOperation({
@@ -1121,15 +1141,63 @@ export class BookingsController {
// ── Clearance charges (post-finalization customer billing) ──────────────── // ── Clearance charges (post-finalization customer billing) ────────────────
@Get(":id/clearance/charges") @Get(":id/clearance/charges")
@BookingStaff([ @MixedAudience([
FREIGHT_PERMS.contracts.clearanceEtActions, FREIGHT_PERMS.contracts.clearanceEtActions,
FREIGHT_PERMS.contracts.clearanceDjActions, FREIGHT_PERMS.contracts.clearanceDjActions,
]) ])
@ApiOperation({ @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) { async getClearanceCharges(
return this.clearanceChargeService.list(id); @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") @Post(":id/clearance/charges/port-document")
@@ -1156,7 +1224,7 @@ export class BookingsController {
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions) @BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
@ApiOperation({ @ApiOperation({
summary: 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( billClearanceCharge(
@Param("id", ParseUUIDPipe) id: string, @Param("id", ParseUUIDPipe) id: string,
@@ -1176,7 +1244,7 @@ export class BookingsController {
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions) @BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
@ApiOperation({ @ApiOperation({
summary: 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( sendClearanceCharge(
@Param("id", ParseUUIDPipe) id: string, @Param("id", ParseUUIDPipe) id: string,
@@ -1196,7 +1264,7 @@ export class BookingsController {
@ApiConsumes("multipart/form-data") @ApiConsumes("multipart/form-data")
@ApiOperation({ @ApiOperation({
summary: 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( createMiscellaneousCharge(
@Param("id", ParseUUIDPipe) id: string, @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 { AdditionalChargeRepository } from './additional-charge.repository';
import { AdditionalChargeService } from './additional-charge.service'; import { AdditionalChargeService } from './additional-charge.service';
import { BookingClearanceChargeService } from './booking-clearance-charge.service'; import { BookingClearanceChargeService } from './booking-clearance-charge.service';
import { BookingPayablesService } from './booking-payables.service';
import { BookingClearanceEvent } from './entities/booking-clearance-event.entity'; import { BookingClearanceEvent } from './entities/booking-clearance-event.entity';
import { ClearanceEventService } from './clearance-event.service'; import { ClearanceEventService } from './clearance-event.service';
import { BookingContainer } from './entities/booking-container.entity'; import { BookingContainer } from './entities/booking-container.entity';
@@ -122,6 +123,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
BookingContractService, BookingContractService,
BookingInvoiceService, BookingInvoiceService,
BookingClearanceChargeService, BookingClearanceChargeService,
BookingPayablesService,
ClearanceEventService, ClearanceEventService,
AdditionalChargeRepository, AdditionalChargeRepository,
AdditionalChargeService, AdditionalChargeService,

View File

@@ -1,6 +1,13 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer'; 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 { export class BillClearanceChargeDto {
@ApiProperty({ example: 12500.5 }) @ApiProperty({ example: 12500.5 })
@@ -13,4 +20,18 @@ export class BillClearanceChargeDto {
@IsString() @IsString()
@Length(3, 8) @Length(3, 8)
currency!: string; 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', 'DOC_UPLOADED',
'BILLED', 'BILLED',
'SENT', 'SENT',
'REJECTED',
'ACCEPTED',
'PAID', 'PAID',
] as const; ] as const;
export type ClearanceChargeStatus = (typeof CLEARANCE_CHARGE_STATUSES)[number]; 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 * Clearance charge billed to the customer. One PORT_CHARGES row per booking
* (enforced by a partial unique index) and any number of MISCELLANEOUS rows. * (enforced by a partial unique index) and any number of MISCELLANEOUS rows.
* GL Djibouti uploads the port-charges document (DOC_UPLOADED); GL Ethiopia * GL Djibouti uploads the port-charges document (DOC_UPLOADED); GL Ethiopia
* sets amount + currency (BILLED) and issues the invoice (SENT); the billing * sets amount + currency + description (BILLED) and proposes it to the
* `clearance_charge.invoice.paid` event marks it PAID. The two levels are * customer (SENT). The customer either REJECTS with a note (GL revises and
* independent — either may be raised first. * 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' }) @Entity({ schema: 'freight', name: 'booking_clearance_charge' })
@Index(['bookingId']) @Index(['bookingId'])
@@ -47,6 +51,20 @@ export class BookingClearanceCharge extends BaseEntity {
@Column({ name: 'currency', type: 'varchar', length: 8, nullable: true }) @Column({ name: 'currency', type: 'varchar', length: 8, nullable: true })
currency?: string | null; 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). */ /** The payable invoice issued for this charge (null until SENT). */
@Column({ name: 'invoice_id', type: 'uuid', nullable: true }) @Column({ name: 'invoice_id', type: 'uuid', nullable: true })
invoiceId?: string | null; invoiceId?: string | null;

View File

@@ -376,12 +376,21 @@ export class ContractPricingService {
// own container-type rate), bulk contracts freeze the route's bulk fee. // own container-type rate), bulk contracts freeze the route's bulk fee.
// A customs contract may not proceed without the fee(s) configured. // A customs contract may not proceed without the fee(s) configured.
if (contract.customsClearingEnabled) { 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. // Strict, no route-less fallback.
// ponytail: multi-route contracts bill the first lane's fee; per-lane fees need per-route snapshots. // ponytail: multi-route contracts bill the first lane's fee; per-lane fees need per-route snapshots.
const onLeg = route const onLeg = route
? liveRates.filter( ? liveRates.filter(
(r) => (r) =>
r.rateType === 'CUSTOMS_CLEARANCE' && r.rateType === customsType &&
r.currency === 'USD' && r.currency === 'USD' &&
r.tradeDirection === contract.tradeDirection && r.tradeDirection === contract.tradeDirection &&
r.originYardId === route.originYardId && r.originYardId === route.originYardId &&
@@ -406,14 +415,14 @@ export class ContractPricingService {
); );
if (!rate || Number(rate.rateValue) <= 0) { if (!rate || Number(rate.rateValue) <= 0) {
throw new UnprocessableEntityException( 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({ lineItems.push({
// Distinct code per size so the frozen snapshots don't collide — // Distinct code per size so the frozen snapshots don't collide —
// booking pricing looks each size up by CUSTOMS_CLEARANCE_<FT>FT. // booking pricing looks each size up by <customsType>_<FT>FT.
code: `CUSTOMS_CLEARANCE_${sizeFt}FT`, code: `${customsType}_${sizeFt}FT`,
label: `Customs clearance service (${size})`, label: `${customsLabel} (${size})`,
unit: toContractUnit(rate.rateUnit), unit: toContractUnit(rate.rateUnit),
unitPrice: convert(Number(rate.rateValue)), unitPrice: convert(Number(rate.rateValue)),
containerSize: size, containerSize: size,
@@ -432,12 +441,12 @@ export class ContractPricingService {
: undefined) ?? onLeg.find((r) => !r.containerTypeId && !r.cargoTypeId); : undefined) ?? onLeg.find((r) => !r.containerTypeId && !r.cargoTypeId);
if (!rate || Number(rate.rateValue) <= 0) { if (!rate || Number(rate.rateValue) <= 0) {
throw new UnprocessableEntityException( 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({ lineItems.push({
code: 'CUSTOMS_CLEARANCE', code: customsType,
label: `Customs clearance service (${scope?.cargoType?.cargoTypeName ?? 'bulk'})`, label: `${customsLabel} (${scope?.cargoType?.cargoTypeName ?? 'bulk'})`,
unit: toContractUnit(rate.rateUnit), unit: toContractUnit(rate.rateUnit),
unitPrice: convert(Number(rate.rateValue)), unitPrice: convert(Number(rate.rateValue)),
cargoTypeCode: scope?.cargoType?.code ?? null, cargoTypeCode: scope?.cargoType?.code ?? null,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -25,6 +25,7 @@ export const RATE_TYPES = [
'RETURN_SURCHARGE', 'RETURN_SURCHARGE',
'PIL_EXTRA_FEE', 'PIL_EXTRA_FEE',
'CUSTOMS_CLEARANCE', 'CUSTOMS_CLEARANCE',
'ETHIOPIAN_CUSTOMS_CLEARANCE',
'FUEL_SURCHARGE', 'FUEL_SURCHARGE',
] as const; ] as const;
@@ -96,12 +97,22 @@ export const RATE_TRIGGERS = [
// Customs clearance service fee — billed up front via a clearance invoice, // Customs clearance service fee — billed up front via a clearance invoice,
// never auto-applied to booking pricing (matchesTrigger returns false). // never auto-applied to booking pricing (matchesTrigger returns false).
'CUSTOMS_CLEARANCE', '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, // Fuel surcharge — fires when the booking's cargo type has hasFuel = true,
// billed off the lane-scoped rate (direction + route + cargo type). // billed off the lane-scoped rate (direction + route + cargo type).
'FUEL', 'FUEL',
] as const; ] as const;
export type RateTrigger = typeof RATE_TRIGGERS[number]; 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' }) @Entity({ schema: 'freight', name: 'rates' })
@Index(['rateType']) @Index(['rateType'])
@Index(['status']) @Index(['status'])
@@ -117,7 +128,7 @@ export class Rate extends BaseEntity {
@Column({ name: 'applies_to', type: 'varchar', length: 20, default: 'OTHER' }) @Column({ name: 'applies_to', type: 'varchar', length: 20, default: 'OTHER' })
appliesTo!: RateAppliesTo; appliesTo!: RateAppliesTo;
@Column({ name: 'trigger', type: 'varchar', length: 20, default: 'ALWAYS' }) @Column({ name: 'trigger', type: 'varchar', length: 30, default: 'ALWAYS' })
trigger!: RateTrigger; trigger!: RateTrigger;
@Column({ name: 'container_type_id', type: 'uuid', nullable: true }) @Column({ name: 'container_type_id', type: 'uuid', nullable: true })

View File

@@ -27,6 +27,14 @@ export class ServiceType extends BaseEntity {
@Column({ name: 'includes_customs', type: 'boolean', default: false }) @Column({ name: 'includes_customs', type: 'boolean', default: false })
includesCustoms!: boolean; includesCustoms!: boolean;
/**
* EDR clears customs on the Ethiopian side only. Requires includesCustoms —
* the clearance flow (GL review, duty) is identical; 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 }) @Column({ name: 'is_active', type: 'boolean', default: true })
isActive!: boolean; isActive!: boolean;

View File

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

View File

@@ -1,5 +1,11 @@
import { PaginatedResponse } from '@edr/types'; 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 { generateCode } from '../../../common/utils/generate-code.util';
import { CreateServiceTypeDto } from '../dto/create-service-type.dto'; import { CreateServiceTypeDto } from '../dto/create-service-type.dto';
import { ListServiceTypesQueryDto } from '../dto/list-rule-engine-query.dto'; import { ListServiceTypesQueryDto } from '../dto/list-rule-engine-query.dto';
@@ -43,6 +49,7 @@ export class ServiceTypesService {
const existing = await this.repository.findByCode(code); const existing = await this.repository.findByCode(code);
if (existing) throw new ConflictException(`Service type with name "${dto.serviceName}" conflicts with existing code "${code}"`); if (existing) throw new ConflictException(`Service type with name "${dto.serviceName}" conflicts with existing code "${code}"`);
this.assertCustomsFlags(dto.includesCustoms ?? false, dto.includesEthiopianCustomsOnly ?? false);
const displayOrder = await this.displayOrder.resolveCreateOrder(ServiceType, 'displayOrder', { const displayOrder = await this.displayOrder.resolveCreateOrder(ServiceType, 'displayOrder', {
explicitOrder: dto.displayOrder, explicitOrder: dto.displayOrder,
insertAfterId: dto.insertAfterId, insertAfterId: dto.insertAfterId,
@@ -56,6 +63,7 @@ export class ServiceTypesService {
includesFirstMile: dto.includesFirstMile ?? false, includesFirstMile: dto.includesFirstMile ?? false,
includesLastMile: dto.includesLastMile ?? false, includesLastMile: dto.includesLastMile ?? false,
includesCustoms: dto.includesCustoms ?? false, includesCustoms: dto.includesCustoms ?? false,
includesEthiopianCustomsOnly: dto.includesEthiopianCustomsOnly ?? false,
isActive: dto.isActive ?? true, isActive: dto.isActive ?? true,
displayOrder, displayOrder,
}); });
@@ -63,13 +71,26 @@ export class ServiceTypesService {
/** Update an existing service type. */ /** Update an existing service type. */
async update(id: string, dto: UpdateServiceTypeDto): Promise<ServiceType> { async update(id: string, dto: UpdateServiceTypeDto): Promise<ServiceType> {
await this.findById(id); const existing = await this.findById(id);
this.assertCustomsFlags(
dto.includesCustoms ?? existing.includesCustoms,
dto.includesEthiopianCustomsOnly ?? existing.includesEthiopianCustomsOnly,
);
const { ...patch } = dto; const { ...patch } = dto;
const updated = await this.repository.update(id, patch); const updated = await this.repository.update(id, patch);
if (!updated) throw new NotFoundException(`Service type ${id} not found`); if (!updated) throw new NotFoundException(`Service type ${id} not found`);
return updated; return updated;
} }
/** "Ethiopian customs only" narrows a customs service — it cannot stand alone. */
private assertCustomsFlags(includesCustoms: boolean, ethiopianOnly: boolean): void {
if (ethiopianOnly && !includesCustoms) {
throw new BadRequestException(
'"Ethiopian customs only" requires "Includes customs" to be enabled.',
);
}
}
/** Soft-delete a service type. */ /** Soft-delete a service type. */
async remove(id: string): Promise<void> { async remove(id: string): Promise<void> {
await this.findById(id); await this.findById(id);

View File

@@ -120,6 +120,16 @@ export class TrainSchedule extends BaseEntity {
@Column({ name: 'max_wagons', type: 'int', default: 53 }) @Column({ name: 'max_wagons', type: 'int', default: 53 })
maxWagons!: number; 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`. */ /** OPEN = accepting/holding bookings; FULL = train filled; CLOSED = manually closed. Orthogonal to `status`. */
@Column({ name: 'booking_window_status', type: 'varchar', length: 10, default: 'OPEN' }) @Column({ name: 'booking_window_status', type: 'varchar', length: 10, default: 'OPEN' })
bookingWindowStatus!: string; bookingWindowStatus!: string;

View File

@@ -48,6 +48,7 @@ import {
} from "../dto/import-djibouti-operation.dto"; } from "../dto/import-djibouti-operation.dto";
import { AvailableLocomotivesQueryDto } from "../dto/available-locomotives-query.dto"; import { AvailableLocomotivesQueryDto } from "../dto/available-locomotives-query.dto";
import { AdjustScheduleConsistDto } from "../dto/adjust-schedule-consist.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 { AvailableTrainsQueryDto } from "../dto/available-trains-query.dto";
import { BatchBoardQueryDto } from "../dto/batch-board-query.dto"; import { BatchBoardQueryDto } from "../dto/batch-board-query.dto";
import { BookableSchedulesQueryDto } from "../dto/bookable-schedules-query.dto"; import { BookableSchedulesQueryDto } from "../dto/bookable-schedules-query.dto";
@@ -201,6 +202,29 @@ export class TrainSchedulingController {
return this.trainSchedulingService.getScheduleConsist(id); 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") @Post("schedules/:id/adjust-consist")
@TrainSchedulingUpdate() @TrainSchedulingUpdate()
@ApiOperation({ @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 { CorridorBudget } from '../corridor-capacity.util';
import { deriveScheduleDirection } from '../utils/derive-schedule-direction.util'; import { deriveScheduleDirection } from '../utils/derive-schedule-direction.util';
import { computeScheduleWagonUsage } from '../utils/schedule-wagon-usage.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 { pickLowestFreeNumber, pickTrainNumberPool } from '../train-number.util';
import { import {
bookingCargoTons, bookingCargoTons,
@@ -1557,6 +1563,7 @@ export class TrainSchedulingService {
// and yard follow the schedule. // and yard follow the schedule.
let builtTrain: Train | null = null; let builtTrain: Train | null = null;
let locomotiveIds: string[]; let locomotiveIds: string[];
let plannedWagonYards: PlannedWagonYards | null = null;
if (dto.trainId) { if (dto.trainId) {
builtTrain = await this.dataSource.getRepository(Train).findOne({ builtTrain = await this.dataSource.getRepository(Train).findOne({
where: { id: dto.trainId }, 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`, `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( const conflict = await this.findTrainRouteDayConflict(
builtTrain.id, builtTrain.id,
route.id, route.id,
@@ -1844,6 +1851,7 @@ export class TrainSchedulingService {
direction, direction,
trainNumber: pairTrainNumber ?? undefined, trainNumber: pairTrainNumber ?? undefined,
maxWagons, maxWagons,
plannedWagonYards,
reverseWagonOrder: dto.reverseWagonOrder ?? false, reverseWagonOrder: dto.reverseWagonOrder ?? false,
shippingLineCompanyId: dto.shippingLineCompanyId ?? null, shippingLineCompanyId: dto.shippingLineCompanyId ?? null,
...windowFields, ...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) => { await this.dataSource.transaction(async (manager) => {
const trainNumber = await this.assignTrainNumber(manager, schedule); const trainNumber = await this.assignTrainNumber(manager, schedule);
@@ -5295,15 +5306,30 @@ export class TrainSchedulingService {
return rows[0]?.train_id ?? null; 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( private async countFleetAvailability(
originYardId: string, originYardId: string,
targetScheduleId?: string, targetScheduleId?: string,
): Promise<Array<{ wagonTypeId: string; wagonTypeCode: string; available: number }>> { ): 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(Wagon).find(),
this.dataSource.getRepository(WagonType).find(), this.dataSource.getRepository(WagonType).find(),
this.builtTrainIdOfSchedule(targetScheduleId), this.builtTrainIdOfSchedule(targetScheduleId),
this.pinnedPhysicalWagonIdsForSchedule(targetScheduleId), this.pinnedPhysicalWagonIdsForSchedule(targetScheduleId),
this.plannedWagonYardsOf(targetScheduleId),
]); ]);
const typeCodeById = new Map(wagonTypes.map((type) => [type.id, type.code])); const typeCodeById = new Map(wagonTypes.map((type) => [type.id, type.code]));
const counts = new Map<string, { code: string; available: number }>(); 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, // A built consist spread across several yards can only offer, at each yard,
// the wagons standing there. A single-yard consist keeps the original // the wagons standing there. A single-yard consist keeps the original
// behaviour: the whole train counts wherever it currently sits. // 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 const consistYards = builtTrainId
? new Set( ? new Set(
wagons wagons
.filter((w) => w.trainId === builtTrainId && w.currentYardId) .filter((w) => w.trainId === builtTrainId && scheduleYardOf(plan, w))
.map((w) => w.currentYardId as string), .map((w) => scheduleYardOf(plan, w) as string),
) )
: new Set<string>(); : new Set<string>();
const consistIsSplit = consistYards.size > 1; const consistIsSplit = consistYards.size > 1;
@@ -5327,7 +5356,7 @@ export class TrainSchedulingService {
// counted at the yard each wagon actually stands in. // counted at the yard each wagon actually stands in.
if (builtTrainId) { if (builtTrainId) {
if (wagon.trainId !== builtTrainId) continue; if (wagon.trainId !== builtTrainId) continue;
if (consistIsSplit && wagon.currentYardId !== originYardId) continue; if (consistIsSplit && scheduleYardOf(plan, wagon) !== originYardId) continue;
} else { } else {
// Schedule-scoped availability: pins held by OTHER schedules never // Schedule-scoped availability: pins held by OTHER schedules never
// consume a wagon here — the same physical wagon may serve the July 17 // 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 pinSchedule = await this.trainSchedulesRepository.findById(scheduleId);
const stops = pinSchedule ? await this.stopYardsForSchedule(pinSchedule) : []; const stops = pinSchedule ? await this.stopYardsForSchedule(pinSchedule) : [];
const plannedYards = pinSchedule?.plannedWagonYards ?? {};
const unpinnable = this.findUnpinnableWagonSlots( const unpinnable = this.findUnpinnableWagonSlots(
planSlots, planSlots,
@@ -5507,6 +5537,7 @@ export class TrainSchedulingService {
builtTrainId, builtTrainId,
pinnedToScheduleIds, pinnedToScheduleIds,
stops, stops,
plannedYards,
); );
if (unpinnable.length) { if (unpinnable.length) {
throw new BadRequestException({ throw new BadRequestException({
@@ -5528,6 +5559,7 @@ export class TrainSchedulingService {
builtTrainId, builtTrainId,
pinnedToScheduleIds, pinnedToScheduleIds,
reverseWagonOrder, reverseWagonOrder,
plannedYards,
); );
if (!physical) continue; if (!physical) continue;
@@ -5577,6 +5609,7 @@ export class TrainSchedulingService {
builtTrainId, builtTrainId,
pinnedToScheduleIds, pinnedToScheduleIds,
stops, stops,
targetSchedule?.plannedWagonYards ?? {},
); );
} }
@@ -5609,6 +5642,7 @@ export class TrainSchedulingService {
builtTrainId: string | null = null, builtTrainId: string | null = null,
pinnedToScheduleIds: Set<string> = new Set(), pinnedToScheduleIds: Set<string> = new Set(),
stops: string[] = [], stops: string[] = [],
plannedYards: PlannedWagonYards = {},
): string[] { ): string[] {
const violations: string[] = []; const violations: string[] = [];
// One physical wagon may serve several slots whose leg spans don't overlap // One physical wagon may serve several slots whose leg spans don't overlap
@@ -5627,6 +5661,8 @@ export class TrainSchedulingService {
span, span,
builtTrainId, builtTrainId,
pinnedToScheduleIds, pinnedToScheduleIds,
false,
plannedYards,
); );
if (!physical) { if (!physical) {
violations.push( violations.push(
@@ -5657,6 +5693,7 @@ export class TrainSchedulingService {
builtTrainId: string | null = null, builtTrainId: string | null = null,
pinnedToScheduleIds: Set<string> = new Set(), pinnedToScheduleIds: Set<string> = new Set(),
reverseWagonOrder = false, reverseWagonOrder = false,
plannedYards: PlannedWagonYards = {},
): Wagon | undefined { ): Wagon | undefined {
// Free for this slot = no already-assigned span on this wagon overlaps the // 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. // 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. // takes slot #1). Unsequenced wagons sort after every sequenced one.
const consistYards = new Set( const consistYards = new Set(
wagons wagons
.filter((w) => w.trainId === builtTrainId && w.currentYardId) .filter((w) => w.trainId === builtTrainId && scheduleYardOf(plannedYards, w))
.map((w) => w.currentYardId as string), .map((w) => scheduleYardOf(plannedYards, w) as string),
); );
// Split consist: a slot boarding at a given yard must take a wagon that // 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. // physically stands there — the train cannot load a Mojo wagon at Dire.
@@ -5703,7 +5740,7 @@ export class TrainSchedulingService {
w.trainId === builtTrainId && w.trainId === builtTrainId &&
w.wagonTypeId === slot.wagonTypeId && w.wagonTypeId === slot.wagonTypeId &&
spanFree(w.id) && spanFree(w.id) &&
(!requiredYardId || w.currentYardId === requiredYardId), (!requiredYardId || scheduleYardOf(plannedYards, w) === requiredYardId),
) )
.sort((a, b) => { .sort((a, b) => {
if (a.sequenceNumber == null || b.sequenceNumber == null) { if (a.sequenceNumber == null || b.sequenceNumber == null) {
@@ -5843,7 +5880,7 @@ export class TrainSchedulingService {
preloadedBuiltTrainId !== undefined preloadedBuiltTrainId !== undefined
? preloadedBuiltTrainId ? preloadedBuiltTrainId
: await this.builtTrainIdOfSchedule(scheduleId); : await this.builtTrainIdOfSchedule(scheduleId);
if (builtTrainId) return this.builtTrainStock(builtTrainId); if (builtTrainId) return this.builtTrainStock(builtTrainId, scheduleId);
const boardYardIds = [ const boardYardIds = [
...new Set([originYardId, ...boardingYardIds].filter((id): id is string => Boolean(id))), ...new Set([originYardId, ...boardingYardIds].filter((id): id is string => Boolean(id))),
@@ -5865,11 +5902,17 @@ export class TrainSchedulingService {
return { mode: 'YARD', remainingByTypeId, codesByTypeId }; return { mode: 'YARD', remainingByTypeId, codesByTypeId };
} }
private async builtTrainStock(builtTrainId: string): Promise<WagonStock> { private async builtTrainStock(
const wagons = await this.dataSource.getRepository(Wagon).find({ builtTrainId: string,
where: { trainId: builtTrainId }, scheduleId?: string,
relations: { wagonType: true }, ): 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 remainingByTypeId = new Map<string, number>();
const codesByTypeId = new Map<string, string>(); const codesByTypeId = new Map<string, string>();
const byYardId = new Map<string, Map<string, number>>(); const byYardId = new Map<string, Map<string, number>>();
@@ -5879,10 +5922,12 @@ export class TrainSchedulingService {
(remainingByTypeId.get(wagon.wagonTypeId) ?? 0) + 1, (remainingByTypeId.get(wagon.wagonTypeId) ?? 0) + 1,
); );
if (wagon.wagonType) codesByTypeId.set(wagon.wagonTypeId, wagon.wagonType.code); if (wagon.wagonType) codesByTypeId.set(wagon.wagonTypeId, wagon.wagonType.code);
if (wagon.currentYardId) { // The schedule's own yard plan, not the physical yard — see plannedWagonYards.
const perType = byYardId.get(wagon.currentYardId) ?? new Map<string, number>(); 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); 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 // 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 * Default yard plan for a schedule created from a built train: every wagon
* through every one of them as origin or an intermediate stop — never only * keeps the yard it physically stands in when that yard is a pickup stop of
* as the final destination (the train has to pick the wagons up en route). * 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({ const wagons = await this.dataSource.getRepository(Wagon).find({
where: { trainId: train.id }, 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 (!wagons.length) return null;
if (!wagonYards.length) return;
const milestones = await this.dataSource const milestones = await this.dataSource
.getRepository(RouteMilestone) .getRepository(RouteMilestone)
@@ -6243,23 +6293,203 @@ export class TrainSchedulingService {
// Every stop except the last one is a pickup point. // Every stop except the last one is a pickup point.
const pickupYards = new Set(stops.slice(0, -1)); const pickupYards = new Set(stops.slice(0, -1));
const uncovered = wagonYards.filter((y) => !pickupYards.has(y)); const { plan, rehomed } = defaultPlannedWagonYards(wagons, pickupYards, route.originYardId);
if (!uncovered.length) return; 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); /** Dispatch gate: every planned wagon must physically stand at its planned yard. */
const destination = stops[stops.length - 1]; private async assertPlannedYardsAligned(schedule: TrainSchedule) {
const detail = uncovered const builtTrainId = schedule.trainSet?.trainId;
.map((y) => if (!builtTrainId) return;
y === destination const wagons = await this.dataSource.getRepository(Wagon).find({
? `${labels.get(y) ?? y} (only as the destination)` where: { trainId: builtTrainId },
: `${labels.get(y) ?? y} (not on route)`, 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(', '); .join(', ');
throw new BadRequestException( throw new ConflictException(
`Route ${formatRouteLabel(route)} does not pass through every yard where train ${train.code}'s wagons stand: ${detail}`, `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) { private async getSchedulableRoute(routeId: string) {
const route = await this.dataSource.getRepository(Route).findOne({ const route = await this.dataSource.getRepository(Route).findOne({
where: { id: routeId }, 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

@@ -1,6 +1,7 @@
import { useState } from "react"; import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { import {
Alert,
Badge, Badge,
Box, Box,
Button, Button,
@@ -12,6 +13,7 @@ import {
Select, Select,
Stack, Stack,
Text, Text,
TextInput,
Tooltip, Tooltip,
} from "@mantine/core"; } from "@mantine/core";
import { import {
@@ -19,9 +21,11 @@ import {
Download, Download,
Eye, Eye,
FileText, FileText,
Lock,
Receipt, Receipt,
Send, Send,
Upload, Upload,
XCircle,
} from "lucide-react"; } from "lucide-react";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import type { Freight } from "@edr/types"; import type { Freight } from "@edr/types";
@@ -42,11 +46,19 @@ const STATUS_META: Record<
{ label: string; color: string } { label: string; color: string }
> = { > = {
DOC_UPLOADED: { label: "Awaiting billing", color: "yellow" }, DOC_UPLOADED: { label: "Awaiting billing", color: "yellow" },
BILLED: { label: "Ready to send", color: "blue" }, BILLED: { label: "Draft — not sent", color: "blue" },
SENT: { label: "Sent — unpaid", color: "orange" }, 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" }, 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 { export interface ClearanceChargesTabProps {
bookingId: string; bookingId: string;
/** DJ uploads the port document; ET bills, sends and creates miscellaneous. */ /** 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 * Post-finalization charges billed to the customer: port charges (document
* (document from GL Djibouti, billed by GL Ethiopia) then miscellaneous * from GL Djibouti, priced by GL Ethiopia) and any number of miscellaneous
* (created whole by GL Ethiopia once the port charge is paid). Each level * charges. GL prices + describes a charge and sends it; the customer accepts
* issues its own payable invoice — ETB settles through the portal gateway * (invoice issued, charge locked) or rejects with a note (GL revises and
* (CBE), other currencies through Finance's manual settlement. * re-sends). ETB settles through the portal gateway (CBE), other currencies
* through Finance's manual settlement.
*/ */
export function ClearanceChargesTab({ export function ClearanceChargesTab({
bookingId, bookingId,
@@ -89,7 +102,7 @@ export function ClearanceChargesTab({
onError, onError,
}); });
const bill = useMutation({ const bill = useMutation({
mutationFn: (p: { chargeId: string; amount: number; currency: string }) => mutationFn: (p: BillInput & { chargeId: string }) =>
bookingsService.billClearanceCharge(bookingId, p.chargeId, p), bookingsService.billClearanceCharge(bookingId, p.chargeId, p),
onSuccess: (next) => { onSuccess: (next) => {
toast.success("Charge amount saved"); toast.success("Charge amount saved");
@@ -101,13 +114,13 @@ export function ClearanceChargesTab({
mutationFn: (chargeId: string) => mutationFn: (chargeId: string) =>
bookingsService.sendClearanceCharge(bookingId, chargeId), bookingsService.sendClearanceCharge(bookingId, chargeId),
onSuccess: (next) => { onSuccess: (next) => {
toast.success("Invoice sent to the customer"); toast.success("Sent to the customer for approval");
refresh(next); refresh(next);
}, },
onError, onError,
}); });
const createMisc = useMutation({ const createMisc = useMutation({
mutationFn: (p: { file: File; amount: number; currency: string }) => mutationFn: (p: BillInput & { file: File }) =>
bookingsService.createMiscellaneousCharge(bookingId, p.file, p), bookingsService.createMiscellaneousCharge(bookingId, p.file, p),
onSuccess: (next) => { onSuccess: (next) => {
toast.success("Miscellaneous charge created"); toast.success("Miscellaneous charge created");
@@ -153,9 +166,7 @@ export function ClearanceChargesTab({
: "Waiting for GL Djibouti to upload the port-charges document." : "Waiting for GL Djibouti to upload the port-charges document."
} }
onViewFile={onViewFile} onViewFile={onViewFile}
onBill={(amount, currency) => onBill={(input) => port && bill.mutate({ chargeId: port.id, ...input })}
port && bill.mutate({ chargeId: port.id, amount, currency })
}
onSend={() => port && send.mutate(port.id)} onSend={() => port && send.mutate(port.id)}
djUpload={ djUpload={
roleMode === "DJ" && (!port || port.status === "DOC_UPLOADED") ? ( roleMode === "DJ" && (!port || port.status === "DOC_UPLOADED") ? (
@@ -196,9 +207,7 @@ export function ClearanceChargesTab({
busy={busy} busy={busy}
emptyHint="" emptyHint=""
onViewFile={onViewFile} onViewFile={onViewFile}
onBill={(amount, currency) => onBill={(input) => bill.mutate({ chargeId: c.id, ...input })}
bill.mutate({ chargeId: c.id, amount, currency })
}
onSend={() => send.mutate(c.id)} onSend={() => send.mutate(c.id)}
/> />
))} ))}
@@ -211,15 +220,13 @@ export function ClearanceChargesTab({
: "Add a miscellaneous charge"} : "Add a miscellaneous charge"}
</Text> </Text>
<Text fz="12px" c="dimmed" mb="sm"> <Text fz="12px" c="dimmed" mb="sm">
Upload the supporting document and set the amount. You can raise as Upload the supporting document, set the amount and say what it is
many as the shipment needs, before or after the port charge. for. The customer sees it once you send it for approval.
</Text> </Text>
<MiscCreateForm <MiscCreateForm
key={miscCreated} key={miscCreated}
busy={createMisc.isPending} busy={createMisc.isPending}
onCreate={(file, amount, currency) => onCreate={(file, input) => createMisc.mutate({ file, ...input })}
createMisc.mutate({ file, amount, currency })
}
/> />
</Paper> </Paper>
)} )}
@@ -265,7 +272,7 @@ function ChargeCard({
busy: boolean; busy: boolean;
emptyHint: string; emptyHint: string;
onViewFile: (file: { name: string; url: string }) => void; onViewFile: (file: { name: string; url: string }) => void;
onBill: (amount: number, currency: string) => void; onBill: (input: BillInput) => void;
onSend: () => void; onSend: () => void;
djUpload?: React.ReactNode; djUpload?: React.ReactNode;
etCreate?: React.ReactNode; etCreate?: React.ReactNode;
@@ -273,13 +280,17 @@ function ChargeCard({
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
const [amount, setAmount] = useState<number | string>(charge?.amount ?? ""); const [amount, setAmount] = useState<number | string>(charge?.amount ?? "");
const [currency, setCurrency] = useState<string>(charge?.currency ?? "ETB"); const [currency, setCurrency] = useState<string>(charge?.currency ?? "ETB");
const [description, setDescription] = useState(charge?.description ?? "");
const status = charge?.status ?? null; const status = charge?.status ?? null;
const meta = status ? STATUS_META[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 = const showBillForm =
roleMode === "ET" && roleMode === "ET" &&
charge != null && charge != null &&
!locked &&
(charge.status === "DOC_UPLOADED" || editing); (charge.status === "DOC_UPLOADED" || editing);
return ( return (
@@ -304,6 +315,12 @@ function ChargeCard({
{formatDateTime(charge.billedAt)} {formatDateTime(charge.billedAt)}
</Text> </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 && ( {charge?.paidAt && (
<Text fz="11.5px" c="edr-green.8" fw={600}> <Text fz="11.5px" c="edr-green.8" fw={600}>
Paid · {formatDateTime(charge.paidAt)} Paid · {formatDateTime(charge.paidAt)}
@@ -379,6 +396,32 @@ function ChargeCard({
</Group> </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 && ( {!charge && (
<Text fz="12.5px" c="dimmed" mt="xs"> <Text fz="12.5px" c="dimmed" mt="xs">
{emptyHint} {emptyHint}
@@ -389,6 +432,15 @@ function ChargeCard({
{showBillForm && ( {showBillForm && (
<Group mt="sm" gap={8} align="flex-end" wrap="wrap"> <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 <NumberInput
label="Amount" label="Amount"
size="xs" size="xs"
@@ -412,13 +464,21 @@ function ChargeCard({
size="compact-sm" size="compact-sm"
color="edr-green" color="edr-green"
radius="md" radius="md"
disabled={busy || !(Number(amount) > 0)} disabled={
busy ||
!(Number(amount) > 0) ||
(needsDescription && !description.trim())
}
onClick={() => { onClick={() => {
onBill(Number(amount), currency); onBill({
amount: Number(amount),
currency,
description: description.trim(),
});
setEditing(false); setEditing(false);
}} }}
> >
Save amount Save
</Button> </Button>
{editing && ( {editing && (
<Button <Button
@@ -435,7 +495,7 @@ function ChargeCard({
</Group> </Group>
)} )}
{roleMode === "ET" && charge && !showBillForm && charge.status !== "PAID" && ( {roleMode === "ET" && charge && !showBillForm && !locked && (
<Group mt="sm" gap={8} justify="flex-end"> <Group mt="sm" gap={8} justify="flex-end">
<Button <Button
size="compact-sm" size="compact-sm"
@@ -446,13 +506,14 @@ function ChargeCard({
onClick={() => { onClick={() => {
setAmount(charge.amount ?? ""); setAmount(charge.amount ?? "");
setCurrency(charge.currency ?? "ETB"); setCurrency(charge.currency ?? "ETB");
setDescription(charge.description ?? "");
setEditing(true); setEditing(true);
}} }}
> >
{charge.status === "SENT" ? "Revise (cancels invoice)" : "Edit amount"} {charge.status === "SENT" ? "Revise" : "Edit"}
</Button> </Button>
{charge.status === "BILLED" && ( {(charge.status === "BILLED" || charge.status === "REJECTED") && (
<Tooltip label="ETB is payable online via CBE; other currencies go to Finance's manual settlement."> <Tooltip label="The customer accepts or rejects the price in the portal; the invoice is issued when they accept.">
<Button <Button
size="compact-sm" size="compact-sm"
color="edr-green" color="edr-green"
@@ -461,15 +522,20 @@ function ChargeCard({
disabled={busy} disabled={busy}
onClick={onSend} onClick={onSend}
> >
Send invoice to customer {charge.status === "REJECTED"
? "Send again for approval"
: "Send to customer for approval"}
</Button> </Button>
</Tooltip> </Tooltip>
)} )}
{charge.status === "SENT" && charge.invoiceNumber && ( </Group>
<Badge variant="light" color="orange" radius="sm"> )}
Invoice {charge.invoiceNumber} {charge?.status === "ACCEPTED" && (
</Badge> <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> </Group>
)} )}
{charge?.status === "PAID" && ( {charge?.status === "PAID" && (
@@ -489,14 +555,25 @@ function MiscCreateForm({
onCreate, onCreate,
}: { }: {
busy: boolean; busy: boolean;
onCreate: (file: File, amount: number, currency: string) => void; onCreate: (file: File, input: BillInput) => void;
}) { }) {
const [file, setFile] = useState<File | null>(null); const [file, setFile] = useState<File | null>(null);
const [amount, setAmount] = useState<number | string>(""); const [amount, setAmount] = useState<number | string>("");
const [currency, setCurrency] = useState("ETB"); const [currency, setCurrency] = useState("ETB");
const [description, setDescription] = useState("");
return ( return (
<Group gap={8} align="flex-end" wrap="wrap"> <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}> <FileButton onChange={setFile} accept="application/pdf,image/*" disabled={busy}>
{(props) => ( {(props) => (
<Button <Button
@@ -534,9 +611,16 @@ function MiscCreateForm({
size="compact-sm" size="compact-sm"
color="edr-green" color="edr-green"
radius="md" radius="md"
disabled={busy || !file || !(Number(amount) > 0)} disabled={busy || !file || !(Number(amount) > 0) || !description.trim()}
loading={busy} loading={busy}
onClick={() => file && onCreate(file, Number(amount), currency)} onClick={() =>
file &&
onCreate(file, {
amount: Number(amount),
currency,
description: description.trim(),
})
}
> >
Create charge Create charge
</Button> </Button>

View File

@@ -199,7 +199,13 @@ export function RequestServiceTypeCard({
if (lastMile) if (lastMile)
chips.push({ label: "Last-mile delivery", color: "teal", icon: Warehouse }); chips.push({ label: "Last-mile delivery", color: "teal", icon: Warehouse });
if (customs) 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 ( return (
<SectionCard <SectionCard

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

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

View File

@@ -221,6 +221,10 @@ const RATE_TRIGGERS = [
{ label: "Cancellation (per wagon, per direction + cargo type)", value: "CANCELLATION" }, { label: "Cancellation (per wagon, per direction + cargo type)", value: "CANCELLATION" },
{ label: "Shipping line extra fee (PIL)", value: "PIL_EXTRA_FEE" }, { label: "Shipping line extra fee (PIL)", value: "PIL_EXTRA_FEE" },
{ label: "Customs clearance service fee (billed with the booking)", value: "CUSTOMS_CLEARANCE" }, { 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" }, { label: "Fuel (per lane + cargo type)", value: "FUEL" },
]; ];
@@ -279,8 +283,16 @@ const SHIPPING_LINE_CARGO_KINDS = [
const isBaseFreightRate = (values: Record<string, unknown>) => const isBaseFreightRate = (values: Record<string, unknown>) =>
["BULK", "CONTAINER", "INTERCITY"].includes(String(values.appliesTo ?? "")); ["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). * the empty-container return surcharge (sold per route + container type).
*/ */
const isRouteScopedRate = (values: Record<string, unknown>) => const isRouteScopedRate = (values: Record<string, unknown>) =>
@@ -289,19 +301,19 @@ const isRouteScopedRate = (values: Record<string, unknown>) =>
(isShippingLineRate(values) (isShippingLineRate(values)
? hasShippingLine(values) && ? hasShippingLine(values) &&
(values.shippingLineRateKind === "BASE" || (values.shippingLineRateKind === "BASE" ||
["CUSTOMS_CLEARANCE", "WITH_RETURN", "FUEL"].includes( ROUTE_SCOPED_TRIGGERS.includes(String(values.trigger ?? "")))
String(values.trigger ?? ""),
))
: isBaseFreightRate(values)) || : isBaseFreightRate(values)) ||
(String(values.appliesTo ?? "") === "OTHER" && (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 * Surcharges sold per cargo kind: the admin says container or bulk, then names
* the container type or bulk commodity the fee covers. * the container type or bulk commodity the fee covers.
*/ */
const isCargoKindTrigger = (values: Record<string, unknown>) => 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 }); const unitOption = (value: string) => ({ label: value.replace(/_/g, " "), value });
@@ -345,6 +357,7 @@ const unitsForShape = (
// Wagon cancellation fee — scales with the cancelled wagons only. // Wagon cancellation fee — scales with the cancelled wagons only.
return ["PER_WAGON"]; return ["PER_WAGON"];
case "CUSTOMS_CLEARANCE": case "CUSTOMS_CLEARANCE":
case "ETHIOPIAN_CUSTOMS_CLEARANCE":
// Per cargo kind: container fees per box/wagon, bulk per ton/wagon. // Per cargo kind: container fees per box/wagon, bulk per ton/wagon.
return cargoKind === "BULK" return cargoKind === "BULK"
? ["PER_TON", "PER_WAGON"] ? ["PER_TON", "PER_WAGON"]
@@ -876,6 +889,14 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
{ name: "includesFirstMile", label: "Includes first mile", type: "boolean" }, { name: "includesFirstMile", label: "Includes first mile", type: "boolean" },
{ name: "includesLastMile", label: "Includes last mile", type: "boolean" }, { name: "includesLastMile", label: "Includes last mile", type: "boolean" },
{ name: "includesCustoms", label: "Includes customs", type: "boolean" }, { name: "includesCustoms", label: "Includes customs", type: "boolean" },
{
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 instead of the standard one.",
showIf: (v) => v.includesCustoms === true,
},
{ name: "isActive", label: "Active", type: "boolean" }, { name: "isActive", label: "Active", type: "boolean" },
], ],
}, },
@@ -1081,6 +1102,11 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
label: "Customs clearance", label: "Customs clearance",
filters: { trigger: "CUSTOMS_CLEARANCE", isShippingLineRate: "false" }, filters: { trigger: "CUSTOMS_CLEARANCE", isShippingLineRate: "false" },
}, },
{
key: "ethiopian-customs",
label: "Ethiopian customs",
filters: { trigger: "ETHIOPIAN_CUSTOMS_CLEARANCE", isShippingLineRate: "false" },
},
{ {
key: "return", key: "return",
label: "Container return", label: "Container return",
@@ -1235,6 +1261,7 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
(String(v.appliesTo ?? "") === "OTHER" && (String(v.appliesTo ?? "") === "OTHER" &&
[ [
"CUSTOMS_CLEARANCE", "CUSTOMS_CLEARANCE",
"ETHIOPIAN_CUSTOMS_CLEARANCE",
"CANCELLATION", "CANCELLATION",
"WITH_RETURN", "WITH_RETURN",
"LASHING", "LASHING",

View File

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

View File

@@ -232,6 +232,9 @@ import {
type BuiltTrainListFilters, type BuiltTrainListFilters,
type BuiltTrainListResponse, type BuiltTrainListResponse,
type ScheduleConsist, type ScheduleConsist,
type ScheduleWagonYards,
type UpdateScheduleWagonYardsPayload,
type UpdateScheduleWagonYardsResult,
type ScheduleHistoryEntry, type ScheduleHistoryEntry,
type TrainComposition, type TrainComposition,
type UpdateTrainDetailsPayload, 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< adjustConsist: endpoint<
{ scheduleId: string; payload: AdjustConsistPayload }, { scheduleId: string; payload: AdjustConsistPayload },
AdjustConsistResult AdjustConsistResult

View File

@@ -466,11 +466,11 @@ export const bookingsService = {
return unwrap(response.data) as Freight.ClearanceCharge[]; 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 ( billClearanceCharge: async (
id: string, id: string,
chargeId: string, chargeId: string,
payload: { amount: number; currency: string }, payload: { amount: number; currency: string; description?: string },
): Promise<Freight.ClearanceCharge[]> => { ): Promise<Freight.ClearanceCharge[]> => {
const response = await client.patch( const response = await client.patch(
`/bookings/${id}/clearance/charges/${chargeId}/bill`, `/bookings/${id}/clearance/charges/${chargeId}/bill`,
@@ -479,7 +479,7 @@ export const bookingsService = {
return unwrap(response.data) as Freight.ClearanceCharge[]; 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 ( sendClearanceCharge: async (
id: string, id: string,
chargeId: string, chargeId: string,
@@ -490,16 +490,17 @@ export const bookingsService = {
return unwrap(response.data) as Freight.ClearanceCharge[]; 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 ( createMiscellaneousCharge: async (
id: string, id: string,
file: File, file: File,
payload: { amount: number; currency: string }, payload: { amount: number; currency: string; description: string },
): Promise<Freight.ClearanceCharge[]> => { ): Promise<Freight.ClearanceCharge[]> => {
const form = new FormData(); const form = new FormData();
form.append("file", file); form.append("file", file);
form.append("amount", String(payload.amount)); form.append("amount", String(payload.amount));
form.append("currency", payload.currency); form.append("currency", payload.currency);
form.append("description", payload.description);
const response = await client.post( const response = await client.post(
`/bookings/${id}/clearance/charges/miscellaneous`, `/bookings/${id}/clearance/charges/miscellaneous`,
form, form,

View File

@@ -300,6 +300,45 @@ export interface ScheduleHistoryEntry {
/** Adjust response: fresh consist + schedule-impact warnings to surface. */ /** Adjust response: fresh consist + schedule-impact warnings to surface. */
export type AdjustConsistResult = ScheduleConsist & { warnings: string[] }; 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 = { export const trainBuilderService = {
list: (filters: BuiltTrainListFilters = {}) => list: (filters: BuiltTrainListFilters = {}) =>
apiClient.get<BuiltTrainListResponse>(`${BASE}${toQuery(filters)}`), apiClient.get<BuiltTrainListResponse>(`${BASE}${toQuery(filters)}`),
@@ -350,6 +389,15 @@ export const trainBuilderService = {
`/train-scheduling/schedules/${scheduleId}/adjust-consist`, `/train-scheduling/schedules/${scheduleId}/adjust-consist`,
payload, 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. */ /** Unified wagon/booking change history for the schedule's History tab. */
scheduleHistory: (scheduleId: string) => scheduleHistory: (scheduleId: string) =>
apiClient.get<ScheduleHistoryEntry[]>( apiClient.get<ScheduleHistoryEntry[]>(

View File

@@ -2,8 +2,8 @@ import { Box, Group, Stack, Text } from "@mantine/core";
import { memo } from "react"; import { memo } from "react";
import { ACTION_PROPS, STATUS_CONFIG, cv } from "../constants"; import { ACTION_PROPS, STATUS_CONFIG, cv } from "../constants";
import { Stepper } from "./Stepper"; import { Stepper } from "./Stepper";
import { PayNowButton } from "@/pages/bookings/payments/PayNowButton"; import { PayButton } from "@/pages/bookings/payments/PayButton";
import { payWindowState } from "@/pages/bookings/payments/payment-drain"; import { useMyPayables } from "@/pages/bookings/payments/useMyPayables";
import { BookingActionButton } from "@/pages/bookings/clearance/BookingActionButton"; import { BookingActionButton } from "@/pages/bookings/clearance/BookingActionButton";
import { bookingHasInlineAction } from "@/pages/bookings/clearance/bookingNextAction"; import { bookingHasInlineAction } from "@/pages/bookings/clearance/bookingNextAction";
import { import {
@@ -28,21 +28,9 @@ export const BookingRow = memo(function BookingRow({
const Icon = cfg.icon; const Icon = cfg.icon;
const AIcon = cfg.action.icon; const AIcon = cfg.action.icon;
const ap = ACTION_PROPS[cfg.action.kind]; const ap = ACTION_PROPS[cfg.action.kind];
// Payable bookings get an inline "Pay now" that opens the payment modal // Anything outstanding (freight, clearance charge, duty slip, cancellation
// instead of navigating to the detail page. A general contract is payable as // fee) → "Pay" jumps to the booking's Payments tab. One shared query.
// soon as it's FULLY_EXECUTED (signed); a one-time booking only after it's const payable = useMyPayables().get(booking.id);
// 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";
// Clearance/operation steps + changes-requested resubmit can be done in place // Clearance/operation steps + changes-requested resubmit can be done in place
// via a modal on the row. // via a modal on the row.
const hasInlineAction = bookingHasInlineAction(booking); const hasInlineAction = bookingHasInlineAction(booking);
@@ -113,8 +101,8 @@ export const BookingRow = memo(function BookingRow({
{cfg.badgeLabel} {cfg.badgeLabel}
</Text> </Text>
</Group> </Group>
{canPay ? ( {payable ? (
<PayNowButton booking={booking} size="sm" /> <PayButton bookingId={booking.id} summary={payable} size="sm" />
) : canSign ? ( ) : canSign ? (
<ContractSignButton booking={booking} size="sm" /> <ContractSignButton booking={booking} size="sm" />
) : canApproveDelivery ? ( ) : canApproveDelivery ? (

View File

@@ -1,11 +1,11 @@
import { useState } from "react"; import {
useState,
} from "react";
import { import {
Alert, Alert,
Anchor, Anchor,
Badge, Badge,
Box,
Button, Button,
FileInput,
Group, Group,
Paper, Paper,
Stack, Stack,
@@ -15,19 +15,20 @@ import {
import { import {
AlertTriangle, AlertTriangle,
Check, Check,
Download,
Eye, Eye,
FileBadge, FileBadge,
MessageSquareWarning, MessageSquareWarning,
Receipt, Receipt,
Upload,
} from "lucide-react"; } from "lucide-react";
import { useQuery } from "@tanstack/react-query"; import {
useQuery,
} from "@tanstack/react-query";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import type { Freight } from "@edr/types"; import type { Freight } from "@edr/types";
import { bookingsService } from "@/services/bookings.service"; import {
import { contractsService } from "@/services/contracts.service"; bookingsService,
} from "@/services/bookings.service";
import { downloadStoredFile } from "@/services/files.service"; import { downloadStoredFile } from "@/services/files.service";
import { ClearancePhaseStepper } from "../contracts/ClearancePhaseStepper"; import { ClearancePhaseStepper } from "../contracts/ClearancePhaseStepper";
import { ClearanceUploadedDocumentsPanel } from "@/components/contracts/ClearanceUploadedDocumentsPanel"; import { ClearanceUploadedDocumentsPanel } from "@/components/contracts/ClearanceUploadedDocumentsPanel";
@@ -36,29 +37,6 @@ import { GREEN, INK } from "../contracts/contract-ui";
const BORDER = "#E6ECF2"; 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; if (!isPhased || !clearance) return null;
const dutyPaid = clearance.milestones?.some( // Duty / tax, additional duty and the final invoice are paid from the
(m) => m.milestoneCode === "DUTY_TAX_PAID" && m.status === "COMPLETED", // booking's Payments tab (CustomsPaymentsCard); this banner keeps the
); // progress, the draft declaration and the documents.
const dutyPending =
clearance.dutyRequired &&
clearance.dutyAdvice &&
!dutyPaid;
// A change request clears the draft while it's open — show the "waiting on // 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. // GL" state instead of the review panel until GL sends a corrected draft.
const draftDeclarationChangeRequestPending = Boolean( const draftDeclarationChangeRequestPending = Boolean(
@@ -131,14 +105,6 @@ export function BookingClearanceWorkflowBanner({
/> />
) : null} ) : null}
{dutyPending && clearance.dutyAdvice ? (
<DutyAdvicePanel
dutyAdvice={clearance.dutyAdvice}
bookingId={booking.id}
onChanged={() => void refetch()}
/>
) : null}
{clearance.riskLevel ? ( {clearance.riskLevel ? (
<Group gap={10} align="center"> <Group gap={10} align="center">
<Text fw={700} fz={14} c={INK}> <Text fw={700} fz={14} c={INK}>
@@ -160,24 +126,6 @@ export function BookingClearanceWorkflowBanner({
</Group> </Group>
) : null} ) : 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 ? ( {clearance.operationReady ? (
<Alert color="green" variant="light"> <Alert color="green" variant="light">
Clearance is complete. You may proceed to request your operation date. 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 * GL Ethiopia sent a draft customs declaration — an estimated price + files
* the customer must accept before the real declaration is filed, or send back * 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> = { const CUSTOMS_RISK_COLOR: Record<string, string> = {
GREEN: "green", GREEN: "green",
YELLOW: "yellow", YELLOW: "yellow",
RED: "red", 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

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

View File

@@ -0,0 +1,251 @@
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, Receipt, X } from "lucide-react";
import { Link } from "react-router-dom";
import toast from "react-hot-toast";
import type { Freight } from "@edr/types";
import { useInvoicePayment } from "@/hooks/useInvoicePayment";
import { bookingsService } from "@/services/bookings.service";
import { formatAmount } from "../utils";
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 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>
{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}
/>
</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; if (!canRequest && !ownRows.length) return null;
return ( return (
<SectionCard> <SectionCard id="wagon-cancellation">
<Group justify="space-between" align="center" mb="sm"> <Group justify="space-between" align="center" mb="sm">
<CardTitle>Wagon Cancellation</CardTitle> <CardTitle>Wagon Cancellation</CardTitle>
{/* {canRequest && !openRow && !creditRow && ( {/* {canRequest && !openRow && !creditRow && (

View File

@@ -34,8 +34,8 @@ import {
} from "lucide-react"; } from "lucide-react";
import { ShipmentTrackingModal } from "./tracking/ShipmentTrackingModal"; import { ShipmentTrackingModal } from "./tracking/ShipmentTrackingModal";
import { PayNowButton } from "./payments/PayNowButton"; import { PayButton } from "./payments/PayButton";
import { payWindowState } from "./payments/payment-drain"; import { useMyPayables } from "./payments/useMyPayables";
import { BookingActionButton } from "./clearance/BookingActionButton"; import { BookingActionButton } from "./clearance/BookingActionButton";
import { bookingHasInlineAction } from "./clearance/bookingNextAction"; import { bookingHasInlineAction } from "./clearance/bookingNextAction";
import { import {
@@ -181,11 +181,14 @@ const STAT_CARDS: Array<{
function PrimaryAction({ function PrimaryAction({
booking, booking,
credit, credit,
payable,
onNavigate, onNavigate,
}: { }: {
booking: Freight.IBooking; booking: Freight.IBooking;
/** CREDIT_AVAILABLE wagon cancellation opened by this booking, if any. */ /** CREDIT_AVAILABLE wagon cancellation opened by this booking, if any. */
credit?: WagonCancellation; credit?: WagonCancellation;
/** Outstanding payments on this booking (from `my-payables`), if any. */
payable?: Freight.BookingPayableSummary;
onNavigate: (path: string) => void; onNavigate: (path: string) => void;
}) { }) {
const { status, id } = booking; 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") { if (status === "DRAFT") {
return ( return (
<Button <Button
@@ -220,24 +220,16 @@ function PrimaryAction({
</Button> </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 // CHANGES_REQUESTED + clearance/operation steps are handled in place by a
// modal (update & resubmit, upload clearance docs, schedule & proceed). // modal (update & resubmit, upload clearance docs, schedule & proceed).
if (bookingHasInlineAction(booking)) { if (bookingHasInlineAction(booking)) {
return <BookingActionButton booking={booking} size="xs" />; 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. // Contract ready for the customer's signature → full-page contract viewer.
if (bookingIsSignable(booking)) { if (bookingIsSignable(booking)) {
return <ContractSignButton booking={booking} size="xs" />; return <ContractSignButton booking={booking} size="xs" />;
@@ -460,6 +452,8 @@ export default function BookingsListPage() {
input: { pageSize: 100 }, input: { pageSize: 100 },
}), }),
); );
// Outstanding payments per booking → row "Pay" button (one shared query).
const payables = useMyPayables();
const creditByBooking = useMemo(() => { const creditByBooking = useMemo(() => {
const m = new Map<string, WagonCancellation>(); const m = new Map<string, WagonCancellation>();
for (const r of myCancellations?.items ?? []) { for (const r of myCancellations?.items ?? []) {
@@ -702,6 +696,7 @@ export default function BookingsListPage() {
<PrimaryAction <PrimaryAction
booking={booking} booking={booking}
credit={creditByBooking.get(booking.id)} credit={creditByBooking.get(booking.id)}
payable={payables.get(booking.id)}
onNavigate={navigate} onNavigate={navigate}
/> />
<Menu position="bottom-end" withinPortal shadow="md" radius="md"> <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; * when the booking has no customer-actionable clearance/operation step;
* otherwise shows a button that opens the in-place {@link BookingActionModal}. * 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. * propagation so it never triggers the row's navigation handler.
*/ */
export function BookingActionButton({ export function BookingActionButton({

View File

@@ -28,7 +28,7 @@ interface ContractSignButtonProps {
* that navigates to the full-page contract viewer ({@link BookingContractPage}) * that navigates to the full-page contract viewer ({@link BookingContractPage})
* where the signature flow lives. * 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. * propagation so it never triggers the row's navigation handler.
*/ */
export function ContractSignButton({ 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", key: "customs",
icon: ShieldCheck, icon: ShieldCheck,
label: "Customs clearance", label: s.includesEthiopianCustomsOnly
? "Ethiopian customs clearance"
: "Customs clearance",
on: s.includesCustoms, on: s.includesCustoms,
}, },
]; ];

View File

@@ -533,6 +533,40 @@ export const bookingsService = {
return data.data ?? data; 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> => { acceptDraftDeclaration: async (id: string): Promise<Freight.IBooking> => {
const { data } = await client.post( const { data } = await client.post(
`/api/bookings/${id}/clearance/draft-declaration/accept`, `/api/bookings/${id}/clearance/draft-declaration/accept`,

View File

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

View File

@@ -833,17 +833,22 @@ export type ClearanceChargeType = "PORT_CHARGES" | "MISCELLANEOUS";
/** /**
* DOC_UPLOADED: GL Djibouti uploaded the supporting document (port charges). * DOC_UPLOADED: GL Djibouti uploaded the supporting document (port charges).
* BILLED: GL Ethiopia set amount + currency. SENT: invoice issued to the * BILLED: GL Ethiopia set amount + currency (draft, customer does not see it).
* customer (ETB pays via gateway, other currencies via Finance's manual * SENT: price proposed to the customer, awaiting their decision.
* settlement). PAID: the invoice settled. * 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 = export type ClearanceChargeStatus =
| "DOC_UPLOADED" | "DOC_UPLOADED"
| "BILLED" | "BILLED"
| "SENT" | "SENT"
| "REJECTED"
| "ACCEPTED"
| "PAID"; | "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 { export interface ClearanceCharge {
id: string; id: string;
bookingId: string; bookingId: string;
@@ -852,6 +857,11 @@ export interface ClearanceCharge {
file: { id: string; name: string; url: string } | null; file: { id: string; name: string; url: string } | null;
amount: number | null; amount: number | null;
currency: string | 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; invoiceId: string | null;
invoiceNumber: string | null; invoiceNumber: string | null;
uploadedByName: string | null; uploadedByName: string | null;
@@ -861,6 +871,18 @@ export interface ClearanceCharge {
paidAt: string | null; 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). */ /** A GL→customer request for additional clearance document(s). */
export interface ClearanceDocRequest { export interface ClearanceDocRequest {
id: string; id: string;
@@ -1179,6 +1201,8 @@ export interface BookingReferenceService {
includesFirstMile: boolean; includesFirstMile: boolean;
includesLastMile: boolean; includesLastMile: boolean;
includesCustoms: boolean; includesCustoms: boolean;
/** Customs cleared on the Ethiopian side only (prices off the Ethiopian customs rate). */
includesEthiopianCustomsOnly?: boolean;
isActive: boolean; isActive: boolean;
displayOrder: number; displayOrder: number;
createdAt: string; createdAt: string;