mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
Merge pull request #1369 from Tria-plc/freight_feature/usermanagement
Freight feature/usermanagement
This commit is contained in:
@@ -67,6 +67,7 @@ const TRIGGER_ROUTE_LABELS: Partial<Record<Rate['trigger'], string>> = {
|
||||
DEMURRAGE: 'Demurrage / wagon detention',
|
||||
PIL_EXTRA_FEE: 'PIL shipping line extra fee',
|
||||
CUSTOMS_CLEARANCE: 'Customs clearance service',
|
||||
ETHIOPIAN_CUSTOMS_CLEARANCE: 'Ethiopian customs clearance service',
|
||||
FUEL: 'Fuel surcharge',
|
||||
};
|
||||
|
||||
|
||||
@@ -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 { BookingsService } from './bookings.service';
|
||||
import { BookingsRepository } from './bookings.repository';
|
||||
import { BookingLifecycleNotifierService } from './booking-lifecycle-notifier.service';
|
||||
import { Booking } from './entities/booking.entity';
|
||||
import {
|
||||
BookingClearanceCharge,
|
||||
ClearanceChargeStatus,
|
||||
ClearanceChargeType,
|
||||
} from './entities/booking-clearance-charge.entity';
|
||||
import { ClearanceEventService } from './clearance-event.service';
|
||||
@@ -32,13 +34,22 @@ const CHARGE_LABEL: Record<ClearanceChargeType, string> = {
|
||||
MISCELLANEOUS: 'Miscellaneous charges',
|
||||
};
|
||||
|
||||
/** Statuses the customer sees — drafts (DOC_UPLOADED / BILLED) stay GL-internal. */
|
||||
export const CUSTOMER_VISIBLE_CHARGE_STATUSES: ReadonlySet<ClearanceChargeStatus> =
|
||||
new Set(['SENT', 'REJECTED', 'ACCEPTED', 'PAID']);
|
||||
|
||||
/** Once the customer has accepted (invoice issued) or paid, GL cannot touch the charge. */
|
||||
export const canStaffEditCharge = (status: ClearanceChargeStatus): boolean =>
|
||||
status !== 'ACCEPTED' && status !== 'PAID';
|
||||
|
||||
/**
|
||||
* Post-finalization clearance charges billed to the customer. Two levels per
|
||||
* booking: GL Djibouti uploads the port-charges document; GL Ethiopia bills it
|
||||
* (amount + currency) and sends the invoice; once that invoice is paid GL
|
||||
* Ethiopia may create and send the miscellaneous charge. ETB invoices are paid
|
||||
* through the portal gateway, other currencies through Finance's manual
|
||||
* settlement worklist — both settle via `clearance_charge.invoice.paid`.
|
||||
* Post-finalization clearance charges billed to the customer: one port charge
|
||||
* (document from GL Djibouti, priced by GL Ethiopia) and any number of
|
||||
* miscellaneous charges. GL prices + describes a charge and SENDs it; the
|
||||
* customer REJECTs with a note (GL revises, re-sends) or ACCEPTs, which issues
|
||||
* the payable invoice and locks the charge. ETB invoices are paid through the
|
||||
* portal gateway, other currencies through Finance's manual settlement
|
||||
* worklist — both settle via `clearance_charge.invoice.paid`.
|
||||
*/
|
||||
@Injectable()
|
||||
export class BookingClearanceChargeService {
|
||||
@@ -51,6 +62,7 @@ export class BookingClearanceChargeService {
|
||||
private readonly bookingsService: BookingsService,
|
||||
private readonly bookingsRepository: BookingsRepository,
|
||||
private readonly clearanceEvents: ClearanceEventService,
|
||||
private readonly notifier: BookingLifecycleNotifierService,
|
||||
) {}
|
||||
|
||||
private repo() {
|
||||
@@ -104,6 +116,11 @@ export class BookingClearanceChargeService {
|
||||
file: file ? { id: file.id, name: file.name, url: file.url } : null,
|
||||
amount: c.amount != null ? Number(c.amount) : null,
|
||||
currency: c.currency ?? null,
|
||||
description: c.description ?? null,
|
||||
customerNote: c.customerNote ?? null,
|
||||
customerDecidedAt: c.customerDecidedAt
|
||||
? c.customerDecidedAt.toISOString()
|
||||
: null,
|
||||
invoiceId: c.invoiceId ?? null,
|
||||
invoiceNumber: c.invoiceId
|
||||
? (invoiceById.get(c.invoiceId)?.invoiceNumber ?? null)
|
||||
@@ -121,6 +138,24 @@ export class BookingClearanceChargeService {
|
||||
});
|
||||
}
|
||||
|
||||
/** The customer's view: only charges GL has sent them. */
|
||||
async listForCustomer(bookingId: string): Promise<Freight.ClearanceCharge[]> {
|
||||
return (await this.list(bookingId)).filter((c) =>
|
||||
CUSTOMER_VISIBLE_CHARGE_STATUSES.has(c.status),
|
||||
);
|
||||
}
|
||||
|
||||
private async findCharge(
|
||||
bookingId: string,
|
||||
chargeId: string,
|
||||
): Promise<BookingClearanceCharge> {
|
||||
const charge = await this.repo().findOne({
|
||||
where: { id: chargeId, bookingId },
|
||||
});
|
||||
if (!charge) throw new NotFoundException('Clearance charge not found');
|
||||
return charge;
|
||||
}
|
||||
|
||||
/** GL Djibouti uploads (or replaces, until billed) the port-charges document. */
|
||||
async uploadPortDocument(
|
||||
bookingId: string,
|
||||
@@ -180,22 +215,21 @@ export class BookingClearanceChargeService {
|
||||
}
|
||||
|
||||
/**
|
||||
* GL Ethiopia sets (or, on the customer's request, revises) amount +
|
||||
* currency. Revising a SENT charge cancels its unpaid invoice; a PAID charge
|
||||
* is immutable.
|
||||
* GL Ethiopia sets (or, after a customer rejection, revises) amount +
|
||||
* currency + description. Allowed until the customer accepts: an ACCEPTED
|
||||
* charge already carries an invoice and a PAID one is settled.
|
||||
*/
|
||||
async billCharge(
|
||||
bookingId: string,
|
||||
chargeId: string,
|
||||
input: { amount: number; currency: string },
|
||||
input: { amount: number; currency: string; description?: string },
|
||||
staffId: string,
|
||||
): Promise<Freight.ClearanceCharge[]> {
|
||||
const charge = await this.repo().findOne({
|
||||
where: { id: chargeId, bookingId },
|
||||
});
|
||||
if (!charge) throw new NotFoundException('Clearance charge not found');
|
||||
if (charge.status === 'PAID') {
|
||||
throw new ConflictException('A paid charge can no longer be changed.');
|
||||
const charge = await this.findCharge(bookingId, chargeId);
|
||||
if (!canStaffEditCharge(charge.status)) {
|
||||
throw new ConflictException(
|
||||
'The customer has accepted this charge — it can no longer be changed.',
|
||||
);
|
||||
}
|
||||
if (!(input.amount > 0)) {
|
||||
throw new BadRequestException('Amount must be greater than zero.');
|
||||
@@ -203,53 +237,117 @@ export class BookingClearanceChargeService {
|
||||
if (!input.currency?.trim()) {
|
||||
throw new BadRequestException('Currency is required.');
|
||||
}
|
||||
|
||||
if (charge.status === 'SENT' && charge.invoiceId) {
|
||||
await this.billing.cancelInvoice(charge.invoiceId);
|
||||
const description = (input.description ?? charge.description ?? '').trim();
|
||||
if (charge.type === 'MISCELLANEOUS' && !description) {
|
||||
throw new BadRequestException('Describe what this charge is for.');
|
||||
}
|
||||
const currency = input.currency.trim().toUpperCase();
|
||||
const revised = charge.status === 'SENT' || charge.status === 'REJECTED';
|
||||
|
||||
// Back to draft: the customer's previous decision no longer applies.
|
||||
await this.repo().update(charge.id, {
|
||||
amount: input.amount.toFixed(2),
|
||||
currency: input.currency.trim().toUpperCase(),
|
||||
currency,
|
||||
description: description || null,
|
||||
status: 'BILLED',
|
||||
invoiceId: null,
|
||||
customerNote: null,
|
||||
customerDecidedAt: null,
|
||||
customerDecidedBy: null,
|
||||
billedByStaffId: staffId,
|
||||
billedAt: new Date(),
|
||||
});
|
||||
await this.clearanceEvents.record({
|
||||
bookingId,
|
||||
action: 'CHARGE_BILLED',
|
||||
label: `${charge.status === 'SENT' ? 'Revised' : 'Billed'} ${CHARGE_LABEL[
|
||||
label: `${revised ? 'Revised' : 'Billed'} ${CHARGE_LABEL[
|
||||
charge.type
|
||||
].toLowerCase()}: ${input.amount} ${input.currency.trim().toUpperCase()}`,
|
||||
].toLowerCase()}: ${input.amount} ${currency}${
|
||||
description ? ` — ${description}` : ''
|
||||
}`,
|
||||
actorId: staffId,
|
||||
metadata: {
|
||||
chargeType: charge.type,
|
||||
amount: input.amount,
|
||||
currency: input.currency.trim().toUpperCase(),
|
||||
revised: charge.status === 'SENT',
|
||||
currency,
|
||||
description: description || null,
|
||||
revised,
|
||||
},
|
||||
});
|
||||
return this.list(bookingId);
|
||||
}
|
||||
|
||||
/** GL Ethiopia issues the payable invoice to the customer. */
|
||||
/**
|
||||
* GL Ethiopia proposes the priced charge to the customer. No invoice yet —
|
||||
* that is issued when the customer accepts. Re-sending after a rejection
|
||||
* goes through here too.
|
||||
*/
|
||||
async sendCharge(
|
||||
bookingId: string,
|
||||
chargeId: string,
|
||||
staffId?: string,
|
||||
staffId: string,
|
||||
): Promise<Freight.ClearanceCharge[]> {
|
||||
const charge = await this.repo().findOne({
|
||||
where: { id: chargeId, bookingId },
|
||||
});
|
||||
if (!charge) throw new NotFoundException('Clearance charge not found');
|
||||
if (charge.status !== 'BILLED') {
|
||||
const charge = await this.findCharge(bookingId, chargeId);
|
||||
if (charge.status !== 'BILLED' && charge.status !== 'REJECTED') {
|
||||
throw new ConflictException(
|
||||
'Set the amount and currency before sending the charge to the customer.',
|
||||
charge.status === 'DOC_UPLOADED'
|
||||
? 'Set the amount and currency before sending the charge to the customer.'
|
||||
: 'This charge has already been sent to the customer.',
|
||||
);
|
||||
}
|
||||
const revised = charge.status === 'REJECTED';
|
||||
const amount = Number(charge.amount);
|
||||
const currency = charge.currency ?? 'ETB';
|
||||
|
||||
await this.repo().update(charge.id, {
|
||||
status: 'SENT',
|
||||
customerNote: null,
|
||||
customerDecidedAt: null,
|
||||
customerDecidedBy: null,
|
||||
});
|
||||
await this.clearanceEvents.record({
|
||||
bookingId,
|
||||
action: 'CHARGE_SENT',
|
||||
label: `${revised ? 'Re-sent' : 'Sent'} ${CHARGE_LABEL[
|
||||
charge.type
|
||||
].toLowerCase()} to the customer for approval: ${amount} ${currency}`,
|
||||
actorId: staffId ?? null,
|
||||
metadata: {
|
||||
chargeType: charge.type,
|
||||
amount,
|
||||
currency,
|
||||
description: charge.description ?? null,
|
||||
revised,
|
||||
},
|
||||
});
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
this.notifier.clearanceChargeProposed(booking, {
|
||||
label: CHARGE_LABEL[charge.type],
|
||||
amount,
|
||||
currency,
|
||||
description: charge.description ?? null,
|
||||
revised,
|
||||
});
|
||||
return this.list(bookingId);
|
||||
}
|
||||
|
||||
/** Customer agrees to the price: the payable invoice is issued and the charge locks. */
|
||||
async customerAccept(
|
||||
bookingId: string,
|
||||
chargeId: string,
|
||||
userId: string,
|
||||
): Promise<Freight.ClearanceCharge[]> {
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
await this.bookingsService.assertCustomerCanAccessBooking(userId, booking);
|
||||
const charge = await this.findCharge(bookingId, chargeId);
|
||||
if (charge.status !== 'SENT' && charge.status !== 'REJECTED') {
|
||||
throw new ConflictException(
|
||||
charge.status === 'ACCEPTED' || charge.status === 'PAID'
|
||||
? 'This charge has already been accepted.'
|
||||
: 'This charge is not awaiting your decision.',
|
||||
);
|
||||
}
|
||||
const amount = Number(charge.amount);
|
||||
const currency = charge.currency ?? 'ETB';
|
||||
const invoice = await this.billing.generateInvoice({
|
||||
source: Freight.InvoiceSource.ClearanceCharge,
|
||||
// The charge's own id, NOT the booking id — booking-scoped invoice
|
||||
@@ -258,46 +356,101 @@ export class BookingClearanceChargeService {
|
||||
type: charge.type,
|
||||
companyId: booking.companyId,
|
||||
companyProfileId: booking.companyProfileId,
|
||||
currency: charge.currency ?? 'ETB',
|
||||
currency,
|
||||
lines: [
|
||||
{
|
||||
chargeType: charge.type,
|
||||
description: `${CHARGE_LABEL[charge.type]} — ${booking.reference ?? bookingId}`,
|
||||
amount: Number(charge.amount),
|
||||
description: `${CHARGE_LABEL[charge.type]} — ${
|
||||
booking.reference ?? bookingId
|
||||
}${charge.description ? `: ${charge.description}` : ''}`,
|
||||
amount,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await this.repo().update(charge.id, {
|
||||
status: 'SENT',
|
||||
status: 'ACCEPTED',
|
||||
invoiceId: invoice.id,
|
||||
customerNote: null,
|
||||
customerDecidedAt: new Date(),
|
||||
customerDecidedBy: userId,
|
||||
});
|
||||
await this.clearanceEvents.record({
|
||||
bookingId,
|
||||
action: 'CHARGE_INVOICE_SENT',
|
||||
label: `Sent ${CHARGE_LABEL[charge.type].toLowerCase()} invoice ${invoice.invoiceNumber} to the customer`,
|
||||
actorId: staffId ?? null,
|
||||
action: 'CHARGE_ACCEPTED',
|
||||
label: `Customer accepted ${CHARGE_LABEL[
|
||||
charge.type
|
||||
].toLowerCase()} (${amount} ${currency}) — invoice ${invoice.invoiceNumber} issued`,
|
||||
actorType: 'CUSTOMER',
|
||||
actorId: userId,
|
||||
metadata: {
|
||||
chargeType: charge.type,
|
||||
invoiceNumber: invoice.invoiceNumber,
|
||||
amount: Number(charge.amount),
|
||||
currency: charge.currency,
|
||||
amount,
|
||||
currency,
|
||||
},
|
||||
});
|
||||
this.notifier.clearanceChargeInvoiceIssued(booking, {
|
||||
label: CHARGE_LABEL[charge.type],
|
||||
amount,
|
||||
currency,
|
||||
invoiceNumber: invoice.invoiceNumber,
|
||||
});
|
||||
this.logger.log(
|
||||
`Clearance charge ${charge.type} on booking ${bookingId} sent as invoice ${invoice.invoiceNumber}`,
|
||||
`Clearance charge ${charge.type} on booking ${bookingId} accepted; invoice ${invoice.invoiceNumber}`,
|
||||
);
|
||||
return this.list(bookingId);
|
||||
return this.listForCustomer(bookingId);
|
||||
}
|
||||
|
||||
/** Customer declines the price with a reason; GL revises and re-sends. */
|
||||
async customerReject(
|
||||
bookingId: string,
|
||||
chargeId: string,
|
||||
note: string,
|
||||
userId: string,
|
||||
): Promise<Freight.ClearanceCharge[]> {
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
await this.bookingsService.assertCustomerCanAccessBooking(userId, booking);
|
||||
const charge = await this.findCharge(bookingId, chargeId);
|
||||
if (charge.status !== 'SENT') {
|
||||
throw new ConflictException(
|
||||
charge.status === 'ACCEPTED' || charge.status === 'PAID'
|
||||
? 'This charge has already been accepted.'
|
||||
: 'This charge is not awaiting your decision.',
|
||||
);
|
||||
}
|
||||
if (!note?.trim()) {
|
||||
throw new BadRequestException('Say why you are rejecting this charge.');
|
||||
}
|
||||
await this.repo().update(charge.id, {
|
||||
status: 'REJECTED',
|
||||
customerNote: note.trim(),
|
||||
customerDecidedAt: new Date(),
|
||||
customerDecidedBy: userId,
|
||||
});
|
||||
await this.clearanceEvents.record({
|
||||
bookingId,
|
||||
action: 'CHARGE_REJECTED',
|
||||
label: `Customer rejected ${CHARGE_LABEL[charge.type].toLowerCase()}: ${note.trim()}`,
|
||||
actorType: 'CUSTOMER',
|
||||
actorId: userId,
|
||||
metadata: { chargeType: charge.type, note: note.trim() },
|
||||
});
|
||||
this.notifier.clearanceChargeRejectedToStaff(booking, {
|
||||
label: CHARGE_LABEL[charge.type],
|
||||
note: note.trim(),
|
||||
});
|
||||
return this.listForCustomer(bookingId);
|
||||
}
|
||||
|
||||
/**
|
||||
* GL Ethiopia creates the miscellaneous charge whole (document + amount +
|
||||
* currency). Second payment level: allowed only once the port charge is paid.
|
||||
* GL Ethiopia creates a miscellaneous charge whole (document + amount +
|
||||
* currency + what it is for). Lands as a BILLED draft; GL sends it next.
|
||||
*/
|
||||
async createMiscellaneous(
|
||||
bookingId: string,
|
||||
file: Express.Multer.File,
|
||||
input: { amount: number; currency: string },
|
||||
input: { amount: number; currency: string; description?: string },
|
||||
staffId: string,
|
||||
): Promise<Freight.ClearanceCharge[]> {
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
@@ -311,6 +464,10 @@ export class BookingClearanceChargeService {
|
||||
if (!input.currency?.trim()) {
|
||||
throw new BadRequestException('Currency is required.');
|
||||
}
|
||||
const description = input.description?.trim() ?? '';
|
||||
if (!description) {
|
||||
throw new BadRequestException('Describe what this charge is for.');
|
||||
}
|
||||
|
||||
// Save the row first so its id can key the document. A booking may carry
|
||||
// several miscellaneous charges, and `upsertByCode` retires whatever sits
|
||||
@@ -323,6 +480,7 @@ export class BookingClearanceChargeService {
|
||||
status: 'BILLED',
|
||||
amount: input.amount.toFixed(2),
|
||||
currency: input.currency.trim().toUpperCase(),
|
||||
description,
|
||||
uploadedByStaffId: staffId,
|
||||
uploadedAt: new Date(),
|
||||
billedByStaffId: staffId,
|
||||
@@ -342,11 +500,12 @@ export class BookingClearanceChargeService {
|
||||
await this.clearanceEvents.record({
|
||||
bookingId,
|
||||
action: 'CHARGE_MISC_CREATED',
|
||||
label: `Created miscellaneous charge: ${input.amount} ${input.currency.trim().toUpperCase()}`,
|
||||
label: `Created miscellaneous charge: ${input.amount} ${input.currency.trim().toUpperCase()} — ${description}`,
|
||||
actorId: staffId,
|
||||
metadata: {
|
||||
amount: input.amount,
|
||||
currency: input.currency.trim().toUpperCase(),
|
||||
description,
|
||||
fileName: file.originalname,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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. */
|
||||
finalInvoicePaid(b: Booking): void {
|
||||
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);
|
||||
});
|
||||
|
||||
it('prices an Ethiopian-customs-only service off ETHIOPIAN_CUSTOMS_CLEARANCE, not the full fee', async () => {
|
||||
const ethiopianFee = {
|
||||
...containerFee20,
|
||||
id: 'rate-et-20',
|
||||
rateType: 'ETHIOPIAN_CUSTOMS_CLEARANCE',
|
||||
trigger: 'ETHIOPIAN_CUSTOMS_CLEARANCE',
|
||||
rateValue: 40,
|
||||
} as Rate;
|
||||
const service = makeService({ liveRates: [containerFee20, ethiopianFee] });
|
||||
const result = await service.computePriceForBooking(
|
||||
containerBooking({
|
||||
serviceType: { includesCustoms: true, includesEthiopianCustomsOnly: true },
|
||||
} as never),
|
||||
);
|
||||
|
||||
const line = result.lineItems.find((l) => l.code === 'ETHIOPIAN_CUSTOMS_CLEARANCE_20FT');
|
||||
expect(line).toBeDefined();
|
||||
expect(line!.amount).toBe(160);
|
||||
expect(result.lineItems.some((l) => l.code === 'CUSTOMS_CLEARANCE_20FT')).toBe(false);
|
||||
});
|
||||
|
||||
it('hard-blocks a container type with no fee configured (never free clearance)', async () => {
|
||||
const service = makeService({ liveRates: [bulkFeePerTon] });
|
||||
const result = await service.computePriceForBooking(containerBooking());
|
||||
|
||||
@@ -1060,9 +1060,18 @@ export class BookingPricingService {
|
||||
const usdToEtb = isEtb ? await this.exchangeService.getRate('USD', 'ETB') : 1;
|
||||
const convert = (usd: number): number => (isEtb ? round2(usd * usdToEtb) : usd);
|
||||
|
||||
// An Ethiopian-side-only customs service prices off its own rate; the
|
||||
// contract froze its snapshots under the matching code prefix.
|
||||
const customsType = booking.serviceType?.includesEthiopianCustomsOnly
|
||||
? 'ETHIOPIAN_CUSTOMS_CLEARANCE'
|
||||
: 'CUSTOMS_CLEARANCE';
|
||||
const customsLabel =
|
||||
customsType === 'ETHIOPIAN_CUSTOMS_CLEARANCE'
|
||||
? 'Ethiopian customs clearance service'
|
||||
: 'Customs clearance service';
|
||||
const onLeg = liveRates.filter(
|
||||
(r) =>
|
||||
r.rateType === 'CUSTOMS_CLEARANCE' &&
|
||||
r.rateType === customsType &&
|
||||
r.currency === 'USD' &&
|
||||
r.tradeDirection === booking.tradeDirection &&
|
||||
r.originYardId === booking.originYardId &&
|
||||
@@ -1070,20 +1079,20 @@ export class BookingPricingService {
|
||||
);
|
||||
const missingRateMessage = (scope: string): string =>
|
||||
`No customs clearance service fee is configured for ${scope} on this ` +
|
||||
'origin → destination. Ask EDR to configure the CUSTOMS_CLEARANCE rate for this route.';
|
||||
`origin → destination. Ask EDR to configure the ${customsType} rate for this route.`;
|
||||
|
||||
if (booking.freightType === 'CONTAINER') {
|
||||
// Legacy short-circuit: an old contract froze one flat fee — bill it once.
|
||||
const hasPerSizeSnapshot =
|
||||
frozenRates?.has('CUSTOMS_CLEARANCE_20FT') ||
|
||||
frozenRates?.has('CUSTOMS_CLEARANCE_40FT');
|
||||
const legacyFlat = this.frozenRateByCode(frozenRates, 'CUSTOMS_CLEARANCE', currency, usdToEtb);
|
||||
frozenRates?.has(`${customsType}_20FT`) ||
|
||||
frozenRates?.has(`${customsType}_40FT`);
|
||||
const legacyFlat = this.frozenRateByCode(frozenRates, customsType, currency, usdToEtb);
|
||||
if (legacyFlat && !hasPerSizeSnapshot) {
|
||||
const amount = Number(legacyFlat.unitPrice);
|
||||
if (amount > 0) {
|
||||
lineItems.push({
|
||||
code: 'CUSTOMS_CLEARANCE',
|
||||
description: 'Customs clearance service',
|
||||
code: customsType,
|
||||
description: customsLabel,
|
||||
amount,
|
||||
unitAmount: amount,
|
||||
unit: 'FLAT',
|
||||
@@ -1106,7 +1115,7 @@ export class BookingPricingService {
|
||||
// unknown type — falls through to the live per-type lookup below
|
||||
}
|
||||
const frozen = sizeFt
|
||||
? this.frozenRateByCode(frozenRates, `CUSTOMS_CLEARANCE_${sizeFt}FT`, currency, usdToEtb)
|
||||
? this.frozenRateByCode(frozenRates, `${customsType}_${sizeFt}FT`, currency, usdToEtb)
|
||||
: null;
|
||||
const live = onLeg.find((r) => r.containerTypeId === bc.containerTypeId);
|
||||
if (!frozen && !live) {
|
||||
@@ -1124,8 +1133,8 @@ export class BookingPricingService {
|
||||
const amount = unit === 'FLAT' ? unitAmount : unitAmount * billedQty;
|
||||
if (!(amount > 0)) continue;
|
||||
lineItems.push({
|
||||
code: sizeFt ? `CUSTOMS_CLEARANCE_${sizeFt}FT` : 'CUSTOMS_CLEARANCE',
|
||||
description: `Customs clearance service${sizeFt ? ` (${sizeFt}ft)` : ''}`,
|
||||
code: sizeFt ? `${customsType}_${sizeFt}FT` : customsType,
|
||||
description: `${customsLabel}${sizeFt ? ` (${sizeFt}ft)` : ''}`,
|
||||
amount,
|
||||
unitAmount,
|
||||
unit,
|
||||
@@ -1141,7 +1150,7 @@ export class BookingPricingService {
|
||||
// flat snapshot share the CUSTOMS_CLEARANCE code; both are the agreed fee.
|
||||
// Live lookup: the rate scoped to the booking's commodity wins; a
|
||||
// commodity-less rate (legacy) is the catch-all fallback.
|
||||
const frozen = this.frozenRateByCode(frozenRates, 'CUSTOMS_CLEARANCE', currency, usdToEtb);
|
||||
const frozen = this.frozenRateByCode(frozenRates, customsType, currency, usdToEtb);
|
||||
const live =
|
||||
(booking.cargoTypeId
|
||||
? onLeg.find(
|
||||
@@ -1172,8 +1181,8 @@ export class BookingPricingService {
|
||||
const amount = unit === 'FLAT' ? unitAmount : unitAmount * billedQty;
|
||||
if (amount > 0) {
|
||||
lineItems.push({
|
||||
code: 'CUSTOMS_CLEARANCE',
|
||||
description: 'Customs clearance service (bulk)',
|
||||
code: customsType,
|
||||
description: `${customsLabel} (bulk)`,
|
||||
amount,
|
||||
unitAmount,
|
||||
unit,
|
||||
|
||||
@@ -40,10 +40,18 @@ import {
|
||||
import type { Response } from "express";
|
||||
|
||||
import { BookingClearanceChargeService } from './booking-clearance-charge.service';
|
||||
import { BookingPayablesService } from './booking-payables.service';
|
||||
import { ClearanceEventService } from './clearance-event.service';
|
||||
<<<<<<< HEAD
|
||||
import {
|
||||
BillClearanceChargeDto,
|
||||
RejectClearanceChargeDto,
|
||||
} from './dto/clearance-charge.dto';
|
||||
=======
|
||||
import { BillClearanceChargeDto } from './dto/clearance-charge.dto';
|
||||
import { AdditionalChargeService } from './additional-charge.service';
|
||||
import { CancelAdditionalChargeDto, CreateAdditionalChargeDto } from './dto/additional-charge.dto';
|
||||
>>>>>>> 82c795999efce5e4422dd332551cecab2592764d
|
||||
import { BookingContractService } from './booking-contract.service';
|
||||
import { BookingPricingService } from './booking-pricing.service';
|
||||
import { BookingTransitionService } from './booking-transition.service';
|
||||
@@ -177,6 +185,7 @@ export class BookingsController {
|
||||
private readonly wagonCancellationService: BookingWagonCancellationService,
|
||||
private readonly consolidationApprovalService: ConsolidationApprovalService,
|
||||
private readonly clearanceChargeService: BookingClearanceChargeService,
|
||||
private readonly bookingPayablesService: BookingPayablesService,
|
||||
private readonly clearanceEventService: ClearanceEventService,
|
||||
private readonly additionalChargeService: AdditionalChargeService,
|
||||
) {}
|
||||
@@ -315,6 +324,21 @@ export class BookingsController {
|
||||
return this.bookingsService.getListSummary(filter);
|
||||
}
|
||||
|
||||
@Get("my-payables")
|
||||
@PortalCustomer()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Outstanding customer payments per booking — invoices to pay, prices to accept, duty slips to upload",
|
||||
})
|
||||
async findMyPayables(@CurrentUser() user: AuthUserPayload) {
|
||||
const companyId = await this.bookingsService.resolveCustomerCompanyId(
|
||||
resolveAuthUserId(user),
|
||||
);
|
||||
return companyId
|
||||
? this.bookingPayablesService.summarizeForCompany(companyId)
|
||||
: [];
|
||||
}
|
||||
|
||||
@Get("my")
|
||||
@PortalCustomer()
|
||||
@ApiOperation({
|
||||
@@ -1121,15 +1145,63 @@ export class BookingsController {
|
||||
// ── Clearance charges (post-finalization customer billing) ────────────────
|
||||
|
||||
@Get(":id/clearance/charges")
|
||||
@BookingStaff([
|
||||
@MixedAudience([
|
||||
FREIGHT_PERMS.contracts.clearanceEtActions,
|
||||
FREIGHT_PERMS.contracts.clearanceDjActions,
|
||||
])
|
||||
@ApiOperation({
|
||||
summary: "Clearance charges billed to the customer (port + miscellaneous)",
|
||||
summary:
|
||||
"Clearance charges billed to the customer (port + miscellaneous); customers see only the charges sent to them",
|
||||
})
|
||||
getClearanceCharges(@Param("id", ParseUUIDPipe) id: string) {
|
||||
return this.clearanceChargeService.list(id);
|
||||
async getClearanceCharges(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
const isStaff =
|
||||
hasFreightPermission(user, FREIGHT_PERMS.contracts.clearanceEtActions) ||
|
||||
hasFreightPermission(user, FREIGHT_PERMS.contracts.clearanceDjActions);
|
||||
if (isStaff) return this.clearanceChargeService.list(id);
|
||||
const booking = await this.bookingsService.findById(id);
|
||||
await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking);
|
||||
return this.clearanceChargeService.listForCustomer(id);
|
||||
}
|
||||
|
||||
@Post(":id/clearance/charges/:chargeId/accept")
|
||||
@PortalCustomer()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Customer accepts a proposed clearance charge — issues the payable invoice and locks the charge",
|
||||
})
|
||||
acceptClearanceCharge(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Param("chargeId", ParseUUIDPipe) chargeId: string,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
return this.clearanceChargeService.customerAccept(
|
||||
id,
|
||||
chargeId,
|
||||
resolveAuthUserId(user),
|
||||
);
|
||||
}
|
||||
|
||||
@Post(":id/clearance/charges/:chargeId/reject")
|
||||
@PortalCustomer()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Customer rejects a proposed clearance charge with a reason — GL Ethiopia revises and re-sends",
|
||||
})
|
||||
rejectClearanceCharge(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Param("chargeId", ParseUUIDPipe) chargeId: string,
|
||||
@Body() dto: RejectClearanceChargeDto,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
return this.clearanceChargeService.customerReject(
|
||||
id,
|
||||
chargeId,
|
||||
dto.note,
|
||||
resolveAuthUserId(user),
|
||||
);
|
||||
}
|
||||
|
||||
@Post(":id/clearance/charges/port-document")
|
||||
@@ -1156,7 +1228,7 @@ export class BookingsController {
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"GL Ethiopia sets or revises the charge's amount + currency (revising a sent charge cancels its unpaid invoice)",
|
||||
"GL Ethiopia sets or revises the charge's amount, currency and description (locked once the customer accepts)",
|
||||
})
|
||||
billClearanceCharge(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@@ -1176,7 +1248,7 @@ export class BookingsController {
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"GL Ethiopia issues the charge's payable invoice to the customer (ETB pays via gateway, other currencies via manual settlement)",
|
||||
"GL Ethiopia sends the priced charge to the customer for approval (the invoice is issued when they accept)",
|
||||
})
|
||||
sendClearanceCharge(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@@ -1196,7 +1268,7 @@ export class BookingsController {
|
||||
@ApiConsumes("multipart/form-data")
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"GL Ethiopia creates the miscellaneous charge (document + amount + currency); unlocked once the port charge is paid",
|
||||
"GL Ethiopia creates a miscellaneous charge (document + amount + currency + description) as a draft to send",
|
||||
})
|
||||
createMiscellaneousCharge(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
|
||||
@@ -42,6 +42,7 @@ import { AdditionalCharge } from './entities/additional-charge.entity';
|
||||
import { AdditionalChargeRepository } from './additional-charge.repository';
|
||||
import { AdditionalChargeService } from './additional-charge.service';
|
||||
import { BookingClearanceChargeService } from './booking-clearance-charge.service';
|
||||
import { BookingPayablesService } from './booking-payables.service';
|
||||
import { BookingClearanceEvent } from './entities/booking-clearance-event.entity';
|
||||
import { ClearanceEventService } from './clearance-event.service';
|
||||
import { BookingContainer } from './entities/booking-container.entity';
|
||||
@@ -122,6 +123,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
|
||||
BookingContractService,
|
||||
BookingInvoiceService,
|
||||
BookingClearanceChargeService,
|
||||
BookingPayablesService,
|
||||
ClearanceEventService,
|
||||
AdditionalChargeRepository,
|
||||
AdditionalChargeService,
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsNumber, IsPositive, IsString, Length } from 'class-validator';
|
||||
import {
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsPositive,
|
||||
IsString,
|
||||
Length,
|
||||
MaxLength,
|
||||
} from 'class-validator';
|
||||
|
||||
export class BillClearanceChargeDto {
|
||||
@ApiProperty({ example: 12500.5 })
|
||||
@@ -13,4 +20,18 @@ export class BillClearanceChargeDto {
|
||||
@IsString()
|
||||
@Length(3, 8)
|
||||
currency!: string;
|
||||
|
||||
/** What the price is for. Required for miscellaneous charges (checked in the service). */
|
||||
@ApiPropertyOptional({ example: 'Container cleaning and weighbridge fee' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(1000)
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export class RejectClearanceChargeDto {
|
||||
@ApiProperty({ example: 'The weighbridge fee was already paid at the port.' })
|
||||
@IsString()
|
||||
@Length(1, 1000)
|
||||
note!: string;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ export const CLEARANCE_CHARGE_STATUSES = [
|
||||
'DOC_UPLOADED',
|
||||
'BILLED',
|
||||
'SENT',
|
||||
'REJECTED',
|
||||
'ACCEPTED',
|
||||
'PAID',
|
||||
] as const;
|
||||
export type ClearanceChargeStatus = (typeof CLEARANCE_CHARGE_STATUSES)[number];
|
||||
@@ -17,9 +19,11 @@ export type ClearanceChargeStatus = (typeof CLEARANCE_CHARGE_STATUSES)[number];
|
||||
* Clearance charge billed to the customer. One PORT_CHARGES row per booking
|
||||
* (enforced by a partial unique index) and any number of MISCELLANEOUS rows.
|
||||
* GL Djibouti uploads the port-charges document (DOC_UPLOADED); GL Ethiopia
|
||||
* sets amount + currency (BILLED) and issues the invoice (SENT); the billing
|
||||
* `clearance_charge.invoice.paid` event marks it PAID. The two levels are
|
||||
* independent — either may be raised first.
|
||||
* sets amount + currency + description (BILLED) and proposes it to the
|
||||
* customer (SENT). The customer either REJECTS with a note (GL revises and
|
||||
* re-sends) or ACCEPTS, which issues the invoice and locks the charge; the
|
||||
* billing `clearance_charge.invoice.paid` event marks it PAID. The two levels
|
||||
* are independent — either may be raised first.
|
||||
*/
|
||||
@Entity({ schema: 'freight', name: 'booking_clearance_charge' })
|
||||
@Index(['bookingId'])
|
||||
@@ -47,6 +51,20 @@ export class BookingClearanceCharge extends BaseEntity {
|
||||
@Column({ name: 'currency', type: 'varchar', length: 8, nullable: true })
|
||||
currency?: string | null;
|
||||
|
||||
/** What the price is for, written by GL. */
|
||||
@Column({ name: 'description', type: 'text', nullable: true })
|
||||
description?: string | null;
|
||||
|
||||
/** Customer's reason when REJECTED; cleared when GL revises. */
|
||||
@Column({ name: 'customer_note', type: 'text', nullable: true })
|
||||
customerNote?: string | null;
|
||||
|
||||
@Column({ name: 'customer_decided_at', type: 'timestamptz', nullable: true })
|
||||
customerDecidedAt?: Date | null;
|
||||
|
||||
@Column({ name: 'customer_decided_by', type: 'uuid', nullable: true })
|
||||
customerDecidedBy?: string | null;
|
||||
|
||||
/** The payable invoice issued for this charge (null until SENT). */
|
||||
@Column({ name: 'invoice_id', type: 'uuid', nullable: true })
|
||||
invoiceId?: string | null;
|
||||
|
||||
@@ -376,12 +376,21 @@ export class ContractPricingService {
|
||||
// own container-type rate), bulk contracts freeze the route's bulk fee.
|
||||
// A customs contract may not proceed without the fee(s) configured.
|
||||
if (contract.customsClearingEnabled) {
|
||||
// An Ethiopian-side-only customs service prices off its own rate; the
|
||||
// snapshot codes carry the same prefix so booking pricing finds them.
|
||||
const customsType = contract.serviceType?.includesEthiopianCustomsOnly
|
||||
? 'ETHIOPIAN_CUSTOMS_CLEARANCE'
|
||||
: 'CUSTOMS_CLEARANCE';
|
||||
const customsLabel =
|
||||
customsType === 'ETHIOPIAN_CUSTOMS_CLEARANCE'
|
||||
? 'Ethiopian customs clearance service'
|
||||
: 'Customs clearance service';
|
||||
// Strict, no route-less fallback.
|
||||
// ponytail: multi-route contracts bill the first lane's fee; per-lane fees need per-route snapshots.
|
||||
const onLeg = route
|
||||
? liveRates.filter(
|
||||
(r) =>
|
||||
r.rateType === 'CUSTOMS_CLEARANCE' &&
|
||||
r.rateType === customsType &&
|
||||
r.currency === 'USD' &&
|
||||
r.tradeDirection === contract.tradeDirection &&
|
||||
r.originYardId === route.originYardId &&
|
||||
@@ -406,14 +415,14 @@ export class ContractPricingService {
|
||||
);
|
||||
if (!rate || Number(rate.rateValue) <= 0) {
|
||||
throw new UnprocessableEntityException(
|
||||
`No customs clearance service fee is configured for ${size} containers on this direction and route. Ask the rates team to set a live CUSTOMS_CLEARANCE rate for this container type and origin → destination.`,
|
||||
`No customs clearance service fee is configured for ${size} containers on this direction and route. Ask the rates team to set a live ${customsType} rate for this container type and origin → destination.`,
|
||||
);
|
||||
}
|
||||
lineItems.push({
|
||||
// Distinct code per size so the frozen snapshots don't collide —
|
||||
// booking pricing looks each size up by CUSTOMS_CLEARANCE_<FT>FT.
|
||||
code: `CUSTOMS_CLEARANCE_${sizeFt}FT`,
|
||||
label: `Customs clearance service (${size})`,
|
||||
// booking pricing looks each size up by <customsType>_<FT>FT.
|
||||
code: `${customsType}_${sizeFt}FT`,
|
||||
label: `${customsLabel} (${size})`,
|
||||
unit: toContractUnit(rate.rateUnit),
|
||||
unitPrice: convert(Number(rate.rateValue)),
|
||||
containerSize: size,
|
||||
@@ -432,12 +441,12 @@ export class ContractPricingService {
|
||||
: undefined) ?? onLeg.find((r) => !r.containerTypeId && !r.cargoTypeId);
|
||||
if (!rate || Number(rate.rateValue) <= 0) {
|
||||
throw new UnprocessableEntityException(
|
||||
'No bulk customs clearance service fee is configured for this cargo type on this direction and route. Ask the rates team to set a live bulk CUSTOMS_CLEARANCE rate for this commodity and origin → destination.',
|
||||
`No bulk customs clearance service fee is configured for this cargo type on this direction and route. Ask the rates team to set a live bulk ${customsType} rate for this commodity and origin → destination.`,
|
||||
);
|
||||
}
|
||||
lineItems.push({
|
||||
code: 'CUSTOMS_CLEARANCE',
|
||||
label: `Customs clearance service (${scope?.cargoType?.cargoTypeName ?? 'bulk'})`,
|
||||
code: customsType,
|
||||
label: `${customsLabel} (${scope?.cargoType?.cargoTypeName ?? 'bulk'})`,
|
||||
unit: toContractUnit(rate.rateUnit),
|
||||
unitPrice: convert(Number(rate.rateValue)),
|
||||
cargoTypeCode: scope?.cargoType?.code ?? null,
|
||||
|
||||
@@ -124,11 +124,14 @@ export class ContractsService {
|
||||
return `CTR-${year}-${String(seq + 1).padStart(5, '0')}`;
|
||||
}
|
||||
|
||||
/** The service type a contract is sold under (null when the id is unknown). */
|
||||
private resolveServiceType(serviceTypeId: string): Promise<ServiceType | null> {
|
||||
return this.dataSource.getRepository(ServiceType).findOne({ where: { id: serviceTypeId } });
|
||||
}
|
||||
|
||||
/** Whether a service type bundles customs clearance. */
|
||||
private async resolveIncludesCustoms(serviceTypeId: string): Promise<boolean> {
|
||||
const serviceType = await this.dataSource
|
||||
.getRepository(ServiceType)
|
||||
.findOne({ where: { id: serviceTypeId } });
|
||||
const serviceType = await this.resolveServiceType(serviceTypeId);
|
||||
return serviceType?.includesCustoms ?? false;
|
||||
}
|
||||
|
||||
@@ -324,7 +327,8 @@ export class ContractsService {
|
||||
}
|
||||
|
||||
// Customs clearing is owned by the service type, not the customer.
|
||||
const includesCustoms = await this.resolveIncludesCustoms(dto.serviceTypeId);
|
||||
const serviceType = await this.resolveServiceType(dto.serviceTypeId);
|
||||
const includesCustoms = serviceType?.includesCustoms ?? false;
|
||||
// Intercity never crosses a border, so a customs-including service type is
|
||||
// a contradiction — the wizard hides them, the API enforces it.
|
||||
if (dto.tradeDirection === 'DOMESTIC' && includesCustoms) {
|
||||
@@ -344,6 +348,8 @@ export class ContractsService {
|
||||
freightType: dto.freightType,
|
||||
paymentCurrency: 'USD',
|
||||
customsClearingEnabled: includesCustoms,
|
||||
// Decides which customs fee the probe looks up (Ethiopian-only vs full).
|
||||
serviceType,
|
||||
isHazardous: dto.isHazardous ?? false,
|
||||
isReefer: dto.isReefer ?? false,
|
||||
equipmentReturn: dto.equipmentReturn ?? null,
|
||||
|
||||
@@ -32,6 +32,14 @@ export class CreateServiceTypeDto {
|
||||
@IsBoolean()
|
||||
includesCustoms?: boolean;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
default: false,
|
||||
description: 'Customs cleared on the Ethiopian side only (alternative to full includesCustoms; implies it). Prices off the Ethiopian customs rate.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
includesEthiopianCustomsOnly?: boolean;
|
||||
|
||||
@ApiPropertyOptional({ default: true })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
|
||||
@@ -16,6 +16,7 @@ describe('deriveRateType — surcharge triggers', () => {
|
||||
['DEMURRAGE', 'DEMURRAGE'],
|
||||
['PIL_EXTRA_FEE', 'PIL_EXTRA_FEE'],
|
||||
['CUSTOMS_CLEARANCE', 'CUSTOMS_CLEARANCE'],
|
||||
['ETHIOPIAN_CUSTOMS_CLEARANCE', 'ETHIOPIAN_CUSTOMS_CLEARANCE'],
|
||||
] as const)('maps trigger %s to %s', (trigger, expected) => {
|
||||
expect(deriveRateType({ appliesTo: 'OTHER', trigger })).toBe(expected);
|
||||
});
|
||||
|
||||
@@ -46,6 +46,8 @@ export function deriveRateType(input: {
|
||||
return 'PIL_EXTRA_FEE';
|
||||
case 'CUSTOMS_CLEARANCE':
|
||||
return 'CUSTOMS_CLEARANCE';
|
||||
case 'ETHIOPIAN_CUSTOMS_CLEARANCE':
|
||||
return 'ETHIOPIAN_CUSTOMS_CLEARANCE';
|
||||
case 'FUEL':
|
||||
return 'FUEL_SURCHARGE';
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ export const isBulkQuantityUnit = (unit: string): boolean =>
|
||||
export function allowedRateUnits(input: {
|
||||
appliesTo: RateAppliesTo;
|
||||
trigger: RateTrigger;
|
||||
/** CUSTOMS_CLEARANCE / CANCELLATION only: which cargo kind the fee covers. */
|
||||
/** Customs clearance / CANCELLATION only: which cargo kind the fee covers. */
|
||||
cargoKind?: 'CONTAINER' | 'BULK' | null;
|
||||
/** Unit of measure of the bulk commodity the rate is scoped to, when any. */
|
||||
cargoUnitOfMeasure?: CargoUom;
|
||||
@@ -67,6 +67,7 @@ function unitsForShape(input: {
|
||||
// per wagon is the only unit the wagon-cancel flow can apply.
|
||||
return ['PER_WAGON'];
|
||||
case 'CUSTOMS_CLEARANCE':
|
||||
case 'ETHIOPIAN_CUSTOMS_CLEARANCE':
|
||||
// Sold per cargo kind: container fees bill per box or per wagon, bulk
|
||||
// fees per ton or per wagon. Billed on the booking invoice.
|
||||
return input.cargoKind === 'BULK'
|
||||
|
||||
@@ -25,6 +25,7 @@ export const RATE_TYPES = [
|
||||
'RETURN_SURCHARGE',
|
||||
'PIL_EXTRA_FEE',
|
||||
'CUSTOMS_CLEARANCE',
|
||||
'ETHIOPIAN_CUSTOMS_CLEARANCE',
|
||||
'FUEL_SURCHARGE',
|
||||
] as const;
|
||||
|
||||
@@ -96,12 +97,22 @@ export const RATE_TRIGGERS = [
|
||||
// Customs clearance service fee — billed up front via a clearance invoice,
|
||||
// never auto-applied to booking pricing (matchesTrigger returns false).
|
||||
'CUSTOMS_CLEARANCE',
|
||||
// Same shape as CUSTOMS_CLEARANCE; priced instead of it when the booking's
|
||||
// service type has includesEthiopianCustomsOnly (Ethiopian-side clearance).
|
||||
'ETHIOPIAN_CUSTOMS_CLEARANCE',
|
||||
// Fuel surcharge — fires when the booking's cargo type has hasFuel = true,
|
||||
// billed off the lane-scoped rate (direction + route + cargo type).
|
||||
'FUEL',
|
||||
] as const;
|
||||
export type RateTrigger = typeof RATE_TRIGGERS[number];
|
||||
|
||||
/**
|
||||
* The two customs clearance service fees share one rate shape (per direction +
|
||||
* route + cargo kind); only which one a booking prices off differs.
|
||||
*/
|
||||
export const isCustomsClearanceTrigger = (trigger: string): boolean =>
|
||||
trigger === 'CUSTOMS_CLEARANCE' || trigger === 'ETHIOPIAN_CUSTOMS_CLEARANCE';
|
||||
|
||||
@Entity({ schema: 'freight', name: 'rates' })
|
||||
@Index(['rateType'])
|
||||
@Index(['status'])
|
||||
@@ -117,7 +128,7 @@ export class Rate extends BaseEntity {
|
||||
@Column({ name: 'applies_to', type: 'varchar', length: 20, default: 'OTHER' })
|
||||
appliesTo!: RateAppliesTo;
|
||||
|
||||
@Column({ name: 'trigger', type: 'varchar', length: 20, default: 'ALWAYS' })
|
||||
@Column({ name: 'trigger', type: 'varchar', length: 30, default: 'ALWAYS' })
|
||||
trigger!: RateTrigger;
|
||||
|
||||
@Column({ name: 'container_type_id', type: 'uuid', nullable: true })
|
||||
|
||||
@@ -27,6 +27,16 @@ export class ServiceType extends BaseEntity {
|
||||
@Column({ name: 'includes_customs', type: 'boolean', default: false })
|
||||
includesCustoms!: boolean;
|
||||
|
||||
/**
|
||||
* EDR clears customs on the Ethiopian side only. The admin picks full customs
|
||||
* OR Ethiopian-only, never both; the API stores includesCustoms = true for
|
||||
* either so every clearance read (GL review, duty, docs) stays unchanged —
|
||||
* only the fee differs: pricing looks up ETHIOPIAN_CUSTOMS_CLEARANCE instead
|
||||
* of CUSTOMS_CLEARANCE.
|
||||
*/
|
||||
@Column({ name: 'includes_ethiopian_customs_only', type: 'boolean', default: false })
|
||||
includesEthiopianCustomsOnly!: boolean;
|
||||
|
||||
@Column({ name: 'is_active', type: 'boolean', default: true })
|
||||
isActive!: boolean;
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import { ShippingLineCompaniesService } from '../../shipping-lines/shipping-line
|
||||
import { CreateRateDto } from '../dto/create-rate.dto';
|
||||
import { ListRatesQueryDto } from '../dto/list-rule-engine-query.dto';
|
||||
import { UpdateRateDto } from '../dto/update-rate.dto';
|
||||
import { Rate } from '../entities/rate.entity';
|
||||
import { Rate, isCustomsClearanceTrigger } from '../entities/rate.entity';
|
||||
import { deriveRateType } from '../entities/rate-type.util';
|
||||
import { CargoUom, allowedRateUnits, isRateUnitAllowed } from '../entities/rate-unit.util';
|
||||
import {
|
||||
@@ -29,10 +29,15 @@ const BASE_FREIGHT_CATEGORIES: readonly Rate['appliesTo'][] = ['BULK', 'CONTAINE
|
||||
* Surcharges sold per cargo kind: the admin says container or bulk, a
|
||||
* container fee then names its container type and a bulk fee its commodity.
|
||||
*/
|
||||
const CARGO_KIND_TRIGGERS: readonly Rate['trigger'][] = ['CUSTOMS_CLEARANCE', 'CANCELLATION'];
|
||||
const CARGO_KIND_TRIGGERS: readonly Rate['trigger'][] = [
|
||||
'CUSTOMS_CLEARANCE',
|
||||
'ETHIOPIAN_CUSTOMS_CLEARANCE',
|
||||
'CANCELLATION',
|
||||
];
|
||||
/** Surcharges that keep a trade direction (everything else is direction-agnostic). */
|
||||
const DIRECTED_SURCHARGE_TRIGGERS: readonly Rate['trigger'][] = [
|
||||
'CUSTOMS_CLEARANCE',
|
||||
'ETHIOPIAN_CUSTOMS_CLEARANCE',
|
||||
'CANCELLATION',
|
||||
'WITH_RETURN',
|
||||
'LASHING',
|
||||
@@ -144,7 +149,7 @@ export class RatesService {
|
||||
private isRouteScoped(appliesTo: Rate['appliesTo'], trigger: Rate['trigger']): boolean {
|
||||
return (
|
||||
this.isBaseFreight(appliesTo, trigger) ||
|
||||
trigger === 'CUSTOMS_CLEARANCE' ||
|
||||
isCustomsClearanceTrigger(trigger) ||
|
||||
trigger === 'WITH_RETURN' ||
|
||||
trigger === 'FUEL'
|
||||
);
|
||||
@@ -261,7 +266,7 @@ export class RatesService {
|
||||
}): void {
|
||||
const { appliesTo, trigger, tradeDirection, intercityKind, cargoKind } = input;
|
||||
const { containerTypeId, cargoTypeId } = input;
|
||||
if (trigger === 'CUSTOMS_CLEARANCE' || trigger === 'CANCELLATION') {
|
||||
if (isCustomsClearanceTrigger(trigger) || trigger === 'CANCELLATION') {
|
||||
// Both fees are sold per direction + cargo kind + type: customs clearance
|
||||
// per lane, the wagon cancellation fee per direction only.
|
||||
const fee = trigger === 'CANCELLATION' ? 'cancellation fee' : 'customs clearance';
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { PaginatedResponse } from '@edr/types';
|
||||
import { ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Inject,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { generateCode } from '../../../common/utils/generate-code.util';
|
||||
import { CreateServiceTypeDto } from '../dto/create-service-type.dto';
|
||||
import { ListServiceTypesQueryDto } from '../dto/list-rule-engine-query.dto';
|
||||
@@ -43,6 +49,10 @@ export class ServiceTypesService {
|
||||
const existing = await this.repository.findByCode(code);
|
||||
if (existing) throw new ConflictException(`Service type with name "${dto.serviceName}" conflicts with existing code "${code}"`);
|
||||
|
||||
const customs = this.resolveCustomsFlags(
|
||||
dto.includesCustoms ?? false,
|
||||
dto.includesEthiopianCustomsOnly ?? false,
|
||||
);
|
||||
const displayOrder = await this.displayOrder.resolveCreateOrder(ServiceType, 'displayOrder', {
|
||||
explicitOrder: dto.displayOrder,
|
||||
insertAfterId: dto.insertAfterId,
|
||||
@@ -55,7 +65,7 @@ export class ServiceTypesService {
|
||||
canBeBookedAlone: dto.canBeBookedAlone ?? true,
|
||||
includesFirstMile: dto.includesFirstMile ?? false,
|
||||
includesLastMile: dto.includesLastMile ?? false,
|
||||
includesCustoms: dto.includesCustoms ?? false,
|
||||
...customs,
|
||||
isActive: dto.isActive ?? true,
|
||||
displayOrder,
|
||||
});
|
||||
@@ -63,13 +73,44 @@ export class ServiceTypesService {
|
||||
|
||||
/** Update an existing service type. */
|
||||
async update(id: string, dto: UpdateServiceTypeDto): Promise<ServiceType> {
|
||||
await this.findById(id);
|
||||
const { ...patch } = dto;
|
||||
const existing = await this.findById(id);
|
||||
const ethiopian = dto.includesEthiopianCustomsOnly ?? existing.includesEthiopianCustomsOnly;
|
||||
// The form sends both flags whenever either is touched; a payload with only
|
||||
// one is a plain edit (name, order…) that keeps the stored pair.
|
||||
const customs =
|
||||
dto.includesCustoms !== undefined || dto.includesEthiopianCustomsOnly !== undefined
|
||||
? this.resolveCustomsFlags(
|
||||
dto.includesCustoms ?? (existing.includesCustoms && !existing.includesEthiopianCustomsOnly),
|
||||
ethiopian,
|
||||
)
|
||||
: {};
|
||||
const patch = { ...dto, ...customs };
|
||||
const updated = await this.repository.update(id, patch);
|
||||
if (!updated) throw new NotFoundException(`Service type ${id} not found`);
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Full customs and Ethiopian-only customs are alternatives: the admin picks
|
||||
* one. Ethiopian-only is still a customs service, so it is stored with
|
||||
* includesCustoms = true — every clearance read keeps working unchanged and
|
||||
* only pricing looks at the Ethiopian flag.
|
||||
*/
|
||||
private resolveCustomsFlags(
|
||||
includesCustoms: boolean,
|
||||
ethiopianOnly: boolean,
|
||||
): Pick<ServiceType, 'includesCustoms' | 'includesEthiopianCustomsOnly'> {
|
||||
if (includesCustoms && ethiopianOnly) {
|
||||
throw new BadRequestException(
|
||||
'Pick either "Includes customs" or "Ethiopian customs only", not both.',
|
||||
);
|
||||
}
|
||||
return {
|
||||
includesCustoms: includesCustoms || ethiopianOnly,
|
||||
includesEthiopianCustomsOnly: ethiopianOnly,
|
||||
};
|
||||
}
|
||||
|
||||
/** Soft-delete a service type. */
|
||||
async remove(id: string): Promise<void> {
|
||||
await this.findById(id);
|
||||
|
||||
@@ -120,6 +120,16 @@ export class TrainSchedule extends BaseEntity {
|
||||
@Column({ name: 'max_wagons', type: 'int', default: 53 })
|
||||
maxWagons!: number;
|
||||
|
||||
/**
|
||||
* Where THIS departure plans to board each consist wagon: `{ wagonId: yardId }`.
|
||||
* Sparse — a wagon absent from the map boards from its physical
|
||||
* `wagons.current_yard_id`. Independent of the built train's physical spread
|
||||
* so a departure can be sold from Dire while the steel still stands in Mojo;
|
||||
* dispatch requires plan and physical yards to agree.
|
||||
*/
|
||||
@Column({ name: 'planned_wagon_yards', type: 'jsonb', nullable: true })
|
||||
plannedWagonYards?: Record<string, string> | null;
|
||||
|
||||
/** OPEN = accepting/holding bookings; FULL = train filled; CLOSED = manually closed. Orthogonal to `status`. */
|
||||
@Column({ name: 'booking_window_status', type: 'varchar', length: 10, default: 'OPEN' })
|
||||
bookingWindowStatus!: string;
|
||||
|
||||
@@ -48,6 +48,7 @@ import {
|
||||
} from "../dto/import-djibouti-operation.dto";
|
||||
import { AvailableLocomotivesQueryDto } from "../dto/available-locomotives-query.dto";
|
||||
import { AdjustScheduleConsistDto } from "../dto/adjust-schedule-consist.dto";
|
||||
import { UpdateScheduleWagonYardsDto } from "../dto/update-schedule-wagon-yards.dto";
|
||||
import { AvailableTrainsQueryDto } from "../dto/available-trains-query.dto";
|
||||
import { BatchBoardQueryDto } from "../dto/batch-board-query.dto";
|
||||
import { BookableSchedulesQueryDto } from "../dto/bookable-schedules-query.dto";
|
||||
@@ -201,6 +202,29 @@ export class TrainSchedulingController {
|
||||
return this.trainSchedulingService.getScheduleConsist(id);
|
||||
}
|
||||
|
||||
@Get("schedules/:id/wagon-yards")
|
||||
@TrainSchedulingView()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Schedule wagon yard plan: where THIS departure boards each consist wagon vs where it physically stands, per-stop totals, locked wagons",
|
||||
})
|
||||
getScheduleWagonYards(@Param("id", ParseUUIDPipe) id: string) {
|
||||
return this.trainSchedulingService.getScheduleWagonYards(id);
|
||||
}
|
||||
|
||||
@Patch("schedules/:id/wagon-yards")
|
||||
@TrainSchedulingUpdate()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Re-plan the yard this departure boards wagons from (schedule-only; physical yards untouched, dispatch requires alignment)",
|
||||
})
|
||||
updateScheduleWagonYards(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Body() dto: UpdateScheduleWagonYardsDto,
|
||||
) {
|
||||
return this.trainSchedulingService.updateScheduleWagonYards(id, dto.moves);
|
||||
}
|
||||
|
||||
@Post("schedules/:id/adjust-consist")
|
||||
@TrainSchedulingUpdate()
|
||||
@ApiOperation({
|
||||
|
||||
@@ -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 { deriveScheduleDirection } from '../utils/derive-schedule-direction.util';
|
||||
import { computeScheduleWagonUsage } from '../utils/schedule-wagon-usage.util';
|
||||
import {
|
||||
defaultPlannedWagonYards,
|
||||
misalignedWagons,
|
||||
type PlannedWagonYards,
|
||||
scheduleYardOf,
|
||||
} from '../utils/planned-wagon-yards.util';
|
||||
import { pickLowestFreeNumber, pickTrainNumberPool } from '../train-number.util';
|
||||
import {
|
||||
bookingCargoTons,
|
||||
@@ -1557,6 +1563,7 @@ export class TrainSchedulingService {
|
||||
// and yard follow the schedule.
|
||||
let builtTrain: Train | null = null;
|
||||
let locomotiveIds: string[];
|
||||
let plannedWagonYards: PlannedWagonYards | null = null;
|
||||
if (dto.trainId) {
|
||||
builtTrain = await this.dataSource.getRepository(Train).findOne({
|
||||
where: { id: dto.trainId },
|
||||
@@ -1589,7 +1596,7 @@ export class TrainSchedulingService {
|
||||
`Train ${builtTrain.code} is not at the origin yard yet; it must arrive before this departure dispatches`,
|
||||
);
|
||||
}
|
||||
await this.assertRouteCoversWagonYards(builtTrain, route);
|
||||
plannedWagonYards = await this.defaultPlannedWagonYardsFor(builtTrain, route, scheduleWarnings);
|
||||
const conflict = await this.findTrainRouteDayConflict(
|
||||
builtTrain.id,
|
||||
route.id,
|
||||
@@ -1844,6 +1851,7 @@ export class TrainSchedulingService {
|
||||
direction,
|
||||
trainNumber: pairTrainNumber ?? undefined,
|
||||
maxWagons,
|
||||
plannedWagonYards,
|
||||
reverseWagonOrder: dto.reverseWagonOrder ?? false,
|
||||
shippingLineCompanyId: dto.shippingLineCompanyId ?? null,
|
||||
...windowFields,
|
||||
@@ -2742,6 +2750,9 @@ export class TrainSchedulingService {
|
||||
);
|
||||
}
|
||||
}
|
||||
// The yard plan this departure was SOLD against must match where the steel
|
||||
// actually stands: a wagon sold from Dire but still in Mojo cannot board.
|
||||
await this.assertPlannedYardsAligned(schedule);
|
||||
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
const trainNumber = await this.assignTrainNumber(manager, schedule);
|
||||
@@ -5295,15 +5306,30 @@ export class TrainSchedulingService {
|
||||
return rows[0]?.train_id ?? null;
|
||||
}
|
||||
|
||||
/** `{ wagonId: yardId }` this schedule boards each wagon from; `{}` when unset. */
|
||||
private async plannedWagonYardsOf(
|
||||
scheduleId: string | undefined,
|
||||
manager?: EntityManager,
|
||||
): Promise<PlannedWagonYards> {
|
||||
if (!scheduleId) return {};
|
||||
const runner = manager ?? this.dataSource;
|
||||
const rows: { planned_wagon_yards: PlannedWagonYards | null }[] = await runner.query(
|
||||
`SELECT planned_wagon_yards FROM freight.train_schedules WHERE id = $1`,
|
||||
[scheduleId],
|
||||
);
|
||||
return rows[0]?.planned_wagon_yards ?? {};
|
||||
}
|
||||
|
||||
private async countFleetAvailability(
|
||||
originYardId: string,
|
||||
targetScheduleId?: string,
|
||||
): Promise<Array<{ wagonTypeId: string; wagonTypeCode: string; available: number }>> {
|
||||
const [wagons, wagonTypes, builtTrainId, pinnedToTargetIds] = await Promise.all([
|
||||
const [wagons, wagonTypes, builtTrainId, pinnedToTargetIds, plan] = await Promise.all([
|
||||
this.dataSource.getRepository(Wagon).find(),
|
||||
this.dataSource.getRepository(WagonType).find(),
|
||||
this.builtTrainIdOfSchedule(targetScheduleId),
|
||||
this.pinnedPhysicalWagonIdsForSchedule(targetScheduleId),
|
||||
this.plannedWagonYardsOf(targetScheduleId),
|
||||
]);
|
||||
const typeCodeById = new Map(wagonTypes.map((type) => [type.id, type.code]));
|
||||
const counts = new Map<string, { code: string; available: number }>();
|
||||
@@ -5311,11 +5337,14 @@ export class TrainSchedulingService {
|
||||
// A built consist spread across several yards can only offer, at each yard,
|
||||
// the wagons standing there. A single-yard consist keeps the original
|
||||
// behaviour: the whole train counts wherever it currently sits.
|
||||
// Yards are the SCHEDULE's plan (falling back to the physical yard), so a
|
||||
// departure sold from Dire counts its Dire wagons even while they still
|
||||
// stand in Mojo — dispatch is what demands the two agree.
|
||||
const consistYards = builtTrainId
|
||||
? new Set(
|
||||
wagons
|
||||
.filter((w) => w.trainId === builtTrainId && w.currentYardId)
|
||||
.map((w) => w.currentYardId as string),
|
||||
.filter((w) => w.trainId === builtTrainId && scheduleYardOf(plan, w))
|
||||
.map((w) => scheduleYardOf(plan, w) as string),
|
||||
)
|
||||
: new Set<string>();
|
||||
const consistIsSplit = consistYards.size > 1;
|
||||
@@ -5327,7 +5356,7 @@ export class TrainSchedulingService {
|
||||
// counted at the yard each wagon actually stands in.
|
||||
if (builtTrainId) {
|
||||
if (wagon.trainId !== builtTrainId) continue;
|
||||
if (consistIsSplit && wagon.currentYardId !== originYardId) continue;
|
||||
if (consistIsSplit && scheduleYardOf(plan, wagon) !== originYardId) continue;
|
||||
} else {
|
||||
// Schedule-scoped availability: pins held by OTHER schedules never
|
||||
// consume a wagon here — the same physical wagon may serve the July 17
|
||||
@@ -5498,6 +5527,7 @@ export class TrainSchedulingService {
|
||||
|
||||
const pinSchedule = await this.trainSchedulesRepository.findById(scheduleId);
|
||||
const stops = pinSchedule ? await this.stopYardsForSchedule(pinSchedule) : [];
|
||||
const plannedYards = pinSchedule?.plannedWagonYards ?? {};
|
||||
|
||||
const unpinnable = this.findUnpinnableWagonSlots(
|
||||
planSlots,
|
||||
@@ -5507,6 +5537,7 @@ export class TrainSchedulingService {
|
||||
builtTrainId,
|
||||
pinnedToScheduleIds,
|
||||
stops,
|
||||
plannedYards,
|
||||
);
|
||||
if (unpinnable.length) {
|
||||
throw new BadRequestException({
|
||||
@@ -5528,6 +5559,7 @@ export class TrainSchedulingService {
|
||||
builtTrainId,
|
||||
pinnedToScheduleIds,
|
||||
reverseWagonOrder,
|
||||
plannedYards,
|
||||
);
|
||||
if (!physical) continue;
|
||||
|
||||
@@ -5577,6 +5609,7 @@ export class TrainSchedulingService {
|
||||
builtTrainId,
|
||||
pinnedToScheduleIds,
|
||||
stops,
|
||||
targetSchedule?.plannedWagonYards ?? {},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5609,6 +5642,7 @@ export class TrainSchedulingService {
|
||||
builtTrainId: string | null = null,
|
||||
pinnedToScheduleIds: Set<string> = new Set(),
|
||||
stops: string[] = [],
|
||||
plannedYards: PlannedWagonYards = {},
|
||||
): string[] {
|
||||
const violations: string[] = [];
|
||||
// One physical wagon may serve several slots whose leg spans don't overlap
|
||||
@@ -5627,6 +5661,8 @@ export class TrainSchedulingService {
|
||||
span,
|
||||
builtTrainId,
|
||||
pinnedToScheduleIds,
|
||||
false,
|
||||
plannedYards,
|
||||
);
|
||||
if (!physical) {
|
||||
violations.push(
|
||||
@@ -5657,6 +5693,7 @@ export class TrainSchedulingService {
|
||||
builtTrainId: string | null = null,
|
||||
pinnedToScheduleIds: Set<string> = new Set(),
|
||||
reverseWagonOrder = false,
|
||||
plannedYards: PlannedWagonYards = {},
|
||||
): Wagon | undefined {
|
||||
// Free for this slot = no already-assigned span on this wagon overlaps the
|
||||
// slot's own leg. Disjoint legs (alight before board) share the wagon.
|
||||
@@ -5689,8 +5726,8 @@ export class TrainSchedulingService {
|
||||
// takes slot #1). Unsequenced wagons sort after every sequenced one.
|
||||
const consistYards = new Set(
|
||||
wagons
|
||||
.filter((w) => w.trainId === builtTrainId && w.currentYardId)
|
||||
.map((w) => w.currentYardId as string),
|
||||
.filter((w) => w.trainId === builtTrainId && scheduleYardOf(plannedYards, w))
|
||||
.map((w) => scheduleYardOf(plannedYards, w) as string),
|
||||
);
|
||||
// Split consist: a slot boarding at a given yard must take a wagon that
|
||||
// physically stands there — the train cannot load a Mojo wagon at Dire.
|
||||
@@ -5703,7 +5740,7 @@ export class TrainSchedulingService {
|
||||
w.trainId === builtTrainId &&
|
||||
w.wagonTypeId === slot.wagonTypeId &&
|
||||
spanFree(w.id) &&
|
||||
(!requiredYardId || w.currentYardId === requiredYardId),
|
||||
(!requiredYardId || scheduleYardOf(plannedYards, w) === requiredYardId),
|
||||
)
|
||||
.sort((a, b) => {
|
||||
if (a.sequenceNumber == null || b.sequenceNumber == null) {
|
||||
@@ -5843,7 +5880,7 @@ export class TrainSchedulingService {
|
||||
preloadedBuiltTrainId !== undefined
|
||||
? preloadedBuiltTrainId
|
||||
: await this.builtTrainIdOfSchedule(scheduleId);
|
||||
if (builtTrainId) return this.builtTrainStock(builtTrainId);
|
||||
if (builtTrainId) return this.builtTrainStock(builtTrainId, scheduleId);
|
||||
|
||||
const boardYardIds = [
|
||||
...new Set([originYardId, ...boardingYardIds].filter((id): id is string => Boolean(id))),
|
||||
@@ -5865,11 +5902,17 @@ export class TrainSchedulingService {
|
||||
return { mode: 'YARD', remainingByTypeId, codesByTypeId };
|
||||
}
|
||||
|
||||
private async builtTrainStock(builtTrainId: string): Promise<WagonStock> {
|
||||
const wagons = await this.dataSource.getRepository(Wagon).find({
|
||||
where: { trainId: builtTrainId },
|
||||
relations: { wagonType: true },
|
||||
});
|
||||
private async builtTrainStock(
|
||||
builtTrainId: string,
|
||||
scheduleId?: string,
|
||||
): Promise<WagonStock> {
|
||||
const [wagons, plan] = await Promise.all([
|
||||
this.dataSource.getRepository(Wagon).find({
|
||||
where: { trainId: builtTrainId },
|
||||
relations: { wagonType: true },
|
||||
}),
|
||||
this.plannedWagonYardsOf(scheduleId),
|
||||
]);
|
||||
const remainingByTypeId = new Map<string, number>();
|
||||
const codesByTypeId = new Map<string, string>();
|
||||
const byYardId = new Map<string, Map<string, number>>();
|
||||
@@ -5879,10 +5922,12 @@ export class TrainSchedulingService {
|
||||
(remainingByTypeId.get(wagon.wagonTypeId) ?? 0) + 1,
|
||||
);
|
||||
if (wagon.wagonType) codesByTypeId.set(wagon.wagonTypeId, wagon.wagonType.code);
|
||||
if (wagon.currentYardId) {
|
||||
const perType = byYardId.get(wagon.currentYardId) ?? new Map<string, number>();
|
||||
// The schedule's own yard plan, not the physical yard — see plannedWagonYards.
|
||||
const yardId = scheduleYardOf(plan, wagon);
|
||||
if (yardId) {
|
||||
const perType = byYardId.get(yardId) ?? new Map<string, number>();
|
||||
perType.set(wagon.wagonTypeId, (perType.get(wagon.wagonTypeId) ?? 0) + 1);
|
||||
byYardId.set(wagon.currentYardId, perType);
|
||||
byYardId.set(yardId, perType);
|
||||
}
|
||||
}
|
||||
// Single-yard consist (the overwhelming majority): the whole train is
|
||||
@@ -6222,17 +6267,22 @@ export class TrainSchedulingService {
|
||||
}
|
||||
|
||||
/**
|
||||
* A built train's wagons may stand in several yards. The route must pass
|
||||
* through every one of them as origin or an intermediate stop — never only
|
||||
* as the final destination (the train has to pick the wagons up en route).
|
||||
* Default yard plan for a schedule created from a built train: every wagon
|
||||
* keeps the yard it physically stands in when that yard is a pickup stop of
|
||||
* the route (origin or intermediate — never only the destination, the train
|
||||
* has to collect it en route); the rest are planned at the origin and
|
||||
* reported as a warning so staff can redistribute in the schedule-yards tab.
|
||||
*/
|
||||
private async assertRouteCoversWagonYards(train: Train, route: Route) {
|
||||
private async defaultPlannedWagonYardsFor(
|
||||
train: Train,
|
||||
route: Route,
|
||||
warnings: string[],
|
||||
): Promise<PlannedWagonYards | null> {
|
||||
const wagons = await this.dataSource.getRepository(Wagon).find({
|
||||
where: { trainId: train.id },
|
||||
select: { id: true, currentYardId: true },
|
||||
select: { id: true, currentYardId: true, wagonNumber: true },
|
||||
});
|
||||
const wagonYards = [...new Set(wagons.map((w) => w.currentYardId).filter((y): y is string => !!y))];
|
||||
if (!wagonYards.length) return;
|
||||
if (!wagons.length) return null;
|
||||
|
||||
const milestones = await this.dataSource
|
||||
.getRepository(RouteMilestone)
|
||||
@@ -6243,23 +6293,203 @@ export class TrainSchedulingService {
|
||||
// Every stop except the last one is a pickup point.
|
||||
const pickupYards = new Set(stops.slice(0, -1));
|
||||
|
||||
const uncovered = wagonYards.filter((y) => !pickupYards.has(y));
|
||||
if (!uncovered.length) return;
|
||||
const { plan, rehomed } = defaultPlannedWagonYards(wagons, pickupYards, route.originYardId);
|
||||
if (rehomed.length) {
|
||||
const labels = await this.yardLabelMap([
|
||||
...new Set(rehomed.map((w) => w.currentYardId).filter((y): y is string => !!y)),
|
||||
]);
|
||||
const origin = labels.get(route.originYardId) ?? route.originYard?.label ?? 'the origin';
|
||||
const where = [...new Set(rehomed.map((w) => (w.currentYardId ? labels.get(w.currentYardId) ?? w.currentYardId : 'no yard')))].join(', ');
|
||||
warnings.push(
|
||||
`${rehomed.length} wagon(s) of train ${train.code} stand off this route (${where}) and were planned at ${origin}; ` +
|
||||
'they must be moved there before dispatch — adjust in the schedule yards tab if they should board elsewhere',
|
||||
);
|
||||
}
|
||||
return plan;
|
||||
}
|
||||
|
||||
const labels = await this.yardLabelMap(uncovered);
|
||||
const destination = stops[stops.length - 1];
|
||||
const detail = uncovered
|
||||
.map((y) =>
|
||||
y === destination
|
||||
? `${labels.get(y) ?? y} (only as the destination)`
|
||||
: `${labels.get(y) ?? y} (not on route)`,
|
||||
)
|
||||
/** Dispatch gate: every planned wagon must physically stand at its planned yard. */
|
||||
private async assertPlannedYardsAligned(schedule: TrainSchedule) {
|
||||
const builtTrainId = schedule.trainSet?.trainId;
|
||||
if (!builtTrainId) return;
|
||||
const wagons = await this.dataSource.getRepository(Wagon).find({
|
||||
where: { trainId: builtTrainId },
|
||||
select: { id: true, currentYardId: true, wagonNumber: true },
|
||||
});
|
||||
const off = misalignedWagons(schedule.plannedWagonYards, wagons);
|
||||
if (!off.length) return;
|
||||
const plan = schedule.plannedWagonYards ?? {};
|
||||
const labels = await this.yardLabelMap([
|
||||
...new Set(off.flatMap((w) => [plan[w.id], w.currentYardId]).filter((y): y is string => !!y)),
|
||||
]);
|
||||
const detail = off
|
||||
.slice(0, 5)
|
||||
.map((w) => `${w.wagonNumber} (planned ${labels.get(plan[w.id]) ?? plan[w.id]}, at ${w.currentYardId ? labels.get(w.currentYardId) ?? w.currentYardId : 'no yard'})`)
|
||||
.join(', ');
|
||||
throw new BadRequestException(
|
||||
`Route ${formatRouteLabel(route)} does not pass through every yard where train ${train.code}'s wagons stand: ${detail}`,
|
||||
throw new ConflictException(
|
||||
`Cannot dispatch: ${off.length} wagon(s) are not at the yard this schedule planned them for — ${detail}` +
|
||||
(off.length > 5 ? ', …' : '') +
|
||||
'. Move them in the train builder or re-plan them in the schedule yards tab.',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule-yards tab: where THIS departure boards each consist wagon vs
|
||||
* where it physically stands, per stop totals, and which wagons are locked
|
||||
* (already carrying this schedule's cargo).
|
||||
*/
|
||||
async getScheduleWagonYards(scheduleId: string) {
|
||||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||||
if (!schedule) throw new NotFoundException(`Train schedule ${scheduleId} not found`);
|
||||
const builtTrain = schedule.trainSet?.train;
|
||||
if (!builtTrain) {
|
||||
throw new BadRequestException(
|
||||
'This schedule was not created from a built train — it has no wagon yard plan',
|
||||
);
|
||||
}
|
||||
const stops = this.mapScheduleStops(schedule);
|
||||
const pickupYardIds = new Set(stops.slice(0, -1).map((s) => s.yardId));
|
||||
const plan = schedule.plannedWagonYards ?? {};
|
||||
const wagons = await this.dataSource.getRepository(Wagon).find({
|
||||
where: { trainId: builtTrain.id },
|
||||
relations: { wagonType: true, currentYard: true },
|
||||
order: { sequenceNumber: 'ASC' },
|
||||
});
|
||||
const lockedIds = new Set(
|
||||
(schedule.trainSet?.wagons ?? [])
|
||||
.filter((slot) => slot.physicalWagonId && (slot.allocations?.length ?? 0) > 0)
|
||||
.map((slot) => slot.physicalWagonId as string),
|
||||
);
|
||||
const offRouteYardIds = [
|
||||
...new Set(
|
||||
wagons
|
||||
.flatMap((w) => [scheduleYardOf(plan, w), w.currentYardId])
|
||||
.filter((y): y is string => !!y && !stops.some((s) => s.yardId === y)),
|
||||
),
|
||||
];
|
||||
const labels = new Map([
|
||||
...stops.map((s) => [s.yardId, s.label] as const),
|
||||
...(await this.yardLabelMap(offRouteYardIds)),
|
||||
]);
|
||||
const editable =
|
||||
schedule.status === TrainScheduleStatusEnum.Draft ||
|
||||
schedule.status === TrainScheduleStatusEnum.Scheduled;
|
||||
|
||||
const rows = wagons.map((w) => {
|
||||
const plannedYardId = scheduleYardOf(plan, w);
|
||||
const locked = lockedIds.has(w.id);
|
||||
return {
|
||||
id: w.id,
|
||||
wagonNumber: w.wagonNumber,
|
||||
sequenceNumber: w.sequenceNumber,
|
||||
wagonType: w.wagonType
|
||||
? { id: w.wagonType.id, code: w.wagonType.code, name: w.wagonType.name }
|
||||
: { id: w.wagonTypeId, code: w.wagonTypeId, name: w.wagonTypeId },
|
||||
physicalYardId: w.currentYardId,
|
||||
physicalYardLabel: w.currentYardId ? labels.get(w.currentYardId) ?? w.currentYardId : null,
|
||||
plannedYardId,
|
||||
plannedYardLabel: plannedYardId ? labels.get(plannedYardId) ?? plannedYardId : null,
|
||||
aligned: plannedYardId === w.currentYardId,
|
||||
locked,
|
||||
lockReason: locked ? 'Carries cargo booked on this schedule' : null,
|
||||
};
|
||||
});
|
||||
const perStop = stops.map((s) => ({
|
||||
yardId: s.yardId,
|
||||
label: s.label,
|
||||
pickup: pickupYardIds.has(s.yardId),
|
||||
planned: rows.filter((r) => r.plannedYardId === s.yardId).length,
|
||||
physical: rows.filter((r) => r.physicalYardId === s.yardId).length,
|
||||
}));
|
||||
return {
|
||||
scheduleId,
|
||||
train: { id: builtTrain.id, code: builtTrain.code },
|
||||
editable,
|
||||
stops: perStop,
|
||||
wagons: rows,
|
||||
misaligned: rows.filter((r) => !r.aligned).length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-plan which yard this departure boards wagons from. Only DRAFT/SCHEDULED
|
||||
* schedules, only the train's own wagons, only pickup stops of the route,
|
||||
* never a wagon already carrying this schedule's cargo. Physical yards are
|
||||
* untouched — the train builder owns those.
|
||||
*/
|
||||
async updateScheduleWagonYards(
|
||||
scheduleId: string,
|
||||
moves: Array<{ wagonId: string; yardId: string }>,
|
||||
) {
|
||||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||||
if (!schedule) throw new NotFoundException(`Train schedule ${scheduleId} not found`);
|
||||
const builtTrain = schedule.trainSet?.train;
|
||||
if (!builtTrain) {
|
||||
throw new BadRequestException(
|
||||
'This schedule was not created from a built train — it has no wagon yard plan',
|
||||
);
|
||||
}
|
||||
if (
|
||||
schedule.status !== TrainScheduleStatusEnum.Draft &&
|
||||
schedule.status !== TrainScheduleStatusEnum.Scheduled
|
||||
) {
|
||||
throw new ConflictException(
|
||||
`Wagon yards can only be re-planned before departure (schedule is ${schedule.status})`,
|
||||
);
|
||||
}
|
||||
const stops = this.mapScheduleStops(schedule);
|
||||
const pickupYardIds = new Set(stops.slice(0, -1).map((s) => s.yardId));
|
||||
const wagons = await this.dataSource.getRepository(Wagon).find({
|
||||
where: { trainId: builtTrain.id },
|
||||
select: { id: true, currentYardId: true, wagonNumber: true },
|
||||
});
|
||||
const wagonById = new Map(wagons.map((w) => [w.id, w]));
|
||||
const lockedIds = new Set(
|
||||
(schedule.trainSet?.wagons ?? [])
|
||||
.filter((slot) => slot.physicalWagonId && (slot.allocations?.length ?? 0) > 0)
|
||||
.map((slot) => slot.physicalWagonId as string),
|
||||
);
|
||||
|
||||
const plan: PlannedWagonYards = { ...(schedule.plannedWagonYards ?? {}) };
|
||||
for (const move of moves) {
|
||||
const wagon = wagonById.get(move.wagonId);
|
||||
if (!wagon) {
|
||||
throw new BadRequestException(`Wagon ${move.wagonId} is not coupled to train ${builtTrain.code}`);
|
||||
}
|
||||
if (!pickupYardIds.has(move.yardId)) {
|
||||
throw new BadRequestException(
|
||||
`Yard ${move.yardId} is not a pickup stop of this schedule's route`,
|
||||
);
|
||||
}
|
||||
if (lockedIds.has(wagon.id) && scheduleYardOf(plan, wagon) !== move.yardId) {
|
||||
throw new ConflictException(
|
||||
`Wagon ${wagon.wagonNumber} already carries cargo booked on this schedule and cannot change yard`,
|
||||
);
|
||||
}
|
||||
plan[wagon.id] = move.yardId;
|
||||
}
|
||||
await this.dataSource
|
||||
.getRepository(TrainSchedule)
|
||||
.update(scheduleId, { plannedWagonYards: plan });
|
||||
|
||||
// ponytail: per-stop over-booking check counts bookings boarding at the
|
||||
// stop against wagons planned there, ignoring leg sharing — a warning, not
|
||||
// a gate; switch to CorridorBudget per stop if staff need exact numbers.
|
||||
const warnings: string[] = [];
|
||||
for (const stop of stops.slice(0, -1)) {
|
||||
const planned = wagons.filter((w) => scheduleYardOf(plan, w) === stop.yardId).length;
|
||||
const booked = (schedule.scheduleBookings ?? [])
|
||||
.filter((sb) => sb.booking?.originYardId === stop.yardId)
|
||||
.reduce((sum, sb) => sum + (sb.booking ? this.effectiveWagonsRequired(sb.booking) : 0), 0);
|
||||
if (booked > planned) {
|
||||
warnings.push(
|
||||
`${stop.label}: bookings boarding here need ${booked} wagon(s) but only ${planned} are planned at this yard`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return { ...(await this.getScheduleWagonYards(scheduleId)), warnings };
|
||||
}
|
||||
|
||||
private async getSchedulableRoute(routeId: string) {
|
||||
const route = await this.dataSource.getRepository(Route).findOne({
|
||||
where: { id: routeId },
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -53,7 +53,7 @@ export const bookingInput = {
|
||||
|
||||
export const bookingTable = {
|
||||
headerCell:
|
||||
"h-11 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground",
|
||||
"whitespace-nowrap text-[10px] font-semibold uppercase tracking-[0.08em] text-edr-muted",
|
||||
rowHover:
|
||||
"transition-colors hover:bg-muted/25 data-[state=selected]:bg-muted/30",
|
||||
rowIcon: `flex size-10 shrink-0 items-center justify-center rounded-xl ${bookingGlass.iconWellGreen}`,
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
ActionIcon,
|
||||
Alert,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Collapse,
|
||||
FileButton,
|
||||
Group,
|
||||
Loader,
|
||||
@@ -20,6 +22,7 @@ import {
|
||||
import {
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
ChevronDown,
|
||||
Download,
|
||||
Eye,
|
||||
FileCheck2,
|
||||
@@ -187,73 +190,102 @@ export function ClearanceReviewSection({
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<SectionCard
|
||||
icon={FileText}
|
||||
title="Customer documents"
|
||||
subtitle="Approve each document, or open a query to tell the customer what to fix."
|
||||
extra={
|
||||
<Text size="xs" c="dimmed" fw={600}>
|
||||
{stats.approved}/{stats.total} approved
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
<Stack gap={12}>
|
||||
{!hideSummary && stats.total > 0 && (
|
||||
<Box>
|
||||
<Progress
|
||||
value={stats.pct}
|
||||
color="edr-green"
|
||||
radius="xl"
|
||||
size="sm"
|
||||
mb={6}
|
||||
/>
|
||||
<Group gap="lg">
|
||||
<StatPill color="edr-green" label="Approved" value={stats.approved} />
|
||||
<StatPill color="red" label="Queried" value={stats.queried} />
|
||||
<StatPill color="gray" label="Pending" value={stats.pending} />
|
||||
</Group>
|
||||
<Paper radius={13} withBorder style={{ overflow: "hidden" }} p={0}>
|
||||
<Group
|
||||
justify="space-between"
|
||||
wrap="nowrap"
|
||||
px={18}
|
||||
py={15}
|
||||
style={{ borderBottom: "1px solid #EFF3F7" }}
|
||||
>
|
||||
<Group gap={9} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<FileText size={16} color="#0A8A5F" />
|
||||
<Box style={{ minWidth: 0 }}>
|
||||
<Text fz={14} fw={700} c="edr-text">
|
||||
Customer documents
|
||||
</Text>
|
||||
<Text fz={11.5} c="#93A4B5" truncate>
|
||||
{stats.approved} of {stats.total} approved
|
||||
{stats.queried > 0 ? ` · ${stats.queried} queried` : ""} · required
|
||||
marked *
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
{customerDocs.length === 0 ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
No customer documents are required for this booking.
|
||||
</Group>
|
||||
<Group
|
||||
gap={5}
|
||||
wrap="nowrap"
|
||||
px={8}
|
||||
py={3}
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
borderRadius: 6,
|
||||
background: approvalsLocked ? "#F4F7FA" : "#E7F5EF",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
style={{
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: 999,
|
||||
background: approvalsLocked ? "#93A4B5" : "#0A8A5F",
|
||||
}}
|
||||
/>
|
||||
<Text
|
||||
fz={10.5}
|
||||
fw={700}
|
||||
style={{ color: approvalsLocked ? "#67788A" : "#0A8A5F" }}
|
||||
>
|
||||
{approvalsLocked ? "Uploads closed" : "Uploads open"}
|
||||
</Text>
|
||||
) : (
|
||||
customerDocs.map((doc) => (
|
||||
<DocReviewCard
|
||||
key={`${doc.settingCode}:${doc.fileKey}`}
|
||||
doc={doc}
|
||||
approvalsLocked={effectiveApprovalsLocked}
|
||||
queriesLocked={queriesLocked}
|
||||
readOnly={readOnly}
|
||||
note={queryNotes[doc.fileKey] ?? ""}
|
||||
queryOpen={openQuery[doc.fileKey] ?? false}
|
||||
onToggleQuery={(open) =>
|
||||
setOpenQuery((o) => ({ ...o, [doc.fileKey]: open }))
|
||||
}
|
||||
onNote={(v) =>
|
||||
setQueryNotes((n) => ({ ...n, [doc.fileKey]: v }))
|
||||
}
|
||||
onApprove={() =>
|
||||
reviewMutation.mutate({
|
||||
fileKey: doc.fileKey,
|
||||
status: "APPROVED",
|
||||
})
|
||||
}
|
||||
onQuery={() =>
|
||||
reviewMutation.mutate({
|
||||
fileKey: doc.fileKey,
|
||||
status: "QUERIED",
|
||||
note: queryNotes[doc.fileKey],
|
||||
})
|
||||
}
|
||||
onView={view}
|
||||
busy={reviewMutation.isPending}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</Stack>
|
||||
</SectionCard>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{!hideSummary && stats.total > 0 && (
|
||||
<Box px={18} py={12} style={{ borderBottom: "1px solid #EFF3F7" }}>
|
||||
<Progress value={stats.pct} color="edr-green" radius="xl" size="sm" mb={8} />
|
||||
<Group gap="lg">
|
||||
<StatPill color="edr-green" label="Approved" value={stats.approved} />
|
||||
<StatPill color="red" label="Queried" value={stats.queried} />
|
||||
<StatPill color="gray" label="Pending" value={stats.pending} />
|
||||
</Group>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{customerDocs.length === 0 ? (
|
||||
<Text size="sm" c="dimmed" px={18} py={20}>
|
||||
No customer documents are required for this booking.
|
||||
</Text>
|
||||
) : (
|
||||
customerDocs.map((doc, i) => (
|
||||
<DocReviewCard
|
||||
key={`${doc.settingCode}:${doc.fileKey}`}
|
||||
doc={doc}
|
||||
first={i === 0}
|
||||
approvalsLocked={effectiveApprovalsLocked}
|
||||
queriesLocked={queriesLocked}
|
||||
readOnly={readOnly}
|
||||
note={queryNotes[doc.fileKey] ?? ""}
|
||||
queryOpen={openQuery[doc.fileKey] ?? false}
|
||||
onToggleQuery={(open) =>
|
||||
setOpenQuery((o) => ({ ...o, [doc.fileKey]: open }))
|
||||
}
|
||||
onNote={(v) => setQueryNotes((n) => ({ ...n, [doc.fileKey]: v }))}
|
||||
onApprove={() =>
|
||||
reviewMutation.mutate({ fileKey: doc.fileKey, status: "APPROVED" })
|
||||
}
|
||||
onQuery={() =>
|
||||
reviewMutation.mutate({
|
||||
fileKey: doc.fileKey,
|
||||
status: "QUERIED",
|
||||
note: queryNotes[doc.fileKey],
|
||||
})
|
||||
}
|
||||
onView={view}
|
||||
busy={reviewMutation.isPending}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
{clearance.outputCode && !phasedCustoms && (
|
||||
<SectionCard
|
||||
@@ -559,6 +591,23 @@ function StatPill({
|
||||
);
|
||||
}
|
||||
|
||||
/** Row tints straight from the design tokens. */
|
||||
const ROW_TONE: Record<
|
||||
Freight.DocumentReviewStatus,
|
||||
{ bg: string; chipBg: string; fg: string }
|
||||
> = {
|
||||
APPROVED: { bg: "#FFFFFF", chipBg: "#E7F5EF", fg: "#0A8A5F" },
|
||||
QUERIED: { bg: "#FBECEA", chipBg: "#FBECEA", fg: "#C0392B" },
|
||||
PENDING: { bg: "#FFFFFF", chipBg: "#FCF2E2", fg: "#A76F08" },
|
||||
};
|
||||
|
||||
/**
|
||||
* One document as a compact 60px row that expands in place. Collapsed it shows
|
||||
* name, file line, status chip and the review actions; expanded it reveals the
|
||||
* per-document history timeline and the query note. Keeping the actions in the
|
||||
* collapsed row means approving a stack of documents never needs a single
|
||||
* expand.
|
||||
*/
|
||||
function DocReviewCard({
|
||||
doc,
|
||||
approvalsLocked,
|
||||
@@ -572,6 +621,7 @@ function DocReviewCard({
|
||||
onQuery,
|
||||
onView,
|
||||
busy,
|
||||
first,
|
||||
}: {
|
||||
doc: Freight.ClearanceDocument;
|
||||
approvalsLocked: boolean;
|
||||
@@ -585,146 +635,177 @@ function DocReviewCard({
|
||||
onQuery: () => void;
|
||||
onView: (file: { name: string; url: string }) => void;
|
||||
busy: boolean;
|
||||
first: boolean;
|
||||
}) {
|
||||
const status = doc.reviewStatus ?? "PENDING";
|
||||
const meta = STATUS_META[status];
|
||||
const tone = ROW_TONE[status];
|
||||
const hasFile = !!doc.file;
|
||||
const isApproved = status === "APPROVED";
|
||||
const history = doc.history ?? [];
|
||||
// A queried document is the one the reviewer must act on, so it opens itself.
|
||||
const [open, setOpen] = useState(status === "QUERIED");
|
||||
const expandable = history.length > 0 || Boolean(doc.note);
|
||||
// Opening the query form has to reveal the body it lives in.
|
||||
const bodyOpen = open || queryOpen;
|
||||
|
||||
// The file line carries the same at-a-glance summary as the design: file
|
||||
// name, who decided, when.
|
||||
const last = history[history.length - 1];
|
||||
const fileLine = hasFile
|
||||
? [
|
||||
doc.file!.name,
|
||||
status === "APPROVED" && last ? `Approved by ${last.byName ?? "staff"}` : null,
|
||||
status === "PENDING" ? "awaiting review" : null,
|
||||
status === "QUERIED" ? doc.note : null,
|
||||
last ? formatDateTime(last.at) : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ")
|
||||
: "Not uploaded by customer";
|
||||
|
||||
return (
|
||||
<Paper
|
||||
withBorder
|
||||
radius="md"
|
||||
p="md"
|
||||
<Box
|
||||
style={{
|
||||
borderColor:
|
||||
status === "QUERIED"
|
||||
? "var(--mantine-color-red-2)"
|
||||
: status === "APPROVED"
|
||||
? "var(--mantine-color-edr-green-2)"
|
||||
: "var(--mantine-color-edr-border-6)",
|
||||
background: tone.bg,
|
||||
borderTop: first ? undefined : "1px solid #EFF3F7",
|
||||
}}
|
||||
>
|
||||
<Group justify="space-between" align="flex-start" wrap="nowrap">
|
||||
<Group gap={12} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<ThemeIcon
|
||||
variant="light"
|
||||
color={hasFile ? "edr-green" : "gray"}
|
||||
radius="md"
|
||||
size={40}
|
||||
>
|
||||
<FileText size={19} />
|
||||
</ThemeIcon>
|
||||
<Box style={{ minWidth: 0 }}>
|
||||
<Text fz="14px" fw={700} c="edr-text" truncate>
|
||||
{doc.label}
|
||||
{doc.required ? " *" : ""}
|
||||
</Text>
|
||||
<Text fz="12px" c="edr-muted" truncate>
|
||||
{hasFile ? doc.file!.name : "Not uploaded by customer"}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
<Group gap={12} wrap="nowrap" align="center" px={18} py={13}>
|
||||
<Box
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexShrink: 0,
|
||||
width: 34,
|
||||
height: 34,
|
||||
borderRadius: 9,
|
||||
background: tone.chipBg,
|
||||
color: tone.fg,
|
||||
}}
|
||||
>
|
||||
<FileText size={16} />
|
||||
</Box>
|
||||
|
||||
<Group gap={8} wrap="nowrap">
|
||||
<Badge variant="light" color={meta.color} radius="sm">
|
||||
{meta.label}
|
||||
</Badge>
|
||||
{hasFile &&
|
||||
isViewable({
|
||||
name: doc.file!.name,
|
||||
url: "",
|
||||
}) && (
|
||||
<Tooltip label="Preview document">
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="default"
|
||||
radius="md"
|
||||
leftSection={<Eye size={13} />}
|
||||
onClick={() =>
|
||||
void fetchViewableFile(doc.file!.id, doc.file!.name).then(
|
||||
onView,
|
||||
)
|
||||
}
|
||||
>
|
||||
View
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Box style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text fz={12.5} fw={600} c="edr-text" truncate>
|
||||
{doc.label}
|
||||
{doc.required ? " *" : ""}
|
||||
</Text>
|
||||
<Text fz={11} c="#93A4B5" truncate>
|
||||
{fileLine}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Badge
|
||||
variant="light"
|
||||
radius="xl"
|
||||
color={meta.color}
|
||||
styles={{ root: { flexShrink: 0 } }}
|
||||
>
|
||||
{meta.label}
|
||||
</Badge>
|
||||
|
||||
<Group gap={6} wrap="nowrap" style={{ flexShrink: 0 }}>
|
||||
{hasFile && !readOnly && !isApproved && !approvalsLocked && (
|
||||
<Button
|
||||
size="compact-sm"
|
||||
radius={7}
|
||||
color="edr-green"
|
||||
leftSection={<CheckCircle2 size={12} />}
|
||||
disabled={busy}
|
||||
onClick={onApprove}
|
||||
>
|
||||
Approve
|
||||
</Button>
|
||||
)}
|
||||
{hasFile && !readOnly && !queriesLocked && !queryOpen && (
|
||||
<Button
|
||||
size="compact-sm"
|
||||
radius={7}
|
||||
variant="default"
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
onToggleQuery(true);
|
||||
setOpen(true);
|
||||
}}
|
||||
>
|
||||
Query
|
||||
</Button>
|
||||
)}
|
||||
{hasFile && isViewable({ name: doc.file!.name, url: "" }) && (
|
||||
<Tooltip label="Preview document">
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
radius={7}
|
||||
size={29}
|
||||
onClick={() =>
|
||||
void fetchViewableFile(doc.file!.id, doc.file!.name).then(onView)
|
||||
}
|
||||
>
|
||||
<Eye size={13} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
{hasFile && (
|
||||
<Tooltip label="Download">
|
||||
<Box
|
||||
component="button"
|
||||
type="button"
|
||||
onClick={() =>
|
||||
void downloadBookingFile(doc.file!.id, doc.file!.name)
|
||||
}
|
||||
c="edr-green"
|
||||
style={{
|
||||
display: "flex",
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
radius={7}
|
||||
size={29}
|
||||
onClick={() => void downloadBookingFile(doc.file!.id, doc.file!.name)}
|
||||
>
|
||||
<Download size={15} />
|
||||
</Box>
|
||||
<Download size={13} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
{expandable && (
|
||||
<Tooltip label={bodyOpen ? "Hide history" : "Show history"}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
radius={7}
|
||||
size={29}
|
||||
aria-expanded={bodyOpen}
|
||||
aria-label={bodyOpen ? "Hide history" : "Show history"}
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
>
|
||||
<ChevronDown
|
||||
size={14}
|
||||
style={{
|
||||
transition: "transform 150ms",
|
||||
transform: bodyOpen ? "rotate(180deg)" : undefined,
|
||||
}}
|
||||
/>
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{(doc.history?.length ?? 0) > 0 && (
|
||||
<DocHistoryTimeline history={doc.history!} />
|
||||
)}
|
||||
<Collapse expanded={bodyOpen}>
|
||||
<Box px={18} pb={14} pl={64}>
|
||||
{status === "QUERIED" && doc.note ? (
|
||||
<Alert
|
||||
color="red"
|
||||
variant="light"
|
||||
radius="md"
|
||||
icon={<MessageSquareWarning size={15} />}
|
||||
p="xs"
|
||||
mb="sm"
|
||||
>
|
||||
<Text fz={12.5} c="red.9">
|
||||
{doc.note}
|
||||
</Text>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{status === "QUERIED" && doc.note && (
|
||||
<Alert
|
||||
mt="sm"
|
||||
color="red"
|
||||
variant="light"
|
||||
radius="md"
|
||||
icon={<MessageSquareWarning size={15} />}
|
||||
p="xs"
|
||||
>
|
||||
<Text fz="12.5px" c="red.9">
|
||||
{doc.note}
|
||||
</Text>
|
||||
</Alert>
|
||||
)}
|
||||
{history.length > 0 ? <DocHistoryTimeline history={history} /> : null}
|
||||
|
||||
{hasFile && !readOnly && (
|
||||
<Box mt="sm">
|
||||
{!queryOpen ? (
|
||||
<Group justify="flex-end" gap={8}>
|
||||
{!queriesLocked && (
|
||||
<Button
|
||||
size="compact-sm"
|
||||
variant="light"
|
||||
color="red"
|
||||
radius="md"
|
||||
leftSection={<MessageSquareWarning size={14} />}
|
||||
disabled={busy}
|
||||
onClick={() => onToggleQuery(true)}
|
||||
>
|
||||
Open query
|
||||
</Button>
|
||||
)}
|
||||
{!isApproved && !approvalsLocked && (
|
||||
<Button
|
||||
size="compact-sm"
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
leftSection={<CheckCircle2 size={14} />}
|
||||
disabled={busy}
|
||||
onClick={onApprove}
|
||||
>
|
||||
Approve
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
) : (
|
||||
{queryOpen && !readOnly ? (
|
||||
<Box
|
||||
mt="sm"
|
||||
p="sm"
|
||||
style={{
|
||||
borderRadius: 12,
|
||||
@@ -733,11 +814,8 @@ function DocReviewCard({
|
||||
}}
|
||||
>
|
||||
<Group gap={6} mb={6}>
|
||||
<MessageSquareWarning
|
||||
size={14}
|
||||
color="var(--mantine-color-red-7)"
|
||||
/>
|
||||
<Text fz="12.5px" fw={700} c="red.8">
|
||||
<MessageSquareWarning size={14} color="var(--mantine-color-red-7)" />
|
||||
<Text fz={12.5} fw={700} c="red.8">
|
||||
Describe the problem for the customer
|
||||
</Text>
|
||||
</Group>
|
||||
@@ -775,9 +853,9 @@ function DocReviewCard({
|
||||
</Button>
|
||||
</Group>
|
||||
</Box>
|
||||
)}
|
||||
) : null}
|
||||
</Box>
|
||||
)}
|
||||
</Paper>
|
||||
</Collapse>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
@@ -12,6 +13,7 @@ import {
|
||||
Select,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
@@ -19,9 +21,11 @@ import {
|
||||
Download,
|
||||
Eye,
|
||||
FileText,
|
||||
Lock,
|
||||
Receipt,
|
||||
Send,
|
||||
Upload,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import toast from "react-hot-toast";
|
||||
import type { Freight } from "@edr/types";
|
||||
@@ -42,11 +46,19 @@ const STATUS_META: Record<
|
||||
{ label: string; color: string }
|
||||
> = {
|
||||
DOC_UPLOADED: { label: "Awaiting billing", color: "yellow" },
|
||||
BILLED: { label: "Ready to send", color: "blue" },
|
||||
SENT: { label: "Sent — unpaid", color: "orange" },
|
||||
BILLED: { label: "Draft — not sent", color: "blue" },
|
||||
SENT: { label: "Awaiting customer approval", color: "orange" },
|
||||
REJECTED: { label: "Rejected by customer", color: "red" },
|
||||
ACCEPTED: { label: "Accepted — invoice unpaid", color: "teal" },
|
||||
PAID: { label: "Paid", color: "edr-green" },
|
||||
};
|
||||
|
||||
/** Once the customer accepts, the invoice exists and GL can no longer edit. */
|
||||
const isLocked = (s: Freight.ClearanceChargeStatus) =>
|
||||
s === "ACCEPTED" || s === "PAID";
|
||||
|
||||
type BillInput = { amount: number; currency: string; description: string };
|
||||
|
||||
export interface ClearanceChargesTabProps {
|
||||
bookingId: string;
|
||||
/** DJ uploads the port document; ET bills, sends and creates miscellaneous. */
|
||||
@@ -55,11 +67,12 @@ export interface ClearanceChargesTabProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* Post-finalization charges billed to the customer, two levels: port charges
|
||||
* (document from GL Djibouti, billed by GL Ethiopia) then miscellaneous
|
||||
* (created whole by GL Ethiopia once the port charge is paid). Each level
|
||||
* issues its own payable invoice — ETB settles through the portal gateway
|
||||
* (CBE), other currencies through Finance's manual settlement.
|
||||
* Post-finalization charges billed to the customer: port charges (document
|
||||
* from GL Djibouti, priced by GL Ethiopia) and any number of miscellaneous
|
||||
* charges. GL prices + describes a charge and sends it; the customer accepts
|
||||
* (invoice issued, charge locked) or rejects with a note (GL revises and
|
||||
* re-sends). ETB settles through the portal gateway (CBE), other currencies
|
||||
* through Finance's manual settlement.
|
||||
*/
|
||||
export function ClearanceChargesTab({
|
||||
bookingId,
|
||||
@@ -89,8 +102,9 @@ export function ClearanceChargesTab({
|
||||
onError,
|
||||
});
|
||||
const bill = useMutation({
|
||||
mutationFn: (p: { chargeId: string; amount: number; currency: string }) =>
|
||||
bookingsService.billClearanceCharge(bookingId, p.chargeId, p),
|
||||
// Body must be exactly the DTO — the API rejects unknown keys like chargeId.
|
||||
mutationFn: ({ chargeId, ...payload }: BillInput & { chargeId: string }) =>
|
||||
bookingsService.billClearanceCharge(bookingId, chargeId, payload),
|
||||
onSuccess: (next) => {
|
||||
toast.success("Charge amount saved");
|
||||
refresh(next);
|
||||
@@ -101,14 +115,14 @@ export function ClearanceChargesTab({
|
||||
mutationFn: (chargeId: string) =>
|
||||
bookingsService.sendClearanceCharge(bookingId, chargeId),
|
||||
onSuccess: (next) => {
|
||||
toast.success("Invoice sent to the customer");
|
||||
toast.success("Sent to the customer for approval");
|
||||
refresh(next);
|
||||
},
|
||||
onError,
|
||||
});
|
||||
const createMisc = useMutation({
|
||||
mutationFn: (p: { file: File; amount: number; currency: string }) =>
|
||||
bookingsService.createMiscellaneousCharge(bookingId, p.file, p),
|
||||
mutationFn: ({ file, ...payload }: BillInput & { file: File }) =>
|
||||
bookingsService.createMiscellaneousCharge(bookingId, file, payload),
|
||||
onSuccess: (next) => {
|
||||
toast.success("Miscellaneous charge created");
|
||||
// Remount the form so the next charge starts from an empty one.
|
||||
@@ -153,9 +167,7 @@ export function ClearanceChargesTab({
|
||||
: "Waiting for GL Djibouti to upload the port-charges document."
|
||||
}
|
||||
onViewFile={onViewFile}
|
||||
onBill={(amount, currency) =>
|
||||
port && bill.mutate({ chargeId: port.id, amount, currency })
|
||||
}
|
||||
onBill={(input) => port && bill.mutate({ chargeId: port.id, ...input })}
|
||||
onSend={() => port && send.mutate(port.id)}
|
||||
djUpload={
|
||||
roleMode === "DJ" && (!port || port.status === "DOC_UPLOADED") ? (
|
||||
@@ -196,9 +208,7 @@ export function ClearanceChargesTab({
|
||||
busy={busy}
|
||||
emptyHint=""
|
||||
onViewFile={onViewFile}
|
||||
onBill={(amount, currency) =>
|
||||
bill.mutate({ chargeId: c.id, amount, currency })
|
||||
}
|
||||
onBill={(input) => bill.mutate({ chargeId: c.id, ...input })}
|
||||
onSend={() => send.mutate(c.id)}
|
||||
/>
|
||||
))}
|
||||
@@ -211,15 +221,13 @@ export function ClearanceChargesTab({
|
||||
: "Add a miscellaneous charge"}
|
||||
</Text>
|
||||
<Text fz="12px" c="dimmed" mb="sm">
|
||||
Upload the supporting document and set the amount. You can raise as
|
||||
many as the shipment needs, before or after the port charge.
|
||||
Upload the supporting document, set the amount and say what it is
|
||||
for. The customer sees it once you send it for approval.
|
||||
</Text>
|
||||
<MiscCreateForm
|
||||
key={miscCreated}
|
||||
busy={createMisc.isPending}
|
||||
onCreate={(file, amount, currency) =>
|
||||
createMisc.mutate({ file, amount, currency })
|
||||
}
|
||||
onCreate={(file, input) => createMisc.mutate({ file, ...input })}
|
||||
/>
|
||||
</Paper>
|
||||
)}
|
||||
@@ -265,7 +273,7 @@ function ChargeCard({
|
||||
busy: boolean;
|
||||
emptyHint: string;
|
||||
onViewFile: (file: { name: string; url: string }) => void;
|
||||
onBill: (amount: number, currency: string) => void;
|
||||
onBill: (input: BillInput) => void;
|
||||
onSend: () => void;
|
||||
djUpload?: React.ReactNode;
|
||||
etCreate?: React.ReactNode;
|
||||
@@ -273,13 +281,17 @@ function ChargeCard({
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [amount, setAmount] = useState<number | string>(charge?.amount ?? "");
|
||||
const [currency, setCurrency] = useState<string>(charge?.currency ?? "ETB");
|
||||
const [description, setDescription] = useState(charge?.description ?? "");
|
||||
|
||||
const status = charge?.status ?? null;
|
||||
const meta = status ? STATUS_META[status] : null;
|
||||
// ET enters/revises the amount while the charge is unpaid.
|
||||
const locked = status != null && isLocked(status);
|
||||
const needsDescription = charge?.type === "MISCELLANEOUS";
|
||||
// ET enters/revises the price until the customer accepts it.
|
||||
const showBillForm =
|
||||
roleMode === "ET" &&
|
||||
charge != null &&
|
||||
!locked &&
|
||||
(charge.status === "DOC_UPLOADED" || editing);
|
||||
|
||||
return (
|
||||
@@ -304,6 +316,12 @@ function ChargeCard({
|
||||
{formatDateTime(charge.billedAt)}
|
||||
</Text>
|
||||
)}
|
||||
{charge?.status === "ACCEPTED" && charge.customerDecidedAt && (
|
||||
<Text fz="11.5px" c="teal.8" fw={600}>
|
||||
Accepted by the customer · {formatDateTime(charge.customerDecidedAt)}
|
||||
{charge.invoiceNumber ? ` (invoice ${charge.invoiceNumber})` : ""}
|
||||
</Text>
|
||||
)}
|
||||
{charge?.paidAt && (
|
||||
<Text fz="11.5px" c="edr-green.8" fw={600}>
|
||||
Paid · {formatDateTime(charge.paidAt)}
|
||||
@@ -379,6 +397,32 @@ function ChargeCard({
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{charge?.description && !showBillForm && (
|
||||
<Text fz="12.5px" c="edr-text" mt="xs">
|
||||
{charge.description}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{charge?.status === "REJECTED" && charge.customerNote && (
|
||||
<Alert
|
||||
color="red"
|
||||
variant="light"
|
||||
radius="md"
|
||||
p="xs"
|
||||
mt="sm"
|
||||
icon={<XCircle size={16} />}
|
||||
title="Rejected by the customer"
|
||||
>
|
||||
<Text fz="12.5px">{charge.customerNote}</Text>
|
||||
{charge.customerDecidedAt && (
|
||||
<Text fz="11px" c="dimmed" mt={4}>
|
||||
{formatDateTime(charge.customerDecidedAt)} — fix the price or
|
||||
description and send it again.
|
||||
</Text>
|
||||
)}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{!charge && (
|
||||
<Text fz="12.5px" c="dimmed" mt="xs">
|
||||
{emptyHint}
|
||||
@@ -389,6 +433,15 @@ function ChargeCard({
|
||||
|
||||
{showBillForm && (
|
||||
<Group mt="sm" gap={8} align="flex-end" wrap="wrap">
|
||||
<TextInput
|
||||
label={needsDescription ? "What is this charge for?" : "Description (optional)"}
|
||||
size="xs"
|
||||
radius="md"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.currentTarget.value)}
|
||||
maxLength={1000}
|
||||
w={320}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Amount"
|
||||
size="xs"
|
||||
@@ -412,13 +465,21 @@ function ChargeCard({
|
||||
size="compact-sm"
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
disabled={busy || !(Number(amount) > 0)}
|
||||
disabled={
|
||||
busy ||
|
||||
!(Number(amount) > 0) ||
|
||||
(needsDescription && !description.trim())
|
||||
}
|
||||
onClick={() => {
|
||||
onBill(Number(amount), currency);
|
||||
onBill({
|
||||
amount: Number(amount),
|
||||
currency,
|
||||
description: description.trim(),
|
||||
});
|
||||
setEditing(false);
|
||||
}}
|
||||
>
|
||||
Save amount
|
||||
Save
|
||||
</Button>
|
||||
{editing && (
|
||||
<Button
|
||||
@@ -435,7 +496,7 @@ function ChargeCard({
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{roleMode === "ET" && charge && !showBillForm && charge.status !== "PAID" && (
|
||||
{roleMode === "ET" && charge && !showBillForm && !locked && (
|
||||
<Group mt="sm" gap={8} justify="flex-end">
|
||||
<Button
|
||||
size="compact-sm"
|
||||
@@ -446,13 +507,14 @@ function ChargeCard({
|
||||
onClick={() => {
|
||||
setAmount(charge.amount ?? "");
|
||||
setCurrency(charge.currency ?? "ETB");
|
||||
setDescription(charge.description ?? "");
|
||||
setEditing(true);
|
||||
}}
|
||||
>
|
||||
{charge.status === "SENT" ? "Revise (cancels invoice)" : "Edit amount"}
|
||||
{charge.status === "SENT" ? "Revise" : "Edit"}
|
||||
</Button>
|
||||
{charge.status === "BILLED" && (
|
||||
<Tooltip label="ETB is payable online via CBE; other currencies go to Finance's manual settlement.">
|
||||
{(charge.status === "BILLED" || charge.status === "REJECTED") && (
|
||||
<Tooltip label="The customer accepts or rejects the price in the portal; the invoice is issued when they accept.">
|
||||
<Button
|
||||
size="compact-sm"
|
||||
color="edr-green"
|
||||
@@ -461,15 +523,20 @@ function ChargeCard({
|
||||
disabled={busy}
|
||||
onClick={onSend}
|
||||
>
|
||||
Send invoice to customer
|
||||
{charge.status === "REJECTED"
|
||||
? "Send again for approval"
|
||||
: "Send to customer for approval"}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{charge.status === "SENT" && charge.invoiceNumber && (
|
||||
<Badge variant="light" color="orange" radius="sm">
|
||||
Invoice {charge.invoiceNumber}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
)}
|
||||
{charge?.status === "ACCEPTED" && (
|
||||
<Group mt="sm" gap={6} justify="flex-end">
|
||||
<Lock size={14} color="var(--mantine-color-teal-7)" />
|
||||
<Text fz="12px" c="teal.8" fw={600}>
|
||||
Locked — invoice {charge.invoiceNumber ?? ""} awaiting payment
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
{charge?.status === "PAID" && (
|
||||
@@ -489,14 +556,25 @@ function MiscCreateForm({
|
||||
onCreate,
|
||||
}: {
|
||||
busy: boolean;
|
||||
onCreate: (file: File, amount: number, currency: string) => void;
|
||||
onCreate: (file: File, input: BillInput) => void;
|
||||
}) {
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [amount, setAmount] = useState<number | string>("");
|
||||
const [currency, setCurrency] = useState("ETB");
|
||||
const [description, setDescription] = useState("");
|
||||
|
||||
return (
|
||||
<Group gap={8} align="flex-end" wrap="wrap">
|
||||
<TextInput
|
||||
label="What is this charge for?"
|
||||
placeholder="e.g. Container cleaning and weighbridge fee"
|
||||
size="xs"
|
||||
radius="md"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.currentTarget.value)}
|
||||
maxLength={1000}
|
||||
w={320}
|
||||
/>
|
||||
<FileButton onChange={setFile} accept="application/pdf,image/*" disabled={busy}>
|
||||
{(props) => (
|
||||
<Button
|
||||
@@ -534,9 +612,16 @@ function MiscCreateForm({
|
||||
size="compact-sm"
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
disabled={busy || !file || !(Number(amount) > 0)}
|
||||
disabled={busy || !file || !(Number(amount) > 0) || !description.trim()}
|
||||
loading={busy}
|
||||
onClick={() => file && onCreate(file, Number(amount), currency)}
|
||||
onClick={() =>
|
||||
file &&
|
||||
onCreate(file, {
|
||||
amount: Number(amount),
|
||||
currency,
|
||||
description: description.trim(),
|
||||
})
|
||||
}
|
||||
>
|
||||
Create charge
|
||||
</Button>
|
||||
|
||||
@@ -2,7 +2,11 @@ import { Check } from "lucide-react";
|
||||
import { Box, Group, Stack, Text } from "@mantine/core";
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
const BRAND_GREEN = "var(--freight-brand, #0A6F4D)";
|
||||
const GREEN = "#0A8A5F";
|
||||
const BLUE = "#1D6FD1";
|
||||
const BORDER = "#E4EBF1";
|
||||
const MUTED = "#93A4B5";
|
||||
const INK = "#10202F";
|
||||
|
||||
const IMPORT_PHASES = [
|
||||
"CUSTOMER_INTAKE",
|
||||
@@ -24,6 +28,18 @@ const PHASE_LABELS: Record<string, string> = {
|
||||
POST_TRANSIT: "Transit",
|
||||
};
|
||||
|
||||
/** Which desk owns each phase — shown under the label, as in the design. */
|
||||
const PHASE_ACTOR: Record<string, string> = {
|
||||
CUSTOMER_INTAKE: "CUSTOMER",
|
||||
GL_ET_REVIEW: "GL ET",
|
||||
GL_ET_OUTPUT: "GL ET",
|
||||
CUSTOMER_DUTY: "CUSTOMER",
|
||||
GL_ET_POST_CLEARANCE: "GL ET",
|
||||
GL_DJ_COLLECTION: "GL DJ",
|
||||
GL_DJ_LOADING: "GL DJ",
|
||||
POST_TRANSIT: "OPS",
|
||||
};
|
||||
|
||||
const EXPORT_PHASES = [
|
||||
"CUSTOMER_INTAKE",
|
||||
"GL_ET_REVIEW",
|
||||
@@ -38,6 +54,20 @@ function phaseIndex(phases: readonly string[], current?: string | null): number
|
||||
return idx >= 0 ? idx : 0;
|
||||
}
|
||||
|
||||
/** Half-width connector; only the segment behind a completed dot is green. */
|
||||
function Line({ done, hidden }: { done: boolean; hidden: boolean }) {
|
||||
return (
|
||||
<Box
|
||||
style={{
|
||||
flex: 1,
|
||||
height: 2,
|
||||
borderRadius: 2,
|
||||
background: hidden ? "transparent" : done ? GREEN : BORDER,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function ClearancePhaseStepper({
|
||||
clearance,
|
||||
tradeDirection,
|
||||
@@ -50,61 +80,69 @@ export function ClearancePhaseStepper({
|
||||
const phases = tradeDirection === "EXPORT" ? EXPORT_PHASES : IMPORT_PHASES;
|
||||
const current = clearance?.phase ?? phases[0];
|
||||
const activeIdx = phaseIndex(phases, current);
|
||||
const dot = compact ? 26 : 28;
|
||||
|
||||
return (
|
||||
<Group gap={0} wrap="nowrap" align="flex-start" style={{ overflowX: "auto" }}>
|
||||
{phases.map((phase, index) => {
|
||||
const isComplete = index < activeIdx;
|
||||
const isActive = index === activeIdx;
|
||||
const isLast = index === phases.length - 1;
|
||||
const actor = PHASE_ACTOR[phase];
|
||||
|
||||
return (
|
||||
<Box key={phase} style={{ flex: isLast ? "0 0 auto" : 1, minWidth: compact ? 72 : 88 }}>
|
||||
<Group gap={0} wrap="nowrap" align="center">
|
||||
<Stack gap={4} align="center" style={{ flexShrink: 0 }}>
|
||||
<Box
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: compact ? 28 : 34,
|
||||
height: compact ? 28 : 34,
|
||||
borderRadius: "50%",
|
||||
background: isComplete ? BRAND_GREEN : isActive ? "white" : "var(--mantine-color-gray-1)",
|
||||
border: isActive
|
||||
? `2px solid ${BRAND_GREEN}`
|
||||
: isComplete
|
||||
? "2px solid transparent"
|
||||
: "2px solid var(--mantine-color-gray-3)",
|
||||
color: isComplete ? "white" : isActive ? BRAND_GREEN : "var(--mantine-color-gray-5)",
|
||||
}}
|
||||
>
|
||||
{isComplete ? <Check size={compact ? 14 : 16} strokeWidth={3} /> : null}
|
||||
</Box>
|
||||
<Text
|
||||
size={compact ? "10px" : "xs"}
|
||||
fw={isActive ? 600 : 500}
|
||||
c={isActive ? "edr-green.7" : isComplete ? "dark" : "dimmed"}
|
||||
ta="center"
|
||||
style={{ whiteSpace: "nowrap" }}
|
||||
>
|
||||
{PHASE_LABELS[phase] ?? phase}
|
||||
</Text>
|
||||
</Stack>
|
||||
{!isLast && (
|
||||
<Box
|
||||
style={{
|
||||
flex: 1,
|
||||
height: 2,
|
||||
marginInline: 6,
|
||||
marginBottom: compact ? 16 : 20,
|
||||
borderRadius: 2,
|
||||
background: isComplete ? BRAND_GREEN : "var(--mantine-color-gray-2)",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Stack
|
||||
key={phase}
|
||||
gap={7}
|
||||
align="center"
|
||||
style={{ flex: 1, minWidth: compact ? 92 : 112 }}
|
||||
>
|
||||
{/* Dot sits centred on its own row so the connectors meet it edge-to-edge. */}
|
||||
<Group gap={0} wrap="nowrap" align="center" style={{ width: "100%" }}>
|
||||
<Line done={isComplete || isActive} hidden={index === 0} />
|
||||
<Box
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexShrink: 0,
|
||||
width: dot,
|
||||
height: dot,
|
||||
borderRadius: 999,
|
||||
background: isComplete ? GREEN : "#FFFFFF",
|
||||
border: `2px solid ${
|
||||
isComplete ? GREEN : isActive ? BLUE : BORDER
|
||||
}`,
|
||||
color: isComplete ? "#FFFFFF" : isActive ? BLUE : MUTED,
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
lineHeight: 1,
|
||||
}}
|
||||
>
|
||||
{isComplete ? <Check size={14} strokeWidth={3} /> : index + 1}
|
||||
</Box>
|
||||
<Line done={isComplete} hidden={index === phases.length - 1} />
|
||||
</Group>
|
||||
</Box>
|
||||
|
||||
<Text
|
||||
fz={10.5}
|
||||
fw={700}
|
||||
lh={1.3}
|
||||
ta="center"
|
||||
style={{ color: isActive || isComplete ? INK : MUTED }}
|
||||
>
|
||||
{PHASE_LABELS[phase] ?? phase}
|
||||
</Text>
|
||||
{actor ? (
|
||||
<Text
|
||||
fz={9}
|
||||
fw={700}
|
||||
lts="0.3px"
|
||||
style={{ color: isActive ? BLUE : MUTED, marginTop: -3 }}
|
||||
>
|
||||
{actor}
|
||||
</Text>
|
||||
) : null}
|
||||
</Stack>
|
||||
);
|
||||
})}
|
||||
</Group>
|
||||
|
||||
@@ -122,7 +122,10 @@ function phaseCountdown(w: WindowRow): {
|
||||
}
|
||||
|
||||
/** Badge label + Mantine color per UI state — same state the countdown uses. */
|
||||
const KIND_BADGE: Record<BookingWindowUiKind, { label: string; color: string }> = {
|
||||
const KIND_BADGE: Record<
|
||||
BookingWindowUiKind,
|
||||
{ label: string; color: string }
|
||||
> = {
|
||||
OPEN: { label: "Open now", color: "edr-green" },
|
||||
FULL: { label: "Train full", color: "red" },
|
||||
PRE_WINDOW: { label: "Opens soon", color: "yellow" },
|
||||
@@ -301,8 +304,12 @@ export function GlUpcomingWindowsSection({
|
||||
// Order by the train's dispatch (departure) date, nearest first. Open-now
|
||||
// breaks ties on the same departure.
|
||||
return rows.sort((a, b) => {
|
||||
const da = a.departureDate ? new Date(a.departureDate).getTime() : Infinity;
|
||||
const db = b.departureDate ? new Date(b.departureDate).getTime() : Infinity;
|
||||
const da = a.departureDate
|
||||
? new Date(a.departureDate).getTime()
|
||||
: Infinity;
|
||||
const db = b.departureDate
|
||||
? new Date(b.departureDate).getTime()
|
||||
: Infinity;
|
||||
if (da !== db) return da - db;
|
||||
return Number(b.isOpenNow) - Number(a.isOpenNow);
|
||||
});
|
||||
@@ -319,14 +326,16 @@ export function GlUpcomingWindowsSection({
|
||||
|
||||
return (
|
||||
<Card withBorder shadow="sm" radius="lg" p="lg">
|
||||
<Group justify="space-between" align="flex-start" mb="md" wrap="nowrap">
|
||||
<Group gap={8} wrap="nowrap">
|
||||
<CalendarClock size={18} />
|
||||
<Group justify="space-between" align="center" mb="md" wrap="nowrap">
|
||||
<Group gap={10} wrap="nowrap">
|
||||
<div className="flex size-8 shrink-0 items-center justify-center rounded-[9px] bg-edr-soft text-edr-primary-dark">
|
||||
<CalendarClock size={16} />
|
||||
</div>
|
||||
<Box>
|
||||
<Text fw={700} fz={16}>
|
||||
<Text ff="heading" fw={600} fz={15} lh={1.2}>
|
||||
Booking windows
|
||||
</Text>
|
||||
<Text fz={13} c="dimmed">
|
||||
<Text fz={12} c="edr-muted">
|
||||
{contractId
|
||||
? "Booking windows on this contract's routes (EAT)"
|
||||
: "Import and export booking windows across all lanes (EAT)"}
|
||||
@@ -386,7 +395,11 @@ export function GlUpcomingWindowsSection({
|
||||
))}
|
||||
</SimpleGrid>
|
||||
) : (
|
||||
<SimpleGrid key={safePage} cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
<SimpleGrid
|
||||
key={safePage}
|
||||
cols={{ base: 1, sm: 2, lg: 3 }}
|
||||
spacing="md"
|
||||
>
|
||||
{visible.map((w) => (
|
||||
<WindowCard key={`${w.scheduleId}-${w.bookingCycleNo}`} w={w} />
|
||||
))}
|
||||
|
||||
@@ -2,10 +2,12 @@ import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Group,
|
||||
NumberInput,
|
||||
Paper,
|
||||
Progress,
|
||||
SegmentedControl,
|
||||
Select,
|
||||
Stack,
|
||||
@@ -14,14 +16,9 @@ import {
|
||||
Text,
|
||||
TextInput,
|
||||
} from "@mantine/core";
|
||||
import { PhasedFileDropzone, PhasedMultiFileDropzone } from "@/components/contracts/PhasedFileDropzone";
|
||||
import { TransitAssigneePanel } from "@/components/contracts/TransitAssigneePanel";
|
||||
import {
|
||||
TransitPermitMultiUpload,
|
||||
type TransitPermitUploadedRow,
|
||||
} from "@/components/contracts/TransitPermitMultiUpload";
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowRight,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
FileText,
|
||||
@@ -30,10 +27,17 @@ import {
|
||||
PackageOpen,
|
||||
Receipt,
|
||||
ShieldAlert,
|
||||
ShieldCheck,
|
||||
Ship,
|
||||
Truck,
|
||||
Upload,
|
||||
} from "lucide-react";
|
||||
import { PhasedFileDropzone, PhasedMultiFileDropzone } from "@/components/contracts/PhasedFileDropzone";
|
||||
import { TransitAssigneePanel } from "@/components/contracts/TransitAssigneePanel";
|
||||
import {
|
||||
TransitPermitMultiUpload,
|
||||
type TransitPermitUploadedRow,
|
||||
} from "@/components/contracts/TransitPermitMultiUpload";
|
||||
import {
|
||||
deliveryOrderFileLabel,
|
||||
isDeliveryOrderFileCode,
|
||||
@@ -113,6 +117,9 @@ export function isBookingMilestoneDone(
|
||||
return m?.status === "COMPLETED" || m?.status === "SKIPPED";
|
||||
}
|
||||
|
||||
/** Number of steps in the import stepper — drives the header progress bar. */
|
||||
const IMPORT_STEP_COUNT = 12;
|
||||
|
||||
function computeImportActiveStep(
|
||||
clearance: ClearanceViewLike,
|
||||
bookingCreated: boolean,
|
||||
@@ -322,19 +329,64 @@ export function PhasedClearanceActionPanel({
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{clearance.nextAction ? (
|
||||
<Alert color="blue" variant="light" title="Next step">
|
||||
<Text size="sm">
|
||||
<strong>{clearance.nextAction.actor.replace("_", " ")}</strong> —{" "}
|
||||
{clearance.nextAction.action}
|
||||
</Text>
|
||||
</Alert>
|
||||
) : null}
|
||||
<Paper withBorder radius={13} p={0} style={{ overflow: "hidden" }}>
|
||||
{/* Header: what this workflow is, and how far along it is. */}
|
||||
<Group
|
||||
justify="space-between"
|
||||
wrap="nowrap"
|
||||
px={18}
|
||||
py={15}
|
||||
style={{ borderBottom: "1px solid #EFF3F7" }}
|
||||
>
|
||||
<Group gap={9} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<ShieldCheck size={16} color="#0A8A5F" />
|
||||
<Box style={{ minWidth: 0 }}>
|
||||
<Text fz={14} fw={700} c="edr-text">
|
||||
Import pre-booking clearance
|
||||
</Text>
|
||||
<Text fz={11.5} c="#93A4B5" truncate>
|
||||
Step {Math.min(activeStep + 1, IMPORT_STEP_COUNT)} of{" "}
|
||||
{IMPORT_STEP_COUNT}
|
||||
{clearance.nextAction
|
||||
? ` · ${clearance.nextAction.action}`
|
||||
: ""}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
<Group gap={8} wrap="nowrap" style={{ flexShrink: 0 }}>
|
||||
<Progress
|
||||
value={Math.round((activeStep / IMPORT_STEP_COUNT) * 100)}
|
||||
color="edr-green"
|
||||
radius="xl"
|
||||
size={6}
|
||||
w={110}
|
||||
/>
|
||||
<Text fz={11.5} c="#67788A" fw={600}>
|
||||
{Math.round((activeStep / IMPORT_STEP_COUNT) * 100)}%
|
||||
</Text>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Text fw={600} size="sm" mb="md">
|
||||
Import pre-booking clearance
|
||||
</Text>
|
||||
{/* Whose desk the flow is sitting on right now. */}
|
||||
{clearance.nextAction ? (
|
||||
<Group
|
||||
gap={10}
|
||||
wrap="nowrap"
|
||||
px={18}
|
||||
py={12}
|
||||
style={{ background: "#E9F1FC", borderBottom: "1px solid #EFF3F7" }}
|
||||
>
|
||||
<ArrowRight size={15} color="#1D6FD1" style={{ flexShrink: 0 }} />
|
||||
<Text fz={10.5} fw={700} lts="0.4px" c="#1D6FD1" style={{ flexShrink: 0 }}>
|
||||
{clearance.nextAction.actor.replace("_", " ").toUpperCase()}
|
||||
</Text>
|
||||
<Text fz={11.5} fw={600} c="edr-text" style={{ minWidth: 0 }}>
|
||||
{clearance.nextAction.action}
|
||||
</Text>
|
||||
</Group>
|
||||
) : null}
|
||||
|
||||
<Box p="md">
|
||||
<Stepper
|
||||
active={activeStep}
|
||||
orientation="vertical"
|
||||
@@ -826,7 +878,8 @@ export function PhasedClearanceActionPanel({
|
||||
onDownloadFile={onDownloadFile}
|
||||
/>
|
||||
</Stepper.Step>
|
||||
</Stepper>
|
||||
</Stepper>
|
||||
</Box>
|
||||
</Paper>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
@@ -199,7 +199,13 @@ export function RequestServiceTypeCard({
|
||||
if (lastMile)
|
||||
chips.push({ label: "Last-mile delivery", color: "teal", icon: Warehouse });
|
||||
if (customs)
|
||||
chips.push({ label: "Customs clearance (GL)", color: "grape", icon: FileCheck });
|
||||
chips.push({
|
||||
label: st.includesEthiopianCustomsOnly
|
||||
? "Ethiopian customs clearance (GL)"
|
||||
: "Customs clearance (GL)",
|
||||
color: "grape",
|
||||
icon: FileCheck,
|
||||
});
|
||||
|
||||
return (
|
||||
<SectionCard
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Card, Skeleton, Text } from "@mantine/core";
|
||||
import { ArrowUpRight } from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import type { ElementType, ReactNode } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
@@ -8,14 +9,14 @@ import { cn } from "@/lib/utils";
|
||||
export interface KpiItem {
|
||||
label: string;
|
||||
value: ReactNode;
|
||||
/** Optional leading icon rendered in a tinted chip. */
|
||||
/** Optional leading icon rendered in a tinted chip beside the label. */
|
||||
icon?: LucideIcon;
|
||||
/** Secondary line under the label (e.g. a unit or comparison). */
|
||||
/** Small tinted pill beside the value (e.g. "+6 today"). */
|
||||
hint?: string;
|
||||
/**
|
||||
* Mantine color name for the icon chip (e.g. "edr-green", "red", "yellow").
|
||||
* Defaults to the brand green so a strip reads as uniform unless a page opts
|
||||
* into semantic tints.
|
||||
* Mantine color name for the icon chip and sparkline (e.g. "edr-green",
|
||||
* "red", "yellow"). Defaults to the brand green so a strip reads as uniform
|
||||
* unless a page opts into semantic tints.
|
||||
*/
|
||||
color?: string;
|
||||
/**
|
||||
@@ -28,6 +29,8 @@ export interface KpiItem {
|
||||
* becomes clickable (pointer, hover tint); when absent it stays static.
|
||||
*/
|
||||
href?: string;
|
||||
/** Tiny bar sparkline, oldest → newest, scaled to its own max. */
|
||||
spark?: number[];
|
||||
}
|
||||
|
||||
export interface KpiStripProps {
|
||||
@@ -36,11 +39,52 @@ export interface KpiStripProps {
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
function Pill({
|
||||
children,
|
||||
tone,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
tone: "green" | "red";
|
||||
}) {
|
||||
const c = tone === "green" ? "edr-green" : "red";
|
||||
return (
|
||||
<span
|
||||
className="inline-flex shrink-0 items-center gap-1 rounded-full px-[7px] py-[2px] text-[10px] font-medium leading-none"
|
||||
style={{
|
||||
background: `var(--mantine-color-${c}-0)`,
|
||||
color: `var(--mantine-color-${c}-7)`,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function Spark({ values, color }: { values: number[]; color: string }) {
|
||||
const max = Math.max(1, ...values);
|
||||
return (
|
||||
<div className="flex h-[26px] shrink-0 items-end gap-[3px]" aria-hidden>
|
||||
{values.map((v, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="w-1 rounded-sm"
|
||||
style={{
|
||||
height: Math.max(3, Math.round((v / max) * 26)),
|
||||
background: `var(--mantine-color-${color}-7)`,
|
||||
opacity: i === values.length - 1 ? 0.9 : 0.28,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A single bordered card divided into up to five KPI cells:
|
||||
* `[ kpi | kpi | kpi ]`. Hairline dividers separate cells (vertical on wide
|
||||
* screens, horizontal when they wrap). Surface, border and shadow all come from
|
||||
* the theme — no per-cell backgrounds, gradients or custom shadows.
|
||||
* `[ kpi | kpi | kpi ]`. Each cell stacks a tinted icon + label over a large
|
||||
* display-font value, with an optional hint/delta pill and a sparkline on the
|
||||
* right. Hairline dividers separate cells (vertical on wide screens,
|
||||
* horizontal when they wrap).
|
||||
*/
|
||||
export function KpiStrip({ items, loading = false }: KpiStripProps) {
|
||||
// The spec caps a strip at five cells; extra items are dropped rather than
|
||||
@@ -48,7 +92,7 @@ export function KpiStrip({ items, loading = false }: KpiStripProps) {
|
||||
const cells = items.slice(0, 5);
|
||||
|
||||
return (
|
||||
<Card withBorder shadow="sm" p={0} className="overflow-hidden">
|
||||
<Card withBorder shadow="sm" radius="lg" p={0} className="overflow-hidden">
|
||||
<div className="flex flex-col sm:flex-row">
|
||||
{cells.map((item, index) => {
|
||||
const Icon = item.icon;
|
||||
@@ -66,66 +110,63 @@ export function KpiStrip({ items, loading = false }: KpiStripProps) {
|
||||
className={cn(
|
||||
// min-w-0 lets a crowded strip (five cells, long labels)
|
||||
// truncate its labels instead of overflowing the card.
|
||||
"flex min-w-0 flex-1 items-center gap-3 px-5 py-4",
|
||||
"flex min-w-0 flex-1 flex-col justify-center gap-2 px-[18px] py-4",
|
||||
index > 0 &&
|
||||
"border-t border-edr-border sm:border-l sm:border-t-0",
|
||||
item.href &&
|
||||
"cursor-pointer no-underline transition-colors hover:bg-gray-50 focus-visible:bg-gray-50",
|
||||
)}
|
||||
>
|
||||
{Icon ? (
|
||||
<div
|
||||
className="flex size-10 shrink-0 items-center justify-center rounded-lg"
|
||||
style={{
|
||||
background: `var(--mantine-color-${color}-1)`,
|
||||
color: `var(--mantine-color-${color}-7)`,
|
||||
}}
|
||||
>
|
||||
<Icon size={20} strokeWidth={2} />
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex items-center gap-2">
|
||||
{Icon ? (
|
||||
<div
|
||||
className="flex size-7 shrink-0 items-center justify-center rounded-lg"
|
||||
style={{
|
||||
background: `var(--mantine-color-${color}-0)`,
|
||||
color: `var(--mantine-color-${color}-7)`,
|
||||
}}
|
||||
>
|
||||
<Icon size={14} strokeWidth={2} />
|
||||
</div>
|
||||
) : null}
|
||||
<Text fz={12} fw={500} c="edr-muted" truncate>
|
||||
{item.label}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<div style={{ minWidth: 0 }}>
|
||||
{loading ? (
|
||||
<Skeleton height={26} width={72} radius="sm" my={2} />
|
||||
) : (
|
||||
<div className="flex items-baseline gap-2">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
{loading ? (
|
||||
<Skeleton height={28} width={64} radius="sm" />
|
||||
) : (
|
||||
<Text
|
||||
fw={800}
|
||||
fz={24}
|
||||
lh={1.05}
|
||||
ff="heading"
|
||||
fw={600}
|
||||
fz={27}
|
||||
lh={1}
|
||||
c="edr-text"
|
||||
style={{ letterSpacing: "-0.02em" }}
|
||||
style={{ letterSpacing: "-0.03em" }}
|
||||
truncate
|
||||
>
|
||||
{item.value}
|
||||
</Text>
|
||||
{item.delta != null && item.delta !== 0 ? (
|
||||
<Text
|
||||
component="span"
|
||||
fz="xs"
|
||||
fw={700}
|
||||
c={item.delta > 0 ? "edr-green.7" : "red.7"}
|
||||
style={{
|
||||
whiteSpace: "nowrap",
|
||||
background:
|
||||
item.delta > 0
|
||||
? "var(--mantine-color-edr-green-0)"
|
||||
: "var(--mantine-color-red-0)",
|
||||
borderRadius: 999,
|
||||
padding: "1px 7px",
|
||||
}}
|
||||
>
|
||||
{item.delta > 0 ? "▲" : "▼"}
|
||||
{Math.abs(item.delta)}%
|
||||
</Text>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
<Text size="xs" fw={600} c="edr-muted" truncate>
|
||||
{item.label}
|
||||
{item.hint ? ` · ${item.hint}` : ""}
|
||||
</Text>
|
||||
)}
|
||||
{!loading && item.hint ? (
|
||||
<Pill tone="green">
|
||||
<ArrowUpRight size={10} />
|
||||
{item.hint}
|
||||
</Pill>
|
||||
) : null}
|
||||
{!loading && item.delta != null && item.delta !== 0 ? (
|
||||
<Pill tone={item.delta > 0 ? "green" : "red"}>
|
||||
{item.delta > 0 ? "▲" : "▼"}
|
||||
{Math.abs(item.delta)}%
|
||||
</Pill>
|
||||
) : null}
|
||||
</div>
|
||||
{item.spark?.length ? (
|
||||
<Spark values={item.spark} color={color} />
|
||||
) : null}
|
||||
</div>
|
||||
</Cell>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { Group, Pagination, Select, Text } from "@mantine/core";
|
||||
import type { DataTableFooterProps } from "@edr/ui-common";
|
||||
|
||||
export interface TablePagerProps<T> extends DataTableFooterProps<T> {
|
||||
/** Plural noun for the row count — "Showing 1–10 of 48 shipments". */
|
||||
noun?: string;
|
||||
pageSizes?: number[];
|
||||
}
|
||||
|
||||
/**
|
||||
* DataTable footer: row range on the left, rows-per-page select + numbered
|
||||
* pager on the right. Pass via `footer={(p) => <TablePager {...p} noun="…" />}`.
|
||||
*/
|
||||
export function TablePager<T>({
|
||||
table,
|
||||
pagination,
|
||||
noun = "rows",
|
||||
pageSizes = [10, 25, 50],
|
||||
}: TablePagerProps<T>) {
|
||||
const pageIndex = pagination.pageIndex ?? 0;
|
||||
const pageSize = pagination.pageSize ?? 10;
|
||||
const total = pagination.totalCount ?? 0;
|
||||
const pageCount = Math.max(
|
||||
1,
|
||||
pagination.pageCount ?? Math.ceil(total / pageSize),
|
||||
);
|
||||
const start = total === 0 ? 0 : pageIndex * pageSize + 1;
|
||||
const end = Math.min((pageIndex + 1) * pageSize, total);
|
||||
|
||||
return (
|
||||
<Group
|
||||
justify="space-between"
|
||||
gap="sm"
|
||||
wrap="wrap"
|
||||
px="md"
|
||||
py={10}
|
||||
style={{ borderTop: "1px solid var(--mantine-color-edr-divider-6)" }}
|
||||
>
|
||||
<Text fz={12} c="edr-muted">
|
||||
Showing {start}–{end} of {total} {noun}
|
||||
</Text>
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Text fz={12} c="edr-muted">
|
||||
Rows
|
||||
</Text>
|
||||
<Select
|
||||
size="xs"
|
||||
w={70}
|
||||
radius="md"
|
||||
value={String(pageSize)}
|
||||
data={pageSizes.map(String)}
|
||||
onChange={(v) => v && table.setPageSize(Number(v))}
|
||||
allowDeselect={false}
|
||||
comboboxProps={{ withinPortal: true }}
|
||||
aria-label="Rows per page"
|
||||
/>
|
||||
</Group>
|
||||
<div className="h-5 w-px bg-edr-border" />
|
||||
<Pagination
|
||||
size="sm"
|
||||
radius="md"
|
||||
color="edr-ink"
|
||||
total={pageCount}
|
||||
value={pageIndex + 1}
|
||||
onChange={(p) => table.setPageIndex(p - 1)}
|
||||
siblings={1}
|
||||
boundaries={1}
|
||||
/>
|
||||
</Group>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
export default TablePager;
|
||||
@@ -254,6 +254,14 @@ const RuleEngineFormDialog = ({
|
||||
next.cargoTypeId = "";
|
||||
next.rateUnit = "";
|
||||
}
|
||||
// Full customs and Ethiopian-only customs are alternatives on a service
|
||||
// type — switching one on drops the other so the API never sees both.
|
||||
if (name === "includesCustoms" && value === true) {
|
||||
next.includesEthiopianCustomsOnly = false;
|
||||
}
|
||||
if (name === "includesEthiopianCustomsOnly" && value === true) {
|
||||
next.includesCustoms = false;
|
||||
}
|
||||
// Turning the shipping-line toggle on or off swaps the entire form, so
|
||||
// nothing answered under the other shape may survive into the payload.
|
||||
if (name === "isShippingLineRate") {
|
||||
@@ -388,7 +396,11 @@ const RuleEngineFormDialog = ({
|
||||
// A toggle that re-targets what an existing record means (e.g. who
|
||||
// a rate is priced for) is create-only — flipping it on a saved row
|
||||
// would silently change every booking that prices off it.
|
||||
disabled={field.disabled || (field.disabledOnEdit && !!initialRecord)}
|
||||
disabled={
|
||||
field.disabled ||
|
||||
(field.disabledOnEdit && !!initialRecord) ||
|
||||
field.disabledIf?.(values) === true
|
||||
}
|
||||
size="md"
|
||||
color="edr-green"
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -8,15 +8,20 @@ import {
|
||||
Card,
|
||||
Group,
|
||||
Menu,
|
||||
Select,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
Tooltip,
|
||||
UnstyledButton,
|
||||
} from "@mantine/core";
|
||||
import { useInterval } from "@mantine/hooks";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import {
|
||||
ArrowRight,
|
||||
Calendar,
|
||||
Building2,
|
||||
CalendarClock,
|
||||
ExternalLink,
|
||||
Eye,
|
||||
FileText,
|
||||
@@ -27,26 +32,27 @@ import {
|
||||
RefreshCw,
|
||||
Search,
|
||||
ShieldCheck,
|
||||
User,
|
||||
ShipWheel,
|
||||
TriangleAlert,
|
||||
Truck,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
DataTable,
|
||||
usePagination,
|
||||
type ColumnDef,
|
||||
} from "@edr/ui-common";
|
||||
import { DataTable, usePagination, type ColumnDef } from "@edr/ui-common";
|
||||
import type { Freight } from "@edr/types";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { PageContainer } from "@/components/page/PageContainer";
|
||||
import { PageHeader } from "@/components/page/PageHeader";
|
||||
import { KpiStrip } from "@/components/page/KpiStrip";
|
||||
import { TablePager } from "@/components/page/TablePager";
|
||||
import { bookingTable } from "@/components/bookings/booking-ui.styles";
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { useBookingEtClearanceQueue } from "@/hooks/bookings/useBookings";
|
||||
import type { BookingDetail } from "@/types/booking";
|
||||
import { FREIGHT_PERMS, hasPermission, isDjiboutiGl } from "@/lib/permissions";
|
||||
import { formatDate } from "@/lib/format";
|
||||
import { GlUpcomingWindowsSection } from "@/components/contracts/GlUpcomingWindowsSection";
|
||||
import { CLEARANCE_TABS } from "@/features/clearance/clearance-tabs.config";
|
||||
import {
|
||||
RequestedCargoChips,
|
||||
summarizeRequestedCargo,
|
||||
@@ -62,53 +68,134 @@ function yardLabel(
|
||||
return yard.label ?? yard.name ?? yard.code ?? "—";
|
||||
}
|
||||
|
||||
/**
|
||||
* "Origin → Destination", wrapping past 120px as "Addis Ababa" /
|
||||
* "→ Djibouti": the arrow is glued to the destination with an nbsp, and
|
||||
* text wraps normally (the table's cells are otherwise nowrap) so a long
|
||||
* lane never spills into the next column.
|
||||
*/
|
||||
function RouteLabel({
|
||||
origin,
|
||||
destination,
|
||||
}: {
|
||||
origin: string;
|
||||
destination: string;
|
||||
}) {
|
||||
const prettyStatus = (s: string) =>
|
||||
s
|
||||
.toLowerCase()
|
||||
.replace(/_/g, " ")
|
||||
.replace(/^\w/, (c) => c.toUpperCase());
|
||||
|
||||
const shipmentStatusColor = (s: string) => {
|
||||
if (s === "AWAITING_DOCUMENTS") return "yellow";
|
||||
if (s === "DOCUMENTS_UNDER_REVIEW") return "blue";
|
||||
if (s === "CLEARANCE_READY") return "edr-green";
|
||||
if (
|
||||
[
|
||||
"SELECTED_FOR_BATCH",
|
||||
"PNR_GENERATED",
|
||||
"AWAITING_PAYMENT",
|
||||
"PAYMENT_VERIFICATION_IN_PROGRESS",
|
||||
].includes(s)
|
||||
)
|
||||
return "violet";
|
||||
if (s === "EXPIRED") return "orange";
|
||||
if (s === "CANCELLED" || s === "REJECTED") return "red";
|
||||
return "gray";
|
||||
};
|
||||
|
||||
/** Rows created per day over the last `days` days, oldest → newest. */
|
||||
function perDay(rows: { createdAt: string | null }[], days = 8): number[] {
|
||||
const today = new Date().setHours(0, 0, 0, 0);
|
||||
const out = new Array<number>(days).fill(0);
|
||||
for (const r of rows) {
|
||||
if (!r.createdAt) continue;
|
||||
const age = Math.floor(
|
||||
(today - new Date(r.createdAt).setHours(0, 0, 0, 0)) / 86_400_000,
|
||||
);
|
||||
if (age >= 0 && age < days) out[days - 1 - age] += 1;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ── Tabs ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
type TabKey = "all" | "import" | "export" | "review";
|
||||
|
||||
const TABS: { key: TabKey; label: string; icon: LucideIcon }[] = [
|
||||
...CLEARANCE_TABS,
|
||||
{ key: "review", label: "Needs approval", icon: TriangleAlert },
|
||||
];
|
||||
|
||||
// ── Small pieces ─────────────────────────────────────────────────────────────
|
||||
|
||||
function LivePill({ updatedAt }: { updatedAt: number }) {
|
||||
// Re-render every 30s so "Xm ago" keeps ticking between refetches.
|
||||
const [, setTick] = useState(0);
|
||||
useInterval(() => setTick((t) => t + 1), 30_000, { autoInvoke: true });
|
||||
const mins = Math.max(0, Math.round((Date.now() - updatedAt) / 60_000));
|
||||
const label = !updatedAt
|
||||
? "Connecting…"
|
||||
: mins < 1
|
||||
? "Live · updated just now"
|
||||
: `Live · updated ${mins}m ago`;
|
||||
return (
|
||||
<Text
|
||||
size="sm"
|
||||
maw={120}
|
||||
lh={1.35}
|
||||
style={{ whiteSpace: "normal", overflowWrap: "anywhere" }}
|
||||
>
|
||||
{origin}{" "}
|
||||
<ArrowRight
|
||||
size={13}
|
||||
className="text-muted-foreground"
|
||||
style={{ display: "inline-block", verticalAlign: "-2px" }}
|
||||
/>
|
||||
{"\u00A0"}
|
||||
{destination}
|
||||
</Text>
|
||||
<span className="inline-flex shrink-0 items-center gap-1.5 whitespace-nowrap rounded-full bg-edr-soft px-2.5 py-1 text-[11px] font-medium text-edr-primary-dark">
|
||||
<span className="size-1.5 rounded-full bg-edr-primary-dark" />
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function CustomsBadge({ customs }: { customs: boolean }) {
|
||||
return customs ? (
|
||||
<Badge
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
radius="sm"
|
||||
leftSection={<ShieldCheck size={11} />}
|
||||
function DirectionPill({ direction }: { direction: string }) {
|
||||
const isImport = direction === "IMPORT";
|
||||
const Icon = isImport ? Truck : ShipWheel;
|
||||
const color = isImport ? "blue" : "teal";
|
||||
return (
|
||||
<span
|
||||
className="inline-flex items-center gap-1 rounded-[5px] px-1.5 py-[2px] text-[10px] font-medium leading-none"
|
||||
style={{
|
||||
background: `var(--mantine-color-${color}-0)`,
|
||||
color: `var(--mantine-color-${color}-7)`,
|
||||
}}
|
||||
>
|
||||
Customs
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge size="xs" variant="light" color="gray" radius="sm">
|
||||
No customs
|
||||
</Badge>
|
||||
<Icon size={10} />
|
||||
{prettyStatus(direction)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function OutlinePill({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<span className="inline-flex items-center rounded-[5px] border border-edr-border px-1.5 py-[2px] text-[10px] leading-none text-edr-muted">
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function RouteCell({
|
||||
origin,
|
||||
destination,
|
||||
direction,
|
||||
freightType,
|
||||
customs,
|
||||
}: {
|
||||
origin: string;
|
||||
destination: string;
|
||||
direction: string;
|
||||
freightType: string;
|
||||
customs: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Stack gap={5} py={2}>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Text fz={12.5} fw={500} c="edr-text">
|
||||
{origin}
|
||||
</Text>
|
||||
<ArrowRight size={12} className="shrink-0 text-edr-muted" />
|
||||
<Text fz={12.5} fw={500} c="edr-text">
|
||||
{destination}
|
||||
</Text>
|
||||
</Group>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<DirectionPill direction={direction} />
|
||||
<OutlinePill>{prettyStatus(freightType)}</OutlinePill>
|
||||
{customs ? (
|
||||
<span className="inline-flex items-center gap-1 rounded-[5px] bg-edr-soft px-1.5 py-[2px] text-[10px] font-medium leading-none text-edr-primary-dark">
|
||||
<ShieldCheck size={10} />
|
||||
Customs
|
||||
</span>
|
||||
) : null}
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -129,13 +216,21 @@ export default function ContractClearanceListPage() {
|
||||
!isDjiboutiGl(user);
|
||||
|
||||
const [query, setQuery] = useState("");
|
||||
const [tab, setTab] = useState<TabKey>("all");
|
||||
const [freight, setFreight] = useState<string | null>(null);
|
||||
const [status, setStatus] = useState<string | null>(null);
|
||||
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
||||
const resetPage = useCallback(
|
||||
() => setPagination({ pageIndex: 0, pageSize: pagination.pageSize }),
|
||||
[setPagination, pagination.pageSize],
|
||||
);
|
||||
|
||||
const {
|
||||
data: bookingQueue,
|
||||
isLoading,
|
||||
isError,
|
||||
isFetching,
|
||||
dataUpdatedAt,
|
||||
refetch,
|
||||
} = useBookingEtClearanceQueue(true);
|
||||
|
||||
@@ -149,7 +244,8 @@ export default function ContractClearanceListPage() {
|
||||
const requestedByBooking = useMemo(() => {
|
||||
const map = new Map<string, Freight.RequestedShipmentLines>();
|
||||
for (const req of requestQueue ?? []) {
|
||||
if (req.createdBookingId) map.set(req.createdBookingId, req.requestedLines);
|
||||
if (req.createdBookingId)
|
||||
map.set(req.createdBookingId, req.requestedLines);
|
||||
}
|
||||
return map;
|
||||
}, [requestQueue]);
|
||||
@@ -170,7 +266,8 @@ export default function ContractClearanceListPage() {
|
||||
contractId: b.contractId ?? null,
|
||||
contractReference: b.contractReference ?? null,
|
||||
contractKind: b.contractKind ?? null,
|
||||
customs: b.serviceType?.includesCustoms ?? Boolean(b.customsClearingEnabled),
|
||||
customs:
|
||||
b.serviceType?.includesCustoms ?? Boolean(b.customsClearingEnabled),
|
||||
createdAt: b.createdAt ?? null,
|
||||
// A bare initiated instance has no cargo/price yet — GL still has to
|
||||
// create (complete) the booking.
|
||||
@@ -178,23 +275,9 @@ export default function ContractClearanceListPage() {
|
||||
})) as ShipmentBookingRow[];
|
||||
}, [bookingQueue, requestedByBooking]);
|
||||
|
||||
const rows = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return allRows;
|
||||
return allRows.filter(
|
||||
(r) =>
|
||||
r.reference.toLowerCase().includes(q) ||
|
||||
r.customerLabel.toLowerCase().includes(q) ||
|
||||
(r.contractReference ?? "").toLowerCase().includes(q) ||
|
||||
r.originLabel.toLowerCase().includes(q) ||
|
||||
r.destinationLabel.toLowerCase().includes(q) ||
|
||||
summarizeRequestedCargo(r.requested).toLowerCase().includes(q),
|
||||
);
|
||||
}, [allRows, query]);
|
||||
|
||||
const counts = useMemo(
|
||||
// KPI groups span the whole queue, regardless of tab/filters.
|
||||
const groups = useMemo(
|
||||
() => ({
|
||||
all: allRows.length,
|
||||
// Counts anything actually waiting on GL, including a document added
|
||||
// after clearance was finalized (the status stays CLEARANCE_READY).
|
||||
review: allRows.filter(
|
||||
@@ -202,13 +285,72 @@ export default function ContractClearanceListPage() {
|
||||
r.status === "AWAITING_DOCUMENTS" ||
|
||||
r.status === "DOCUMENTS_UNDER_REVIEW" ||
|
||||
r.hasDocumentsAwaitingReview,
|
||||
).length,
|
||||
ready: allRows.filter((r) => r.status === "CLEARANCE_READY" || r.bookingCreated)
|
||||
.length,
|
||||
),
|
||||
approval: allRows.filter((r) => r.hasDocumentsAwaitingReview),
|
||||
ready: allRows.filter(
|
||||
(r) => r.status === "CLEARANCE_READY" || r.bookingCreated,
|
||||
),
|
||||
}),
|
||||
[allRows],
|
||||
);
|
||||
const newToday = perDay(allRows, 1)[0];
|
||||
|
||||
const tabCounts = useMemo<Record<TabKey, number>>(
|
||||
() => ({
|
||||
all: allRows.length,
|
||||
import: allRows.filter((r) => r.tradeDirection === "IMPORT").length,
|
||||
export: allRows.filter((r) => r.tradeDirection === "EXPORT").length,
|
||||
review: groups.approval.length,
|
||||
}),
|
||||
[allRows, groups.approval.length],
|
||||
);
|
||||
|
||||
const statusOptions = useMemo(
|
||||
() =>
|
||||
[...new Set(allRows.map((r) => r.status))].sort().map((s) => ({
|
||||
value: s,
|
||||
label: prettyStatus(s),
|
||||
})),
|
||||
[allRows],
|
||||
);
|
||||
|
||||
const rows = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
return allRows.filter((r) => {
|
||||
if (tab === "review" && !r.hasDocumentsAwaitingReview) return false;
|
||||
if (
|
||||
(tab === "import" || tab === "export") &&
|
||||
r.tradeDirection !== tab.toUpperCase()
|
||||
)
|
||||
return false;
|
||||
if (freight && r.freightType !== freight) return false;
|
||||
if (status && r.status !== status) return false;
|
||||
if (!q) return true;
|
||||
return [
|
||||
r.reference,
|
||||
r.customerLabel,
|
||||
r.contractReference ?? "",
|
||||
r.originLabel,
|
||||
r.destinationLabel,
|
||||
summarizeRequestedCargo(r.requested),
|
||||
].some((v) => v.toLowerCase().includes(q));
|
||||
});
|
||||
}, [allRows, tab, freight, status, query]);
|
||||
|
||||
const total = rows.length;
|
||||
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
|
||||
const pagedRows = useMemo(() => {
|
||||
const start = pagination.pageIndex * pagination.pageSize;
|
||||
return rows.slice(start, start + pagination.pageSize);
|
||||
}, [rows, pagination.pageIndex, pagination.pageSize]);
|
||||
|
||||
const hasFilters = Boolean(query || freight || status);
|
||||
const clearFilters = useCallback(() => {
|
||||
setQuery("");
|
||||
setFreight(null);
|
||||
setStatus(null);
|
||||
resetPage();
|
||||
}, [resetPage]);
|
||||
|
||||
const openBooking = useCallback(
|
||||
// `from` so the detail page's Back returns to this hub.
|
||||
@@ -223,31 +365,20 @@ export default function ContractClearanceListPage() {
|
||||
<PageContainer>
|
||||
<Stack gap="lg">
|
||||
<PageHeader
|
||||
title="Document Clearance"
|
||||
title="Clearance queue"
|
||||
subtitle="Every customs shipment in phased clearance — the documents live on the shipment, not on the contract."
|
||||
meta={
|
||||
<Badge
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
radius="sm"
|
||||
leftSection={<ShieldCheck size={13} />}
|
||||
>
|
||||
{counts.all} in clearance
|
||||
</Badge>
|
||||
}
|
||||
meta={<LivePill updatedAt={dataUpdatedAt} />}
|
||||
action={
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
size="lg"
|
||||
radius="md"
|
||||
onClick={() => void refetch()}
|
||||
loading={isFetching}
|
||||
aria-label="Refresh"
|
||||
>
|
||||
<RefreshCw size={16} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
<Button
|
||||
variant="default"
|
||||
radius="md"
|
||||
size="sm"
|
||||
leftSection={<RefreshCw size={14} />}
|
||||
loading={isFetching}
|
||||
onClick={() => void refetch()}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -256,65 +387,220 @@ export default function ContractClearanceListPage() {
|
||||
items={[
|
||||
{
|
||||
label: "In clearance",
|
||||
value: counts.all,
|
||||
value: allRows.length,
|
||||
icon: Inbox,
|
||||
color: "edr-green",
|
||||
color: "blue",
|
||||
hint: newToday ? `+${newToday} today` : undefined,
|
||||
spark: perDay(allRows),
|
||||
},
|
||||
{
|
||||
label: "Awaiting review",
|
||||
value: counts.review,
|
||||
value: groups.review.length,
|
||||
icon: ShieldCheck,
|
||||
color: "yellow",
|
||||
spark: perDay(groups.review),
|
||||
},
|
||||
{
|
||||
label: "Needs approval",
|
||||
value: groups.approval.length,
|
||||
icon: TriangleAlert,
|
||||
color: "red",
|
||||
spark: perDay(groups.approval),
|
||||
},
|
||||
{
|
||||
label: "Ready / booked",
|
||||
value: counts.ready,
|
||||
value: groups.ready.length,
|
||||
icon: PackageCheck,
|
||||
color: "edr-green",
|
||||
spark: perDay(groups.ready),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<GlUpcomingWindowsSection />
|
||||
|
||||
<Card p={0} withBorder shadow="sm" radius="lg">
|
||||
<Card
|
||||
p={0}
|
||||
withBorder
|
||||
shadow="sm"
|
||||
radius="lg"
|
||||
style={{ overflow: "hidden" }}
|
||||
>
|
||||
<Stack gap={0}>
|
||||
<Box px="md" pt="md" pb="sm">
|
||||
<Group justify="space-between" gap="md" wrap="wrap">
|
||||
<TextInput
|
||||
placeholder="Search shipment, contract, customer or route…"
|
||||
leftSection={<Search size={18} />}
|
||||
value={query}
|
||||
onChange={(e) => {
|
||||
setQuery(e.currentTarget.value);
|
||||
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
|
||||
}}
|
||||
rightSection={
|
||||
query ? (
|
||||
<ActionIcon
|
||||
size="sm"
|
||||
color="gray"
|
||||
radius="md"
|
||||
variant="transparent"
|
||||
onClick={() => setQuery("")}
|
||||
{/* ── Tabs ─────────────────────────────────────────────── */}
|
||||
<Group
|
||||
justify="space-between"
|
||||
align="stretch"
|
||||
px="md"
|
||||
h={46}
|
||||
wrap="nowrap"
|
||||
style={{
|
||||
borderBottom: "1px solid var(--mantine-color-edr-border-6)",
|
||||
}}
|
||||
>
|
||||
<Group gap={2} wrap="nowrap" align="stretch">
|
||||
{TABS.map((t) => {
|
||||
const active = tab === t.key;
|
||||
const Icon = t.icon;
|
||||
return (
|
||||
<UnstyledButton
|
||||
key={t.key}
|
||||
onClick={() => {
|
||||
setTab(t.key);
|
||||
resetPage();
|
||||
}}
|
||||
px={13}
|
||||
className="flex items-center gap-2 transition-colors"
|
||||
style={{
|
||||
borderBottom: `2px solid ${
|
||||
active
|
||||
? "var(--mantine-color-edr-green-6)"
|
||||
: "transparent"
|
||||
}`,
|
||||
marginBottom: -1,
|
||||
}}
|
||||
aria-pressed={active}
|
||||
>
|
||||
<Icon
|
||||
size={14}
|
||||
style={{
|
||||
color: active
|
||||
? "var(--mantine-color-edr-green-6)"
|
||||
: "var(--mantine-color-gray-5)",
|
||||
}}
|
||||
/>
|
||||
<Text
|
||||
fz={13}
|
||||
fw={active ? 600 : 500}
|
||||
c={active ? "edr-text" : "edr-muted"}
|
||||
>
|
||||
<X size={16} />
|
||||
</ActionIcon>
|
||||
) : null
|
||||
}
|
||||
radius="lg"
|
||||
style={{ flex: 1, minWidth: 220 }}
|
||||
/>
|
||||
<Text size="sm" c="dimmed">
|
||||
{rows.length} record{rows.length !== 1 ? "s" : ""}
|
||||
</Text>
|
||||
{t.label}
|
||||
</Text>
|
||||
<span
|
||||
className="rounded-full px-1.5 py-px text-[10.5px] font-semibold leading-[1.4]"
|
||||
style={{
|
||||
background: active
|
||||
? "var(--mantine-color-edr-green-0)"
|
||||
: "var(--mantine-color-gray-1)",
|
||||
color: active
|
||||
? "var(--mantine-color-edr-green-7)"
|
||||
: "var(--mantine-color-edr-muted-6)",
|
||||
}}
|
||||
>
|
||||
{tabCounts[t.key]}
|
||||
</span>
|
||||
</UnstyledButton>
|
||||
);
|
||||
})}
|
||||
</Group>
|
||||
</Box>
|
||||
<Text
|
||||
fz={12}
|
||||
c="edr-muted"
|
||||
className="self-center whitespace-nowrap"
|
||||
>
|
||||
{total} record{total !== 1 ? "s" : ""}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
{/* ── Filter bar ───────────────────────────────────────── */}
|
||||
<Group
|
||||
gap={9}
|
||||
px="md"
|
||||
py={12}
|
||||
wrap="wrap"
|
||||
style={{
|
||||
borderBottom: "1px solid var(--mantine-color-edr-divider-6)",
|
||||
}}
|
||||
>
|
||||
<TextInput
|
||||
placeholder="Search reference, customer, contract, or route…"
|
||||
leftSection={<Search size={15} />}
|
||||
value={query}
|
||||
onChange={(e) => {
|
||||
setQuery(e.currentTarget.value);
|
||||
resetPage();
|
||||
}}
|
||||
rightSection={
|
||||
query ? (
|
||||
<ActionIcon
|
||||
size="sm"
|
||||
color="gray"
|
||||
radius="md"
|
||||
variant="transparent"
|
||||
onClick={() => {
|
||||
setQuery("");
|
||||
resetPage();
|
||||
}}
|
||||
aria-label="Clear search"
|
||||
>
|
||||
<X size={14} />
|
||||
</ActionIcon>
|
||||
) : null
|
||||
}
|
||||
radius="md"
|
||||
size="sm"
|
||||
styles={{
|
||||
input: { background: "var(--mantine-color-gray-0)" },
|
||||
}}
|
||||
style={{ flex: 1, minWidth: 220 }}
|
||||
/>
|
||||
<Select
|
||||
placeholder="Freight"
|
||||
data={[
|
||||
{ value: "CONTAINER", label: "Container" },
|
||||
{ value: "BULK", label: "Bulk" },
|
||||
]}
|
||||
value={freight}
|
||||
onChange={(v) => {
|
||||
setFreight(v);
|
||||
resetPage();
|
||||
}}
|
||||
clearable
|
||||
radius="md"
|
||||
size="sm"
|
||||
w={124}
|
||||
comboboxProps={{ withinPortal: true }}
|
||||
aria-label="Filter by freight type"
|
||||
/>
|
||||
<Select
|
||||
placeholder="Status"
|
||||
data={statusOptions}
|
||||
value={status}
|
||||
onChange={(v) => {
|
||||
setStatus(v);
|
||||
resetPage();
|
||||
}}
|
||||
clearable
|
||||
radius="md"
|
||||
size="sm"
|
||||
w={180}
|
||||
comboboxProps={{ withinPortal: true }}
|
||||
aria-label="Filter by status"
|
||||
/>
|
||||
{hasFilters ? (
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="compact-sm"
|
||||
radius="md"
|
||||
leftSection={<X size={14} />}
|
||||
onClick={clearFilters}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
) : null}
|
||||
</Group>
|
||||
|
||||
<ShipmentBookingsTable
|
||||
rows={rows}
|
||||
rows={pagedRows}
|
||||
total={total}
|
||||
pageCount={pageCount}
|
||||
pagination={pagination}
|
||||
setPagination={setPagination}
|
||||
loading={isLoading}
|
||||
error={isError}
|
||||
hasFilters={hasFilters}
|
||||
onClearFilters={clearFilters}
|
||||
canCreateBooking={canCreateBooking}
|
||||
onOpen={openBooking}
|
||||
onCreateBooking={(row) =>
|
||||
@@ -337,7 +623,6 @@ export default function ContractClearanceListPage() {
|
||||
</Stack>
|
||||
</Card>
|
||||
</Stack>
|
||||
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -367,43 +652,19 @@ interface ShipmentBookingRow {
|
||||
bookingCreated: boolean;
|
||||
}
|
||||
|
||||
const formatDate = (iso: string | null) => {
|
||||
if (!iso) return "—";
|
||||
const d = new Date(iso);
|
||||
return Number.isNaN(d.getTime())
|
||||
? "—"
|
||||
: d.toLocaleDateString(undefined, { day: "2-digit", month: "short", year: "numeric" });
|
||||
};
|
||||
|
||||
const prettyStatus = (s: string) =>
|
||||
s
|
||||
.toLowerCase()
|
||||
.replace(/_/g, " ")
|
||||
.replace(/^\w/, (c) => c.toUpperCase());
|
||||
|
||||
const shipmentStatusColor = (s: string) => {
|
||||
if (s === "AWAITING_DOCUMENTS") return "yellow";
|
||||
if (s === "DOCUMENTS_UNDER_REVIEW") return "blue";
|
||||
if (s === "CLEARANCE_READY") return "edr-green";
|
||||
if (
|
||||
[
|
||||
"SELECTED_FOR_BATCH",
|
||||
"PNR_GENERATED",
|
||||
"AWAITING_PAYMENT",
|
||||
"PAYMENT_VERIFICATION_IN_PROGRESS",
|
||||
].includes(s)
|
||||
)
|
||||
return "violet";
|
||||
if (s === "EXPIRED") return "orange";
|
||||
if (s === "CANCELLED" || s === "REJECTED") return "red";
|
||||
return "gray";
|
||||
};
|
||||
type PaginationState = ReturnType<typeof usePagination>["pagination"];
|
||||
|
||||
/** GENERAL-contract shipment bookings currently in per-booking clearance. */
|
||||
function ShipmentBookingsTable({
|
||||
rows,
|
||||
total,
|
||||
pageCount,
|
||||
pagination,
|
||||
setPagination,
|
||||
loading,
|
||||
error,
|
||||
hasFilters,
|
||||
onClearFilters,
|
||||
canCreateBooking,
|
||||
onOpen,
|
||||
onCreateBooking,
|
||||
@@ -411,8 +672,14 @@ function ShipmentBookingsTable({
|
||||
onViewContract,
|
||||
}: {
|
||||
rows: ShipmentBookingRow[];
|
||||
total: number;
|
||||
pageCount: number;
|
||||
pagination: PaginationState;
|
||||
setPagination: ReturnType<typeof usePagination>["setPagination"];
|
||||
loading: boolean;
|
||||
error: boolean;
|
||||
hasFilters: boolean;
|
||||
onClearFilters: () => void;
|
||||
canCreateBooking: boolean;
|
||||
onOpen: (id: string) => void;
|
||||
onCreateBooking: (row: ShipmentBookingRow) => void;
|
||||
@@ -440,18 +707,23 @@ function ShipmentBookingsTable({
|
||||
id: "booking",
|
||||
header: () => <span className={bookingTable.headerCell}>Booking</span>,
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center gap-3 py-1.5">
|
||||
<div className={bookingTable.rowIcon}>
|
||||
<PackageCheck className="size-4" strokeWidth={1.75} />
|
||||
<div className="flex items-center gap-2.5 py-1">
|
||||
<div className="flex size-[30px] shrink-0 items-center justify-center rounded-[9px] bg-edr-divider text-edr-muted">
|
||||
<PackageCheck size={15} strokeWidth={1.75} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium text-foreground">
|
||||
<Text fz={13} fw={600} c="edr-text">
|
||||
{row.original.reference}
|
||||
</p>
|
||||
<p className="mt-0.5 flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<User className="size-3 shrink-0 opacity-70" />
|
||||
{row.original.customerLabel}
|
||||
</p>
|
||||
</Text>
|
||||
<Group gap={4} wrap="nowrap" align="flex-start">
|
||||
<Building2
|
||||
size={10}
|
||||
className="mt-[3px] shrink-0 text-edr-muted opacity-70"
|
||||
/>
|
||||
<Text fz={11} c="edr-muted" className="cell-wrap">
|
||||
{row.original.customerLabel}
|
||||
</Text>
|
||||
</Group>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
@@ -462,17 +734,19 @@ function ShipmentBookingsTable({
|
||||
cell: ({ row }) => {
|
||||
const r = row.original;
|
||||
return (
|
||||
<Stack gap={4} py={2}>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<FileText size={13} className="shrink-0 text-muted-foreground" />
|
||||
<Text size="sm" fw={500}>
|
||||
<Stack gap={3} py={2}>
|
||||
<Group gap={5} wrap="nowrap">
|
||||
<FileText size={12} className="shrink-0 text-edr-muted" />
|
||||
<Text fz={12.5} c="edr-text">
|
||||
{r.contractReference ?? "—"}
|
||||
</Text>
|
||||
</Group>
|
||||
{r.contractKind ? (
|
||||
<Badge size="xs" variant="default" radius="sm" tt="uppercase">
|
||||
{r.contractKind === "GENERAL" ? "General" : "One-time"}
|
||||
</Badge>
|
||||
<Text fz={10.5} c="edr-muted">
|
||||
{r.contractKind === "GENERAL"
|
||||
? "General contract"
|
||||
: "One-time"}
|
||||
</Text>
|
||||
) : null}
|
||||
</Stack>
|
||||
);
|
||||
@@ -481,44 +755,33 @@ function ShipmentBookingsTable({
|
||||
{
|
||||
id: "route",
|
||||
header: () => <span className={bookingTable.headerCell}>Route</span>,
|
||||
cell: ({ row }) => (
|
||||
<RouteLabel
|
||||
origin={row.original.originLabel}
|
||||
destination={row.original.destinationLabel}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "kind",
|
||||
header: () => <span className={bookingTable.headerCell}>Type</span>,
|
||||
cell: ({ row }) => (
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Badge variant="light" color="gray" radius="sm">
|
||||
{prettyStatus(row.original.tradeDirection)}
|
||||
</Badge>
|
||||
<Badge variant="outline" color="gray" radius="sm">
|
||||
{prettyStatus(row.original.freightType)}
|
||||
</Badge>
|
||||
<CustomsBadge customs={row.original.customs} />
|
||||
</Group>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const r = row.original;
|
||||
return (
|
||||
<RouteCell
|
||||
origin={r.originLabel}
|
||||
destination={r.destinationLabel}
|
||||
direction={r.tradeDirection}
|
||||
freightType={r.freightType}
|
||||
customs={r.customs}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "requested",
|
||||
header: () => (
|
||||
<span className={bookingTable.headerCell}>Requested cargo</span>
|
||||
),
|
||||
header: () => <span className={bookingTable.headerCell}>Cargo</span>,
|
||||
cell: ({ row }) => (
|
||||
<RequestedCargoChips lines={row.original.requested} size="sm" />
|
||||
<RequestedCargoChips lines={row.original.requested} size="xs" />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "created",
|
||||
header: () => <span className={bookingTable.headerCell}>Created</span>,
|
||||
cell: ({ row }) => (
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Calendar size={13} className="shrink-0 text-muted-foreground" />
|
||||
<Text size="sm" c="dimmed">
|
||||
<Group gap={5} wrap="nowrap">
|
||||
<CalendarClock size={12} className="shrink-0 text-edr-muted" />
|
||||
<Text fz={11.5} c="edr-muted">
|
||||
{formatDate(row.original.createdAt)}
|
||||
</Text>
|
||||
</Group>
|
||||
@@ -533,14 +796,14 @@ function ShipmentBookingsTable({
|
||||
a file added after clearance was finalized leaves the status at
|
||||
CLEARANCE_READY, and the row must still call for the review. */}
|
||||
{row.original.hasDocumentsAwaitingReview ? (
|
||||
<Badge variant="filled" color="orange" radius="sm">
|
||||
<Badge variant="filled" color="orange" radius="sm" size="sm">
|
||||
Needs approval
|
||||
</Badge>
|
||||
) : /* All docs approved but not yet finalized: the booking status is
|
||||
still DOCUMENTS_UNDER_REVIEW — show the real review state. */
|
||||
row.original.status === "DOCUMENTS_UNDER_REVIEW" &&
|
||||
row.original.allDocsApproved ? (
|
||||
<Badge variant="light" color="edr-green" radius="sm">
|
||||
<Badge variant="light" color="edr-green" radius="sm" size="sm">
|
||||
Documents approved
|
||||
</Badge>
|
||||
) : (
|
||||
@@ -548,6 +811,7 @@ function ShipmentBookingsTable({
|
||||
variant="light"
|
||||
color={shipmentStatusColor(row.original.status)}
|
||||
radius="sm"
|
||||
size="sm"
|
||||
>
|
||||
{prettyStatus(row.original.status)}
|
||||
</Badge>
|
||||
@@ -558,6 +822,7 @@ function ShipmentBookingsTable({
|
||||
variant="light"
|
||||
color="blue"
|
||||
radius="sm"
|
||||
size="sm"
|
||||
leftSection={<PackagePlus size={11} />}
|
||||
>
|
||||
Booked
|
||||
@@ -617,7 +882,10 @@ function ShipmentBookingsTable({
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item leftSection={<Eye size={14} />} onClick={() => onOpen(r.id)}>
|
||||
<Menu.Item
|
||||
leftSection={<Eye size={14} />}
|
||||
onClick={() => onOpen(r.id)}
|
||||
>
|
||||
Open booking
|
||||
</Menu.Item>
|
||||
{bookable ? (
|
||||
@@ -655,25 +923,53 @@ function ShipmentBookingsTable({
|
||||
[canCreateBooking, onOpen, onCreateBooking, onViewContract],
|
||||
);
|
||||
|
||||
if (!loading && !error && rows.length === 0) {
|
||||
if (!loading && !error && total === 0) {
|
||||
return (
|
||||
<Stack align="center" gap={8} py={48}>
|
||||
<ThemeIcon variant="light" color="gray" radius="xl" size={48}>
|
||||
<Inbox size={22} />
|
||||
</ThemeIcon>
|
||||
<Text c="dimmed">No shipment bookings in clearance.</Text>
|
||||
<Text c="dimmed">
|
||||
{hasFilters
|
||||
? "No shipments match these filters."
|
||||
: "No shipment bookings in clearance."}
|
||||
</Text>
|
||||
{hasFilters ? (
|
||||
<Button
|
||||
variant="light"
|
||||
color="gray"
|
||||
size="compact-sm"
|
||||
radius="md"
|
||||
onClick={onClearFilters}
|
||||
>
|
||||
Clear filters
|
||||
</Button>
|
||||
) : null}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box w="100%" miw={0} style={{ overflowX: "auto" }}>
|
||||
<Box w="100%" miw={0}>
|
||||
<DataTable<ShipmentBookingRow, unknown>
|
||||
columns={columns}
|
||||
data={rows}
|
||||
status={loading ? "loading" : error ? "error" : "success"}
|
||||
onRowClick={(row) => onOpen(row.id)}
|
||||
pagination={{
|
||||
pageIndex: pagination.pageIndex,
|
||||
pageSize: pagination.pageSize,
|
||||
pageCount,
|
||||
totalCount: total,
|
||||
}}
|
||||
tableOptions={{
|
||||
state: { pagination },
|
||||
onPaginationChange: setPagination,
|
||||
manualPagination: true,
|
||||
pageCount,
|
||||
}}
|
||||
containerClassName="edr-clearance-table border-0 shadow-none rounded-none bg-transparent"
|
||||
footer={(p) => <TablePager {...p} noun="shipments" />}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -15,33 +15,33 @@ import {
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
Tooltip,
|
||||
UnstyledButton,
|
||||
} from "@mantine/core";
|
||||
import { useInterval } from "@mantine/hooks";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowRight,
|
||||
Building2,
|
||||
CalendarClock,
|
||||
ChevronRight,
|
||||
FileText,
|
||||
Inbox,
|
||||
Layers,
|
||||
PackageCheck,
|
||||
RefreshCw,
|
||||
Search,
|
||||
ShipWheel,
|
||||
Truck,
|
||||
User,
|
||||
Weight,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
DataTable,
|
||||
DataTableFooter,
|
||||
usePagination,
|
||||
type ColumnDef,
|
||||
} from "@edr/ui-common";
|
||||
import { DataTable, usePagination, type ColumnDef } from "@edr/ui-common";
|
||||
|
||||
import { PageContainer } from "@/components/page/PageContainer";
|
||||
import { PageHeader } from "@/components/page/PageHeader";
|
||||
import { KpiStrip } from "@/components/page/KpiStrip";
|
||||
import { TablePager } from "@/components/page/TablePager";
|
||||
import { bookingTable } from "@/components/bookings/booking-ui.styles";
|
||||
import { useBookingDjClearanceQueue } from "@/hooks/bookings/useBookings";
|
||||
import type { BookingDetail } from "@/types/booking";
|
||||
@@ -106,6 +106,20 @@ function statusColor(status: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
/** Rows created/scheduled per day over the last `days` days, oldest → newest. */
|
||||
function perDay(rows: { scheduledDate: string | null }[], days = 8): number[] {
|
||||
const today = new Date().setHours(0, 0, 0, 0);
|
||||
const out = new Array<number>(days).fill(0);
|
||||
for (const r of rows) {
|
||||
if (!r.scheduledDate) continue;
|
||||
const age = Math.floor(
|
||||
(today - new Date(r.scheduledDate).setHours(0, 0, 0, 0)) / 86_400_000,
|
||||
);
|
||||
if (age >= 0 && age < days) out[days - 1 - age] += 1;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ── DJ next action (shipments) ───────────────────────────────────────────────
|
||||
|
||||
type DjActionKey = "RO_HOLD" | "COLLECT_DO" | "ISSUE_RO" | "LOADING" | "REVIEW";
|
||||
@@ -180,28 +194,65 @@ function toShipmentRow(b: BookingDetail): ShipmentRow {
|
||||
};
|
||||
}
|
||||
|
||||
// ── Tabs ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
type TabKey = "all" | "import" | "export" | "hold";
|
||||
|
||||
const TABS: { key: TabKey; label: string; icon: LucideIcon }[] = [
|
||||
{ key: "all", label: "All", icon: Layers },
|
||||
{ key: "import", label: "Import", icon: Truck },
|
||||
{ key: "export", label: "Export", icon: ShipWheel },
|
||||
{ key: "hold", label: "On hold", icon: AlertTriangle },
|
||||
];
|
||||
|
||||
// ── Shared cell pieces ───────────────────────────────────────────────────────
|
||||
|
||||
function DirectionIcon({ direction }: { direction: string }) {
|
||||
function LivePill({ updatedAt }: { updatedAt: number }) {
|
||||
// Re-render every 30s so "Xm ago" keeps ticking between refetches.
|
||||
const [, setTick] = useState(0);
|
||||
useInterval(() => setTick((t) => t + 1), 30_000, { autoInvoke: true });
|
||||
const mins = Math.max(0, Math.round((Date.now() - updatedAt) / 60_000));
|
||||
const label = !updatedAt
|
||||
? "Connecting…"
|
||||
: mins < 1
|
||||
? "Live · updated just now"
|
||||
: `Live · updated ${mins}m ago`;
|
||||
return (
|
||||
<span className="inline-flex shrink-0 items-center gap-1.5 whitespace-nowrap rounded-full bg-edr-soft px-2.5 py-1 text-[11px] font-medium text-edr-primary-dark">
|
||||
<span className="size-1.5 rounded-full bg-edr-primary-dark" />
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function DirectionPill({ direction }: { direction: string }) {
|
||||
const isImport = direction === "IMPORT";
|
||||
const Icon = isImport ? Truck : ShipWheel;
|
||||
const label = directionLabel(direction);
|
||||
const color = isImport ? "blue" : "teal";
|
||||
return (
|
||||
<Tooltip label={label} withArrow>
|
||||
<ThemeIcon
|
||||
variant="light"
|
||||
color={isImport ? "edr-green" : "gray"}
|
||||
radius="md"
|
||||
size={26}
|
||||
aria-label={label}
|
||||
<Tooltip label={directionLabel(direction)} withArrow>
|
||||
<span
|
||||
className="inline-flex items-center gap-1 rounded-[5px] px-1.5 py-[2px] text-[10px] font-medium leading-none"
|
||||
style={{
|
||||
background: `var(--mantine-color-${color}-0)`,
|
||||
color: `var(--mantine-color-${color}-7)`,
|
||||
}}
|
||||
>
|
||||
<Icon size={14} strokeWidth={1.9} />
|
||||
</ThemeIcon>
|
||||
<Icon size={10} />
|
||||
{prettyStatus(direction)}
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function OutlinePill({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<span className="inline-flex items-center rounded-[5px] border border-edr-border px-1.5 py-[2px] text-[10px] leading-none text-edr-muted">
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function RouteCell({
|
||||
origin,
|
||||
destination,
|
||||
@@ -214,30 +265,19 @@ function RouteCell({
|
||||
freightType: string;
|
||||
}) {
|
||||
return (
|
||||
<Stack gap={4} py={2}>
|
||||
{/* Wraps past 120px as "Addis Ababa" / "→ Djibouti"; text wraps
|
||||
normally (cells are otherwise nowrap) so it never spills over. */}
|
||||
<Text
|
||||
size="sm"
|
||||
fw={500}
|
||||
maw={120}
|
||||
lh={1.35}
|
||||
style={{ whiteSpace: "normal", overflowWrap: "anywhere" }}
|
||||
>
|
||||
{origin}{" "}
|
||||
<ArrowRight
|
||||
size={13}
|
||||
className="text-muted-foreground"
|
||||
style={{ display: "inline-block", verticalAlign: "-2px" }}
|
||||
/>
|
||||
{"\u00A0"}
|
||||
{destination}
|
||||
</Text>
|
||||
<Group gap={8} align="center">
|
||||
<DirectionIcon direction={direction} />
|
||||
<Badge size="xs" variant="default" radius="sm">
|
||||
{freightType}
|
||||
</Badge>
|
||||
<Stack gap={5} py={2}>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Text fz={12.5} fw={500} c="edr-text">
|
||||
{origin}
|
||||
</Text>
|
||||
<ArrowRight size={12} className="shrink-0 text-edr-muted" />
|
||||
<Text fz={12.5} fw={500} c="edr-text">
|
||||
{destination}
|
||||
</Text>
|
||||
</Group>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<DirectionPill direction={direction} />
|
||||
<OutlinePill>{prettyStatus(freightType)}</OutlinePill>
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
@@ -254,7 +294,7 @@ function RouteCell({
|
||||
export default function GlDjiboutiClearanceListPage() {
|
||||
const navigate = useNavigate();
|
||||
const [query, setQuery] = useState("");
|
||||
const [direction, setDirection] = useState<string | null>(null);
|
||||
const [tab, setTab] = useState<TabKey>("all");
|
||||
const [freight, setFreight] = useState<string | null>(null);
|
||||
const [status, setStatus] = useState<string | null>(null);
|
||||
const [action, setAction] = useState<string | null>(null);
|
||||
@@ -265,6 +305,7 @@ export default function GlDjiboutiClearanceListPage() {
|
||||
isLoading: bookingsLoading,
|
||||
isError: bookingsError,
|
||||
isFetching: bookingsFetching,
|
||||
dataUpdatedAt,
|
||||
refetch: refetchBookings,
|
||||
} = useBookingDjClearanceQueue();
|
||||
|
||||
@@ -277,18 +318,30 @@ export default function GlDjiboutiClearanceListPage() {
|
||||
() => (bookingQueue ?? []).map(toShipmentRow),
|
||||
[bookingQueue],
|
||||
);
|
||||
|
||||
// KPI metrics span the whole queue, regardless of filters.
|
||||
const metrics = useMemo(
|
||||
() => ({
|
||||
shipments: allShipmentRows.length,
|
||||
collectDo: allShipmentRows.filter((r) => r.action.key === "COLLECT_DO")
|
||||
.length,
|
||||
issueRo: allShipmentRows.filter((r) => r.action.key === "ISSUE_RO").length,
|
||||
roHolds: allShipmentRows.filter((r) => r.action.key === "RO_HOLD").length,
|
||||
shipments: allShipmentRows,
|
||||
collectDo: allShipmentRows.filter((r) => r.action.key === "COLLECT_DO"),
|
||||
issueRo: allShipmentRows.filter((r) => r.action.key === "ISSUE_RO"),
|
||||
roHolds: allShipmentRows.filter((r) => r.action.key === "RO_HOLD"),
|
||||
}),
|
||||
[allShipmentRows],
|
||||
);
|
||||
|
||||
const tabCounts = useMemo<Record<TabKey, number>>(
|
||||
() => ({
|
||||
all: allShipmentRows.length,
|
||||
import: allShipmentRows.filter((r) => r.tradeDirection === "IMPORT")
|
||||
.length,
|
||||
export: allShipmentRows.filter((r) => r.tradeDirection === "EXPORT")
|
||||
.length,
|
||||
hold: metrics.roHolds.length,
|
||||
}),
|
||||
[allShipmentRows, metrics.roHolds.length],
|
||||
);
|
||||
|
||||
const statusOptions = useMemo(
|
||||
() =>
|
||||
[...new Set(allShipmentRows.map((r) => r.status))].sort().map((s) => ({
|
||||
@@ -298,64 +351,45 @@ export default function GlDjiboutiClearanceListPage() {
|
||||
[allShipmentRows],
|
||||
);
|
||||
|
||||
const matchesShared = useCallback(
|
||||
(
|
||||
r: {
|
||||
reference: string;
|
||||
customerLabel: string;
|
||||
originLabel: string;
|
||||
destinationLabel: string;
|
||||
tradeDirection: string;
|
||||
freightType: string;
|
||||
status: string;
|
||||
},
|
||||
extraSearchFields: string[] = [],
|
||||
) => {
|
||||
if (direction && r.tradeDirection !== direction) return false;
|
||||
const shipmentRows = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
return allShipmentRows.filter((r) => {
|
||||
if (tab === "hold" && r.action.key !== "RO_HOLD") return false;
|
||||
if (
|
||||
(tab === "import" || tab === "export") &&
|
||||
r.tradeDirection !== tab.toUpperCase()
|
||||
)
|
||||
return false;
|
||||
if (freight && r.freightType !== freight) return false;
|
||||
if (status && r.status !== status) return false;
|
||||
const q = query.trim().toLowerCase();
|
||||
if (action && r.action.key !== action) return false;
|
||||
if (!q) return true;
|
||||
return [
|
||||
r.reference,
|
||||
r.customerLabel,
|
||||
r.contractReference,
|
||||
r.originLabel,
|
||||
r.destinationLabel,
|
||||
prettyStatus(r.status),
|
||||
...extraSearchFields,
|
||||
].some((v) => v.toLowerCase().includes(q));
|
||||
},
|
||||
[direction, freight, status, query],
|
||||
);
|
||||
|
||||
const shipmentRows = useMemo(
|
||||
() =>
|
||||
allShipmentRows.filter(
|
||||
(r) =>
|
||||
(!action || r.action.key === action) &&
|
||||
// Shipments also match the parent contract reference in search.
|
||||
matchesShared(r, [r.contractReference]),
|
||||
),
|
||||
[allShipmentRows, action, matchesShared],
|
||||
);
|
||||
});
|
||||
}, [allShipmentRows, tab, freight, status, action, query]);
|
||||
|
||||
const isLoading = bookingsLoading;
|
||||
const isError = bookingsError;
|
||||
const isFetching = bookingsFetching;
|
||||
const total = shipmentRows.length;
|
||||
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
|
||||
const showEmpty = !isLoading && !isError && total === 0;
|
||||
|
||||
const pagedShipmentRows = useMemo(() => {
|
||||
const start = pagination.pageIndex * pagination.pageSize;
|
||||
return shipmentRows.slice(start, start + pagination.pageSize);
|
||||
}, [shipmentRows, pagination.pageIndex, pagination.pageSize]);
|
||||
|
||||
const hasFilters = Boolean(query || direction || freight || status || action);
|
||||
const hasFilters = Boolean(query || freight || status || action);
|
||||
|
||||
const clearFilters = useCallback(() => {
|
||||
setQuery("");
|
||||
setDirection(null);
|
||||
setFreight(null);
|
||||
setStatus(null);
|
||||
setAction(null);
|
||||
@@ -379,18 +413,23 @@ export default function GlDjiboutiClearanceListPage() {
|
||||
cell: ({ row }) => {
|
||||
const r = row.original;
|
||||
return (
|
||||
<div className="flex items-center gap-3 py-1.5">
|
||||
<div className={bookingTable.rowIcon}>
|
||||
<PackageCheck className="size-4" strokeWidth={1.75} />
|
||||
<div className="flex items-center gap-2.5 py-1">
|
||||
<div className="flex size-[30px] shrink-0 items-center justify-center rounded-[9px] bg-edr-divider text-edr-muted">
|
||||
<PackageCheck size={15} strokeWidth={1.75} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium text-foreground">
|
||||
<Text fz={13} fw={600} c="edr-text">
|
||||
{r.reference}
|
||||
</p>
|
||||
<p className="mt-0.5 flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<User className="size-3 shrink-0 opacity-70" />
|
||||
{r.customerLabel}
|
||||
</p>
|
||||
</Text>
|
||||
<Group gap={4} wrap="nowrap" align="flex-start">
|
||||
<Building2
|
||||
size={10}
|
||||
className="mt-[3px] shrink-0 text-edr-muted opacity-70"
|
||||
/>
|
||||
<Text fz={11} c="edr-muted" className="cell-wrap">
|
||||
{r.customerLabel}
|
||||
</Text>
|
||||
</Group>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -400,9 +439,11 @@ export default function GlDjiboutiClearanceListPage() {
|
||||
id: "contract",
|
||||
header: () => <span className={bookingTable.headerCell}>Contract</span>,
|
||||
cell: ({ row }) => (
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<FileText size={13} className="shrink-0 text-muted-foreground" />
|
||||
<Text size="sm">{row.original.contractReference}</Text>
|
||||
<Group gap={5} wrap="nowrap">
|
||||
<FileText size={12} className="shrink-0 text-edr-muted" />
|
||||
<Text fz={12.5} c="edr-text">
|
||||
{row.original.contractReference}
|
||||
</Text>
|
||||
</Group>
|
||||
),
|
||||
},
|
||||
@@ -428,9 +469,11 @@ export default function GlDjiboutiClearanceListPage() {
|
||||
const r = row.original;
|
||||
return (
|
||||
<Stack gap={4} py={2}>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Weight size={13} className="shrink-0 text-muted-foreground" />
|
||||
<Text size="sm">{r.weightTons} t</Text>
|
||||
<Group gap={5} wrap="nowrap">
|
||||
<Weight size={12} className="shrink-0 text-edr-muted" />
|
||||
<Text fz={12} fw={500} c="edr-text">
|
||||
{r.weightTons} t
|
||||
</Text>
|
||||
</Group>
|
||||
{r.isHazardous ? (
|
||||
<Badge
|
||||
@@ -449,7 +492,9 @@ export default function GlDjiboutiClearanceListPage() {
|
||||
},
|
||||
{
|
||||
id: "action",
|
||||
header: () => <span className={bookingTable.headerCell}>DJ action</span>,
|
||||
header: () => (
|
||||
<span className={bookingTable.headerCell}>DJ action</span>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const r = row.original;
|
||||
const badge = (
|
||||
@@ -466,7 +511,7 @@ export default function GlDjiboutiClearanceListPage() {
|
||||
) : (
|
||||
badge
|
||||
)}
|
||||
<Text size="xs" c="dimmed">
|
||||
<Text fz={10.5} c="edr-muted">
|
||||
{phaseLabel(r.phase)}
|
||||
</Text>
|
||||
</Stack>
|
||||
@@ -489,11 +534,13 @@ export default function GlDjiboutiClearanceListPage() {
|
||||
},
|
||||
{
|
||||
id: "scheduled",
|
||||
header: () => <span className={bookingTable.headerCell}>Scheduled</span>,
|
||||
header: () => (
|
||||
<span className={bookingTable.headerCell}>Scheduled</span>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<CalendarClock size={13} className="shrink-0 text-muted-foreground" />
|
||||
<Text size="sm" c="dimmed">
|
||||
<Group gap={5} wrap="nowrap">
|
||||
<CalendarClock size={12} className="shrink-0 text-edr-muted" />
|
||||
<Text fz={11.5} c="edr-muted">
|
||||
{formatDate(row.original.scheduledDate)}
|
||||
</Text>
|
||||
</Group>
|
||||
@@ -505,7 +552,7 @@ export default function GlDjiboutiClearanceListPage() {
|
||||
header: "",
|
||||
cell: () => (
|
||||
<Group justify="flex-end" pr="xs">
|
||||
<ChevronRight size={16} className="text-muted-foreground" />
|
||||
<ChevronRight size={16} className="text-edr-muted" />
|
||||
</Group>
|
||||
),
|
||||
},
|
||||
@@ -519,17 +566,18 @@ export default function GlDjiboutiClearanceListPage() {
|
||||
<PageHeader
|
||||
title="GL Djibouti — Clearance"
|
||||
subtitle="Customs shipments handed off to Djibouti GL — every clearance step lives on the shipment."
|
||||
meta={<LivePill updatedAt={dataUpdatedAt} />}
|
||||
action={
|
||||
<ActionIcon
|
||||
<Button
|
||||
variant="default"
|
||||
size="lg"
|
||||
radius="md"
|
||||
size="sm"
|
||||
leftSection={<RefreshCw size={14} />}
|
||||
loading={isFetching}
|
||||
onClick={handleRefresh}
|
||||
aria-label="Refresh"
|
||||
>
|
||||
<RefreshCw size={16} />
|
||||
</ActionIcon>
|
||||
Refresh
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -538,139 +586,230 @@ export default function GlDjiboutiClearanceListPage() {
|
||||
items={[
|
||||
{
|
||||
label: "Shipments in queue",
|
||||
value: metrics.shipments,
|
||||
value: metrics.shipments.length,
|
||||
icon: PackageCheck,
|
||||
color: "blue",
|
||||
spark: perDay(metrics.shipments),
|
||||
},
|
||||
{
|
||||
label: "Imports — collect DO",
|
||||
value: metrics.collectDo,
|
||||
value: metrics.collectDo.length,
|
||||
icon: Truck,
|
||||
color: "yellow",
|
||||
spark: perDay(metrics.collectDo),
|
||||
},
|
||||
{
|
||||
label: "Exports — issue RO",
|
||||
value: metrics.issueRo,
|
||||
value: metrics.issueRo.length,
|
||||
icon: ShipWheel,
|
||||
color: "blue",
|
||||
spark: perDay(metrics.issueRo),
|
||||
},
|
||||
{
|
||||
label: "RO amendment holds",
|
||||
value: metrics.roHolds,
|
||||
value: metrics.roHolds.length,
|
||||
icon: AlertTriangle,
|
||||
color: "red",
|
||||
spark: perDay(metrics.roHolds),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<Card p={0} withBorder shadow="sm" radius="lg">
|
||||
<Card
|
||||
p={0}
|
||||
withBorder
|
||||
shadow="sm"
|
||||
radius="lg"
|
||||
style={{ overflow: "hidden" }}
|
||||
>
|
||||
<Stack gap={0}>
|
||||
<Box px="md" pt="md" pb="sm">
|
||||
<Group gap="sm" wrap="wrap">
|
||||
<TextInput
|
||||
placeholder="Search reference, customer, route, or status…"
|
||||
leftSection={<Search size={18} />}
|
||||
value={query}
|
||||
onChange={(e) => {
|
||||
setQuery(e.currentTarget.value);
|
||||
resetPage();
|
||||
}}
|
||||
rightSection={
|
||||
query ? (
|
||||
<ActionIcon
|
||||
size="sm"
|
||||
color="gray"
|
||||
radius="md"
|
||||
variant="transparent"
|
||||
onClick={() => {
|
||||
setQuery("");
|
||||
resetPage();
|
||||
{/* ── Tabs ─────────────────────────────────────────────── */}
|
||||
<Group
|
||||
justify="space-between"
|
||||
align="stretch"
|
||||
px="md"
|
||||
h={46}
|
||||
wrap="nowrap"
|
||||
style={{
|
||||
borderBottom: "1px solid var(--mantine-color-edr-border-6)",
|
||||
}}
|
||||
>
|
||||
<Group gap={2} wrap="nowrap" align="stretch">
|
||||
{TABS.map((t) => {
|
||||
const active = tab === t.key;
|
||||
const Icon = t.icon;
|
||||
return (
|
||||
<UnstyledButton
|
||||
key={t.key}
|
||||
onClick={() => {
|
||||
setTab(t.key);
|
||||
resetPage();
|
||||
}}
|
||||
px={13}
|
||||
className="flex items-center gap-2 transition-colors"
|
||||
style={{
|
||||
borderBottom: `2px solid ${
|
||||
active
|
||||
? "var(--mantine-color-edr-green-6)"
|
||||
: "transparent"
|
||||
}`,
|
||||
marginBottom: -1,
|
||||
}}
|
||||
aria-pressed={active}
|
||||
>
|
||||
<Icon
|
||||
size={14}
|
||||
style={{
|
||||
color: active
|
||||
? "var(--mantine-color-edr-green-6)"
|
||||
: "var(--mantine-color-gray-5)",
|
||||
}}
|
||||
/>
|
||||
<Text
|
||||
fz={13}
|
||||
fw={active ? 600 : 500}
|
||||
c={active ? "edr-text" : "edr-muted"}
|
||||
>
|
||||
{t.label}
|
||||
</Text>
|
||||
<span
|
||||
className="rounded-full px-1.5 py-px text-[10.5px] font-semibold leading-[1.4]"
|
||||
style={{
|
||||
background: active
|
||||
? "var(--mantine-color-edr-green-0)"
|
||||
: "var(--mantine-color-gray-1)",
|
||||
color: active
|
||||
? "var(--mantine-color-edr-green-7)"
|
||||
: "var(--mantine-color-edr-muted-6)",
|
||||
}}
|
||||
>
|
||||
<X size={16} />
|
||||
</ActionIcon>
|
||||
) : null
|
||||
}
|
||||
radius="lg"
|
||||
style={{ flex: 1, minWidth: 220 }}
|
||||
/>
|
||||
<Select
|
||||
placeholder="Direction"
|
||||
data={[
|
||||
{ value: "IMPORT", label: "Import" },
|
||||
{ value: "EXPORT", label: "Export" },
|
||||
]}
|
||||
value={direction}
|
||||
onChange={(v) => {
|
||||
setDirection(v);
|
||||
resetPage();
|
||||
}}
|
||||
clearable
|
||||
radius="lg"
|
||||
w={130}
|
||||
/>
|
||||
<Select
|
||||
placeholder="Freight"
|
||||
data={[
|
||||
{ value: "CONTAINER", label: "Container" },
|
||||
{ value: "BULK", label: "Bulk" },
|
||||
]}
|
||||
value={freight}
|
||||
onChange={(v) => {
|
||||
setFreight(v);
|
||||
resetPage();
|
||||
}}
|
||||
clearable
|
||||
radius="lg"
|
||||
w={130}
|
||||
/>
|
||||
<Select
|
||||
placeholder="Status"
|
||||
data={statusOptions}
|
||||
value={status}
|
||||
onChange={(v) => {
|
||||
setStatus(v);
|
||||
resetPage();
|
||||
}}
|
||||
clearable
|
||||
radius="lg"
|
||||
w={190}
|
||||
/>
|
||||
<Select
|
||||
placeholder="DJ action"
|
||||
data={DJ_ACTION_OPTIONS}
|
||||
value={action}
|
||||
onChange={(v) => {
|
||||
setAction(v);
|
||||
resetPage();
|
||||
}}
|
||||
clearable
|
||||
radius="lg"
|
||||
w={180}
|
||||
/>
|
||||
{hasFilters ? (
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="compact-sm"
|
||||
radius="md"
|
||||
leftSection={<X size={14} />}
|
||||
onClick={clearFilters}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
) : null}
|
||||
{tabCounts[t.key]}
|
||||
</span>
|
||||
</UnstyledButton>
|
||||
);
|
||||
})}
|
||||
</Group>
|
||||
</Box>
|
||||
<Text
|
||||
fz={12}
|
||||
c="edr-muted"
|
||||
className="self-center whitespace-nowrap"
|
||||
>
|
||||
{total} record{total !== 1 ? "s" : ""}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
{showEmpty ? (
|
||||
{/* ── Filter bar ───────────────────────────────────────── */}
|
||||
<Group
|
||||
gap={9}
|
||||
px="md"
|
||||
py={12}
|
||||
wrap="wrap"
|
||||
style={{
|
||||
borderBottom: "1px solid var(--mantine-color-edr-divider-6)",
|
||||
}}
|
||||
>
|
||||
<TextInput
|
||||
placeholder="Search reference, customer, route, or status…"
|
||||
leftSection={<Search size={15} />}
|
||||
value={query}
|
||||
onChange={(e) => {
|
||||
setQuery(e.currentTarget.value);
|
||||
resetPage();
|
||||
}}
|
||||
rightSection={
|
||||
query ? (
|
||||
<ActionIcon
|
||||
size="sm"
|
||||
color="gray"
|
||||
radius="md"
|
||||
variant="transparent"
|
||||
onClick={() => {
|
||||
setQuery("");
|
||||
resetPage();
|
||||
}}
|
||||
aria-label="Clear search"
|
||||
>
|
||||
<X size={14} />
|
||||
</ActionIcon>
|
||||
) : null
|
||||
}
|
||||
radius="md"
|
||||
size="sm"
|
||||
styles={{
|
||||
input: { background: "var(--mantine-color-gray-0)" },
|
||||
}}
|
||||
style={{ flex: 1, minWidth: 220 }}
|
||||
/>
|
||||
<Select
|
||||
placeholder="Freight"
|
||||
data={[
|
||||
{ value: "CONTAINER", label: "Container" },
|
||||
{ value: "BULK", label: "Bulk" },
|
||||
]}
|
||||
value={freight}
|
||||
onChange={(v) => {
|
||||
setFreight(v);
|
||||
resetPage();
|
||||
}}
|
||||
clearable
|
||||
radius="md"
|
||||
size="sm"
|
||||
w={124}
|
||||
comboboxProps={{ withinPortal: true }}
|
||||
aria-label="Filter by freight type"
|
||||
/>
|
||||
<Select
|
||||
placeholder="Status"
|
||||
data={statusOptions}
|
||||
value={status}
|
||||
onChange={(v) => {
|
||||
setStatus(v);
|
||||
resetPage();
|
||||
}}
|
||||
clearable
|
||||
radius="md"
|
||||
size="sm"
|
||||
w={180}
|
||||
comboboxProps={{ withinPortal: true }}
|
||||
aria-label="Filter by status"
|
||||
/>
|
||||
<Select
|
||||
placeholder="DJ action"
|
||||
data={DJ_ACTION_OPTIONS}
|
||||
value={action}
|
||||
onChange={(v) => {
|
||||
setAction(v);
|
||||
resetPage();
|
||||
}}
|
||||
clearable
|
||||
radius="md"
|
||||
size="sm"
|
||||
w={170}
|
||||
comboboxProps={{ withinPortal: true }}
|
||||
aria-label="Filter by DJ action"
|
||||
/>
|
||||
{hasFilters ? (
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="compact-sm"
|
||||
radius="md"
|
||||
leftSection={<X size={14} />}
|
||||
onClick={clearFilters}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
) : null}
|
||||
</Group>
|
||||
|
||||
{!isLoading && !isError && total === 0 ? (
|
||||
<Stack align="center" gap={8} py={48}>
|
||||
<ThemeIcon variant="light" color="gray" radius="xl" size={48}>
|
||||
<Inbox size={22} />
|
||||
</ThemeIcon>
|
||||
<Text c="dimmed">
|
||||
{hasFilters
|
||||
? "No records match these filters."
|
||||
? "No shipments match these filters."
|
||||
: "No shipments awaiting a Djibouti action."}
|
||||
</Text>
|
||||
{hasFilters ? (
|
||||
@@ -686,7 +825,7 @@ export default function GlDjiboutiClearanceListPage() {
|
||||
) : null}
|
||||
</Stack>
|
||||
) : (
|
||||
<Box w="100%" miw={0} style={{ overflowX: "auto" }}>
|
||||
<Box w="100%" miw={0}>
|
||||
<DataTable<ShipmentRow, unknown>
|
||||
columns={shipmentColumns}
|
||||
data={pagedShipmentRows}
|
||||
@@ -705,7 +844,7 @@ export default function GlDjiboutiClearanceListPage() {
|
||||
pageCount,
|
||||
}}
|
||||
containerClassName="edr-clearance-table border-0 shadow-none rounded-none bg-transparent"
|
||||
footer={DataTableFooter}
|
||||
footer={(p) => <TablePager {...p} noun="shipments" />}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
@@ -32,6 +32,22 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/*
|
||||
* Booking (col 1) and Contract (col 2) carry free-text company/contract names.
|
||||
* Cap those two columns and let their content wrap onto 2+ lines so a very long
|
||||
* name (e.g. "SHAFICI PHARMACEUTICAL MEDICAL SUPPLIES WHOLESALER PARTINERSHIP")
|
||||
* stacks inside its own cell instead of shoving the next column off-screen.
|
||||
* Everything below the header row so the header labels still sit on one line.
|
||||
*/
|
||||
.edr-clearance-table tbody td:not([colspan]):nth-child(1) {
|
||||
max-width: 240px;
|
||||
white-space: normal;
|
||||
}
|
||||
.edr-clearance-table tbody td:not([colspan]):nth-child(2) {
|
||||
max-width: 200px;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
/*
|
||||
* Mantine Badge caps itself at max-width: 100%; inside an auto-layout table
|
||||
* cell that resolves against min-content and clips the label. Let badges size
|
||||
@@ -41,6 +57,22 @@
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
/*
|
||||
* Opt-out for long free text (company/customer names). The blanket nowrap rule
|
||||
* above keeps every cell on one line so columns size to content; a very long
|
||||
* name would otherwise force the column absurdly wide. Mark such text with
|
||||
* `cell-wrap` to cap it and wrap onto 2+ lines instead of pushing the layout.
|
||||
*/
|
||||
.edr-clearance-table .cell-wrap,
|
||||
.edr-clearance-table .mantine-Group-root > .cell-wrap {
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
/*
|
||||
* Mantine Group's preventGrowOverflow caps every child at 100%/N of the cell.
|
||||
* In an auto-width table cell that resolves against min-content and collapses
|
||||
@@ -70,7 +102,7 @@
|
||||
min-width: 0;
|
||||
position: sticky;
|
||||
right: 0;
|
||||
box-shadow: -12px 0 16px -6px rgba(16, 32, 47, 0.3);
|
||||
box-shadow: -10px 0 14px -8px rgba(16, 32, 47, 0.12);
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -78,17 +110,41 @@
|
||||
* background or the columns underneath show through.
|
||||
*/
|
||||
.edr-clearance-table td:last-child:not([colspan]) {
|
||||
background: #f5f8fb;
|
||||
background: var(--mantine-color-body);
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
/* Row hover uses the tailwind `hover:bg-accent` class on the <tr>. */
|
||||
.edr-clearance-table tbody tr:hover td:last-child:not([colspan]) {
|
||||
background: var(--accent, #f4fbf8);
|
||||
background: #f7fbf9;
|
||||
}
|
||||
|
||||
/* Header cell is sticky on both axes — it must outrank the body's sticky column. */
|
||||
.edr-clearance-table th:last-child {
|
||||
background: #f4f7fa;
|
||||
background: var(--mantine-color-gray-0);
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
/* ── Design pass: flat head band, 64px rows, hairline dividers ─────────── */
|
||||
.edr-clearance-table thead th {
|
||||
height: 38px;
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
background: var(--mantine-color-gray-0);
|
||||
border-bottom: 1px solid var(--mantine-color-edr-divider-6);
|
||||
}
|
||||
|
||||
.edr-clearance-table tbody td:not([colspan]) {
|
||||
height: 64px;
|
||||
padding-top: 8px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid var(--mantine-color-edr-divider-6);
|
||||
}
|
||||
|
||||
.edr-clearance-table tbody tr:last-child td:not([colspan]) {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.edr-clearance-table tbody tr:hover td {
|
||||
background: #f7fbf9;
|
||||
}
|
||||
|
||||
@@ -59,6 +59,7 @@ import {
|
||||
import {
|
||||
DEFAULT_CONFIGURATION_SLUG,
|
||||
DEFAULT_RULES_SLUG,
|
||||
ROUTE_SCOPED_TRIGGERS,
|
||||
RULE_ENGINE_CATEGORY_BASE_PATH,
|
||||
RULE_ENGINE_SELECT_NONE,
|
||||
getRuleEngineResource,
|
||||
@@ -120,9 +121,7 @@ const yardOptionsForLegEnd = (
|
||||
// direction + route, so their yard dropdowns narrow exactly like base
|
||||
// freight.
|
||||
(appliesTo === "OTHER" &&
|
||||
["CUSTOMS_CLEARANCE", "WITH_RETURN", "FUEL"].includes(
|
||||
String(values.trigger ?? ""),
|
||||
))
|
||||
ROUTE_SCOPED_TRIGGERS.includes(String(values.trigger ?? "")))
|
||||
) {
|
||||
const direction = String(values.tradeDirection ?? "");
|
||||
// Direction is what decides the countries, so offer nothing until it is set
|
||||
|
||||
@@ -41,6 +41,8 @@ export interface FormFieldDef {
|
||||
disabled?: boolean;
|
||||
/** Editable on create, locked when editing an existing record. */
|
||||
disabledOnEdit?: boolean;
|
||||
/** Lock the field while the predicate accepts the live form values. */
|
||||
disabledIf?: (values: Record<string, unknown>) => boolean;
|
||||
/** Trailing unit label shown inside the input (e.g. "USD" on a rate value). */
|
||||
suffix?: string;
|
||||
/** Hide this field when another field currently equals one of these values. */
|
||||
@@ -221,6 +223,10 @@ const RATE_TRIGGERS = [
|
||||
{ label: "Cancellation (per wagon, per direction + cargo type)", value: "CANCELLATION" },
|
||||
{ label: "Shipping line extra fee (PIL)", value: "PIL_EXTRA_FEE" },
|
||||
{ label: "Customs clearance service fee (billed with the booking)", value: "CUSTOMS_CLEARANCE" },
|
||||
{
|
||||
label: "Ethiopian customs clearance service fee (Ethiopian-side-only services)",
|
||||
value: "ETHIOPIAN_CUSTOMS_CLEARANCE",
|
||||
},
|
||||
{ label: "Fuel (per lane + cargo type)", value: "FUEL" },
|
||||
];
|
||||
|
||||
@@ -279,8 +285,16 @@ const SHIPPING_LINE_CARGO_KINDS = [
|
||||
const isBaseFreightRate = (values: Record<string, unknown>) =>
|
||||
["BULK", "CONTAINER", "INTERCITY"].includes(String(values.appliesTo ?? ""));
|
||||
|
||||
/** Surcharges sold per origin → destination leg (mirrors RatesService.isRouteScoped). */
|
||||
export const ROUTE_SCOPED_TRIGGERS = [
|
||||
"CUSTOMS_CLEARANCE",
|
||||
"ETHIOPIAN_CUSTOMS_CLEARANCE",
|
||||
"WITH_RETURN",
|
||||
"FUEL",
|
||||
];
|
||||
|
||||
/**
|
||||
* Rates priced per leg: base rail freight, plus the customs clearance fee and
|
||||
* Rates priced per leg: base rail freight, plus the customs clearance fees and
|
||||
* the empty-container return surcharge (sold per route + container type).
|
||||
*/
|
||||
const isRouteScopedRate = (values: Record<string, unknown>) =>
|
||||
@@ -289,19 +303,19 @@ const isRouteScopedRate = (values: Record<string, unknown>) =>
|
||||
(isShippingLineRate(values)
|
||||
? hasShippingLine(values) &&
|
||||
(values.shippingLineRateKind === "BASE" ||
|
||||
["CUSTOMS_CLEARANCE", "WITH_RETURN", "FUEL"].includes(
|
||||
String(values.trigger ?? ""),
|
||||
))
|
||||
ROUTE_SCOPED_TRIGGERS.includes(String(values.trigger ?? "")))
|
||||
: isBaseFreightRate(values)) ||
|
||||
(String(values.appliesTo ?? "") === "OTHER" &&
|
||||
["CUSTOMS_CLEARANCE", "WITH_RETURN", "FUEL"].includes(String(values.trigger ?? "")));
|
||||
ROUTE_SCOPED_TRIGGERS.includes(String(values.trigger ?? "")));
|
||||
|
||||
/**
|
||||
* Surcharges sold per cargo kind: the admin says container or bulk, then names
|
||||
* the container type or bulk commodity the fee covers.
|
||||
*/
|
||||
const isCargoKindTrigger = (values: Record<string, unknown>) =>
|
||||
["CUSTOMS_CLEARANCE", "CANCELLATION"].includes(String(values.trigger ?? ""));
|
||||
["CUSTOMS_CLEARANCE", "ETHIOPIAN_CUSTOMS_CLEARANCE", "CANCELLATION"].includes(
|
||||
String(values.trigger ?? ""),
|
||||
);
|
||||
|
||||
const unitOption = (value: string) => ({ label: value.replace(/_/g, " "), value });
|
||||
|
||||
@@ -345,6 +359,7 @@ const unitsForShape = (
|
||||
// Wagon cancellation fee — scales with the cancelled wagons only.
|
||||
return ["PER_WAGON"];
|
||||
case "CUSTOMS_CLEARANCE":
|
||||
case "ETHIOPIAN_CUSTOMS_CLEARANCE":
|
||||
// Per cargo kind: container fees per box/wagon, bulk per ton/wagon.
|
||||
return cargoKind === "BULK"
|
||||
? ["PER_TON", "PER_WAGON"]
|
||||
@@ -875,7 +890,28 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
{ name: "canBeBookedAlone", label: "Can be booked alone", type: "boolean" },
|
||||
{ name: "includesFirstMile", label: "Includes first mile", type: "boolean" },
|
||||
{ name: "includesLastMile", label: "Includes last mile", type: "boolean" },
|
||||
{ name: "includesCustoms", label: "Includes customs", type: "boolean" },
|
||||
// Full customs and Ethiopian-only customs are alternatives — turning one
|
||||
// on clears and locks the other (see RuleEngineFormDialog.setField). The
|
||||
// API stores includesCustoms = true for both; the toggle shown here is
|
||||
// "full customs", so an Ethiopian-only record reads it back as off.
|
||||
{
|
||||
name: "includesCustoms",
|
||||
label: "Includes customs",
|
||||
type: "boolean",
|
||||
description:
|
||||
"Full customs clearance bundled with the service. Cannot be combined with Ethiopian customs only.",
|
||||
getInitialValue: (record) =>
|
||||
record.includesCustoms === true && record.includesEthiopianCustomsOnly !== true,
|
||||
disabledIf: (v) => v.includesEthiopianCustomsOnly === true,
|
||||
},
|
||||
{
|
||||
name: "includesEthiopianCustomsOnly",
|
||||
label: "Ethiopian customs only",
|
||||
type: "boolean",
|
||||
description:
|
||||
"EDR clears customs on the Ethiopian side only. Same clearance flow; contracts and bookings price off the Ethiopian customs clearance rate. Cannot be combined with Includes customs.",
|
||||
disabledIf: (v) => v.includesCustoms === true,
|
||||
},
|
||||
{ name: "isActive", label: "Active", type: "boolean" },
|
||||
],
|
||||
},
|
||||
@@ -1081,6 +1117,11 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
label: "Customs clearance",
|
||||
filters: { trigger: "CUSTOMS_CLEARANCE", isShippingLineRate: "false" },
|
||||
},
|
||||
{
|
||||
key: "ethiopian-customs",
|
||||
label: "Ethiopian customs",
|
||||
filters: { trigger: "ETHIOPIAN_CUSTOMS_CLEARANCE", isShippingLineRate: "false" },
|
||||
},
|
||||
{
|
||||
key: "return",
|
||||
label: "Container return",
|
||||
@@ -1235,6 +1276,7 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
(String(v.appliesTo ?? "") === "OTHER" &&
|
||||
[
|
||||
"CUSTOMS_CLEARANCE",
|
||||
"ETHIOPIAN_CUSTOMS_CLEARANCE",
|
||||
"CANCELLATION",
|
||||
"WITH_RETURN",
|
||||
"LASHING",
|
||||
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
Train,
|
||||
Weight,
|
||||
Workflow as WorkflowIcon,
|
||||
Warehouse,
|
||||
} from "lucide-react";
|
||||
import { DateTimePicker } from "@mantine/dates";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
@@ -59,6 +60,7 @@ import { ContainerPlacementGrid } from "@/components/trainScheduling/ContainerPl
|
||||
import { FleetAvailabilitySummary } from "@/components/trainScheduling/FleetAvailabilitySummary";
|
||||
import { IntercityRideAlongPanel } from "@/components/trainScheduling/IntercityRideAlongPanel";
|
||||
import { LegCapacityPanel } from "@/components/trainScheduling/LegCapacityPanel";
|
||||
import { ScheduleWagonYardPanel } from "@/components/trainScheduling/ScheduleWagonYardPanel";
|
||||
import { LegLoadBoardPanel } from "@/components/trainScheduling/LegLoadBoardPanel";
|
||||
import { LoadEmptyContainersModal } from "@/components/trainScheduling/LoadEmptyContainersModal";
|
||||
import MergeScheduleTrainModal from "@/components/trainScheduling/MergeScheduleTrainModal";
|
||||
@@ -1287,6 +1289,9 @@ export default function TrainScheduleV2DetailPage() {
|
||||
<Tabs.Tab value="leg-board" leftSection={<Grid3x3 size={16} />}>
|
||||
Leg board
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="wagon-yards" leftSection={<Warehouse size={16} />}>
|
||||
Schedule yards
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="history" leftSection={<HistoryIcon size={16} />}>
|
||||
History
|
||||
</Tabs.Tab>
|
||||
@@ -1382,6 +1387,15 @@ export default function TrainScheduleV2DetailPage() {
|
||||
/>
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="wagon-yards">
|
||||
{scheduleId ? (
|
||||
<ScheduleWagonYardPanel
|
||||
scheduleId={scheduleId}
|
||||
canEdit={hasPermission(authUser, FREIGHT_PERMS.trainScheduling.update)}
|
||||
/>
|
||||
) : null}
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="history">
|
||||
{scheduleId ? <ScheduleHistoryPanel scheduleId={scheduleId} /> : null}
|
||||
</Tabs.Panel>
|
||||
|
||||
@@ -232,6 +232,9 @@ import {
|
||||
type BuiltTrainListFilters,
|
||||
type BuiltTrainListResponse,
|
||||
type ScheduleConsist,
|
||||
type ScheduleWagonYards,
|
||||
type UpdateScheduleWagonYardsPayload,
|
||||
type UpdateScheduleWagonYardsResult,
|
||||
type ScheduleHistoryEntry,
|
||||
type TrainComposition,
|
||||
type UpdateTrainDetailsPayload,
|
||||
@@ -416,6 +419,30 @@ export const api = {
|
||||
],
|
||||
),
|
||||
|
||||
scheduleWagonYards: endpoint<{ scheduleId: string }, ScheduleWagonYards>(
|
||||
"train-scheduling",
|
||||
"schedule-wagon-yards",
|
||||
({ scheduleId }) =>
|
||||
trainBuilderService.scheduleWagonYards(scheduleId).then((r) => r.data),
|
||||
({ scheduleId }) => [
|
||||
...QUERY_KEYS.TRAIN_SCHEDULING.ROOT,
|
||||
"wagon-yards",
|
||||
scheduleId,
|
||||
],
|
||||
),
|
||||
|
||||
updateScheduleWagonYards: endpoint<
|
||||
{ scheduleId: string; payload: UpdateScheduleWagonYardsPayload },
|
||||
UpdateScheduleWagonYardsResult
|
||||
>(
|
||||
"train-scheduling",
|
||||
"update-schedule-wagon-yards",
|
||||
({ scheduleId, payload }) =>
|
||||
trainBuilderService.updateScheduleWagonYards(scheduleId, payload).then((r) => r.data),
|
||||
undefined,
|
||||
() => TRAIN_SCHEDULING_INVALIDATIONS,
|
||||
),
|
||||
|
||||
adjustConsist: endpoint<
|
||||
{ scheduleId: string; payload: AdjustConsistPayload },
|
||||
AdjustConsistResult
|
||||
|
||||
@@ -466,11 +466,11 @@ export const bookingsService = {
|
||||
return unwrap(response.data) as Freight.ClearanceCharge[];
|
||||
},
|
||||
|
||||
/** GL Ethiopia sets or revises a charge's amount + currency. */
|
||||
/** GL Ethiopia sets or revises a charge's amount, currency and description. */
|
||||
billClearanceCharge: async (
|
||||
id: string,
|
||||
chargeId: string,
|
||||
payload: { amount: number; currency: string },
|
||||
payload: { amount: number; currency: string; description?: string },
|
||||
): Promise<Freight.ClearanceCharge[]> => {
|
||||
const response = await client.patch(
|
||||
`/bookings/${id}/clearance/charges/${chargeId}/bill`,
|
||||
@@ -479,7 +479,7 @@ export const bookingsService = {
|
||||
return unwrap(response.data) as Freight.ClearanceCharge[];
|
||||
},
|
||||
|
||||
/** GL Ethiopia issues the charge's payable invoice to the customer. */
|
||||
/** GL Ethiopia sends the priced charge to the customer for approval. */
|
||||
sendClearanceCharge: async (
|
||||
id: string,
|
||||
chargeId: string,
|
||||
@@ -490,16 +490,17 @@ export const bookingsService = {
|
||||
return unwrap(response.data) as Freight.ClearanceCharge[];
|
||||
},
|
||||
|
||||
/** GL Ethiopia creates the miscellaneous charge (document + amount + currency). */
|
||||
/** GL Ethiopia creates a miscellaneous charge (document + amount + currency + description). */
|
||||
createMiscellaneousCharge: async (
|
||||
id: string,
|
||||
file: File,
|
||||
payload: { amount: number; currency: string },
|
||||
payload: { amount: number; currency: string; description: string },
|
||||
): Promise<Freight.ClearanceCharge[]> => {
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
form.append("amount", String(payload.amount));
|
||||
form.append("currency", payload.currency);
|
||||
form.append("description", payload.description);
|
||||
const response = await client.post(
|
||||
`/bookings/${id}/clearance/charges/miscellaneous`,
|
||||
form,
|
||||
|
||||
@@ -300,6 +300,45 @@ export interface ScheduleHistoryEntry {
|
||||
/** Adjust response: fresh consist + schedule-impact warnings to surface. */
|
||||
export type AdjustConsistResult = ScheduleConsist & { warnings: string[] };
|
||||
|
||||
/** One consist wagon in the schedule-yards tab: where this departure plans it vs where it stands. */
|
||||
export interface ScheduleWagonYardRow {
|
||||
id: string;
|
||||
wagonNumber: string;
|
||||
sequenceNumber: number | null;
|
||||
wagonType: { id: string; code: string; name: string };
|
||||
physicalYardId: string | null;
|
||||
physicalYardLabel: string | null;
|
||||
plannedYardId: string | null;
|
||||
plannedYardLabel: string | null;
|
||||
aligned: boolean;
|
||||
locked: boolean;
|
||||
lockReason: string | null;
|
||||
}
|
||||
|
||||
export interface ScheduleWagonYardStop {
|
||||
yardId: string;
|
||||
label: string;
|
||||
/** Origin or intermediate stop — wagons can board here. The destination cannot. */
|
||||
pickup: boolean;
|
||||
planned: number;
|
||||
physical: number;
|
||||
}
|
||||
|
||||
export interface ScheduleWagonYards {
|
||||
scheduleId: string;
|
||||
train: { id: string; code: string };
|
||||
editable: boolean;
|
||||
stops: ScheduleWagonYardStop[];
|
||||
wagons: ScheduleWagonYardRow[];
|
||||
misaligned: number;
|
||||
}
|
||||
|
||||
export interface UpdateScheduleWagonYardsPayload {
|
||||
moves: Array<{ wagonId: string; yardId: string }>;
|
||||
}
|
||||
|
||||
export type UpdateScheduleWagonYardsResult = ScheduleWagonYards & { warnings: string[] };
|
||||
|
||||
export const trainBuilderService = {
|
||||
list: (filters: BuiltTrainListFilters = {}) =>
|
||||
apiClient.get<BuiltTrainListResponse>(`${BASE}${toQuery(filters)}`),
|
||||
@@ -350,6 +389,15 @@ export const trainBuilderService = {
|
||||
`/train-scheduling/schedules/${scheduleId}/adjust-consist`,
|
||||
payload,
|
||||
),
|
||||
/** Schedule-only wagon yard plan (where THIS departure boards each wagon). */
|
||||
scheduleWagonYards: (scheduleId: string) =>
|
||||
apiClient.get<ScheduleWagonYards>(`/train-scheduling/schedules/${scheduleId}/wagon-yards`),
|
||||
/** Re-plan boarding yards for this schedule; physical wagon yards untouched. */
|
||||
updateScheduleWagonYards: (scheduleId: string, payload: UpdateScheduleWagonYardsPayload) =>
|
||||
apiClient.patch<UpdateScheduleWagonYardsResult>(
|
||||
`/train-scheduling/schedules/${scheduleId}/wagon-yards`,
|
||||
payload,
|
||||
),
|
||||
/** Unified wagon/booking change history for the schedule's History tab. */
|
||||
scheduleHistory: (scheduleId: string) =>
|
||||
apiClient.get<ScheduleHistoryEntry[]>(
|
||||
|
||||
@@ -2,8 +2,8 @@ import { Box, Group, Stack, Text } from "@mantine/core";
|
||||
import { memo } from "react";
|
||||
import { ACTION_PROPS, STATUS_CONFIG, cv } from "../constants";
|
||||
import { Stepper } from "./Stepper";
|
||||
import { PayNowButton } from "@/pages/bookings/payments/PayNowButton";
|
||||
import { payWindowState } from "@/pages/bookings/payments/payment-drain";
|
||||
import { PayButton } from "@/pages/bookings/payments/PayButton";
|
||||
import { useMyPayables } from "@/pages/bookings/payments/useMyPayables";
|
||||
import { BookingActionButton } from "@/pages/bookings/clearance/BookingActionButton";
|
||||
import { bookingHasInlineAction } from "@/pages/bookings/clearance/bookingNextAction";
|
||||
import {
|
||||
@@ -28,21 +28,9 @@ export const BookingRow = memo(function BookingRow({
|
||||
const Icon = cfg.icon;
|
||||
const AIcon = cfg.action.icon;
|
||||
const ap = ACTION_PROPS[cfg.action.kind];
|
||||
// Payable bookings get an inline "Pay now" that opens the payment modal
|
||||
// instead of navigating to the detail page. A general contract is payable as
|
||||
// soon as it's FULLY_EXECUTED (signed); a one-time booking only after it's
|
||||
// SELECTED_FOR_BATCH — same rule as the bookings list's PrimaryAction.
|
||||
const payableStatus =
|
||||
booking.bookingType === "GENERAL_CONTRACT"
|
||||
? "FULLY_EXECUTED"
|
||||
: "SELECTED_FOR_BATCH";
|
||||
// A fully-closed pay window (deadline + drain both elapsed) has nothing to pay
|
||||
// against, so the row falls back to its normal action instead of an empty slot.
|
||||
// The drain itself still routes here — PayNowButton renders the wait notice.
|
||||
const canPay =
|
||||
booking.status === payableStatus &&
|
||||
booking.paymentStatus !== "PAID" &&
|
||||
payWindowState(booking).phase !== "closed";
|
||||
// Anything outstanding (freight, clearance charge, duty slip, cancellation
|
||||
// fee) → "Pay" jumps to the booking's Payments tab. One shared query.
|
||||
const payable = useMyPayables().get(booking.id);
|
||||
// Clearance/operation steps + changes-requested resubmit can be done in place
|
||||
// via a modal on the row.
|
||||
const hasInlineAction = bookingHasInlineAction(booking);
|
||||
@@ -113,8 +101,8 @@ export const BookingRow = memo(function BookingRow({
|
||||
{cfg.badgeLabel}
|
||||
</Text>
|
||||
</Group>
|
||||
{canPay ? (
|
||||
<PayNowButton booking={booking} size="sm" />
|
||||
{payable ? (
|
||||
<PayButton bookingId={booking.id} summary={payable} size="sm" />
|
||||
) : canSign ? (
|
||||
<ContractSignButton booking={booking} size="sm" />
|
||||
) : canApproveDelivery ? (
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
useState,
|
||||
} from "react";
|
||||
import {
|
||||
Alert,
|
||||
Anchor,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
FileInput,
|
||||
Group,
|
||||
Paper,
|
||||
Stack,
|
||||
@@ -15,19 +15,20 @@ import {
|
||||
import {
|
||||
AlertTriangle,
|
||||
Check,
|
||||
Download,
|
||||
Eye,
|
||||
FileBadge,
|
||||
MessageSquareWarning,
|
||||
Receipt,
|
||||
Upload,
|
||||
} from "lucide-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
useQuery,
|
||||
} from "@tanstack/react-query";
|
||||
import toast from "react-hot-toast";
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
import { bookingsService } from "@/services/bookings.service";
|
||||
import { contractsService } from "@/services/contracts.service";
|
||||
import {
|
||||
bookingsService,
|
||||
} from "@/services/bookings.service";
|
||||
import { downloadStoredFile } from "@/services/files.service";
|
||||
import { ClearancePhaseStepper } from "../contracts/ClearancePhaseStepper";
|
||||
import { ClearanceUploadedDocumentsPanel } from "@/components/contracts/ClearanceUploadedDocumentsPanel";
|
||||
@@ -36,29 +37,6 @@ import { GREEN, INK } from "../contracts/contract-ui";
|
||||
|
||||
const BORDER = "#E6ECF2";
|
||||
|
||||
// Customer-facing labels for an invoice status (Freight.InvoiceStatus).
|
||||
const INVOICE_STATUS_LABELS: Record<string, string> = {
|
||||
DRAFT: "Draft",
|
||||
ISSUED: "Issued",
|
||||
PENDING: "Due",
|
||||
PAYMENT_PROCESSING: "Payment processing",
|
||||
PARTIALLY_PAID: "Partially paid",
|
||||
PAID: "Paid",
|
||||
OVERDUE: "Overdue",
|
||||
CANCELLED: "Cancelled",
|
||||
REFUNDED: "Refunded",
|
||||
EXPIRED: "Expired",
|
||||
};
|
||||
|
||||
function invoiceStatusLabel(status: string): string {
|
||||
return (
|
||||
INVOICE_STATUS_LABELS[status] ??
|
||||
status
|
||||
.replace(/_/g, " ")
|
||||
.toLowerCase()
|
||||
.replace(/\b\w/g, (m) => m.toUpperCase())
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -81,13 +59,9 @@ export function BookingClearanceWorkflowBanner({
|
||||
|
||||
if (!isPhased || !clearance) return null;
|
||||
|
||||
const dutyPaid = clearance.milestones?.some(
|
||||
(m) => m.milestoneCode === "DUTY_TAX_PAID" && m.status === "COMPLETED",
|
||||
);
|
||||
const dutyPending =
|
||||
clearance.dutyRequired &&
|
||||
clearance.dutyAdvice &&
|
||||
!dutyPaid;
|
||||
// Duty / tax, additional duty and the final invoice are paid from the
|
||||
// booking's Payments tab (CustomsPaymentsCard); this banner keeps the
|
||||
// progress, the draft declaration and the documents.
|
||||
// A change request clears the draft while it's open — show the "waiting on
|
||||
// GL" state instead of the review panel until GL sends a corrected draft.
|
||||
const draftDeclarationChangeRequestPending = Boolean(
|
||||
@@ -131,14 +105,6 @@ export function BookingClearanceWorkflowBanner({
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{dutyPending && clearance.dutyAdvice ? (
|
||||
<DutyAdvicePanel
|
||||
dutyAdvice={clearance.dutyAdvice}
|
||||
bookingId={booking.id}
|
||||
onChanged={() => void refetch()}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{clearance.riskLevel ? (
|
||||
<Group gap={10} align="center">
|
||||
<Text fw={700} fz={14} c={INK}>
|
||||
@@ -160,24 +126,6 @@ export function BookingClearanceWorkflowBanner({
|
||||
</Group>
|
||||
) : null}
|
||||
|
||||
{clearance.secondDuty?.advised ? (
|
||||
<SecondDutyDueCard
|
||||
duty={clearance.secondDuty}
|
||||
bookingId={booking.id}
|
||||
onView={(f) => view(f)}
|
||||
onChanged={() => void refetch()}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{clearance.finalInvoice ? (
|
||||
<FinalInvoiceDueCard
|
||||
invoice={clearance.finalInvoice}
|
||||
bookingId={booking.id}
|
||||
onView={(f) => view(f)}
|
||||
onChanged={() => void refetch()}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{clearance.operationReady ? (
|
||||
<Alert color="green" variant="light">
|
||||
Clearance is complete. You may proceed to request your operation date.
|
||||
@@ -197,76 +145,6 @@ export function BookingClearanceWorkflowBanner({
|
||||
);
|
||||
}
|
||||
|
||||
function DutyAdvicePanel({
|
||||
dutyAdvice,
|
||||
bookingId,
|
||||
onChanged,
|
||||
}: {
|
||||
dutyAdvice: NonNullable<Freight.ClearanceView["dutyAdvice"]>;
|
||||
bookingId: string;
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const noticeFile = dutyAdvice.noticeFile;
|
||||
|
||||
return (
|
||||
<Paper withBorder radius="md" p="md" bg="#FFFBF0">
|
||||
<Stack gap="sm">
|
||||
<GroupLabel icon={Receipt} text="Duty / tax payment" />
|
||||
<Text size="sm">
|
||||
Amount due:{" "}
|
||||
<strong>
|
||||
{dutyAdvice.amount.toLocaleString()} {dutyAdvice.currency}
|
||||
</strong>
|
||||
{dutyAdvice.declarationSerial
|
||||
? ` · Payment code: ${dutyAdvice.declarationSerial}`
|
||||
: null}
|
||||
</Text>
|
||||
{noticeFile ? (
|
||||
<Anchor
|
||||
component="button"
|
||||
type="button"
|
||||
onClick={() => void downloadStoredFile(noticeFile.id, noticeFile.name)}
|
||||
size="sm"
|
||||
>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Download size={14} />
|
||||
Download duty notice ({noticeFile.name})
|
||||
</Group>
|
||||
</Anchor>
|
||||
) : null}
|
||||
<Text size="sm" c="dimmed">
|
||||
Pay the amount above, then upload your payment slip so clearance can continue.
|
||||
</Text>
|
||||
<FileInput label="Payment slip" value={file} onChange={setFile} size="sm" />
|
||||
<Button
|
||||
color="orange"
|
||||
loading={loading}
|
||||
disabled={!file}
|
||||
leftSection={<Upload size={16} />}
|
||||
onClick={async () => {
|
||||
if (!file) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
await bookingsService.uploadBookingClearanceDutySlip(bookingId, file);
|
||||
toast.success("Payment slip uploaded");
|
||||
onChanged();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Upload failed");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Submit payment slip
|
||||
</Button>
|
||||
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* GL Ethiopia sent a draft customs declaration — an estimated price + files
|
||||
* the customer must accept before the real declaration is filed, or send back
|
||||
@@ -446,328 +324,8 @@ function GroupLabel({ icon: Icon, text }: { icon: typeof Receipt; text: string }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Post-offload final invoice from GL Djibouti (export): shows the due amount +
|
||||
* invoice document; the customer pays offline and attaches the payment slip
|
||||
* here, then GL confirms and the badge flips to PAID.
|
||||
*/
|
||||
function FinalInvoiceDueCard({
|
||||
invoice,
|
||||
bookingId,
|
||||
onView,
|
||||
onChanged,
|
||||
}: {
|
||||
invoice: NonNullable<Freight.ClearanceView["finalInvoice"]>;
|
||||
bookingId: string;
|
||||
onView: (file: { name: string; url: string }) => void;
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
const [slip, setSlip] = useState<File | null>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [approving, setApproving] = useState(false);
|
||||
const paid = invoice.status === "PAID";
|
||||
// GL Djibouti raises it as a draft: nothing is payable until the customer
|
||||
// reviews the attached invoice and approves it.
|
||||
const approved = Boolean(invoice.approvedAt);
|
||||
|
||||
return (
|
||||
<Paper
|
||||
withBorder
|
||||
radius="lg"
|
||||
p="lg"
|
||||
style={{
|
||||
borderColor: paid ? "#CDEBDD" : "#F2D9A6",
|
||||
background: paid ? "#F6FBF8" : "#FFFBF2",
|
||||
position: "relative",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: 3,
|
||||
background: paid ? GREEN : "#E3A93C",
|
||||
}}
|
||||
/>
|
||||
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
|
||||
<div>
|
||||
<Group gap={8} align="center">
|
||||
<FileBadge size={16} color={paid ? GREEN : "#B07C1F"} />
|
||||
<Text fw={700} fz={15} c={INK}>
|
||||
{paid
|
||||
? "Final invoice paid"
|
||||
: approved
|
||||
? "Final invoice due"
|
||||
: "Final invoice — your approval needed"}{" "}
|
||||
— {invoice.invoiceNumber}
|
||||
</Text>
|
||||
<Badge color={paid ? "edr-green" : "yellow"} variant="light" radius="sm">
|
||||
{approved
|
||||
? invoiceStatusLabel(invoice.status)
|
||||
: "Awaiting your approval"}
|
||||
</Badge>
|
||||
</Group>
|
||||
<Text fz={20} fw={800} mt={6} c={INK}>
|
||||
{invoice.totalAmount.toLocaleString()} {invoice.currency}
|
||||
</Text>
|
||||
{invoice.description ? (
|
||||
<Text fz={13} c="dimmed" mt={2}>
|
||||
{invoice.description}
|
||||
</Text>
|
||||
) : null}
|
||||
{!paid ? (
|
||||
<Text fz={13} c="#9A6B1F" mt={6}>
|
||||
{approved
|
||||
? "Pay the amount above and attach your payment slip — Global Logistics will confirm the payment."
|
||||
: "Review the invoice document from Global Logistics Djibouti and approve it to proceed with payment."}
|
||||
</Text>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<Stack gap="xs" miw={260}>
|
||||
{invoice.invoiceFile ? (
|
||||
<Button
|
||||
variant="default"
|
||||
radius="md"
|
||||
size="sm"
|
||||
leftSection={<Eye size={15} />}
|
||||
onClick={() =>
|
||||
onView({
|
||||
name: invoice.invoiceFile!.name,
|
||||
url: invoice.invoiceFile!.url,
|
||||
})
|
||||
}
|
||||
>
|
||||
View invoice
|
||||
</Button>
|
||||
) : null}
|
||||
{invoice.slipFile ? (
|
||||
<Button
|
||||
variant="default"
|
||||
radius="md"
|
||||
size="sm"
|
||||
leftSection={<Eye size={15} />}
|
||||
onClick={() =>
|
||||
onView({
|
||||
name: invoice.slipFile!.name,
|
||||
url: invoice.slipFile!.url,
|
||||
})
|
||||
}
|
||||
>
|
||||
View payment slip
|
||||
</Button>
|
||||
) : null}
|
||||
{!paid && !approved ? (
|
||||
<Button
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
size="sm"
|
||||
loading={approving}
|
||||
leftSection={<Check size={15} />}
|
||||
onClick={async () => {
|
||||
setApproving(true);
|
||||
try {
|
||||
await contractsService.approveFinalInvoice(bookingId);
|
||||
toast.success("Invoice approved — you can now pay");
|
||||
onChanged();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Approval failed");
|
||||
} finally {
|
||||
setApproving(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Approve invoice
|
||||
</Button>
|
||||
) : null}
|
||||
{!paid && approved ? (
|
||||
<>
|
||||
<FileInput
|
||||
placeholder={
|
||||
invoice.slipFile ? "Replace payment slip" : "Attach payment slip"
|
||||
}
|
||||
value={slip}
|
||||
onChange={setSlip}
|
||||
size="sm"
|
||||
radius="md"
|
||||
/>
|
||||
<Button
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
size="sm"
|
||||
loading={uploading}
|
||||
disabled={!slip}
|
||||
leftSection={<Upload size={15} />}
|
||||
onClick={async () => {
|
||||
if (!slip) return;
|
||||
setUploading(true);
|
||||
try {
|
||||
await contractsService.uploadFinalInvoiceSlip(bookingId, slip);
|
||||
setSlip(null);
|
||||
toast.success("Payment slip attached");
|
||||
onChanged();
|
||||
} catch (e) {
|
||||
toast.error(
|
||||
e instanceof Error ? e.message : "Upload failed",
|
||||
);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{invoice.slipFile ? "Replace slip" : "Submit payment slip"}
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Group>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
const CUSTOMS_RISK_COLOR: Record<string, string> = {
|
||||
GREEN: "green",
|
||||
YELLOW: "yellow",
|
||||
RED: "red",
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Post-arrival additional duty/tax round (import): GL advises an extra amount
|
||||
* with a notice; the customer pays offline and attaches another slip here.
|
||||
*/
|
||||
function SecondDutyDueCard({
|
||||
duty,
|
||||
bookingId,
|
||||
onView,
|
||||
onChanged,
|
||||
}: {
|
||||
duty: NonNullable<Freight.ClearanceView["secondDuty"]>;
|
||||
bookingId: string;
|
||||
onView: (file: { name: string; url: string }) => void;
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
const [slip, setSlip] = useState<File | null>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const paid = duty.paid;
|
||||
|
||||
return (
|
||||
<Paper
|
||||
withBorder
|
||||
radius="lg"
|
||||
p="lg"
|
||||
style={{
|
||||
borderColor: paid ? "#CDEBDD" : "#F2D9A6",
|
||||
background: paid ? "#F6FBF8" : "#FFFBF2",
|
||||
position: "relative",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: 3,
|
||||
background: paid ? GREEN : "#E3A93C",
|
||||
}}
|
||||
/>
|
||||
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
|
||||
<div>
|
||||
<Group gap={8} align="center">
|
||||
<FileBadge size={16} color={paid ? GREEN : "#B07C1F"} />
|
||||
<Text fw={700} fz={15} c={INK}>
|
||||
{paid ? "Additional duty & tax paid" : "Additional duty & tax due"}
|
||||
</Text>
|
||||
<Badge color={paid ? "edr-green" : "yellow"} variant="light" radius="sm">
|
||||
{paid ? "PAID" : "DUE"}
|
||||
</Badge>
|
||||
</Group>
|
||||
<Text fz={20} fw={800} mt={6} c={INK}>
|
||||
{(duty.amount ?? 0).toLocaleString()} {duty.currency ?? ""}
|
||||
</Text>
|
||||
{duty.declarationSerial ? (
|
||||
<Text fz={13} c="dimmed" mt={2}>
|
||||
Payment code: {duty.declarationSerial}
|
||||
</Text>
|
||||
) : null}
|
||||
{!paid ? (
|
||||
<Text fz={13} c="#9A6B1F" mt={6}>
|
||||
Customs advised additional duty/tax after arrival. Pay the amount
|
||||
above and attach your payment slip.
|
||||
</Text>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<Stack gap="xs" miw={260}>
|
||||
{duty.noticeFile ? (
|
||||
<Button
|
||||
variant="default"
|
||||
radius="md"
|
||||
size="sm"
|
||||
leftSection={<Eye size={15} />}
|
||||
onClick={() =>
|
||||
onView({ name: duty.noticeFile!.name, url: duty.noticeFile!.url })
|
||||
}
|
||||
>
|
||||
View duty notice
|
||||
</Button>
|
||||
) : null}
|
||||
{duty.slipFile ? (
|
||||
<Button
|
||||
variant="default"
|
||||
radius="md"
|
||||
size="sm"
|
||||
leftSection={<Eye size={15} />}
|
||||
onClick={() =>
|
||||
onView({ name: duty.slipFile!.name, url: duty.slipFile!.url })
|
||||
}
|
||||
>
|
||||
View payment slip
|
||||
</Button>
|
||||
) : null}
|
||||
{!paid ? (
|
||||
<>
|
||||
<FileInput
|
||||
placeholder={duty.slipFile ? "Replace payment slip" : "Attach payment slip"}
|
||||
value={slip}
|
||||
onChange={setSlip}
|
||||
size="sm"
|
||||
radius="md"
|
||||
/>
|
||||
<Button
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
size="sm"
|
||||
loading={uploading}
|
||||
disabled={!slip}
|
||||
leftSection={<Upload size={15} />}
|
||||
onClick={async () => {
|
||||
if (!slip) return;
|
||||
setUploading(true);
|
||||
try {
|
||||
await contractsService.uploadSecondDutySlip(bookingId, slip);
|
||||
setSlip(null);
|
||||
toast.success("Payment slip attached");
|
||||
onChanged();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Upload failed");
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{duty.slipFile ? "Replace slip" : "Submit payment slip"}
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Group>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,11 +8,12 @@ import {
|
||||
Package,
|
||||
TrainFront,
|
||||
Truck,
|
||||
Wallet,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
|
||||
import { useFileViewer } from "@/hooks/useFileViewer";
|
||||
import { bookingsService } from "@/services/bookings.service";
|
||||
@@ -44,17 +45,17 @@ import { HeaderButton, PageHeader } from "./components/PageHeader";
|
||||
import { PaymentMethodModal } from "./components/PaymentMethodModal";
|
||||
import { ScheduleCard } from "./components/ScheduleCard";
|
||||
import { WarehousePaymentsSection } from "./components/WarehousePaymentsSection";
|
||||
import { PaymentsDueStrip, PaymentsTab } from "./components/PaymentsTab";
|
||||
import { WarehouseLocationCard } from "./components/WarehouseLocationCard";
|
||||
import { ShipmentDetailsCard } from "./components/ShipmentDetailsCard";
|
||||
import { ShipmentTrackingCard } from "./components/ShipmentTrackingCard";
|
||||
import { StatusHero } from "./components/StatusHero";
|
||||
import { SupportCard } from "./components/SupportCard";
|
||||
import { WagonCancellationCard } from "./components/WagonCancellationCard";
|
||||
import { WagonsTab } from "./components/WagonsTab";
|
||||
import { fmtDate, isNegative, priceTotal } from "./utils";
|
||||
import { useScrollToHash } from "@/hooks/useScrollToHash";
|
||||
import { useBookingPayment } from "@/pages/bookings/payments/useBookingPayment";
|
||||
import { isUsdOfflineBooking } from "@/pages/bookings/payments/offline-payment";
|
||||
import { useBookingPayables } from "@/pages/bookings/payments/useBookingPayables";
|
||||
|
||||
// Pre-payment statuses the customer may self-cancel from this view (free of
|
||||
// charge). DRAFT / CHANGES_REQUESTED render their own views and drafts can
|
||||
@@ -87,6 +88,22 @@ export function ReadonlyBookingView({
|
||||
const navigate = useNavigate();
|
||||
// Deep-link support: e.g. /bookings/:id#warehouse-payments from an invoice.
|
||||
useScrollToHash();
|
||||
// Active tab lives in the URL (?tab=payments) so list/home "Pay" buttons and
|
||||
// notifications can land straight on the Payments tab.
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const tab = searchParams.get("tab") ?? "overview";
|
||||
const setTab = (next: string | null) =>
|
||||
setSearchParams(
|
||||
(prev) => {
|
||||
if (!next || next === "overview") prev.delete("tab");
|
||||
else prev.set("tab", next);
|
||||
return prev;
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
// Everything the customer owes or must decide on — drives the header "Pay"
|
||||
// button, the tab badge and the overview strip.
|
||||
const payables = useBookingPayables(booking);
|
||||
const status = booking.status as string;
|
||||
const { viewer } = useFileViewer();
|
||||
|
||||
@@ -187,17 +204,19 @@ export function ReadonlyBookingView({
|
||||
<PageHeader
|
||||
booking={booking}
|
||||
actions={
|
||||
(canApproveDelivery || (canPay && !showCountdown) || canCancel) && (
|
||||
(canApproveDelivery ||
|
||||
(payables.items.length > 0 && tab !== "payments") ||
|
||||
canCancel) && (
|
||||
<Group gap={8} wrap="nowrap">
|
||||
{canApproveDelivery && (
|
||||
<ApproveDeliveryButton bookingId={booking.id} />
|
||||
)}
|
||||
{canPay && !showCountdown && !isUsdOfflineBooking(booking) && (
|
||||
{payables.items.length > 0 && tab !== "payments" && (
|
||||
<HeaderButton
|
||||
green
|
||||
icon={<CreditCard size={16} />}
|
||||
label="Pay now"
|
||||
onClick={pay.open}
|
||||
label="Pay"
|
||||
onClick={() => setTab("payments")}
|
||||
/>
|
||||
)}
|
||||
{canCancel && (
|
||||
@@ -260,7 +279,8 @@ export function ReadonlyBookingView({
|
||||
<KeyFactsStrip booking={booking} />
|
||||
|
||||
<Tabs
|
||||
defaultValue="overview"
|
||||
value={tab}
|
||||
onChange={setTab}
|
||||
keepMounted={false}
|
||||
color="edr-green"
|
||||
styles={{
|
||||
@@ -277,6 +297,32 @@ export function ReadonlyBookingView({
|
||||
<Tabs.Tab value="overview" leftSection={<LayoutGrid size={15} />}>
|
||||
Overview
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab
|
||||
value="payments"
|
||||
leftSection={<Wallet size={15} />}
|
||||
rightSection={
|
||||
payables.items.length > 0 ? (
|
||||
<Text
|
||||
component="span"
|
||||
fz={11}
|
||||
fw={800}
|
||||
c="white"
|
||||
px={6}
|
||||
style={{
|
||||
borderRadius: 999,
|
||||
background: "#B45309",
|
||||
lineHeight: "18px",
|
||||
minWidth: 18,
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
{payables.items.length}
|
||||
</Text>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
Payments
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="cargo" leftSection={<Package size={15} />}>
|
||||
Cargo
|
||||
</Tabs.Tab>
|
||||
@@ -298,12 +344,16 @@ export function ReadonlyBookingView({
|
||||
|
||||
<Tabs.Panel value="overview">
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* The only payment surface on Overview — everything payable lives
|
||||
on the Payments tab. Hidden when nothing is outstanding. */}
|
||||
<PaymentsDueStrip booking={booking} onOpen={() => setTab("payments")} />
|
||||
|
||||
<ContractCard booking={booking} />
|
||||
|
||||
{isClearance && <ClearanceCard booking={booking} />}
|
||||
|
||||
{/* Customs (Path B) shipments: GL's phased progress, the duty /
|
||||
additional-duty payments and the final invoice — all per booking. */}
|
||||
{/* Customs (Path B) shipments: GL's phased progress, draft
|
||||
declaration and documents. Its payments moved to the Payments tab. */}
|
||||
<BookingClearanceWorkflowBanner booking={booking} />
|
||||
|
||||
<BodyGrid
|
||||
@@ -333,11 +383,6 @@ export function ReadonlyBookingView({
|
||||
title="Consignment & Schedule"
|
||||
consignment
|
||||
/>
|
||||
{/* Renders only on PAID + paid + contract-backed bookings. */}
|
||||
<WagonCancellationCard
|
||||
booking={booking}
|
||||
onBookingUpdated={onBookingUpdated}
|
||||
/>
|
||||
<CompanyInfoCard booking={booking} />
|
||||
<SupportCard />
|
||||
</>
|
||||
@@ -346,6 +391,15 @@ export function ReadonlyBookingView({
|
||||
</div>
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="payments">
|
||||
<PaymentsTab
|
||||
booking={booking}
|
||||
pay={pay}
|
||||
showCountdown={showCountdown}
|
||||
onBookingUpdated={onBookingUpdated}
|
||||
/>
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="cargo">
|
||||
<CargoTab booking={booking} />
|
||||
</Tabs.Panel>
|
||||
|
||||
@@ -252,9 +252,9 @@ export function BookingPaymentPanel({
|
||||
};
|
||||
|
||||
return (
|
||||
<SectionCard p={22}>
|
||||
<SectionCard id="freight-payment" p={22}>
|
||||
<Group justify="space-between" align="center">
|
||||
<CardTitle>Payment</CardTitle>
|
||||
<CardTitle>Freight payment</CardTitle>
|
||||
<Group
|
||||
component="span"
|
||||
gap={6}
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
import { useState } from "react";
|
||||
import { Alert, Box, Button, Group, Stack, Text, Textarea } from "@mantine/core";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Check, CreditCard, Download, Eye, FileText, Receipt, X } from "lucide-react";
|
||||
import { Link } from "react-router-dom";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import type { Freight } from "@edr/types";
|
||||
import { isViewable } from "@edr/ui-common";
|
||||
|
||||
import { useFileViewer } from "@/hooks/useFileViewer";
|
||||
import { useInvoicePayment } from "@/hooks/useInvoicePayment";
|
||||
import { bookingsService } from "@/services/bookings.service";
|
||||
import { downloadStoredFile, fetchViewableFile } from "@/services/files.service";
|
||||
import { formatAmount } from "../utils";
|
||||
|
||||
import { IconSquare } from "./Documents";
|
||||
import { PaymentMethodModal } from "./PaymentMethodModal";
|
||||
import { CardTitle, SectionCard } from "./layout";
|
||||
|
||||
const LABEL: Record<Freight.ClearanceChargeType, string> = {
|
||||
PORT_CHARGES: "Port charges",
|
||||
MISCELLANEOUS: "Miscellaneous charge",
|
||||
};
|
||||
|
||||
const STATUS: Record<
|
||||
Freight.ClearanceChargeStatus,
|
||||
{ label: string; bg: string; fg: string }
|
||||
> = {
|
||||
DOC_UPLOADED: { label: "DRAFT", bg: "#EEF2F6", fg: "#64748B" },
|
||||
BILLED: { label: "DRAFT", bg: "#EEF2F6", fg: "#64748B" },
|
||||
SENT: { label: "NEEDS YOUR APPROVAL", bg: "#FEF3E2", fg: "#B45309" },
|
||||
REJECTED: { label: "REJECTED", bg: "#FEE2E2", fg: "#B91C1C" },
|
||||
ACCEPTED: { label: "ACCEPTED — UNPAID", bg: "#E0F2FE", fg: "#0369A1" },
|
||||
PAID: { label: "PAID", bg: "#E6F7EF", fg: "#0A6F4D" },
|
||||
};
|
||||
|
||||
const money = (c: Freight.ClearanceCharge) =>
|
||||
`${formatAmount(c.amount)} ${c.currency ?? ""}`;
|
||||
|
||||
/**
|
||||
* Clearance charges Global Logistics proposed for this shipment. The customer
|
||||
* accepts a price (its invoice is then issued and payable here) or rejects it
|
||||
* with a note so GL can revise. Renders nothing until GL sends a charge.
|
||||
*/
|
||||
export function ClearanceChargesSection({ bookingId }: { bookingId: string }) {
|
||||
const qc = useQueryClient();
|
||||
const key = ["booking-clearance-charges", bookingId];
|
||||
const { data: charges = [] } = useQuery({
|
||||
queryKey: key,
|
||||
queryFn: () => bookingsService.getClearanceCharges(bookingId),
|
||||
});
|
||||
const [rejecting, setRejecting] = useState<string | null>(null);
|
||||
const [note, setNote] = useState("");
|
||||
const [payCharge, setPayCharge] = useState<Freight.ClearanceCharge | null>(null);
|
||||
const pay = useInvoicePayment();
|
||||
const { view, viewer } = useFileViewer();
|
||||
|
||||
const onError = (e: unknown) =>
|
||||
toast.error(e instanceof Error ? e.message : "Could not update the charge");
|
||||
const accept = useMutation({
|
||||
mutationFn: (chargeId: string) =>
|
||||
bookingsService.acceptClearanceCharge(bookingId, chargeId),
|
||||
onSuccess: (next) => {
|
||||
qc.setQueryData(key, next);
|
||||
toast.success("Accepted — your invoice is ready to pay");
|
||||
},
|
||||
onError,
|
||||
});
|
||||
const reject = useMutation({
|
||||
mutationFn: (p: { chargeId: string; note: string }) =>
|
||||
bookingsService.rejectClearanceCharge(bookingId, p.chargeId, p.note),
|
||||
onSuccess: (next) => {
|
||||
qc.setQueryData(key, next);
|
||||
setRejecting(null);
|
||||
setNote("");
|
||||
toast.success("Sent back to Global Logistics");
|
||||
},
|
||||
onError,
|
||||
});
|
||||
const busy = accept.isPending || reject.isPending;
|
||||
|
||||
if (charges.length === 0) return null;
|
||||
|
||||
return (
|
||||
<SectionCard id="clearance-charges">
|
||||
<Group justify="space-between" align="center" mb="md">
|
||||
<CardTitle>Clearance charges</CardTitle>
|
||||
<Text fz="12.5px" fw={600} c="#9AA8B5">
|
||||
{charges.length} {charges.length === 1 ? "charge" : "charges"}
|
||||
</Text>
|
||||
</Group>
|
||||
<Stack gap={12}>
|
||||
{charges.map((c) => {
|
||||
const st = STATUS[c.status];
|
||||
return (
|
||||
<Box
|
||||
key={c.id}
|
||||
style={{ border: "1px solid #EEF2F6", borderRadius: 12, padding: "12px 14px" }}
|
||||
>
|
||||
<Group justify="space-between" align="flex-start" wrap="nowrap">
|
||||
<Box style={{ minWidth: 0 }}>
|
||||
<Group gap={8} wrap="nowrap">
|
||||
<Text fz="13.5px" fw={700} c="#10202F">
|
||||
{LABEL[c.type]}
|
||||
</Text>
|
||||
<Box
|
||||
style={{
|
||||
padding: "3px 9px",
|
||||
borderRadius: 999,
|
||||
background: st.bg,
|
||||
color: st.fg,
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{st.label}
|
||||
</Box>
|
||||
</Group>
|
||||
{c.description && (
|
||||
<Text fz="12.5px" c="#6B7C8E" mt={4}>
|
||||
{c.description}
|
||||
</Text>
|
||||
)}
|
||||
{c.invoiceNumber && c.invoiceId && (
|
||||
<Text fz="12px" c="#9AA8B5" mt={4}>
|
||||
Invoice{" "}
|
||||
<Link to={`/billing/${c.invoiceId}`} style={{ color: "#2E5B96" }}>
|
||||
{c.invoiceNumber}
|
||||
</Link>
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
<Text fz="14px" fw={800} c="#10202F" style={{ whiteSpace: "nowrap" }}>
|
||||
{money(c)}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
{/* GL's supporting document (port bill, receipt…) — what the
|
||||
price is based on, so the customer can check before deciding. */}
|
||||
{c.file && (
|
||||
<Group
|
||||
justify="space-between"
|
||||
wrap="nowrap"
|
||||
mt="sm"
|
||||
style={{ border: "1px solid #EEF2F6", borderRadius: 10, padding: "8px 10px" }}
|
||||
>
|
||||
<Group gap={8} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<FileText size={16} color="#2E5B96" />
|
||||
<Text fz="12.5px" c="#10202F" truncate>
|
||||
{c.file.name}
|
||||
</Text>
|
||||
</Group>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
{isViewable({ name: c.file.name, url: "" }) && (
|
||||
<IconSquare
|
||||
icon={<Eye size={15} />}
|
||||
onClick={() =>
|
||||
void fetchViewableFile(c.file!.id, c.file!.name).then(view)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<IconSquare
|
||||
icon={<Download size={15} />}
|
||||
onClick={() => void downloadStoredFile(c.file!.id, c.file!.name)}
|
||||
/>
|
||||
</Group>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{c.status === "REJECTED" && c.customerNote && (
|
||||
<Alert color="red" variant="light" radius="md" p="xs" mt="sm">
|
||||
<Text fz="12.5px">
|
||||
You rejected this price: “{c.customerNote}”. Global Logistics
|
||||
will revise it and send it again.
|
||||
</Text>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{c.status === "SENT" &&
|
||||
(rejecting === c.id ? (
|
||||
<Stack gap={6} mt="sm">
|
||||
<Textarea
|
||||
label="Why are you rejecting this charge?"
|
||||
placeholder="Tell Global Logistics what is wrong with the price…"
|
||||
minRows={2}
|
||||
autosize
|
||||
maxLength={1000}
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.currentTarget.value)}
|
||||
/>
|
||||
<Group gap="xs" justify="flex-end">
|
||||
<Button
|
||||
variant="default"
|
||||
size="xs"
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
setRejecting(null);
|
||||
setNote("");
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="red"
|
||||
size="xs"
|
||||
loading={reject.isPending}
|
||||
disabled={!note.trim()}
|
||||
onClick={() => reject.mutate({ chargeId: c.id, note: note.trim() })}
|
||||
>
|
||||
Submit rejection
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
) : (
|
||||
<Group gap="xs" justify="flex-end" mt="sm">
|
||||
<Button
|
||||
variant="default"
|
||||
size="xs"
|
||||
radius={10}
|
||||
leftSection={<X size={14} />}
|
||||
disabled={busy}
|
||||
onClick={() => setRejecting(c.id)}
|
||||
>
|
||||
Reject
|
||||
</Button>
|
||||
<Button
|
||||
color="edr-green"
|
||||
size="xs"
|
||||
radius={10}
|
||||
leftSection={<Check size={14} />}
|
||||
loading={accept.isPending}
|
||||
disabled={busy}
|
||||
onClick={() => accept.mutate(c.id)}
|
||||
>
|
||||
Accept price
|
||||
</Button>
|
||||
</Group>
|
||||
))}
|
||||
|
||||
{c.status === "ACCEPTED" && c.invoiceId && (
|
||||
<Group justify="flex-end" mt="sm">
|
||||
<Button
|
||||
size="xs"
|
||||
radius={10}
|
||||
color="edr-green"
|
||||
leftSection={<CreditCard size={14} />}
|
||||
onClick={() => setPayCharge(c)}
|
||||
>
|
||||
Pay
|
||||
</Button>
|
||||
</Group>
|
||||
)}
|
||||
{c.status === "PAID" && (
|
||||
<Group gap={6} justify="flex-end" mt="sm">
|
||||
<Receipt size={14} color="#0A6F4D" />
|
||||
<Text fz="12px" c="#0A6F4D" fw={600}>
|
||||
Paid{c.paidAt ? ` · ${new Date(c.paidAt).toLocaleString()}` : ""}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
|
||||
<PaymentMethodModal
|
||||
opened={payCharge !== null}
|
||||
onClose={() => {
|
||||
if (!pay.processing) {
|
||||
setPayCharge(null);
|
||||
pay.reset();
|
||||
}
|
||||
}}
|
||||
amountLabel={payCharge ? money(payCharge) : undefined}
|
||||
currency={payCharge?.currency}
|
||||
onConfirm={(method, payerAccount) =>
|
||||
payCharge?.invoiceId && pay.pay(payCharge.invoiceId, method, payerAccount)
|
||||
}
|
||||
processing={pay.processing}
|
||||
error={pay.error}
|
||||
otp={pay.otp}
|
||||
bill={pay.bill}
|
||||
/>
|
||||
{viewer}
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
return (
|
||||
<SectionCard>
|
||||
<SectionCard id="wagon-cancellation">
|
||||
<Group justify="space-between" align="center" mb="sm">
|
||||
<CardTitle>Wagon Cancellation</CardTitle>
|
||||
{/* {canRequest && !openRow && !creditRow && (
|
||||
|
||||
@@ -34,8 +34,8 @@ import {
|
||||
} from "lucide-react";
|
||||
|
||||
import { ShipmentTrackingModal } from "./tracking/ShipmentTrackingModal";
|
||||
import { PayNowButton } from "./payments/PayNowButton";
|
||||
import { payWindowState } from "./payments/payment-drain";
|
||||
import { PayButton } from "./payments/PayButton";
|
||||
import { useMyPayables } from "./payments/useMyPayables";
|
||||
import { BookingActionButton } from "./clearance/BookingActionButton";
|
||||
import { bookingHasInlineAction } from "./clearance/bookingNextAction";
|
||||
import {
|
||||
@@ -181,11 +181,14 @@ const STAT_CARDS: Array<{
|
||||
function PrimaryAction({
|
||||
booking,
|
||||
credit,
|
||||
payable,
|
||||
onNavigate,
|
||||
}: {
|
||||
booking: Freight.IBooking;
|
||||
/** CREDIT_AVAILABLE wagon cancellation opened by this booking, if any. */
|
||||
credit?: WagonCancellation;
|
||||
/** Outstanding payments on this booking (from `my-payables`), if any. */
|
||||
payable?: Freight.BookingPayableSummary;
|
||||
onNavigate: (path: string) => void;
|
||||
}) {
|
||||
const { status, id } = booking;
|
||||
@@ -199,9 +202,6 @@ function PrimaryAction({
|
||||
/>
|
||||
);
|
||||
}
|
||||
// A general contract is payable as soon as it's FULLY_EXECUTED (signed); a
|
||||
// one-time booking only after it's SELECTED_FOR_BATCH.
|
||||
const isGeneralContract = booking.bookingType === "GENERAL_CONTRACT";
|
||||
if (status === "DRAFT") {
|
||||
return (
|
||||
<Button
|
||||
@@ -220,24 +220,16 @@ function PrimaryAction({
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
// Anything outstanding (freight, clearance charge, duty slip, cancellation
|
||||
// fee) → "Pay" jumps to the booking's Payments tab.
|
||||
if (payable) {
|
||||
return <PayButton bookingId={id} summary={payable} />;
|
||||
}
|
||||
// CHANGES_REQUESTED + clearance/operation steps are handled in place by a
|
||||
// modal (update & resubmit, upload clearance docs, schedule & proceed).
|
||||
if (bookingHasInlineAction(booking)) {
|
||||
return <BookingActionButton booking={booking} size="xs" />;
|
||||
}
|
||||
const payableStatus = isGeneralContract
|
||||
? "FULLY_EXECUTED"
|
||||
: "SELECTED_FOR_BATCH";
|
||||
// A fully-closed pay window (deadline + drain both elapsed) falls through to
|
||||
// the default action. The drain itself still routes here — PayNowButton
|
||||
// renders the "payment processing" wait notice instead of a pay action.
|
||||
if (
|
||||
status === payableStatus &&
|
||||
booking.paymentStatus !== "PAID" &&
|
||||
payWindowState(booking).phase !== "closed"
|
||||
) {
|
||||
return <PayNowButton booking={booking} />;
|
||||
}
|
||||
// Contract ready for the customer's signature → full-page contract viewer.
|
||||
if (bookingIsSignable(booking)) {
|
||||
return <ContractSignButton booking={booking} size="xs" />;
|
||||
@@ -460,6 +452,8 @@ export default function BookingsListPage() {
|
||||
input: { pageSize: 100 },
|
||||
}),
|
||||
);
|
||||
// Outstanding payments per booking → row "Pay" button (one shared query).
|
||||
const payables = useMyPayables();
|
||||
const creditByBooking = useMemo(() => {
|
||||
const m = new Map<string, WagonCancellation>();
|
||||
for (const r of myCancellations?.items ?? []) {
|
||||
@@ -702,6 +696,7 @@ export default function BookingsListPage() {
|
||||
<PrimaryAction
|
||||
booking={booking}
|
||||
credit={creditByBooking.get(booking.id)}
|
||||
payable={payables.get(booking.id)}
|
||||
onNavigate={navigate}
|
||||
/>
|
||||
<Menu position="bottom-end" withinPortal shadow="md" radius="md">
|
||||
|
||||
@@ -39,7 +39,7 @@ interface BookingActionButtonProps {
|
||||
* when the booking has no customer-actionable clearance/operation step;
|
||||
* otherwise shows a button that opens the in-place {@link BookingActionModal}.
|
||||
*
|
||||
* Drop it into a list row exactly like {@link PayNowButton} — it stops click
|
||||
* Drop it into a list row exactly like {@link PayButton} — it stops click
|
||||
* propagation so it never triggers the row's navigation handler.
|
||||
*/
|
||||
export function BookingActionButton({
|
||||
|
||||
@@ -28,7 +28,7 @@ interface ContractSignButtonProps {
|
||||
* that navigates to the full-page contract viewer ({@link BookingContractPage})
|
||||
* where the signature flow lives.
|
||||
*
|
||||
* Drop it into a list row exactly like {@link PayNowButton} — it stops click
|
||||
* Drop it into a list row exactly like {@link PayButton} — it stops click
|
||||
* propagation so it never triggers the row's navigation handler.
|
||||
*/
|
||||
export function ContractSignButton({
|
||||
|
||||
@@ -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",
|
||||
icon: ShieldCheck,
|
||||
label: "Customs clearance",
|
||||
label: s.includesEthiopianCustomsOnly
|
||||
? "Ethiopian customs clearance"
|
||||
: "Customs clearance",
|
||||
on: s.includesCustoms,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -533,6 +533,40 @@ export const bookingsService = {
|
||||
return data.data ?? data;
|
||||
},
|
||||
|
||||
/** Outstanding payments per booking — drives the "Pay" badge on list/home rows. */
|
||||
getMyPayables: async (): Promise<Freight.BookingPayableSummary[]> => {
|
||||
const { data } = await client.get(`/api/bookings/my-payables`);
|
||||
return data.data ?? data;
|
||||
},
|
||||
|
||||
// ── Clearance charges (port + miscellaneous) the customer approves, then pays ──
|
||||
getClearanceCharges: async (id: string): Promise<Freight.ClearanceCharge[]> => {
|
||||
const { data } = await client.get(`/api/bookings/${id}/clearance/charges`);
|
||||
return data.data ?? data;
|
||||
},
|
||||
|
||||
acceptClearanceCharge: async (
|
||||
id: string,
|
||||
chargeId: string,
|
||||
): Promise<Freight.ClearanceCharge[]> => {
|
||||
const { data } = await client.post(
|
||||
`/api/bookings/${id}/clearance/charges/${chargeId}/accept`,
|
||||
);
|
||||
return data.data ?? data;
|
||||
},
|
||||
|
||||
rejectClearanceCharge: async (
|
||||
id: string,
|
||||
chargeId: string,
|
||||
note: string,
|
||||
): Promise<Freight.ClearanceCharge[]> => {
|
||||
const { data } = await client.post(
|
||||
`/api/bookings/${id}/clearance/charges/${chargeId}/reject`,
|
||||
{ note },
|
||||
);
|
||||
return data.data ?? data;
|
||||
},
|
||||
|
||||
acceptDraftDeclaration: async (id: string): Promise<Freight.IBooking> => {
|
||||
const { data } = await client.post(
|
||||
`/api/bookings/${id}/clearance/draft-declaration/accept`,
|
||||
|
||||
@@ -769,6 +769,7 @@ export interface IContract extends BaseEntity {
|
||||
includesFirstMile?: boolean;
|
||||
includesLastMile?: boolean;
|
||||
includesCustoms: boolean;
|
||||
includesEthiopianCustomsOnly?: boolean;
|
||||
} | null;
|
||||
paymentCurrency: string;
|
||||
customsClearingEnabled: boolean;
|
||||
|
||||
@@ -833,17 +833,22 @@ export type ClearanceChargeType = "PORT_CHARGES" | "MISCELLANEOUS";
|
||||
|
||||
/**
|
||||
* DOC_UPLOADED: GL Djibouti uploaded the supporting document (port charges).
|
||||
* BILLED: GL Ethiopia set amount + currency. SENT: invoice issued to the
|
||||
* customer (ETB pays via gateway, other currencies via Finance's manual
|
||||
* settlement). PAID: the invoice settled.
|
||||
* BILLED: GL Ethiopia set amount + currency (draft, customer does not see it).
|
||||
* SENT: price proposed to the customer, awaiting their decision.
|
||||
* REJECTED: customer declined with a note; GL revises and re-sends.
|
||||
* ACCEPTED: customer agreed — invoice issued (ETB pays via gateway, other
|
||||
* currencies via Finance's manual settlement); GL can no longer edit.
|
||||
* PAID: the invoice settled.
|
||||
*/
|
||||
export type ClearanceChargeStatus =
|
||||
| "DOC_UPLOADED"
|
||||
| "BILLED"
|
||||
| "SENT"
|
||||
| "REJECTED"
|
||||
| "ACCEPTED"
|
||||
| "PAID";
|
||||
|
||||
/** One clearance charge level on a booking — at most one per type. */
|
||||
/** One clearance charge on a booking — one port charge, any number of miscellaneous. */
|
||||
export interface ClearanceCharge {
|
||||
id: string;
|
||||
bookingId: string;
|
||||
@@ -852,6 +857,11 @@ export interface ClearanceCharge {
|
||||
file: { id: string; name: string; url: string } | null;
|
||||
amount: number | null;
|
||||
currency: string | null;
|
||||
/** What the price is for, written by GL (required for miscellaneous). */
|
||||
description: string | null;
|
||||
/** Customer's reason when REJECTED; cleared when GL revises. */
|
||||
customerNote: string | null;
|
||||
customerDecidedAt: string | null;
|
||||
invoiceId: string | null;
|
||||
invoiceNumber: string | null;
|
||||
uploadedByName: string | null;
|
||||
@@ -861,6 +871,18 @@ export interface ClearanceCharge {
|
||||
paidAt: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* One booking's outstanding customer payments — invoices to pay, prices to
|
||||
* accept, slips to upload — for the "Pay" badge on list/home rows.
|
||||
*/
|
||||
export interface BookingPayableSummary {
|
||||
bookingId: string;
|
||||
/** Items waiting on the customer (payable + needing review). */
|
||||
count: number;
|
||||
/** Amounts actually payable now, per currency (items under review excluded). */
|
||||
totals: Array<{ currency: string; amount: number }>;
|
||||
}
|
||||
|
||||
/** A GL→customer request for additional clearance document(s). */
|
||||
export interface ClearanceDocRequest {
|
||||
id: string;
|
||||
@@ -1179,6 +1201,8 @@ export interface BookingReferenceService {
|
||||
includesFirstMile: boolean;
|
||||
includesLastMile: boolean;
|
||||
includesCustoms: boolean;
|
||||
/** Customs cleared on the Ethiopian side only (prices off the Ethiopian customs rate). */
|
||||
includesEthiopianCustomsOnly?: boolean;
|
||||
isActive: boolean;
|
||||
displayOrder: number;
|
||||
createdAt: string;
|
||||
|
||||
Reference in New Issue
Block a user