mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
[200~feat: add CustomsPaymentsCard and PaymentsTab components for handling customs payments and payment summaries
This commit is contained in:
@@ -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',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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.`;
|
||||||
|
|||||||
@@ -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()];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 { BookingContractService } from './booking-contract.service';
|
import { BookingContractService } from './booking-contract.service';
|
||||||
import { BookingPricingService } from './booking-pricing.service';
|
import { BookingPricingService } from './booking-pricing.service';
|
||||||
import { BookingTransitionService } from './booking-transition.service';
|
import { BookingTransitionService } from './booking-transition.service';
|
||||||
@@ -175,6 +179,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,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -312,6 +317,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({
|
||||||
@@ -1118,15 +1138,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")
|
||||||
@@ -1153,7 +1221,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,
|
||||||
@@ -1173,7 +1241,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,
|
||||||
@@ -1193,7 +1261,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,
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity';
|
|||||||
import { BookingDocumentReview } from './entities/booking-document-review.entity';
|
import { BookingDocumentReview } from './entities/booking-document-review.entity';
|
||||||
import { BookingClearanceCharge } from './entities/booking-clearance-charge.entity';
|
import { BookingClearanceCharge } from './entities/booking-clearance-charge.entity';
|
||||||
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';
|
||||||
@@ -118,6 +119,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
|
|||||||
BookingContractService,
|
BookingContractService,
|
||||||
BookingInvoiceService,
|
BookingInvoiceService,
|
||||||
BookingClearanceChargeService,
|
BookingClearanceChargeService,
|
||||||
|
BookingPayablesService,
|
||||||
ClearanceEventService,
|
ClearanceEventService,
|
||||||
ContractTemplateResolver,
|
ContractTemplateResolver,
|
||||||
ContractViewModelBuilder,
|
ContractViewModelBuilder,
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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 })
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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[];
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
@@ -5288,15 +5299,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 }>();
|
||||||
@@ -5304,11 +5330,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;
|
||||||
@@ -5320,7 +5349,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
|
||||||
@@ -5491,6 +5520,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,
|
||||||
@@ -5500,6 +5530,7 @@ export class TrainSchedulingService {
|
|||||||
builtTrainId,
|
builtTrainId,
|
||||||
pinnedToScheduleIds,
|
pinnedToScheduleIds,
|
||||||
stops,
|
stops,
|
||||||
|
plannedYards,
|
||||||
);
|
);
|
||||||
if (unpinnable.length) {
|
if (unpinnable.length) {
|
||||||
throw new BadRequestException({
|
throw new BadRequestException({
|
||||||
@@ -5521,6 +5552,7 @@ export class TrainSchedulingService {
|
|||||||
builtTrainId,
|
builtTrainId,
|
||||||
pinnedToScheduleIds,
|
pinnedToScheduleIds,
|
||||||
reverseWagonOrder,
|
reverseWagonOrder,
|
||||||
|
plannedYards,
|
||||||
);
|
);
|
||||||
if (!physical) continue;
|
if (!physical) continue;
|
||||||
|
|
||||||
@@ -5570,6 +5602,7 @@ export class TrainSchedulingService {
|
|||||||
builtTrainId,
|
builtTrainId,
|
||||||
pinnedToScheduleIds,
|
pinnedToScheduleIds,
|
||||||
stops,
|
stops,
|
||||||
|
targetSchedule?.plannedWagonYards ?? {},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5602,6 +5635,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
|
||||||
@@ -5620,6 +5654,8 @@ export class TrainSchedulingService {
|
|||||||
span,
|
span,
|
||||||
builtTrainId,
|
builtTrainId,
|
||||||
pinnedToScheduleIds,
|
pinnedToScheduleIds,
|
||||||
|
false,
|
||||||
|
plannedYards,
|
||||||
);
|
);
|
||||||
if (!physical) {
|
if (!physical) {
|
||||||
violations.push(
|
violations.push(
|
||||||
@@ -5650,6 +5686,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.
|
||||||
@@ -5682,8 +5719,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.
|
||||||
@@ -5696,7 +5733,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) {
|
||||||
@@ -5836,7 +5873,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))),
|
||||||
@@ -5858,11 +5895,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>>();
|
||||||
@@ -5872,10 +5915,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
|
||||||
@@ -6215,17 +6260,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)
|
||||||
@@ -6236,23 +6286,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 },
|
||||||
|
|||||||
@@ -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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -190,6 +190,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" },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -248,8 +252,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>) =>
|
||||||
@@ -258,19 +270,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 });
|
||||||
|
|
||||||
@@ -314,6 +326,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"]
|
||||||
@@ -726,6 +739,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" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -931,6 +952,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",
|
||||||
@@ -1085,6 +1111,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",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -226,6 +226,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,
|
||||||
@@ -410,6 +413,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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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[]>(
|
||||||
|
|||||||
@@ -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 ? (
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -8,11 +8,12 @@ import {
|
|||||||
Package,
|
Package,
|
||||||
TrainFront,
|
TrainFront,
|
||||||
Truck,
|
Truck,
|
||||||
|
Wallet,
|
||||||
XCircle,
|
XCircle,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||||
|
|
||||||
import { useFileViewer } from "@/hooks/useFileViewer";
|
import { useFileViewer } from "@/hooks/useFileViewer";
|
||||||
import { bookingsService } from "@/services/bookings.service";
|
import { bookingsService } from "@/services/bookings.service";
|
||||||
@@ -38,22 +39,21 @@ import {
|
|||||||
ConsolidationPairedNotice,
|
ConsolidationPairedNotice,
|
||||||
ConsolidationWaitingBanner,
|
ConsolidationWaitingBanner,
|
||||||
} from "./components/Notices";
|
} from "./components/Notices";
|
||||||
import { BookingPaymentPanel } from "./components/BookingPaymentPanel";
|
|
||||||
import { HeaderButton, PageHeader } from "./components/PageHeader";
|
import { HeaderButton, PageHeader } from "./components/PageHeader";
|
||||||
import { PaymentMethodModal } from "./components/PaymentMethodModal";
|
import { PaymentMethodModal } from "./components/PaymentMethodModal";
|
||||||
import { ScheduleCard } from "./components/ScheduleCard";
|
import { ScheduleCard } from "./components/ScheduleCard";
|
||||||
import { WarehousePaymentsSection } from "./components/WarehousePaymentsSection";
|
import { WarehousePaymentsSection } from "./components/WarehousePaymentsSection";
|
||||||
|
import { PaymentsDueStrip, PaymentsTab } from "./components/PaymentsTab";
|
||||||
import { WarehouseLocationCard } from "./components/WarehouseLocationCard";
|
import { WarehouseLocationCard } from "./components/WarehouseLocationCard";
|
||||||
import { ShipmentDetailsCard } from "./components/ShipmentDetailsCard";
|
import { ShipmentDetailsCard } from "./components/ShipmentDetailsCard";
|
||||||
import { ShipmentTrackingCard } from "./components/ShipmentTrackingCard";
|
import { ShipmentTrackingCard } from "./components/ShipmentTrackingCard";
|
||||||
import { StatusHero } from "./components/StatusHero";
|
import { StatusHero } from "./components/StatusHero";
|
||||||
import { SupportCard } from "./components/SupportCard";
|
import { SupportCard } from "./components/SupportCard";
|
||||||
import { WagonCancellationCard } from "./components/WagonCancellationCard";
|
|
||||||
import { WagonsTab } from "./components/WagonsTab";
|
import { WagonsTab } from "./components/WagonsTab";
|
||||||
import { fmtDate, isNegative, priceTotal } from "./utils";
|
import { fmtDate, isNegative, priceTotal } from "./utils";
|
||||||
import { useScrollToHash } from "@/hooks/useScrollToHash";
|
import { useScrollToHash } from "@/hooks/useScrollToHash";
|
||||||
import { useBookingPayment } from "@/pages/bookings/payments/useBookingPayment";
|
import { useBookingPayment } from "@/pages/bookings/payments/useBookingPayment";
|
||||||
import { isUsdOfflineBooking } from "@/pages/bookings/payments/offline-payment";
|
import { useBookingPayables } from "@/pages/bookings/payments/useBookingPayables";
|
||||||
|
|
||||||
// Pre-payment statuses the customer may self-cancel from this view (free of
|
// Pre-payment statuses the customer may self-cancel from this view (free of
|
||||||
// charge). DRAFT / CHANGES_REQUESTED render their own views and drafts can
|
// charge). DRAFT / CHANGES_REQUESTED render their own views and drafts can
|
||||||
@@ -86,6 +86,22 @@ export function ReadonlyBookingView({
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
// Deep-link support: e.g. /bookings/:id#warehouse-payments from an invoice.
|
// Deep-link support: e.g. /bookings/:id#warehouse-payments from an invoice.
|
||||||
useScrollToHash();
|
useScrollToHash();
|
||||||
|
// Active tab lives in the URL (?tab=payments) so list/home "Pay" buttons and
|
||||||
|
// notifications can land straight on the Payments tab.
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const tab = searchParams.get("tab") ?? "overview";
|
||||||
|
const setTab = (next: string | null) =>
|
||||||
|
setSearchParams(
|
||||||
|
(prev) => {
|
||||||
|
if (!next || next === "overview") prev.delete("tab");
|
||||||
|
else prev.set("tab", next);
|
||||||
|
return prev;
|
||||||
|
},
|
||||||
|
{ replace: true },
|
||||||
|
);
|
||||||
|
// Everything the customer owes or must decide on — drives the header "Pay"
|
||||||
|
// button, the tab badge and the overview strip.
|
||||||
|
const payables = useBookingPayables(booking);
|
||||||
const status = booking.status as string;
|
const status = booking.status as string;
|
||||||
const { viewer } = useFileViewer();
|
const { viewer } = useFileViewer();
|
||||||
|
|
||||||
@@ -186,17 +202,19 @@ export function ReadonlyBookingView({
|
|||||||
<PageHeader
|
<PageHeader
|
||||||
booking={booking}
|
booking={booking}
|
||||||
actions={
|
actions={
|
||||||
(canApproveDelivery || (canPay && !showCountdown) || canCancel) && (
|
(canApproveDelivery ||
|
||||||
|
(payables.items.length > 0 && tab !== "payments") ||
|
||||||
|
canCancel) && (
|
||||||
<Group gap={8} wrap="nowrap">
|
<Group gap={8} wrap="nowrap">
|
||||||
{canApproveDelivery && (
|
{canApproveDelivery && (
|
||||||
<ApproveDeliveryButton bookingId={booking.id} />
|
<ApproveDeliveryButton bookingId={booking.id} />
|
||||||
)}
|
)}
|
||||||
{canPay && !showCountdown && !isUsdOfflineBooking(booking) && (
|
{payables.items.length > 0 && tab !== "payments" && (
|
||||||
<HeaderButton
|
<HeaderButton
|
||||||
green
|
green
|
||||||
icon={<CreditCard size={16} />}
|
icon={<CreditCard size={16} />}
|
||||||
label="Pay now"
|
label="Pay"
|
||||||
onClick={pay.open}
|
onClick={() => setTab("payments")}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{canCancel && (
|
{canCancel && (
|
||||||
@@ -259,7 +277,8 @@ export function ReadonlyBookingView({
|
|||||||
<KeyFactsStrip booking={booking} />
|
<KeyFactsStrip booking={booking} />
|
||||||
|
|
||||||
<Tabs
|
<Tabs
|
||||||
defaultValue="overview"
|
value={tab}
|
||||||
|
onChange={setTab}
|
||||||
keepMounted={false}
|
keepMounted={false}
|
||||||
color="edr-green"
|
color="edr-green"
|
||||||
styles={{
|
styles={{
|
||||||
@@ -276,6 +295,32 @@ export function ReadonlyBookingView({
|
|||||||
<Tabs.Tab value="overview" leftSection={<LayoutGrid size={15} />}>
|
<Tabs.Tab value="overview" leftSection={<LayoutGrid size={15} />}>
|
||||||
Overview
|
Overview
|
||||||
</Tabs.Tab>
|
</Tabs.Tab>
|
||||||
|
<Tabs.Tab
|
||||||
|
value="payments"
|
||||||
|
leftSection={<Wallet size={15} />}
|
||||||
|
rightSection={
|
||||||
|
payables.items.length > 0 ? (
|
||||||
|
<Text
|
||||||
|
component="span"
|
||||||
|
fz={11}
|
||||||
|
fw={800}
|
||||||
|
c="white"
|
||||||
|
px={6}
|
||||||
|
style={{
|
||||||
|
borderRadius: 999,
|
||||||
|
background: "#B45309",
|
||||||
|
lineHeight: "18px",
|
||||||
|
minWidth: 18,
|
||||||
|
textAlign: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{payables.items.length}
|
||||||
|
</Text>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Payments
|
||||||
|
</Tabs.Tab>
|
||||||
<Tabs.Tab value="cargo" leftSection={<Package size={15} />}>
|
<Tabs.Tab value="cargo" leftSection={<Package size={15} />}>
|
||||||
Cargo
|
Cargo
|
||||||
</Tabs.Tab>
|
</Tabs.Tab>
|
||||||
@@ -297,12 +342,16 @@ export function ReadonlyBookingView({
|
|||||||
|
|
||||||
<Tabs.Panel value="overview">
|
<Tabs.Panel value="overview">
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
|
{/* The only payment surface on Overview — everything payable lives
|
||||||
|
on the Payments tab. Hidden when nothing is outstanding. */}
|
||||||
|
<PaymentsDueStrip booking={booking} onOpen={() => setTab("payments")} />
|
||||||
|
|
||||||
<ContractCard booking={booking} />
|
<ContractCard booking={booking} />
|
||||||
|
|
||||||
{isClearance && <ClearanceCard booking={booking} />}
|
{isClearance && <ClearanceCard booking={booking} />}
|
||||||
|
|
||||||
{/* Customs (Path B) shipments: GL's phased progress, the duty /
|
{/* Customs (Path B) shipments: GL's phased progress, draft
|
||||||
additional-duty payments and the final invoice — all per booking. */}
|
declaration and documents. Its payments moved to the Payments tab. */}
|
||||||
<BookingClearanceWorkflowBanner booking={booking} />
|
<BookingClearanceWorkflowBanner booking={booking} />
|
||||||
|
|
||||||
<BodyGrid
|
<BodyGrid
|
||||||
@@ -319,23 +368,11 @@ export function ReadonlyBookingView({
|
|||||||
}
|
}
|
||||||
right={
|
right={
|
||||||
<>
|
<>
|
||||||
<BookingPaymentPanel
|
|
||||||
booking={booking}
|
|
||||||
pricing={pricing}
|
|
||||||
onPay={pay.open}
|
|
||||||
paying={pay.processing}
|
|
||||||
showCountdown={showCountdown}
|
|
||||||
/>
|
|
||||||
<ScheduleCard
|
<ScheduleCard
|
||||||
booking={booking}
|
booking={booking}
|
||||||
title="Consignment & Schedule"
|
title="Consignment & Schedule"
|
||||||
consignment
|
consignment
|
||||||
/>
|
/>
|
||||||
{/* Renders only on PAID + paid + contract-backed bookings. */}
|
|
||||||
<WagonCancellationCard
|
|
||||||
booking={booking}
|
|
||||||
onBookingUpdated={onBookingUpdated}
|
|
||||||
/>
|
|
||||||
<CompanyInfoCard booking={booking} />
|
<CompanyInfoCard booking={booking} />
|
||||||
<SupportCard />
|
<SupportCard />
|
||||||
</>
|
</>
|
||||||
@@ -344,6 +381,15 @@ export function ReadonlyBookingView({
|
|||||||
</div>
|
</div>
|
||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
|
|
||||||
|
<Tabs.Panel value="payments">
|
||||||
|
<PaymentsTab
|
||||||
|
booking={booking}
|
||||||
|
pay={pay}
|
||||||
|
showCountdown={showCountdown}
|
||||||
|
onBookingUpdated={onBookingUpdated}
|
||||||
|
/>
|
||||||
|
</Tabs.Panel>
|
||||||
|
|
||||||
<Tabs.Panel value="cargo">
|
<Tabs.Panel value="cargo">
|
||||||
<CargoTab booking={booking} />
|
<CargoTab booking={booking} />
|
||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 && (
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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],
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -528,6 +528,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`,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -828,17 +828,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;
|
||||||
@@ -847,6 +852,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;
|
||||||
@@ -856,6 +866,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;
|
||||||
@@ -1144,6 +1166,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;
|
||||||
|
|||||||
Reference in New Issue
Block a user