diff --git a/apps/edr-freight-api/src/contracts/contract-rate-schedule.builder.ts b/apps/edr-freight-api/src/contracts/contract-rate-schedule.builder.ts index 2f1991df2..8990e1b43 100644 --- a/apps/edr-freight-api/src/contracts/contract-rate-schedule.builder.ts +++ b/apps/edr-freight-api/src/contracts/contract-rate-schedule.builder.ts @@ -67,6 +67,7 @@ const TRIGGER_ROUTE_LABELS: Partial> = { 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', }; diff --git a/apps/edr-freight-api/src/migrations/3620000000000-SchedulePlannedWagonYards.ts b/apps/edr-freight-api/src/migrations/3620000000000-SchedulePlannedWagonYards.ts new file mode 100644 index 000000000..9313c22a5 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3620000000000-SchedulePlannedWagonYards.ts @@ -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 { + await queryRunner.query(` + ALTER TABLE freight.train_schedules + ADD COLUMN IF NOT EXISTS planned_wagon_yards jsonb + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.train_schedules DROP COLUMN IF EXISTS planned_wagon_yards + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/3630000000000-ClearanceChargeCustomerDecision.ts b/apps/edr-freight-api/src/migrations/3630000000000-ClearanceChargeCustomerDecision.ts new file mode 100644 index 000000000..a9cb259cd --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3630000000000-ClearanceChargeCustomerDecision.ts @@ -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 { + 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 { + 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" + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/3640000000000-EthiopianCustomsClearance.ts b/apps/edr-freight-api/src/migrations/3640000000000-EthiopianCustomsClearance.ts new file mode 100644 index 000000000..50ceda3ca --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3640000000000-EthiopianCustomsClearance.ts @@ -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 { + 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 { + 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`, + ); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-clearance-charge.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-clearance-charge.service.ts index 3be119470..a1bceb844 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-clearance-charge.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-clearance-charge.service.ts @@ -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 = { MISCELLANEOUS: 'Miscellaneous charges', }; +/** Statuses the customer sees — drafts (DOC_UPLOADED / BILLED) stay GL-internal. */ +export const CUSTOMER_VISIBLE_CHARGE_STATUSES: ReadonlySet = + 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 { + return (await this.list(bookingId)).filter((c) => + CUSTOMER_VISIBLE_CHARGE_STATUSES.has(c.status), + ); + } + + private async findCharge( + bookingId: string, + chargeId: string, + ): Promise { + 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 { - 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 { - 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 { + 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 { + 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 { 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, }, }); diff --git a/apps/edr-freight-api/src/modules/bookings/booking-clearance-charge.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-clearance-charge.spec.ts new file mode 100644 index 000000000..ac1a5a225 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/booking-clearance-charge.spec.ts @@ -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']); + }); +}); diff --git a/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts index f457e782a..ad0e62ef3 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts @@ -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.`; diff --git a/apps/edr-freight-api/src/modules/bookings/booking-payables.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-payables.service.ts new file mode 100644 index 000000000..e815fe929 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/booking-payables.service.ts @@ -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 { + 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(); + 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()]; + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts index 75a179b5c..99fc81c9a 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts @@ -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()); diff --git a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts index f7dc40a82..5e8741df2 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts @@ -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, diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts index e122a8800..98bc2332e 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -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, diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts index 97128c428..e119af52e 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts @@ -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, diff --git a/apps/edr-freight-api/src/modules/bookings/dto/clearance-charge.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/clearance-charge.dto.ts index f2bf15c0e..214236c8f 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/clearance-charge.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/clearance-charge.dto.ts @@ -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; } diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking-clearance-charge.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking-clearance-charge.entity.ts index 31ae26203..1cddc7a87 100644 --- a/apps/edr-freight-api/src/modules/bookings/entities/booking-clearance-charge.entity.ts +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking-clearance-charge.entity.ts @@ -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; diff --git a/apps/edr-freight-api/src/modules/contracts/contract-pricing.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-pricing.service.ts index af0a149d7..0af09a5f8 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-pricing.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-pricing.service.ts @@ -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. - code: `CUSTOMS_CLEARANCE_${sizeFt}FT`, - label: `Customs clearance service (${size})`, + // booking pricing looks each size up by _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, diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.service.ts b/apps/edr-freight-api/src/modules/contracts/contracts.service.ts index 3411414e9..bfbc5675f 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.service.ts @@ -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 { + return this.dataSource.getRepository(ServiceType).findOne({ where: { id: serviceTypeId } }); + } + /** Whether a service type bundles customs clearance. */ private async resolveIncludesCustoms(serviceTypeId: string): Promise { - 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, diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/create-service-type.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/create-service-type.dto.ts index a8e030bba..fd89243f6 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/dto/create-service-type.dto.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-service-type.dto.ts @@ -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() diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/rate-type.util.spec.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/rate-type.util.spec.ts index 480b2c484..5902453a0 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/entities/rate-type.util.spec.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/rate-type.util.spec.ts @@ -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); }); diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/rate-type.util.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/rate-type.util.ts index 536e35527..894080e81 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/entities/rate-type.util.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/rate-type.util.ts @@ -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'; } diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/rate-unit.util.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/rate-unit.util.ts index 5adf225ca..ee64768c2 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/entities/rate-unit.util.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/rate-unit.util.ts @@ -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' diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/rate.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/rate.entity.ts index c60d03d31..cc57e4c65 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/entities/rate.entity.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/rate.entity.ts @@ -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 }) diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/service-type.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/service-type.entity.ts index b882f1a08..ac6f9b83d 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/entities/service-type.entity.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/service-type.entity.ts @@ -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; diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts index 977a31718..6677fcce5 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts @@ -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'; diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/service-types.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/service-types.service.ts index 7592a6f76..1be51a259 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/service-types.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/service-types.service.ts @@ -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 { - 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 { + 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 { await this.findById(id); diff --git a/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts b/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts index 393378740..b3cb3e821 100644 --- a/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts +++ b/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts @@ -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 | 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; diff --git a/apps/edr-freight-api/src/modules/train-scheduling/controllers/train-scheduling.controller.ts b/apps/edr-freight-api/src/modules/train-scheduling/controllers/train-scheduling.controller.ts index 54056404a..834b3d174 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/controllers/train-scheduling.controller.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/controllers/train-scheduling.controller.ts @@ -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({ diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/update-schedule-wagon-yards.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/update-schedule-wagon-yards.dto.ts new file mode 100644 index 000000000..fa4b5e4c7 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/update-schedule-wagon-yards.dto.ts @@ -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[]; +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts index 1587e2808..3c6080c36 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts @@ -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 { + 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> { - 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(); @@ -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(); 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 = 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 = 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 { - const wagons = await this.dataSource.getRepository(Wagon).find({ - where: { trainId: builtTrainId }, - relations: { wagonType: true }, - }); + private async builtTrainStock( + builtTrainId: string, + scheduleId?: string, + ): Promise { + const [wagons, plan] = await Promise.all([ + this.dataSource.getRepository(Wagon).find({ + where: { trainId: builtTrainId }, + relations: { wagonType: true }, + }), + this.plannedWagonYardsOf(scheduleId), + ]); const remainingByTypeId = new Map(); const codesByTypeId = new Map(); const byYardId = new Map>(); @@ -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(); + // 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(); 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 { 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 }, diff --git a/apps/edr-freight-api/src/modules/train-scheduling/utils/planned-wagon-yards.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/utils/planned-wagon-yards.util.spec.ts new file mode 100644 index 000000000..18c5ead25 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/utils/planned-wagon-yards.util.spec.ts @@ -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']); + }); +}); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/utils/planned-wagon-yards.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/utils/planned-wagon-yards.util.ts new file mode 100644 index 000000000..ed914ece3 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/utils/planned-wagon-yards.util.ts @@ -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; + +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, + 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( + plan: PlannedWagonYards | null | undefined, + wagons: readonly T[], +): T[] { + return wagons.filter((w) => { + const planned = plan?.[w.id]; + return planned != null && planned !== w.currentYardId; + }); +} diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/booking-ui.styles.ts b/apps/edr-freight-web/backoffice/src/components/bookings/booking-ui.styles.ts index de357fa05..fa1f3e882 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/booking-ui.styles.ts +++ b/apps/edr-freight-web/backoffice/src/components/bookings/booking-ui.styles.ts @@ -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}`, diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/ClearanceReviewSection.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/detail/ClearanceReviewSection.tsx index 291bc0d05..a11133711 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/detail/ClearanceReviewSection.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/ClearanceReviewSection.tsx @@ -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 ( - - {stats.approved}/{stats.total} approved - - } - > - - {!hideSummary && stats.total > 0 && ( - - - - - - - + + + + + + + Customer documents + + + {stats.approved} of {stats.total} approved + {stats.queried > 0 ? ` · ${stats.queried} queried` : ""} · required + marked * + - )} - {customerDocs.length === 0 ? ( - - No customer documents are required for this booking. + + + + + {approvalsLocked ? "Uploads closed" : "Uploads open"} - ) : ( - customerDocs.map((doc) => ( - - 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} - /> - )) - )} - - + + + + {!hideSummary && stats.total > 0 && ( + + + + + + + + + )} + + {customerDocs.length === 0 ? ( + + No customer documents are required for this booking. + + ) : ( + customerDocs.map((doc, i) => ( + + 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} + /> + )) + )} + {clearance.outputCode && !phasedCustoms && ( = { + 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 ( - - - - - - - - - {doc.label} - {doc.required ? " *" : ""} - - - {hasFile ? doc.file!.name : "Not uploaded by customer"} - - - + + + + - - - {meta.label} - - {hasFile && - isViewable({ - name: doc.file!.name, - url: "", - }) && ( - - - - )} + + + {doc.label} + {doc.required ? " *" : ""} + + + {fileLine} + + + + + {meta.label} + + + + {hasFile && !readOnly && !isApproved && !approvalsLocked && ( + + )} + {hasFile && !readOnly && !queriesLocked && !queryOpen && ( + + )} + {hasFile && isViewable({ name: doc.file!.name, url: "" }) && ( + + + void fetchViewableFile(doc.file!.id, doc.file!.name).then(onView) + } + > + + + + )} {hasFile && ( - - void downloadBookingFile(doc.file!.id, doc.file!.name) - } - c="edr-green" - style={{ - display: "flex", - background: "transparent", - border: "none", - cursor: "pointer", - }} + void downloadBookingFile(doc.file!.id, doc.file!.name)} > - - + + + + )} + {expandable && ( + + setOpen((o) => !o)} + > + + )} - {(doc.history?.length ?? 0) > 0 && ( - - )} + + + {status === "QUERIED" && doc.note ? ( + } + p="xs" + mb="sm" + > + + {doc.note} + + + ) : null} - {status === "QUERIED" && doc.note && ( - } - p="xs" - > - - {doc.note} - - - )} + {history.length > 0 ? : null} - {hasFile && !readOnly && ( - - {!queryOpen ? ( - - {!queriesLocked && ( - - )} - {!isApproved && !approvalsLocked && ( - - )} - - ) : ( + {queryOpen && !readOnly ? ( - - + + Describe the problem for the customer @@ -775,9 +853,9 @@ function DocReviewCard({ - )} + ) : null} - )} - + + ); } diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/ClearanceChargesTab.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/ClearanceChargesTab.tsx index f8725b03a..2b612f09c 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/ClearanceChargesTab.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/ClearanceChargesTab.tsx @@ -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"} - 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. - createMisc.mutate({ file, amount, currency }) - } + onCreate={(file, input) => createMisc.mutate({ file, ...input })} /> )} @@ -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(charge?.amount ?? ""); const [currency, setCurrency] = useState(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)} )} + {charge?.status === "ACCEPTED" && charge.customerDecidedAt && ( + + Accepted by the customer · {formatDateTime(charge.customerDecidedAt)} + {charge.invoiceNumber ? ` (invoice ${charge.invoiceNumber})` : ""} + + )} {charge?.paidAt && ( Paid · {formatDateTime(charge.paidAt)} @@ -379,6 +397,32 @@ function ChargeCard({ )} + {charge?.description && !showBillForm && ( + + {charge.description} + + )} + + {charge?.status === "REJECTED" && charge.customerNote && ( + } + title="Rejected by the customer" + > + {charge.customerNote} + {charge.customerDecidedAt && ( + + {formatDateTime(charge.customerDecidedAt)} — fix the price or + description and send it again. + + )} + + )} + {!charge && ( {emptyHint} @@ -389,6 +433,15 @@ function ChargeCard({ {showBillForm && ( + setDescription(e.currentTarget.value)} + maxLength={1000} + w={320} + /> 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 {editing && ( - {charge.status === "BILLED" && ( - + {(charge.status === "BILLED" || charge.status === "REJECTED") && ( + )} - {charge.status === "SENT" && charge.invoiceNumber && ( - - Invoice {charge.invoiceNumber} - - )} + + )} + {charge?.status === "ACCEPTED" && ( + + + + Locked — invoice {charge.invoiceNumber ?? ""} awaiting payment + )} {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(null); const [amount, setAmount] = useState(""); const [currency, setCurrency] = useState("ETB"); + const [description, setDescription] = useState(""); return ( + setDescription(e.currentTarget.value)} + maxLength={1000} + w={320} + /> {(props) => ( diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/ClearancePhaseStepper.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/ClearancePhaseStepper.tsx index ba93a790d..11cba11ba 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/ClearancePhaseStepper.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/ClearancePhaseStepper.tsx @@ -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 = { POST_TRANSIT: "Transit", }; +/** Which desk owns each phase — shown under the label, as in the design. */ +const PHASE_ACTOR: Record = { + 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 ( + ); })} diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/GlUpcomingWindowsSection.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/GlUpcomingWindowsSection.tsx index df1a66330..0f3443633 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/GlUpcomingWindowsSection.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/GlUpcomingWindowsSection.tsx @@ -122,7 +122,10 @@ function phaseCountdown(w: WindowRow): { } /** Badge label + Mantine color per UI state — same state the countdown uses. */ -const KIND_BADGE: Record = { +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 ( - - - + + +
+ +
- + Booking windows - + {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({ ))} ) : ( - + {visible.map((w) => ( ))} diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/PhasedClearanceActionPanel.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/PhasedClearanceActionPanel.tsx index 4b44edda6..b19c3af41 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/PhasedClearanceActionPanel.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/PhasedClearanceActionPanel.tsx @@ -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({ ) : null} - {clearance.nextAction ? ( - - - {clearance.nextAction.actor.replace("_", " ")} —{" "} - {clearance.nextAction.action} - - - ) : null} + + {/* Header: what this workflow is, and how far along it is. */} + + + + + + Import pre-booking clearance + + + Step {Math.min(activeStep + 1, IMPORT_STEP_COUNT)} of{" "} + {IMPORT_STEP_COUNT} + {clearance.nextAction + ? ` · ${clearance.nextAction.action}` + : ""} + + + + + + + {Math.round((activeStep / IMPORT_STEP_COUNT) * 100)}% + + + - - - Import pre-booking clearance - + {/* Whose desk the flow is sitting on right now. */} + {clearance.nextAction ? ( + + + + {clearance.nextAction.actor.replace("_", " ").toUpperCase()} + + + {clearance.nextAction.action} + + + ) : null} + + - + + ); diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/detail/RequestDetailCards.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/detail/RequestDetailCards.tsx index 1469e06e3..0ecffa278 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/detail/RequestDetailCards.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/detail/RequestDetailCards.tsx @@ -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 ( + {children} + + ); +} + +function Spark({ values, color }: { values: number[]; color: string }) { + const max = Math.max(1, ...values); + return ( +
+ {values.map((v, i) => ( +
+ ))} +
+ ); +} + /** * 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 ( - +
{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 ? ( -
- -
- ) : null} +
+ {Icon ? ( +
+ +
+ ) : null} + + {item.label} + +
-
- {loading ? ( - - ) : ( -
+
+
+ {loading ? ( + + ) : ( {item.value} - {item.delta != null && item.delta !== 0 ? ( - 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)}% - - ) : null} -
- )} - - {item.label} - {item.hint ? ` · ${item.hint}` : ""} - + )} + {!loading && item.hint ? ( + + + {item.hint} + + ) : null} + {!loading && item.delta != null && item.delta !== 0 ? ( + 0 ? "green" : "red"}> + {item.delta > 0 ? "▲" : "▼"} + {Math.abs(item.delta)}% + + ) : null} +
+ {item.spark?.length ? ( + + ) : null}
); diff --git a/apps/edr-freight-web/backoffice/src/components/page/TablePager.tsx b/apps/edr-freight-web/backoffice/src/components/page/TablePager.tsx new file mode 100644 index 000000000..413075b7d --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/page/TablePager.tsx @@ -0,0 +1,75 @@ +import { Group, Pagination, Select, Text } from "@mantine/core"; +import type { DataTableFooterProps } from "@edr/ui-common"; + +export interface TablePagerProps extends DataTableFooterProps { + /** 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) => }`. + */ +export function TablePager({ + table, + pagination, + noun = "rows", + pageSizes = [10, 25, 50], +}: TablePagerProps) { + 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 ( + + + Showing {start}–{end} of {total} {noun} + + + + + Rows + + + + + + + ) : null} + + + + + # + Wagon + Type + Physical yard + Planned yard (this schedule) + Status + + + + {data.wagons.map((w) => { + const planned = effectiveYard(w); + const changed = w.id in pending; + return ( + + {w.sequenceNumber ?? "—"} + + + {w.wagonNumber} + + + {w.wagonType.code} + {w.physicalYardLabel ?? "No yard"} + + {editable && !w.locked ? ( +
+ + {editable ? ( + + + {pendingCount} pending change(s) + + + + + ) : null} + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceListPage.tsx b/apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceListPage.tsx index 392c028aa..e664f1bd3 100644 --- a/apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceListPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceListPage.tsx @@ -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(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 ( - - {origin}{" "} - - {"\u00A0"} - {destination} - + + + {label} + ); } -function CustomsBadge({ customs }: { customs: boolean }) { - return customs ? ( - } +function DirectionPill({ direction }: { direction: string }) { + const isImport = direction === "IMPORT"; + const Icon = isImport ? Truck : ShipWheel; + const color = isImport ? "blue" : "teal"; + return ( + - Customs - - ) : ( - - No customs - + + {prettyStatus(direction)} + + ); +} + +function OutlinePill({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} + +function RouteCell({ + origin, + destination, + direction, + freightType, + customs, +}: { + origin: string; + destination: string; + direction: string; + freightType: string; + customs: boolean; +}) { + return ( + + + + {origin} + + + + {destination} + + + + + {prettyStatus(freightType)} + {customs ? ( + + + Customs + + ) : null} + + ); } @@ -129,13 +216,21 @@ export default function ContractClearanceListPage() { !isDjiboutiGl(user); const [query, setQuery] = useState(""); + const [tab, setTab] = useState("all"); + const [freight, setFreight] = useState(null); + const [status, setStatus] = useState(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(); 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>( + () => ({ + 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() { } - > - {counts.all} in clearance - - } + meta={} action={ - - void refetch()} - loading={isFetching} - aria-label="Refresh" - > - - - + } /> @@ -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), }, ]} /> - + - - - } - value={query} - onChange={(e) => { - setQuery(e.currentTarget.value); - setPagination({ pageIndex: 0, pageSize: pagination.pageSize }); - }} - rightSection={ - query ? ( - setQuery("")} + {/* ── Tabs ─────────────────────────────────────────────── */} + + + {TABS.map((t) => { + const active = tab === t.key; + const Icon = t.icon; + return ( + { + 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} + > + + - - - ) : null - } - radius="lg" - style={{ flex: 1, minWidth: 220 }} - /> - - {rows.length} record{rows.length !== 1 ? "s" : ""} - + {t.label} + + + {tabCounts[t.key]} + + + ); + })} - + + {total} record{total !== 1 ? "s" : ""} + +
+ + {/* ── Filter bar ───────────────────────────────────────── */} + + } + value={query} + onChange={(e) => { + setQuery(e.currentTarget.value); + resetPage(); + }} + rightSection={ + query ? ( + { + setQuery(""); + resetPage(); + }} + aria-label="Clear search" + > + + + ) : null + } + radius="md" + size="sm" + styles={{ + input: { background: "var(--mantine-color-gray-0)" }, + }} + style={{ flex: 1, minWidth: 220 }} + /> + { + setStatus(v); + resetPage(); + }} + clearable + radius="md" + size="sm" + w={180} + comboboxProps={{ withinPortal: true }} + aria-label="Filter by status" + /> + {hasFilters ? ( + + ) : null} + @@ -337,7 +623,6 @@ export default function ContractClearanceListPage() { - ); } @@ -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["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["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: () => Booking, cell: ({ row }) => ( -
-
- +
+
+
-

+ {row.original.reference} -

-

- - {row.original.customerLabel} -

+ + + + + {row.original.customerLabel} + +
), @@ -462,17 +734,19 @@ function ShipmentBookingsTable({ cell: ({ row }) => { const r = row.original; return ( - - - - + + + + {r.contractReference ?? "—"} {r.contractKind ? ( - - {r.contractKind === "GENERAL" ? "General" : "One-time"} - + + {r.contractKind === "GENERAL" + ? "General contract" + : "One-time"} + ) : null} ); @@ -481,44 +755,33 @@ function ShipmentBookingsTable({ { id: "route", header: () => Route, - cell: ({ row }) => ( - - ), - }, - { - id: "kind", - header: () => Type, - cell: ({ row }) => ( - - - {prettyStatus(row.original.tradeDirection)} - - - {prettyStatus(row.original.freightType)} - - - - ), + cell: ({ row }) => { + const r = row.original; + return ( + + ); + }, }, { id: "requested", - header: () => ( - Requested cargo - ), + header: () => Cargo, cell: ({ row }) => ( - + ), }, { id: "created", header: () => Created, cell: ({ row }) => ( - - - + + + {formatDate(row.original.createdAt)} @@ -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 ? ( - + Needs approval ) : /* 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 ? ( - + Documents approved ) : ( @@ -548,6 +811,7 @@ function ShipmentBookingsTable({ variant="light" color={shipmentStatusColor(row.original.status)} radius="sm" + size="sm" > {prettyStatus(row.original.status)} @@ -558,6 +822,7 @@ function ShipmentBookingsTable({ variant="light" color="blue" radius="sm" + size="sm" leftSection={} > Booked @@ -617,7 +882,10 @@ function ShipmentBookingsTable({ - } onClick={() => onOpen(r.id)}> + } + onClick={() => onOpen(r.id)} + > Open booking {bookable ? ( @@ -655,25 +923,53 @@ function ShipmentBookingsTable({ [canCreateBooking, onOpen, onCreateBooking, onViewContract], ); - if (!loading && !error && rows.length === 0) { + if (!loading && !error && total === 0) { return ( - No shipment bookings in clearance. + + {hasFilters + ? "No shipments match these filters." + : "No shipment bookings in clearance."} + + {hasFilters ? ( + + ) : null} ); } return ( - + 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) => } /> ); diff --git a/apps/edr-freight-web/backoffice/src/pages/contracts/GlDjiboutiClearanceListPage.tsx b/apps/edr-freight-web/backoffice/src/pages/contracts/GlDjiboutiClearanceListPage.tsx index 6e34da29a..7f6c1e6bb 100644 --- a/apps/edr-freight-web/backoffice/src/pages/contracts/GlDjiboutiClearanceListPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/contracts/GlDjiboutiClearanceListPage.tsx @@ -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(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 ( + + + {label} + + ); +} + +function DirectionPill({ direction }: { direction: string }) { const isImport = direction === "IMPORT"; const Icon = isImport ? Truck : ShipWheel; - const label = directionLabel(direction); + const color = isImport ? "blue" : "teal"; return ( - - + - - + + {prettyStatus(direction)} + ); } +function OutlinePill({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} + function RouteCell({ origin, destination, @@ -214,30 +265,19 @@ function RouteCell({ freightType: string; }) { return ( - - {/* Wraps past 120px as "Addis Ababa" / "→ Djibouti"; text wraps - normally (cells are otherwise nowrap) so it never spills over. */} - - {origin}{" "} - - {"\u00A0"} - {destination} - - - - - {freightType} - + + + + {origin} + + + + {destination} + + + + + {prettyStatus(freightType)} ); @@ -254,7 +294,7 @@ function RouteCell({ export default function GlDjiboutiClearanceListPage() { const navigate = useNavigate(); const [query, setQuery] = useState(""); - const [direction, setDirection] = useState(null); + const [tab, setTab] = useState("all"); const [freight, setFreight] = useState(null); const [status, setStatus] = useState(null); const [action, setAction] = useState(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>( + () => ({ + 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 ( -
-
- +
+
+
-

+ {r.reference} -

-

- - {r.customerLabel} -

+ + + + + {r.customerLabel} + +
); @@ -400,9 +439,11 @@ export default function GlDjiboutiClearanceListPage() { id: "contract", header: () => Contract, cell: ({ row }) => ( - - - {row.original.contractReference} + + + + {row.original.contractReference} + ), }, @@ -428,9 +469,11 @@ export default function GlDjiboutiClearanceListPage() { const r = row.original; return ( - - - {r.weightTons} t + + + + {r.weightTons} t + {r.isHazardous ? ( DJ action, + header: () => ( + DJ action + ), cell: ({ row }) => { const r = row.original; const badge = ( @@ -466,7 +511,7 @@ export default function GlDjiboutiClearanceListPage() { ) : ( badge )} - + {phaseLabel(r.phase)} @@ -489,11 +534,13 @@ export default function GlDjiboutiClearanceListPage() { }, { id: "scheduled", - header: () => Scheduled, + header: () => ( + Scheduled + ), cell: ({ row }) => ( - - - + + + {formatDate(row.original.scheduledDate)} @@ -505,7 +552,7 @@ export default function GlDjiboutiClearanceListPage() { header: "", cell: () => ( - + ), }, @@ -519,17 +566,18 @@ export default function GlDjiboutiClearanceListPage() { } action={ - } loading={isFetching} onClick={handleRefresh} - aria-label="Refresh" > - - + Refresh + } /> @@ -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), }, ]} /> - + - - - } - value={query} - onChange={(e) => { - setQuery(e.currentTarget.value); - resetPage(); - }} - rightSection={ - query ? ( - { - setQuery(""); - resetPage(); + {/* ── Tabs ─────────────────────────────────────────────── */} + + + {TABS.map((t) => { + const active = tab === t.key; + const Icon = t.icon; + return ( + { + 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} + > + + + {t.label} + + - - - ) : null - } - radius="lg" - style={{ flex: 1, minWidth: 220 }} - /> - { - setFreight(v); - resetPage(); - }} - clearable - radius="lg" - w={130} - /> - { - setAction(v); - resetPage(); - }} - clearable - radius="lg" - w={180} - /> - {hasFilters ? ( - - ) : null} + {tabCounts[t.key]} + + + ); + })} - + + {total} record{total !== 1 ? "s" : ""} + + - {showEmpty ? ( + {/* ── Filter bar ───────────────────────────────────────── */} + + } + value={query} + onChange={(e) => { + setQuery(e.currentTarget.value); + resetPage(); + }} + rightSection={ + query ? ( + { + setQuery(""); + resetPage(); + }} + aria-label="Clear search" + > + + + ) : null + } + radius="md" + size="sm" + styles={{ + input: { background: "var(--mantine-color-gray-0)" }, + }} + style={{ flex: 1, minWidth: 220 }} + /> + { + setStatus(v); + resetPage(); + }} + clearable + radius="md" + size="sm" + w={180} + comboboxProps={{ withinPortal: true }} + aria-label="Filter by status" + /> +