mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 15:48:11 +00:00
Merge pull request #963 from Tria-plc/freight_feature/usermanagement
Freight feature/usermanagement
This commit is contained in:
@@ -81,6 +81,32 @@ export const WagonTransferFulfill = () =>
|
|||||||
export const WagonTransferHistoryAll = () =>
|
export const WagonTransferHistoryAll = () =>
|
||||||
BookingStaff(FREIGHT_PERMS.wagons.transferHistoryAll);
|
BookingStaff(FREIGHT_PERMS.wagons.transferHistoryAll);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open the transfer-requests desk. `wagons:view` is accepted as a one-of
|
||||||
|
* fallback so staff who could already reach the queue keep it without a
|
||||||
|
* re-grant — same pattern the granular fleet keys use.
|
||||||
|
*/
|
||||||
|
export const WagonTransferView = () =>
|
||||||
|
BookingStaff([FREIGHT_PERMS.wagons.transferView, FREIGHT_PERMS.wagons.view]);
|
||||||
|
|
||||||
|
/** Withdraw a request that has not moved any wagon yet. */
|
||||||
|
export const WagonTransferCancel = () =>
|
||||||
|
BookingStaff([
|
||||||
|
FREIGHT_PERMS.wagons.transferCancel,
|
||||||
|
FREIGHT_PERMS.wagons.transferRequest,
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* End a request short of the requested count. Whoever may move wagons may also
|
||||||
|
* declare the yard has no more to give, so fulfil is accepted alongside the
|
||||||
|
* dedicated key.
|
||||||
|
*/
|
||||||
|
export const WagonTransferCloseShort = () =>
|
||||||
|
BookingStaff([
|
||||||
|
FREIGHT_PERMS.wagons.transferCloseShort,
|
||||||
|
FREIGHT_PERMS.wagons.transferFulfill,
|
||||||
|
]);
|
||||||
|
|
||||||
/** Org-administration endpoints (user mgmt, billing config, company CRUD, settings). */
|
/** Org-administration endpoints (user mgmt, billing config, company CRUD, settings). */
|
||||||
export const FreightAdmin = () => BookingStaff(FREIGHT_PERMS.admin);
|
export const FreightAdmin = () => BookingStaff(FREIGHT_PERMS.admin);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store WHO made a contract edit as a name, not just an id. Denormalised on
|
||||||
|
* purpose: an audit trail must still read correctly after the user is renamed,
|
||||||
|
* deactivated or deleted, and `iam.users` lives outside this module's schema.
|
||||||
|
*/
|
||||||
|
export class AddRevisionActorName2920000000000 implements MigrationInterface {
|
||||||
|
name = 'AddRevisionActorName2920000000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE freight.contract_document_revisions ADD COLUMN IF NOT EXISTS actor_name varchar(200);`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE freight.contract_document_revisions DROP COLUMN IF EXISTS actor_name;`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Djibouti GL must record WHEN the vessel arrived and WHEN the Delivery Order
|
||||||
|
* was collected, not just attach the DO file. Both are mandatory on DO upload
|
||||||
|
* (enforced in the clearance services), so the columns are new and nullable —
|
||||||
|
* DOs uploaded before this change have no dates to backfill.
|
||||||
|
*
|
||||||
|
* `vessel_departure_date` is the EXPORT Release-Order date and stays as-is; the
|
||||||
|
* import arrival date gets its own column rather than overloading it.
|
||||||
|
*/
|
||||||
|
export class AddDoCollectionDates2930000000000 implements MigrationInterface {
|
||||||
|
name = 'AddDoCollectionDates2930000000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
for (const table of [
|
||||||
|
'freight.contract_clearance_cycles',
|
||||||
|
'freight.bookings',
|
||||||
|
]) {
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE ${table} ADD COLUMN IF NOT EXISTS vessel_arrival_date date;`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE ${table} ADD COLUMN IF NOT EXISTS do_collected_date date;`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
for (const table of [
|
||||||
|
'freight.contract_clearance_cycles',
|
||||||
|
'freight.bookings',
|
||||||
|
]) {
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE ${table} DROP COLUMN IF EXISTS do_collected_date;`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE ${table} DROP COLUMN IF EXISTS vessel_arrival_date;`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Partial wagon-transfer fulfilment.
|
||||||
|
*
|
||||||
|
* A request for 50 wagons no longer has to be met in one go: OCC moves what the
|
||||||
|
* source yard can spare, whenever it can, and the request stays open until the
|
||||||
|
* full count is met (FULFILLED) or OCC ends it short (CLOSED_SHORT) so the
|
||||||
|
* requester can ask another yard for the rest.
|
||||||
|
*
|
||||||
|
* Existing rows are back-filled so history keeps reading correctly: a FULFILLED
|
||||||
|
* request delivered its whole quantity; anything else delivered nothing.
|
||||||
|
*/
|
||||||
|
export class AddWagonTransferPartialFulfilment2930000000000
|
||||||
|
implements MigrationInterface
|
||||||
|
{
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.wagon_transfer_requests
|
||||||
|
ADD COLUMN IF NOT EXISTS fulfilled_quantity integer NOT NULL DEFAULT 0,
|
||||||
|
ADD COLUMN IF NOT EXISTS closed_short_at timestamptz NULL,
|
||||||
|
ADD COLUMN IF NOT EXISTS closed_short_by_user_id uuid NULL
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
UPDATE freight.wagon_transfer_requests
|
||||||
|
SET fulfilled_quantity = quantity
|
||||||
|
WHERE status = 'FULFILLED'
|
||||||
|
AND fulfilled_quantity = 0
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.wagon_transfer_requests
|
||||||
|
DROP COLUMN IF EXISTS fulfilled_quantity,
|
||||||
|
DROP COLUMN IF EXISTS closed_short_at,
|
||||||
|
DROP COLUMN IF EXISTS closed_short_by_user_id
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Currency moved from the contract to the shipment: a contract now quotes in
|
||||||
|
* USD and the customer picks the billing currency per booking. On a customs
|
||||||
|
* contract GL books on the customer's behalf, so the shipment request is where
|
||||||
|
* the customer states the currency — GL reads it when creating the booking.
|
||||||
|
*
|
||||||
|
* Nullable: requests submitted before this change fall back to the contract's
|
||||||
|
* own currency, which is exactly what their bookings already used.
|
||||||
|
*/
|
||||||
|
export class AddBookingRequestCurrency2940000000000 implements MigrationInterface {
|
||||||
|
name = 'AddBookingRequestCurrency2940000000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE freight.booking_requests ADD COLUMN IF NOT EXISTS payment_currency varchar(5);`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE freight.booking_requests DROP COLUMN IF EXISTS payment_currency;`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keep every version of a stored document.
|
||||||
|
*
|
||||||
|
* Replacing a file used to DELETE the previous row outright, so a staff
|
||||||
|
* correction erased the customer's original upload with no trail. Superseded
|
||||||
|
* versions are now soft-deleted (already excluded from every read by TypeORM's
|
||||||
|
* soft-delete filter) and stamped with who replaced them and why, which is what
|
||||||
|
* the document's version history reads back.
|
||||||
|
*/
|
||||||
|
export class AddFileVersionHistory2940000000000 implements MigrationInterface {
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.files
|
||||||
|
ADD COLUMN IF NOT EXISTS replaced_by_user_id uuid NULL,
|
||||||
|
ADD COLUMN IF NOT EXISTS replace_reason text NULL
|
||||||
|
`);
|
||||||
|
// History reads walk one document's versions, deleted rows included.
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS "IDX_files_version_history"
|
||||||
|
ON freight.files (resource, resource_id, code, created_at DESC)
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`DROP INDEX IF EXISTS freight."IDX_files_version_history"`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.files
|
||||||
|
DROP COLUMN IF EXISTS replaced_by_user_id,
|
||||||
|
DROP COLUMN IF EXISTS replace_reason
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transit-assignee handshake before the customs declaration.
|
||||||
|
*
|
||||||
|
* GL Ethiopia must ask GL Djibouti who will handle the shipment in transit, and
|
||||||
|
* Djibouti answers with a name, before the declaration can be filed. The whole
|
||||||
|
* exchange lives on the clearance cycle so it repeats naturally with each cycle
|
||||||
|
* of a GENERAL contract.
|
||||||
|
*/
|
||||||
|
export class AddTransitAssigneeHandshake2950000000000
|
||||||
|
implements MigrationInterface
|
||||||
|
{
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.contract_clearance_cycles
|
||||||
|
ADD COLUMN IF NOT EXISTS transit_assignee_requested_at timestamptz NULL,
|
||||||
|
ADD COLUMN IF NOT EXISTS transit_assignee_requested_by_user_id uuid NULL,
|
||||||
|
ADD COLUMN IF NOT EXISTS transit_assignee_request_note text NULL,
|
||||||
|
ADD COLUMN IF NOT EXISTS transit_assignee_name text NULL,
|
||||||
|
ADD COLUMN IF NOT EXISTS transit_assignee_assigned_at timestamptz NULL,
|
||||||
|
ADD COLUMN IF NOT EXISTS transit_assignee_assigned_by_user_id uuid NULL
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.contract_clearance_cycles
|
||||||
|
DROP COLUMN IF EXISTS transit_assignee_requested_at,
|
||||||
|
DROP COLUMN IF EXISTS transit_assignee_requested_by_user_id,
|
||||||
|
DROP COLUMN IF EXISTS transit_assignee_request_note,
|
||||||
|
DROP COLUMN IF EXISTS transit_assignee_name,
|
||||||
|
DROP COLUMN IF EXISTS transit_assignee_assigned_at,
|
||||||
|
DROP COLUMN IF EXISTS transit_assignee_assigned_by_user_id
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -200,7 +200,7 @@ export class BookingPricingService {
|
|||||||
// route's container freight, never a frozen OVERWEIGHT_PER_TON value.
|
// route's container freight, never a frozen OVERWEIGHT_PER_TON value.
|
||||||
const frozen = isDerived
|
const frozen = isDerived
|
||||||
? null
|
? null
|
||||||
: this.frozenRateByCode(frozenRates, mod.surchargeCode, paymentCurrency);
|
: this.frozenRateByCode(frozenRates, mod.surchargeCode, paymentCurrency, usdToEtb);
|
||||||
const unitAmount = frozen
|
const unitAmount = frozen
|
||||||
? Number(frozen.unitPrice)
|
? Number(frozen.unitPrice)
|
||||||
: isEtbBooking
|
: isEtbBooking
|
||||||
@@ -547,14 +547,15 @@ export class BookingPricingService {
|
|||||||
booking.originYardId,
|
booking.originYardId,
|
||||||
booking.destinationYardId,
|
booking.destinationYardId,
|
||||||
);
|
);
|
||||||
// H15: frozen contract rate for this container size, when present — its
|
// H15: frozen contract rate for this container size, when present —
|
||||||
// unitPrice is already in the booking currency (no USD→currency convert).
|
// converted into the booking currency by frozenRateForContainer. It also
|
||||||
// It also stands on its own: a contract line prices off the agreed rate
|
// stands on its own: a contract line prices off the agreed rate even when
|
||||||
// even when nobody configured a live rate for this leg + type yet.
|
// nobody configured a live rate for this leg + type yet.
|
||||||
const frozen = await this.frozenRateForContainer(
|
const frozen = await this.frozenRateForContainer(
|
||||||
frozenRates,
|
frozenRates,
|
||||||
container.containerTypeId,
|
container.containerTypeId,
|
||||||
paymentCurrency,
|
paymentCurrency,
|
||||||
|
usdToEtb,
|
||||||
);
|
);
|
||||||
const label = await this.containerTypeLabel(container.containerTypeId);
|
const label = await this.containerTypeLabel(container.containerTypeId);
|
||||||
if (!rate && !frozen) {
|
if (!rate && !frozen) {
|
||||||
@@ -632,7 +633,7 @@ export class BookingPricingService {
|
|||||||
const unitUsd = Number(fallback.rateValue);
|
const unitUsd = Number(fallback.rateValue);
|
||||||
// H15: bulk freight uses the frozen BULK_FREIGHT snapshot when present.
|
// H15: bulk freight uses the frozen BULK_FREIGHT snapshot when present.
|
||||||
const frozen = isBulk
|
const frozen = isBulk
|
||||||
? this.frozenRateByCode(frozenRates, 'BULK_FREIGHT', paymentCurrency)
|
? this.frozenRateByCode(frozenRates, 'BULK_FREIGHT', paymentCurrency, usdToEtb)
|
||||||
: null;
|
: null;
|
||||||
let amount: number;
|
let amount: number;
|
||||||
let unitAmount: number;
|
let unitAmount: number;
|
||||||
@@ -744,12 +745,13 @@ export class BookingPricingService {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
// H15: frozen mile rate (already in booking currency) when the contract
|
// H15: frozen mile rate (converted into the booking currency) when the
|
||||||
// has one; else the live USD rate converted as before.
|
// contract has one; else the live USD rate converted as before.
|
||||||
const frozen = this.frozenRateByCode(
|
const frozen = this.frozenRateByCode(
|
||||||
frozenRates,
|
frozenRates,
|
||||||
leg.rateType,
|
leg.rateType,
|
||||||
paymentCurrency,
|
paymentCurrency,
|
||||||
|
usdToEtb,
|
||||||
);
|
);
|
||||||
let amount: number;
|
let amount: number;
|
||||||
let unitAmount: number;
|
let unitAmount: number;
|
||||||
@@ -908,20 +910,45 @@ export class BookingPricingService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The frozen snapshot for a rate code, or null when there is none, its price
|
* The frozen snapshot for a rate code, expressed in the BOOKING's currency.
|
||||||
* is negative, or it is in a different currency than the booking (in which
|
*
|
||||||
* case the live-rate path is safer than a mis-converted frozen price).
|
* A contract quotes in USD and freezes USD unit prices; the customer chooses
|
||||||
|
* the billing currency per booking. So a currency mismatch is the normal case
|
||||||
|
* now, not an error — the snapshot is converted rather than discarded. (It
|
||||||
|
* previously returned null on mismatch, which silently dropped the agreed
|
||||||
|
* contract price and re-priced the booking at whatever the live rate had
|
||||||
|
* drifted to.) Grandfathered ETB contracts convert the other way for the same
|
||||||
|
* reason.
|
||||||
|
*
|
||||||
|
* Returns null only when there is no snapshot or its price is unusable.
|
||||||
*/
|
*/
|
||||||
private frozenRateByCode(
|
private frozenRateByCode(
|
||||||
frozenRates: Map<string, ContractRateSnapshot> | null,
|
frozenRates: Map<string, ContractRateSnapshot> | null,
|
||||||
code: string,
|
code: string,
|
||||||
bookingCurrency: string,
|
bookingCurrency: string,
|
||||||
|
usdToEtb: number,
|
||||||
): ContractRateSnapshot | null {
|
): ContractRateSnapshot | null {
|
||||||
const snap = frozenRates?.get(code);
|
const snap = frozenRates?.get(code);
|
||||||
if (!snap) return null;
|
if (!snap) return null;
|
||||||
if (snap.currency !== bookingCurrency) return null;
|
const unitPrice = Number(snap.unitPrice);
|
||||||
if (!(Number(snap.unitPrice) >= 0)) return null;
|
if (!(unitPrice >= 0)) return null;
|
||||||
return snap;
|
if (snap.currency === bookingCurrency) return snap;
|
||||||
|
|
||||||
|
// Only USD <-> ETB exist; a rate of 0/NaN would silently zero the price.
|
||||||
|
if (!(usdToEtb > 0)) return null;
|
||||||
|
const converted =
|
||||||
|
snap.currency === 'USD' && bookingCurrency === 'ETB'
|
||||||
|
? Math.round(unitPrice * usdToEtb)
|
||||||
|
: snap.currency === 'ETB' && bookingCurrency === 'USD'
|
||||||
|
? unitPrice / usdToEtb
|
||||||
|
: null;
|
||||||
|
if (converted == null) return null;
|
||||||
|
|
||||||
|
// A copy — the snapshot rows are shared across the pricing pass.
|
||||||
|
return Object.assign(Object.create(Object.getPrototypeOf(snap)), snap, {
|
||||||
|
unitPrice: converted,
|
||||||
|
currency: bookingCurrency,
|
||||||
|
}) as ContractRateSnapshot;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -933,6 +960,7 @@ export class BookingPricingService {
|
|||||||
frozenRates: Map<string, ContractRateSnapshot> | null,
|
frozenRates: Map<string, ContractRateSnapshot> | null,
|
||||||
containerTypeId: string,
|
containerTypeId: string,
|
||||||
bookingCurrency: string,
|
bookingCurrency: string,
|
||||||
|
usdToEtb: number,
|
||||||
): Promise<ContractRateSnapshot | null> {
|
): Promise<ContractRateSnapshot | null> {
|
||||||
if (!frozenRates) return null;
|
if (!frozenRates) return null;
|
||||||
let sizeFt: number | null = null;
|
let sizeFt: number | null = null;
|
||||||
@@ -942,7 +970,7 @@ export class BookingPricingService {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (!sizeFt) return null;
|
if (!sizeFt) return null;
|
||||||
return this.frozenRateByCode(frozenRates, `CONTAINER_${sizeFt}FT`, bookingCurrency);
|
return this.frozenRateByCode(frozenRates, `CONTAINER_${sizeFt}FT`, bookingCurrency, usdToEtb);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -985,7 +1013,7 @@ export class BookingPricingService {
|
|||||||
const hasPerSizeSnapshot =
|
const hasPerSizeSnapshot =
|
||||||
frozenRates?.has('CUSTOMS_CLEARANCE_20FT') ||
|
frozenRates?.has('CUSTOMS_CLEARANCE_20FT') ||
|
||||||
frozenRates?.has('CUSTOMS_CLEARANCE_40FT');
|
frozenRates?.has('CUSTOMS_CLEARANCE_40FT');
|
||||||
const legacyFlat = this.frozenRateByCode(frozenRates, 'CUSTOMS_CLEARANCE', currency);
|
const legacyFlat = this.frozenRateByCode(frozenRates, 'CUSTOMS_CLEARANCE', currency, usdToEtb);
|
||||||
if (legacyFlat && !hasPerSizeSnapshot) {
|
if (legacyFlat && !hasPerSizeSnapshot) {
|
||||||
const amount = Number(legacyFlat.unitPrice);
|
const amount = Number(legacyFlat.unitPrice);
|
||||||
if (amount > 0) {
|
if (amount > 0) {
|
||||||
@@ -1014,7 +1042,7 @@ export class BookingPricingService {
|
|||||||
// unknown type — falls through to the live per-type lookup below
|
// unknown type — falls through to the live per-type lookup below
|
||||||
}
|
}
|
||||||
const frozen = sizeFt
|
const frozen = sizeFt
|
||||||
? this.frozenRateByCode(frozenRates, `CUSTOMS_CLEARANCE_${sizeFt}FT`, currency)
|
? this.frozenRateByCode(frozenRates, `CUSTOMS_CLEARANCE_${sizeFt}FT`, currency, usdToEtb)
|
||||||
: null;
|
: null;
|
||||||
const live = onLeg.find((r) => r.containerTypeId === bc.containerTypeId);
|
const live = onLeg.find((r) => r.containerTypeId === bc.containerTypeId);
|
||||||
if (!frozen && !live) {
|
if (!frozen && !live) {
|
||||||
@@ -1049,7 +1077,7 @@ export class BookingPricingService {
|
|||||||
// flat snapshot share the CUSTOMS_CLEARANCE code; both are the agreed fee.
|
// flat snapshot share the CUSTOMS_CLEARANCE code; both are the agreed fee.
|
||||||
// Live lookup: the rate scoped to the booking's commodity wins; a
|
// Live lookup: the rate scoped to the booking's commodity wins; a
|
||||||
// commodity-less rate (legacy) is the catch-all fallback.
|
// commodity-less rate (legacy) is the catch-all fallback.
|
||||||
const frozen = this.frozenRateByCode(frozenRates, 'CUSTOMS_CLEARANCE', currency);
|
const frozen = this.frozenRateByCode(frozenRates, 'CUSTOMS_CLEARANCE', currency, usdToEtb);
|
||||||
const live =
|
const live =
|
||||||
(booking.cargoTypeId
|
(booking.cargoTypeId
|
||||||
? onLeg.find(
|
? onLeg.find(
|
||||||
|
|||||||
@@ -916,14 +916,15 @@ export class BookingsController {
|
|||||||
async uploadBookingDeliveryOrder(
|
async uploadBookingDeliveryOrder(
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
@UploadedFile() file: Express.Multer.File,
|
@UploadedFile() file: Express.Multer.File,
|
||||||
@Body('vesselDepartureDate') vesselDepartureDate: string | undefined,
|
@Body('vesselArrivalDate') vesselArrivalDate: string | undefined,
|
||||||
|
@Body('doCollectedDate') doCollectedDate: string | undefined,
|
||||||
@CurrentUser() user: TCurrentUser,
|
@CurrentUser() user: TCurrentUser,
|
||||||
) {
|
) {
|
||||||
const booking = await this.bookingClearanceService.uploadDeliveryOrder(
|
const booking = await this.bookingClearanceService.uploadDeliveryOrder(
|
||||||
id,
|
id,
|
||||||
file,
|
file,
|
||||||
resolveAuthUserId(user),
|
resolveAuthUserId(user),
|
||||||
vesselDepartureDate,
|
{ vesselArrivalDate, doCollectedDate },
|
||||||
);
|
);
|
||||||
return this.transitionService.enrichBookingResponse(booking);
|
return this.transitionService.enrichBookingResponse(booking);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -123,7 +123,9 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
|||||||
'booking.files',
|
'booking.files',
|
||||||
FileRecord,
|
FileRecord,
|
||||||
'file',
|
'file',
|
||||||
"file.resource_id = booking.id AND file.resource = 'bookings'",
|
// Superseded versions are soft-deleted, not dropped — keep them out of
|
||||||
|
// the live file list (a manual join condition is not filtered for us).
|
||||||
|
"file.resource_id = booking.id AND file.resource = 'bookings' AND file.deleted_at IS NULL",
|
||||||
)
|
)
|
||||||
.getOne();
|
.getOne();
|
||||||
|
|
||||||
|
|||||||
@@ -540,6 +540,14 @@ export class Booking extends BaseEntity {
|
|||||||
@Column({ name: 'vessel_departure_date', type: 'date', nullable: true })
|
@Column({ name: 'vessel_departure_date', type: 'date', nullable: true })
|
||||||
vesselDepartureDate?: string | null;
|
vesselDepartureDate?: string | null;
|
||||||
|
|
||||||
|
/** Import DO: when the vessel arrived in Djibouti. Required on DO upload. */
|
||||||
|
@Column({ name: 'vessel_arrival_date', type: 'date', nullable: true })
|
||||||
|
vesselArrivalDate?: string | null;
|
||||||
|
|
||||||
|
/** Import DO: when GL Djibouti collected the DO. Required on DO upload. */
|
||||||
|
@Column({ name: 'do_collected_date', type: 'date', nullable: true })
|
||||||
|
doCollectedDate?: string | null;
|
||||||
|
|
||||||
@Column({ name: 'ro_amendment_requested_at', type: 'timestamptz', nullable: true })
|
@Column({ name: 'ro_amendment_requested_at', type: 'timestamptz', nullable: true })
|
||||||
roAmendmentRequestedAt?: Date | null;
|
roAmendmentRequestedAt?: Date | null;
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
BadRequestException,
|
BadRequestException,
|
||||||
ForbiddenException,
|
ForbiddenException,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { DataSource } from "typeorm";
|
import { DataSource, EntityManager } from "typeorm";
|
||||||
import { CompaniesRepository } from "./companies.repository";
|
import { CompaniesRepository } from "./companies.repository";
|
||||||
import { CompanyProfileRepository } from "./company-profile.repository";
|
import { CompanyProfileRepository } from "./company-profile.repository";
|
||||||
import { CompanyChangeRequestRepository } from "./company-change-request.repository";
|
import { CompanyChangeRequestRepository } from "./company-change-request.repository";
|
||||||
@@ -1111,9 +1111,11 @@ export class CompaniesService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Anything other than approval has no document gate and no concurrency
|
// Anything other than approval has no document gate and no concurrency
|
||||||
// hazard — apply it directly.
|
// hazard — no row lock, just the write.
|
||||||
if (status !== ProfileStatus.Active) {
|
if (status !== ProfileStatus.Active) {
|
||||||
return this.applyProfileStatus(existing, status, note, reviewerId);
|
return this.dataSource.transaction((manager) =>
|
||||||
|
this.applyProfileStatus(manager, existing, status, note, reviewerId),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Approving over an outstanding document correction would silently accept the
|
// Approving over an outstanding document correction would silently accept the
|
||||||
@@ -1149,7 +1151,7 @@ export class CompaniesService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.applyProfileStatus(existing, status, note, reviewerId);
|
return this.applyProfileStatus(manager, existing, status, note, reviewerId);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1160,11 +1162,21 @@ export class CompaniesService {
|
|||||||
* transaction while every other status skips that overhead.
|
* transaction while every other status skips that overhead.
|
||||||
*/
|
*/
|
||||||
private async applyProfileStatus(
|
private async applyProfileStatus(
|
||||||
|
manager: EntityManager,
|
||||||
existing: CompanyProfile,
|
existing: CompanyProfile,
|
||||||
status: ProfileStatus,
|
status: ProfileStatus,
|
||||||
note?: string,
|
note?: string,
|
||||||
reviewerId?: string,
|
reviewerId?: string,
|
||||||
): Promise<CompanyProfile> {
|
): Promise<CompanyProfile> {
|
||||||
|
// Every write below goes through `manager`. The approval path holds a
|
||||||
|
// pessimistic_write lock on the company row, and the injected repositories
|
||||||
|
// are bound to the DataSource's default pool — writing the same row through
|
||||||
|
// one of them would block on a lock this very transaction holds, hanging the
|
||||||
|
// request until the statement timed out. That deadlocked the first approval
|
||||||
|
// of any customer: the profile went Active on its own connection while the
|
||||||
|
// company stayed Pending and the caller never got a response.
|
||||||
|
const profileRepo = manager.getRepository(CompanyProfile);
|
||||||
|
const companyRepo = manager.getRepository(Company);
|
||||||
// A reference number is only minted the first time a profile is approved
|
// A reference number is only minted the first time a profile is approved
|
||||||
// (status → Active). Pending/unapproved profiles carry no reference.
|
// (status → Active). Pending/unapproved profiles carry no reference.
|
||||||
const patch: Partial<CompanyProfile> = { status };
|
const patch: Partial<CompanyProfile> = { status };
|
||||||
@@ -1190,7 +1202,8 @@ export class CompaniesService {
|
|||||||
patch.reviewedAt = new Date();
|
patch.reviewedAt = new Date();
|
||||||
}
|
}
|
||||||
|
|
||||||
const updated = await this.companyProfilesRepo.update(existing.id, patch);
|
await profileRepo.update(existing.id, patch);
|
||||||
|
const updated = await profileRepo.findOne({ where: { id: existing.id } });
|
||||||
if (!updated)
|
if (!updated)
|
||||||
throw new NotFoundException(`Company profile ${existing.id} not found`);
|
throw new NotFoundException(`Company profile ${existing.id} not found`);
|
||||||
|
|
||||||
@@ -1208,7 +1221,9 @@ export class CompaniesService {
|
|||||||
: "approved"
|
: "approved"
|
||||||
: null;
|
: null;
|
||||||
if (change) {
|
if (change) {
|
||||||
const company = await this.companiesRepo.findById(updated.companyId);
|
const company = await companyRepo.findOne({
|
||||||
|
where: { id: updated.companyId },
|
||||||
|
});
|
||||||
if (company) {
|
if (company) {
|
||||||
this.companyNotifier.profileStatusChanged(
|
this.companyNotifier.profileStatusChanged(
|
||||||
company,
|
company,
|
||||||
@@ -1222,7 +1237,7 @@ export class CompaniesService {
|
|||||||
status === ProfileStatus.Active &&
|
status === ProfileStatus.Active &&
|
||||||
company.status === CompanyStatus.Pending
|
company.status === CompanyStatus.Pending
|
||||||
) {
|
) {
|
||||||
await this.companiesRepo.update(updated.companyId, {
|
await companyRepo.update(updated.companyId, {
|
||||||
status: CompanyStatus.Active,
|
status: CompanyStatus.Active,
|
||||||
});
|
});
|
||||||
this.companyNotifier.companyApproved(company);
|
this.companyNotifier.companyApproved(company);
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
} from './entities/clearance-milestone.entity';
|
} from './entities/clearance-milestone.entity';
|
||||||
import { Booking } from '../bookings/entities/booking.entity';
|
import { Booking } from '../bookings/entities/booking.entity';
|
||||||
import { clearanceCodesForBooking } from '../bookings/clearance.util';
|
import { clearanceCodesForBooking } from '../bookings/clearance.util';
|
||||||
|
import { assertDoCollectionDates } from './contract-clearance.util';
|
||||||
import { ClearanceWorkflowService } from './clearance-workflow.service';
|
import { ClearanceWorkflowService } from './clearance-workflow.service';
|
||||||
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
||||||
import { GlOperationsService } from './gl-operations.service';
|
import { GlOperationsService } from './gl-operations.service';
|
||||||
@@ -64,6 +65,9 @@ export interface BookingClearanceView {
|
|||||||
roHold?: boolean;
|
roHold?: boolean;
|
||||||
roHoldReason?: string | null;
|
roHoldReason?: string | null;
|
||||||
vesselDepartureDate?: string | null;
|
vesselDepartureDate?: string | null;
|
||||||
|
/** Import DO dates recorded by GL Djibouti on upload. */
|
||||||
|
vesselArrivalDate?: string | null;
|
||||||
|
doCollectedDate?: string | null;
|
||||||
roAmendmentRequestedAt?: string | null;
|
roAmendmentRequestedAt?: string | null;
|
||||||
operationReady?: boolean;
|
operationReady?: boolean;
|
||||||
preClearanceFinalized?: boolean;
|
preClearanceFinalized?: boolean;
|
||||||
@@ -261,6 +265,8 @@ export class BookingClearanceService {
|
|||||||
roHold: Boolean(booking.roHoldReason),
|
roHold: Boolean(booking.roHoldReason),
|
||||||
roHoldReason: booking.roHoldReason ?? null,
|
roHoldReason: booking.roHoldReason ?? null,
|
||||||
vesselDepartureDate: booking.vesselDepartureDate ?? null,
|
vesselDepartureDate: booking.vesselDepartureDate ?? null,
|
||||||
|
vesselArrivalDate: booking.vesselArrivalDate ?? null,
|
||||||
|
doCollectedDate: booking.doCollectedDate ?? null,
|
||||||
roAmendmentRequestedAt: booking.roAmendmentRequestedAt
|
roAmendmentRequestedAt: booking.roAmendmentRequestedAt
|
||||||
? booking.roAmendmentRequestedAt.toISOString()
|
? booking.roAmendmentRequestedAt.toISOString()
|
||||||
: null,
|
: null,
|
||||||
@@ -547,7 +553,7 @@ export class BookingClearanceService {
|
|||||||
bookingId: string,
|
bookingId: string,
|
||||||
file: Express.Multer.File,
|
file: Express.Multer.File,
|
||||||
userId?: string,
|
userId?: string,
|
||||||
vesselDepartureDate?: string,
|
dates?: { vesselArrivalDate?: string; doCollectedDate?: string },
|
||||||
): Promise<Booking> {
|
): Promise<Booking> {
|
||||||
const booking = await this.loadBooking(bookingId);
|
const booking = await this.loadBooking(bookingId);
|
||||||
if (booking.tradeDirection !== 'IMPORT') {
|
if (booking.tradeDirection !== 'IMPORT') {
|
||||||
@@ -556,6 +562,8 @@ export class BookingClearanceService {
|
|||||||
|
|
||||||
if (!file) throw new BadRequestException('No Delivery Order uploaded');
|
if (!file) throw new BadRequestException('No Delivery Order uploaded');
|
||||||
|
|
||||||
|
const { vesselArrivalDate, doCollectedDate } = assertDoCollectionDates(dates);
|
||||||
|
|
||||||
// DO upload is deliberately un-gated: GL Djibouti may attach it at any point,
|
// DO upload is deliberately un-gated: GL Djibouti may attach it at any point,
|
||||||
// any file type. The DO_COLLECTED milestone (and operation readiness) still
|
// any file type. The DO_COLLECTED milestone (and operation readiness) still
|
||||||
// waits for GL Ethiopia to finalize pre-clearance so the workflow order holds.
|
// waits for GL Ethiopia to finalize pre-clearance so the workflow order holds.
|
||||||
@@ -566,11 +574,10 @@ export class BookingClearanceService {
|
|||||||
file,
|
file,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (vesselDepartureDate?.trim()) {
|
await this.bookingsRepository.update(bookingId, {
|
||||||
await this.bookingsRepository.update(bookingId, {
|
vesselArrivalDate,
|
||||||
vesselDepartureDate: vesselDepartureDate.trim(),
|
doCollectedDate,
|
||||||
} as never);
|
} as never);
|
||||||
}
|
|
||||||
|
|
||||||
if (booking.preClearanceFinalizedAt) {
|
if (booking.preClearanceFinalizedAt) {
|
||||||
await this.workflowService.completeMilestoneForBooking(bookingId, 'DO_COLLECTED', userId);
|
await this.workflowService.completeMilestoneForBooking(bookingId, 'DO_COLLECTED', userId);
|
||||||
|
|||||||
@@ -30,18 +30,35 @@ export class BookingRequestService {
|
|||||||
private readonly notifier: ContractNotifierService,
|
private readonly notifier: ContractNotifierService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/** Only GENERAL contracts that bundle customs use the request → GL → clearance flow. */
|
/**
|
||||||
private assertGeneralCustoms(contract: Contract): void {
|
* Shipment requests exist because on a CUSTOMS contract the customer never
|
||||||
if (
|
* books directly — GL Ethiopia does it for them. The request is how the
|
||||||
contract.contractKind !== 'GENERAL' ||
|
* customer states what to ship and, now, which currency to be invoiced in.
|
||||||
!contract.customsClearingEnabled
|
*
|
||||||
) {
|
* GENERAL: each request opens its own per-booking clearance instance.
|
||||||
|
* ONE_TIME: clearance already ran at the contract level, so the request only
|
||||||
|
* records the customer's intent; GL creates the single booking from it.
|
||||||
|
*/
|
||||||
|
private assertCustomsContract(contract: Contract): void {
|
||||||
|
if (!contract.customsClearingEnabled) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
'Shipment requests apply only to general customs-clearance contracts.',
|
'Shipment requests apply only to customs-clearance contracts.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Statuses in which a ONE_TIME customs contract may take a shipment request:
|
||||||
|
* both signatures are in and the contract is at (or past) its clearance
|
||||||
|
* phase, but GL has not booked yet.
|
||||||
|
*/
|
||||||
|
private static readonly ONE_TIME_REQUESTABLE_STATUSES = [
|
||||||
|
'FULLY_EXECUTED',
|
||||||
|
'AWAITING_CLEARANCE_DOCUMENTS',
|
||||||
|
'CLEARANCE_UNDER_REVIEW',
|
||||||
|
'CLEARANCE_READY_FOR_BOOKING',
|
||||||
|
];
|
||||||
|
|
||||||
/** Customer submits a shipment request. */
|
/** Customer submits a shipment request. */
|
||||||
async submit(
|
async submit(
|
||||||
contractId: string,
|
contractId: string,
|
||||||
@@ -50,13 +67,35 @@ export class BookingRequestService {
|
|||||||
): Promise<BookingRequest> {
|
): Promise<BookingRequest> {
|
||||||
const contract = await this.contractsService.findById(contractId);
|
const contract = await this.contractsService.findById(contractId);
|
||||||
await this.contractsService.assertCustomerCanAccessContract(userId, contract);
|
await this.contractsService.assertCustomerCanAccessContract(userId, contract);
|
||||||
this.assertGeneralCustoms(contract);
|
this.assertCustomsContract(contract);
|
||||||
|
const isOneTime = contract.contractKind === 'ONE_TIME';
|
||||||
|
|
||||||
if (contract.status === 'CONTRACT_CLOSED') {
|
if (contract.status === 'CONTRACT_CLOSED') {
|
||||||
throw new ConflictException(
|
throw new ConflictException(
|
||||||
'This contract is completed — the full contracted quantity has been booked.',
|
'This contract is completed — the full contracted quantity has been booked.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (contract.status !== 'CONTRACT_ACTIVE') {
|
if (isOneTime) {
|
||||||
|
if (
|
||||||
|
!BookingRequestService.ONE_TIME_REQUESTABLE_STATUSES.includes(
|
||||||
|
contract.status,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new ConflictException(
|
||||||
|
'The contract must be fully executed before requesting its shipment.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// A one-time contract carries exactly one shipment, so it carries at most
|
||||||
|
// one open request — otherwise GL sees two conflicting currencies.
|
||||||
|
const open = (await this.repo.findForContract(contractId)).find(
|
||||||
|
(r) => r.status === 'PENDING',
|
||||||
|
);
|
||||||
|
if (open) {
|
||||||
|
throw new ConflictException(
|
||||||
|
`Shipment request ${open.reference} is already open on this contract.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (contract.status !== 'CONTRACT_ACTIVE') {
|
||||||
throw new ConflictException(
|
throw new ConflictException(
|
||||||
'The contract must be active before requesting a shipment.',
|
'The contract must be active before requesting a shipment.',
|
||||||
);
|
);
|
||||||
@@ -90,10 +129,14 @@ export class BookingRequestService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await this.contractBookingService.assertRequestWithinCapacity(contract, {
|
// Draw-down capacity is a GENERAL concept — a ONE_TIME contract's single
|
||||||
containers: dto.containers,
|
// shipment is bounded by the contract scope itself, checked when GL books.
|
||||||
bulk: dto.bulk,
|
if (!isOneTime) {
|
||||||
});
|
await this.contractBookingService.assertRequestWithinCapacity(contract, {
|
||||||
|
containers: dto.containers,
|
||||||
|
bulk: dto.bulk,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const requestedLines: Freight.RequestedShipmentLines = isContainer
|
const requestedLines: Freight.RequestedShipmentLines = isContainer
|
||||||
? {
|
? {
|
||||||
@@ -119,10 +162,17 @@ export class BookingRequestService {
|
|||||||
// reviews the documents in the clearance queue and completes the booking
|
// reviews the documents in the clearance queue and completes the booking
|
||||||
// (container numbers, VGM, shipment day) once clearance is ready. The
|
// (container numbers, VGM, shipment day) once clearance is ready. The
|
||||||
// instance is created first so a failure leaves no half-linked request.
|
// instance is created first so a failure leaves no half-linked request.
|
||||||
const booking = await this.contractBookingService.initiateForShipmentRequest(
|
// GENERAL: the request immediately opens a BARE booking instance that runs
|
||||||
contract,
|
// per-booking phased customs clearance. ONE_TIME: clearance already ran on
|
||||||
{ contractRouteId: dto.contractRouteId, userId },
|
// the contract, so there is nothing to open — the request stays PENDING
|
||||||
);
|
// until GL creates the contract's single booking from it.
|
||||||
|
const booking = isOneTime
|
||||||
|
? null
|
||||||
|
: await this.contractBookingService.initiateForShipmentRequest(contract, {
|
||||||
|
contractRouteId: dto.contractRouteId,
|
||||||
|
userId,
|
||||||
|
paymentCurrency: dto.paymentCurrency,
|
||||||
|
});
|
||||||
|
|
||||||
const reference = await this.generateReference();
|
const reference = await this.generateReference();
|
||||||
const request = await this.repo.create({
|
const request = await this.repo.create({
|
||||||
@@ -131,9 +181,14 @@ export class BookingRequestService {
|
|||||||
requestedByUserId: userId ?? null,
|
requestedByUserId: userId ?? null,
|
||||||
contractRouteId: dto.contractRouteId ?? null,
|
contractRouteId: dto.contractRouteId ?? null,
|
||||||
scheduledDate: dto.scheduledDate ? new Date(dto.scheduledDate) : null,
|
scheduledDate: dto.scheduledDate ? new Date(dto.scheduledDate) : null,
|
||||||
status: 'ACCEPTED',
|
status: booking ? 'ACCEPTED' : 'PENDING',
|
||||||
createdBookingId: booking.id,
|
createdBookingId: booking?.id ?? null,
|
||||||
requestedLines,
|
requestedLines,
|
||||||
|
// Intercity is invoiced in birr whatever the customer picked.
|
||||||
|
paymentCurrency:
|
||||||
|
contract.tradeDirection === 'DOMESTIC'
|
||||||
|
? 'ETB'
|
||||||
|
: (dto.paymentCurrency ?? contract.paymentCurrency ?? 'USD'),
|
||||||
notes: dto.notes ?? null,
|
notes: dto.notes ?? null,
|
||||||
} as never);
|
} as never);
|
||||||
this.notifier.shipmentRequestedToStaff(contract, request.id, request.reference);
|
this.notifier.shipmentRequestedToStaff(contract, request.id, request.reference);
|
||||||
|
|||||||
@@ -277,7 +277,7 @@ export class ContractBookingService {
|
|||||||
createdByUserId: user?.id ?? null,
|
createdByUserId: user?.id ?? null,
|
||||||
scheduledDate: dto.scheduledDate ? new Date(dto.scheduledDate) : null,
|
scheduledDate: dto.scheduledDate ? new Date(dto.scheduledDate) : null,
|
||||||
serviceTypeId: contract.serviceTypeId,
|
serviceTypeId: contract.serviceTypeId,
|
||||||
paymentCurrency: contract.paymentCurrency,
|
paymentCurrency: this.resolveShipmentCurrency(contract, dto.paymentCurrency),
|
||||||
contractType: 'NEW',
|
contractType: 'NEW',
|
||||||
customsClearingEnabled: contract.customsClearingEnabled,
|
customsClearingEnabled: contract.customsClearingEnabled,
|
||||||
customsClearingAgent: contract.customsClearingAgent ?? null,
|
customsClearingAgent: contract.customsClearingAgent ?? null,
|
||||||
@@ -481,7 +481,7 @@ export class ContractBookingService {
|
|||||||
createdByUserId: user?.id ?? null,
|
createdByUserId: user?.id ?? null,
|
||||||
scheduledDate: null,
|
scheduledDate: null,
|
||||||
serviceTypeId: contract.serviceTypeId,
|
serviceTypeId: contract.serviceTypeId,
|
||||||
paymentCurrency: contract.paymentCurrency,
|
paymentCurrency: this.resolveShipmentCurrency(contract, null),
|
||||||
contractType: 'NEW',
|
contractType: 'NEW',
|
||||||
customsClearingEnabled: contract.customsClearingEnabled,
|
customsClearingEnabled: contract.customsClearingEnabled,
|
||||||
customsClearingAgent: contract.customsClearingAgent ?? null,
|
customsClearingAgent: contract.customsClearingAgent ?? null,
|
||||||
@@ -520,7 +520,12 @@ export class ContractBookingService {
|
|||||||
*/
|
*/
|
||||||
async initiateForShipmentRequest(
|
async initiateForShipmentRequest(
|
||||||
contract: Contract,
|
contract: Contract,
|
||||||
opts: { contractRouteId?: string; userId?: string | null },
|
opts: {
|
||||||
|
contractRouteId?: string;
|
||||||
|
userId?: string | null;
|
||||||
|
/** Billing currency the customer chose on the shipment request. */
|
||||||
|
paymentCurrency?: string | null;
|
||||||
|
},
|
||||||
): Promise<Booking> {
|
): Promise<Booking> {
|
||||||
const generalCustoms =
|
const generalCustoms =
|
||||||
contract.contractKind === 'GENERAL' && Boolean(contract.customsClearingEnabled);
|
contract.contractKind === 'GENERAL' && Boolean(contract.customsClearingEnabled);
|
||||||
@@ -555,7 +560,7 @@ export class ContractBookingService {
|
|||||||
createdByUserId: opts.userId ?? null,
|
createdByUserId: opts.userId ?? null,
|
||||||
scheduledDate: null,
|
scheduledDate: null,
|
||||||
serviceTypeId: contract.serviceTypeId,
|
serviceTypeId: contract.serviceTypeId,
|
||||||
paymentCurrency: contract.paymentCurrency,
|
paymentCurrency: this.resolveShipmentCurrency(contract, opts?.paymentCurrency),
|
||||||
contractType: 'NEW',
|
contractType: 'NEW',
|
||||||
customsClearingEnabled: contract.customsClearingEnabled,
|
customsClearingEnabled: contract.customsClearingEnabled,
|
||||||
customsClearingAgent: contract.customsClearingAgent ?? null,
|
customsClearingAgent: contract.customsClearingAgent ?? null,
|
||||||
@@ -733,6 +738,13 @@ export class ContractBookingService {
|
|||||||
cargoFreeText: dto.cargoFreeText?.trim() || null,
|
cargoFreeText: dto.cargoFreeText?.trim() || null,
|
||||||
cargoTotalWeightVgm: this.resolveBulkTons(dto),
|
cargoTotalWeightVgm: this.resolveBulkTons(dto),
|
||||||
equipmentReturn: this.resolveShipmentEquipmentReturn(contract, dto),
|
equipmentReturn: this.resolveShipmentEquipmentReturn(contract, dto),
|
||||||
|
// Completion is where the cargo — and therefore the price — is fixed, so
|
||||||
|
// it is also where the billing currency is chosen. A bare instance was
|
||||||
|
// created before the customer had any figure to look at.
|
||||||
|
paymentCurrency: this.resolveShipmentCurrency(
|
||||||
|
contract,
|
||||||
|
dto.paymentCurrency ?? booking.paymentCurrency,
|
||||||
|
),
|
||||||
} as never);
|
} as never);
|
||||||
|
|
||||||
const loaded = await this.bookingsRepository.findByIdWithFiles(booking.id);
|
const loaded = await this.bookingsRepository.findByIdWithFiles(booking.id);
|
||||||
@@ -1565,6 +1577,23 @@ export class ContractBookingService {
|
|||||||
* booking-level override (dto.equipmentReturn ?? contract default) applies.
|
* booking-level override (dto.equipmentReturn ?? contract default) applies.
|
||||||
* Bulk freight keeps the legacy behaviour untouched.
|
* Bulk freight keeps the legacy behaviour untouched.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* The billing currency for a shipment under this contract.
|
||||||
|
*
|
||||||
|
* A contract quotes in USD only — the currency is a per-shipment choice now.
|
||||||
|
* Precedence: intercity is always ETB (domestic transport is invoiced in
|
||||||
|
* birr), then the customer's explicit choice, then the contract's own
|
||||||
|
* currency, which is USD for contracts created under the current rule and the
|
||||||
|
* grandfathered value for older ones.
|
||||||
|
*/
|
||||||
|
private resolveShipmentCurrency(
|
||||||
|
contract: Contract,
|
||||||
|
requested?: string | null,
|
||||||
|
): string {
|
||||||
|
if (contract.tradeDirection === 'DOMESTIC') return 'ETB';
|
||||||
|
return requested?.trim() || contract.paymentCurrency || 'USD';
|
||||||
|
}
|
||||||
|
|
||||||
private resolveShipmentEquipmentReturn(
|
private resolveShipmentEquipmentReturn(
|
||||||
contract: Contract,
|
contract: Contract,
|
||||||
dto: CreateBookingUnderContractDto,
|
dto: CreateBookingUnderContractDto,
|
||||||
@@ -1795,7 +1824,7 @@ export class ContractBookingService {
|
|||||||
contractId: contract.id,
|
contractId: contract.id,
|
||||||
freightType: contract.freightType,
|
freightType: contract.freightType,
|
||||||
tradeDirection: contract.tradeDirection,
|
tradeDirection: contract.tradeDirection,
|
||||||
paymentCurrency: contract.paymentCurrency,
|
paymentCurrency: this.resolveShipmentCurrency(contract, dto.paymentCurrency),
|
||||||
serviceTypeId: contract.serviceTypeId,
|
serviceTypeId: contract.serviceTypeId,
|
||||||
cargoTypeId: this.resolveCargoTypeId(contract, dto),
|
cargoTypeId: this.resolveCargoTypeId(contract, dto),
|
||||||
isHazardous: this.resolveShipmentHandlingFlag(contract, dto, 'hazardousQuantity'),
|
isHazardous: this.resolveShipmentHandlingFlag(contract, dto, 'hazardousQuantity'),
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
import { BadRequestException, ConflictException, Injectable } from '@nestjs/common';
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
ConflictException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
import {
|
import {
|
||||||
ContractDocPhase,
|
ContractDocPhase,
|
||||||
type ClearanceFinalInvoiceSummary,
|
type ClearanceFinalInvoiceSummary,
|
||||||
@@ -13,7 +18,10 @@ import { FilesService } from '../files/files.service';
|
|||||||
import { ContractsRepository } from './contracts.repository';
|
import { ContractsRepository } from './contracts.repository';
|
||||||
import { ContractsService, PaginatedContracts } from './contracts.service';
|
import { ContractsService, PaginatedContracts } from './contracts.service';
|
||||||
import { BookingsService } from '../bookings/bookings.service';
|
import { BookingsService } from '../bookings/bookings.service';
|
||||||
import { contractClearanceCodes } from './contract-clearance.util';
|
import {
|
||||||
|
assertDoCollectionDates,
|
||||||
|
contractClearanceCodes,
|
||||||
|
} from './contract-clearance.util';
|
||||||
import { ClearanceWorkflowService } from './clearance-workflow.service';
|
import { ClearanceWorkflowService } from './clearance-workflow.service';
|
||||||
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
||||||
import { ContractNotifierService } from './contract-notifier.service';
|
import { ContractNotifierService } from './contract-notifier.service';
|
||||||
@@ -72,9 +80,23 @@ export interface ContractClearanceView {
|
|||||||
blockedReason?: string | null;
|
blockedReason?: string | null;
|
||||||
} | null;
|
} | null;
|
||||||
dutyRequired?: boolean | null;
|
dutyRequired?: boolean | null;
|
||||||
|
/**
|
||||||
|
* Pre-declaration handshake with GL Djibouti: who will handle the shipment in
|
||||||
|
* transit. `name` is null until Djibouti answers, and the declaration step is
|
||||||
|
* shut until it is set.
|
||||||
|
*/
|
||||||
|
transitAssignee?: {
|
||||||
|
requestedAt: string | null;
|
||||||
|
requestNote: string | null;
|
||||||
|
name: string | null;
|
||||||
|
assignedAt: string | null;
|
||||||
|
} | null;
|
||||||
roHold?: boolean;
|
roHold?: boolean;
|
||||||
roHoldReason?: string | null;
|
roHoldReason?: string | null;
|
||||||
vesselDepartureDate?: string | null;
|
vesselDepartureDate?: string | null;
|
||||||
|
/** Import DO dates recorded by GL Djibouti on upload. */
|
||||||
|
vesselArrivalDate?: string | null;
|
||||||
|
doCollectedDate?: string | null;
|
||||||
roAmendmentRequestedAt?: string | null;
|
roAmendmentRequestedAt?: string | null;
|
||||||
bookingReady?: boolean;
|
bookingReady?: boolean;
|
||||||
preClearanceFinalized?: boolean;
|
preClearanceFinalized?: boolean;
|
||||||
@@ -98,6 +120,16 @@ export interface ContractClearanceView {
|
|||||||
declarationSerial?: string | null;
|
declarationSerial?: string | null;
|
||||||
noticeFile?: { id: string; name: string; url: string } | null;
|
noticeFile?: { id: string; name: string; url: string } | null;
|
||||||
} | null;
|
} | null;
|
||||||
|
/**
|
||||||
|
* The customer's open objection to the advised duty — present only while GL
|
||||||
|
* has not re-advised (the advice milestone is back to PENDING). `rounds` is
|
||||||
|
* how many times it has been sent back, so both sides can see the loop.
|
||||||
|
*/
|
||||||
|
dutyDispute?: {
|
||||||
|
note: string;
|
||||||
|
raisedAt: string;
|
||||||
|
rounds: number;
|
||||||
|
} | null;
|
||||||
workflowFiles?: ReturnType<typeof buildWorkflowFiles>;
|
workflowFiles?: ReturnType<typeof buildWorkflowFiles>;
|
||||||
/** Import post-allocation T1 transit document state (null until a booking is linked). */
|
/** Import post-allocation T1 transit document state (null until a booking is linked). */
|
||||||
t1?: ClearanceT1State | null;
|
t1?: ClearanceT1State | null;
|
||||||
@@ -247,6 +279,19 @@ export class ContractClearanceService {
|
|||||||
contract = await this.reconcilePrematureBookingReady(contractId, contract, boundary);
|
contract = await this.reconcilePrematureBookingReady(contractId, contract, boundary);
|
||||||
const phase = this.workflowService.resolvePhase(contract, cycle, milestones);
|
const phase = this.workflowService.resolvePhase(contract, cycle, milestones);
|
||||||
const dutyAdvice = this.buildDutyAdvice(files, milestones);
|
const dutyAdvice = this.buildDutyAdvice(files, milestones);
|
||||||
|
const dutyDispute = await this.buildDutyDispute(contractId, milestones);
|
||||||
|
const transitAssignee = cycle
|
||||||
|
? {
|
||||||
|
requestedAt: cycle.transitAssigneeRequestedAt
|
||||||
|
? cycle.transitAssigneeRequestedAt.toISOString()
|
||||||
|
: null,
|
||||||
|
requestNote: cycle.transitAssigneeRequestNote ?? null,
|
||||||
|
name: cycle.transitAssigneeName ?? null,
|
||||||
|
assignedAt: cycle.transitAssigneeAssignedAt
|
||||||
|
? cycle.transitAssigneeAssignedAt.toISOString()
|
||||||
|
: null,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
let workflowFiles = buildWorkflowFiles(
|
let workflowFiles = buildWorkflowFiles(
|
||||||
files,
|
files,
|
||||||
contract.tradeDirection ?? 'IMPORT',
|
contract.tradeDirection ?? 'IMPORT',
|
||||||
@@ -361,6 +406,8 @@ export class ContractClearanceService {
|
|||||||
roHold: Boolean(cycle?.roHoldReason),
|
roHold: Boolean(cycle?.roHoldReason),
|
||||||
roHoldReason: cycle?.roHoldReason ?? null,
|
roHoldReason: cycle?.roHoldReason ?? null,
|
||||||
vesselDepartureDate: cycle?.vesselDepartureDate ?? null,
|
vesselDepartureDate: cycle?.vesselDepartureDate ?? null,
|
||||||
|
vesselArrivalDate: cycle?.vesselArrivalDate ?? null,
|
||||||
|
doCollectedDate: cycle?.doCollectedDate ?? null,
|
||||||
roAmendmentRequestedAt: cycle?.roAmendmentRequestedAt
|
roAmendmentRequestedAt: cycle?.roAmendmentRequestedAt
|
||||||
? cycle.roAmendmentRequestedAt.toISOString()
|
? cycle.roAmendmentRequestedAt.toISOString()
|
||||||
: null,
|
: null,
|
||||||
@@ -373,6 +420,8 @@ export class ContractClearanceService {
|
|||||||
linkedBookingReviewNote,
|
linkedBookingReviewNote,
|
||||||
linkedBookingScheduledDate,
|
linkedBookingScheduledDate,
|
||||||
dutyAdvice,
|
dutyAdvice,
|
||||||
|
dutyDispute,
|
||||||
|
transitAssignee,
|
||||||
workflowFiles,
|
workflowFiles,
|
||||||
t1,
|
t1,
|
||||||
train,
|
train,
|
||||||
@@ -429,6 +478,32 @@ export class ContractClearanceService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The customer's duty objection, but only while it is still OPEN — i.e. the
|
||||||
|
* advice milestone sits back at PENDING because nobody has re-advised yet.
|
||||||
|
* Re-advising completes that milestone again, which closes the dispute here
|
||||||
|
* without any extra state to keep in sync; the notes stay as the audit trail
|
||||||
|
* and their count is the round number.
|
||||||
|
*/
|
||||||
|
private async buildDutyDispute(
|
||||||
|
contractId: string,
|
||||||
|
milestones: ClearanceMilestone[],
|
||||||
|
): Promise<ContractClearanceView['dutyDispute']> {
|
||||||
|
const advised = milestones.find((m) => m.milestoneCode === 'DUTY_TAXES_ADVISED');
|
||||||
|
if (!advised || advised.status === 'COMPLETED') return null;
|
||||||
|
const notes = await this.contractsRepository.findReviewNotes(
|
||||||
|
contractId,
|
||||||
|
'DUTY_DISPUTE',
|
||||||
|
);
|
||||||
|
const latest = notes[0];
|
||||||
|
if (!latest) return null;
|
||||||
|
return {
|
||||||
|
note: latest.body,
|
||||||
|
raisedAt: latest.createdAt.toISOString(),
|
||||||
|
rounds: notes.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* True when every REQUIRED customer-input field has an APPROVED review row in
|
* True when every REQUIRED customer-input field has an APPROVED review row in
|
||||||
* the current cycle. The 100% gate before clearance can be finalized.
|
* the current cycle. The 100% gate before clearance can be finalized.
|
||||||
@@ -654,6 +729,92 @@ export class ContractClearanceService {
|
|||||||
return this.applyReview(contractId, fileKey, status, staffId, 'OPERATIONS', note);
|
return this.applyReview(contractId, fileKey, status, staffId, 'OPERATIONS', note);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GL corrects a clearance document in place instead of bouncing it back to
|
||||||
|
* the customer. The customer's upload is NOT lost — it is retired into the
|
||||||
|
* document's version history, stamped with who replaced it and why — and the
|
||||||
|
* new version starts unreviewed, so GL still has to approve it (or query it)
|
||||||
|
* before clearance can be finalized.
|
||||||
|
*
|
||||||
|
* Use this for the small fixes staff can make faster than the customer can
|
||||||
|
* (a wrong page order, a missing stamp scan); a query is still the right tool
|
||||||
|
* when only the customer can produce the correct document.
|
||||||
|
*/
|
||||||
|
async replaceDocument(
|
||||||
|
contractId: string,
|
||||||
|
fileKey: string,
|
||||||
|
file: Express.Multer.File,
|
||||||
|
staffId: string,
|
||||||
|
reason?: string,
|
||||||
|
): Promise<Contract> {
|
||||||
|
const contract = await this.contractsService.findById(contractId);
|
||||||
|
this.assertClearanceReviewableStatus(contract);
|
||||||
|
if (!file) throw new BadRequestException('No replacement file uploaded');
|
||||||
|
if (!reason?.trim()) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Say why the document is being replaced — it is kept on the file history.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cycle = await this.contractsRepository.currentCycle(contractId);
|
||||||
|
if (this.isPhasedCustoms(contract) && cycle?.preClearanceFinalizedAt) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Documents cannot be changed after pre-clearance is finalized.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await this.filesService.findByCode(
|
||||||
|
contractId,
|
||||||
|
'contracts',
|
||||||
|
fileKey,
|
||||||
|
);
|
||||||
|
if (!existing) {
|
||||||
|
throw new NotFoundException(
|
||||||
|
`No document is stored under "${fileKey}" on this contract.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.filesService.upsertByCode(
|
||||||
|
{ resourceId: contractId, resource: 'contracts', code: fileKey, file },
|
||||||
|
{ userId: staffId, reason: reason.trim() },
|
||||||
|
);
|
||||||
|
|
||||||
|
// A fresh version is unreviewed by definition: clear any earlier verdict so
|
||||||
|
// the corrected file is signed off explicitly rather than inheriting a tick.
|
||||||
|
const { inputCode, outputCode } = contractClearanceCodes(contract);
|
||||||
|
const reviews = await this.contractsRepository.findDocumentReviews(
|
||||||
|
contractId,
|
||||||
|
cycle?.id ?? null,
|
||||||
|
);
|
||||||
|
const settingCode =
|
||||||
|
reviews.find((r) => r.fileKey === fileKey)?.settingCode ??
|
||||||
|
(fileKey.startsWith('custom_') ? 'custom' : (inputCode ?? outputCode ?? 'custom'));
|
||||||
|
await this.contractsRepository.setDocumentReviewStatus({
|
||||||
|
contractId,
|
||||||
|
clearanceCycleId: cycle?.id ?? null,
|
||||||
|
settingCode,
|
||||||
|
fileKey,
|
||||||
|
status: 'PENDING',
|
||||||
|
staffId,
|
||||||
|
note: `Replaced by staff: ${reason.trim()}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.contractsRepository.createReviewNote(
|
||||||
|
contractId,
|
||||||
|
`Document "${fileKey}" replaced by staff: ${reason.trim()}`,
|
||||||
|
'STAFF_NOTE',
|
||||||
|
staffId,
|
||||||
|
'GL_ET',
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.contractsService.findById(contractId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every stored version of one clearance document, newest first. */
|
||||||
|
async documentVersions(contractId: string, fileKey: string) {
|
||||||
|
return this.filesService.versionHistory(contractId, 'contracts', fileKey);
|
||||||
|
}
|
||||||
|
|
||||||
private async applyReview(
|
private async applyReview(
|
||||||
contractId: string,
|
contractId: string,
|
||||||
fileKey: string,
|
fileKey: string,
|
||||||
@@ -968,6 +1129,68 @@ export class ContractClearanceService {
|
|||||||
// ── Phased clearance actions (ONE_TIME customs, Phase 1) ───────────────────
|
// ── Phased clearance actions (ONE_TIME customs, Phase 1) ───────────────────
|
||||||
|
|
||||||
/** Sync DOCUMENTS_APPROVED when reviews are done but the milestone row lags. */
|
/** Sync DOCUMENTS_APPROVED when reviews are done but the milestone row lags. */
|
||||||
|
/**
|
||||||
|
* GL Ethiopia asks Djibouti to name the officer who will handle the shipment
|
||||||
|
* in transit. Nothing else moves until Djibouti answers — the declaration is
|
||||||
|
* gated on it — so this is the first thing ET does once the documents are
|
||||||
|
* approved. Re-requesting is allowed (a nudge) and simply restamps the ask.
|
||||||
|
*/
|
||||||
|
async requestTransitAssignee(
|
||||||
|
contractId: string,
|
||||||
|
note: string | undefined,
|
||||||
|
userId?: string,
|
||||||
|
): Promise<Contract> {
|
||||||
|
const contract = await this.contractsService.findById(contractId);
|
||||||
|
this.assertPhasedCustoms(contract);
|
||||||
|
const cycle = await this.contractsRepository.currentCycle(contractId);
|
||||||
|
if (!cycle) throw new BadRequestException('No clearance cycle found');
|
||||||
|
|
||||||
|
await this.contractsRepository.updateCycle(cycle.id, {
|
||||||
|
transitAssigneeRequestedAt: new Date(),
|
||||||
|
transitAssigneeRequestedByUserId: userId ?? null,
|
||||||
|
transitAssigneeRequestNote: note?.trim() || null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const updated = await this.contractsService.findById(contractId);
|
||||||
|
this.notifier.transitAssigneeRequested(updated, note?.trim() ?? null);
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GL Djibouti names the transit officer — free text, because the person is
|
||||||
|
* not a platform user. Answering unblocks the declaration for Ethiopia. A
|
||||||
|
* later call overwrites the name (reassignment) and re-notifies.
|
||||||
|
*/
|
||||||
|
async assignTransitAssignee(
|
||||||
|
contractId: string,
|
||||||
|
assignee: string,
|
||||||
|
userId?: string,
|
||||||
|
): Promise<Contract> {
|
||||||
|
const contract = await this.contractsService.findById(contractId);
|
||||||
|
this.assertPhasedCustoms(contract);
|
||||||
|
if (!assignee?.trim()) {
|
||||||
|
throw new BadRequestException('Name the officer who will handle the transit.');
|
||||||
|
}
|
||||||
|
const cycle = await this.contractsRepository.currentCycle(contractId);
|
||||||
|
if (!cycle) throw new BadRequestException('No clearance cycle found');
|
||||||
|
if (!cycle.transitAssigneeRequestedAt) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'GL Ethiopia has not requested a transit assignee for this clearance yet.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const previous = cycle.transitAssigneeName ?? null;
|
||||||
|
await this.contractsRepository.updateCycle(cycle.id, {
|
||||||
|
transitAssigneeName: assignee.trim(),
|
||||||
|
transitAssigneeAssignedAt: new Date(),
|
||||||
|
transitAssigneeAssignedByUserId: userId ?? null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const updated = await this.contractsService.findById(contractId);
|
||||||
|
this.notifier.transitAssigneeAssigned(updated, assignee.trim(), previous);
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
private async ensureDeclarationPrerequisites(
|
private async ensureDeclarationPrerequisites(
|
||||||
contractId: string,
|
contractId: string,
|
||||||
contract: Contract,
|
contract: Contract,
|
||||||
@@ -978,6 +1201,16 @@ export class ContractClearanceService {
|
|||||||
'All required customer documents must be approved before uploading a declaration.',
|
'All required customer documents must be approved before uploading a declaration.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
// The transit officer must be named by Djibouti first — the declaration is
|
||||||
|
// filed against whoever will physically handle the shipment there.
|
||||||
|
const cycle = await this.contractsRepository.currentCycle(contractId);
|
||||||
|
if (!cycle?.transitAssigneeName) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
cycle?.transitAssigneeRequestedAt
|
||||||
|
? 'GL Djibouti has not assigned the transit officer yet — the declaration cannot be filed until they do.'
|
||||||
|
: 'Request a transit assignee from GL Djibouti before filing the customs declaration.',
|
||||||
|
);
|
||||||
|
}
|
||||||
const milestones = await this.workflowService.listMilestones(contractId);
|
const milestones = await this.workflowService.listMilestones(contractId);
|
||||||
const docsApproved = milestones.find((m) => m.milestoneCode === 'DOCUMENTS_APPROVED');
|
const docsApproved = milestones.find((m) => m.milestoneCode === 'DOCUMENTS_APPROVED');
|
||||||
if (docsApproved?.status !== 'COMPLETED' && docsApproved?.status !== 'SKIPPED') {
|
if (docsApproved?.status !== 'COMPLETED' && docsApproved?.status !== 'SKIPPED') {
|
||||||
@@ -1084,6 +1317,67 @@ export class ContractClearanceService {
|
|||||||
return this.contractsService.findById(contractId);
|
return this.contractsService.findById(contractId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The customer disagrees with the advised duty & tax and asks GL Ethiopia to
|
||||||
|
* correct it. Nothing is paid; the advice milestone reopens so the Duty & tax
|
||||||
|
* step becomes actionable again on the GL clearance page, with the customer's
|
||||||
|
* message shown beside it. GL re-advises (same endpoint as the first time),
|
||||||
|
* which closes the dispute — the loop may run as many rounds as it takes.
|
||||||
|
*/
|
||||||
|
async disputeDuty(
|
||||||
|
contractId: string,
|
||||||
|
note: string,
|
||||||
|
userId?: string,
|
||||||
|
): Promise<Contract> {
|
||||||
|
const contract = await this.contractsService.findById(contractId);
|
||||||
|
this.assertPhasedCustoms(contract);
|
||||||
|
if (contract.tradeDirection !== 'IMPORT') {
|
||||||
|
throw new BadRequestException('Duty applies only to import contracts.');
|
||||||
|
}
|
||||||
|
if (!note?.trim()) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Say what is wrong with the advised amount so GL can correct it.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cycle = await this.contractsRepository.currentCycle(contractId);
|
||||||
|
if (!cycle?.dutyRequired) {
|
||||||
|
throw new BadRequestException('Duty/tax is not required for this clearance.');
|
||||||
|
}
|
||||||
|
const milestones = await this.workflowService.listMilestones(contractId);
|
||||||
|
const byCode = new Map(milestones.map((m) => [m.milestoneCode, m]));
|
||||||
|
if (byCode.get('DUTY_TAXES_ADVISED')?.status !== 'COMPLETED') {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'There is no advised duty amount to dispute yet.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Once the slip is in, the money is paid — a dispute then is a refund
|
||||||
|
// conversation, not a re-advice.
|
||||||
|
if (byCode.get('DUTY_TAX_PAID')?.status === 'COMPLETED') {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'The duty payment slip has already been submitted — contact GL Ethiopia directly.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.contractsRepository.createReviewNote(
|
||||||
|
contractId,
|
||||||
|
note.trim(),
|
||||||
|
'DUTY_DISPUTE',
|
||||||
|
userId,
|
||||||
|
'CUSTOMER',
|
||||||
|
);
|
||||||
|
// Back to GL: reopening the milestone is what re-arms the Duty & tax step
|
||||||
|
// (the stepper picks its active step from milestone completion).
|
||||||
|
await this.milestoneService.reopenForContract(contractId, 'DUTY_TAXES_ADVISED');
|
||||||
|
await this.contractsRepository.updateCycle(cycle.id, {
|
||||||
|
currentPhase: ContractDocPhase.GlEtOutput,
|
||||||
|
});
|
||||||
|
|
||||||
|
const updated = await this.contractsService.findById(contractId);
|
||||||
|
this.notifier.dutyDisputed(updated, note.trim());
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
async uploadDutySlip(
|
async uploadDutySlip(
|
||||||
contractId: string,
|
contractId: string,
|
||||||
file: Express.Multer.File,
|
file: Express.Multer.File,
|
||||||
@@ -1198,7 +1492,7 @@ export class ContractClearanceService {
|
|||||||
contractId: string,
|
contractId: string,
|
||||||
file: Express.Multer.File,
|
file: Express.Multer.File,
|
||||||
userId?: string,
|
userId?: string,
|
||||||
vesselDepartureDate?: string,
|
dates?: { vesselArrivalDate?: string; doCollectedDate?: string },
|
||||||
): Promise<Contract> {
|
): Promise<Contract> {
|
||||||
const contract = await this.contractsService.findById(contractId);
|
const contract = await this.contractsService.findById(contractId);
|
||||||
this.assertPhasedCustoms(contract);
|
this.assertPhasedCustoms(contract);
|
||||||
@@ -1208,6 +1502,8 @@ export class ContractClearanceService {
|
|||||||
|
|
||||||
if (!file) throw new BadRequestException('No Delivery Order uploaded');
|
if (!file) throw new BadRequestException('No Delivery Order uploaded');
|
||||||
|
|
||||||
|
const { vesselArrivalDate, doCollectedDate } = assertDoCollectionDates(dates);
|
||||||
|
|
||||||
// DO upload is deliberately un-gated: GL Djibouti may attach it at any point,
|
// DO upload is deliberately un-gated: GL Djibouti may attach it at any point,
|
||||||
// any file type. The DO_COLLECTED milestone (and booking readiness) still waits
|
// any file type. The DO_COLLECTED milestone (and booking readiness) still waits
|
||||||
// for GL Ethiopia to finalize pre-clearance so the workflow order holds.
|
// for GL Ethiopia to finalize pre-clearance so the workflow order holds.
|
||||||
@@ -1219,9 +1515,10 @@ export class ContractClearanceService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const cycle = await this.contractsRepository.currentCycle(contractId);
|
const cycle = await this.contractsRepository.currentCycle(contractId);
|
||||||
if (cycle && vesselDepartureDate?.trim()) {
|
if (cycle) {
|
||||||
await this.contractsRepository.updateCycle(cycle.id, {
|
await this.contractsRepository.updateCycle(cycle.id, {
|
||||||
vesselDepartureDate: vesselDepartureDate.trim(),
|
vesselArrivalDate,
|
||||||
|
doCollectedDate,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (cycle?.preClearanceFinalizedAt) {
|
if (cycle?.preClearanceFinalizedAt) {
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { BadRequestException } from '@nestjs/common';
|
||||||
|
|
||||||
import { Contract } from './entities/contract.entity';
|
import { Contract } from './entities/contract.entity';
|
||||||
import { INTERCITY_DOCUMENTS_SETTING_CODE } from '../bookings/clearance.util';
|
import { INTERCITY_DOCUMENTS_SETTING_CODE } from '../bookings/clearance.util';
|
||||||
|
|
||||||
@@ -84,3 +86,47 @@ export function contractClearanceCodes(contract: Contract): {
|
|||||||
includesCustoms,
|
includesCustoms,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Djibouti GL cannot record a Delivery Order without saying WHEN the vessel
|
||||||
|
* arrived and WHEN the DO was collected — the file alone leaves the import
|
||||||
|
* timeline unauditable. Shared by the contract and per-booking DO uploads so
|
||||||
|
* one endpoint can never be laxer than the other.
|
||||||
|
*
|
||||||
|
* Returns the normalized `YYYY-MM-DD` pair; throws if either is missing,
|
||||||
|
* unparseable, or the DO predates the vessel's arrival.
|
||||||
|
*/
|
||||||
|
export function assertDoCollectionDates(dates?: {
|
||||||
|
vesselArrivalDate?: string;
|
||||||
|
doCollectedDate?: string;
|
||||||
|
}): { vesselArrivalDate: string; doCollectedDate: string } {
|
||||||
|
const vesselArrivalDate = normalizeDoDate(
|
||||||
|
dates?.vesselArrivalDate,
|
||||||
|
'Vessel arrival date',
|
||||||
|
);
|
||||||
|
const doCollectedDate = normalizeDoDate(
|
||||||
|
dates?.doCollectedDate,
|
||||||
|
'DO collected date',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (doCollectedDate < vesselArrivalDate) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'DO collected date cannot be earlier than the vessel arrival date.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { vesselArrivalDate, doCollectedDate };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `YYYY-MM-DD` or throw — the column is a DATE, so time zones never enter. */
|
||||||
|
function normalizeDoDate(value: string | undefined, label: string): string {
|
||||||
|
const trimmed = value?.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
throw new BadRequestException(`${label} is required to upload a Delivery Order.`);
|
||||||
|
}
|
||||||
|
const date = trimmed.slice(0, 10);
|
||||||
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(date) || Number.isNaN(Date.parse(date))) {
|
||||||
|
throw new BadRequestException(`${label} is not a valid date.`);
|
||||||
|
}
|
||||||
|
return date;
|
||||||
|
}
|
||||||
|
|||||||
@@ -25,7 +25,18 @@ export type ContractDocumentChange =
|
|||||||
toOrder: number;
|
toOrder: number;
|
||||||
}
|
}
|
||||||
| { kind: 'DOCUMENT_TITLE_CHANGED'; title: string; fromTitle: string | null }
|
| { kind: 'DOCUMENT_TITLE_CHANGED'; title: string; fromTitle: string | null }
|
||||||
| { kind: 'WHEREAS_CHANGED'; added: number; removed: number };
|
| { kind: 'WHEREAS_CHANGED'; added: number; removed: number }
|
||||||
|
/**
|
||||||
|
* A contract field (not a document article) changed — the customer editing a
|
||||||
|
* DRAFT/CHANGES_REQUESTED contract, e.g. its route, cargo or service type.
|
||||||
|
*/
|
||||||
|
| {
|
||||||
|
kind: 'FIELD_CHANGED';
|
||||||
|
field: string;
|
||||||
|
label: string;
|
||||||
|
from: string | null;
|
||||||
|
to: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
type SnapshotLike = Pick<
|
type SnapshotLike = Pick<
|
||||||
ContractDocumentSnapshot,
|
ContractDocumentSnapshot,
|
||||||
@@ -134,6 +145,60 @@ export function diffSnapshots(
|
|||||||
return changes;
|
return changes;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Human label per audited contract field, in the order they read on the form. */
|
||||||
|
export const CONTRACT_FIELD_LABELS: Record<string, string> = {
|
||||||
|
contractKind: 'Contract kind',
|
||||||
|
tradeDirection: 'Trade direction',
|
||||||
|
freightType: 'Freight type',
|
||||||
|
serviceType: 'Service type',
|
||||||
|
paymentCurrency: 'Payment currency',
|
||||||
|
contractType: 'Contract type',
|
||||||
|
isHazardous: 'Hazardous',
|
||||||
|
hazardClass: 'Hazard class',
|
||||||
|
unNumber: 'UN number',
|
||||||
|
isReefer: 'Reefer',
|
||||||
|
equipmentReturn: 'Equipment return',
|
||||||
|
customsClearingAgent: 'Customs clearing agent',
|
||||||
|
firstMilePickupAddress: 'First-mile pickup address',
|
||||||
|
lastMileDeliveryAddress: 'Last-mile delivery address',
|
||||||
|
routes: 'Routes',
|
||||||
|
cargoScope: 'Cargo scope',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Render a field value for the audit trail — never "[object Object]". */
|
||||||
|
function displayValue(value: unknown): string | null {
|
||||||
|
if (value === null || value === undefined || value === '') return null;
|
||||||
|
if (typeof value === 'boolean') return value ? 'Yes' : 'No';
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compare two flat maps of contract fields and report what changed. Only keys
|
||||||
|
* present in `after` are considered, so a partial update never reports the
|
||||||
|
* fields it did not touch.
|
||||||
|
*/
|
||||||
|
export function diffContractFields(
|
||||||
|
before: Record<string, unknown>,
|
||||||
|
after: Record<string, unknown>,
|
||||||
|
): ContractDocumentChange[] {
|
||||||
|
const changes: ContractDocumentChange[] = [];
|
||||||
|
|
||||||
|
for (const [field, nextRaw] of Object.entries(after)) {
|
||||||
|
const next = displayValue(nextRaw);
|
||||||
|
const previous = displayValue(before[field]);
|
||||||
|
if (next === previous) continue;
|
||||||
|
changes.push({
|
||||||
|
kind: 'FIELD_CHANGED',
|
||||||
|
field,
|
||||||
|
label: CONTRACT_FIELD_LABELS[field] ?? field,
|
||||||
|
from: previous,
|
||||||
|
to: next,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return changes;
|
||||||
|
}
|
||||||
|
|
||||||
/** Short human summary of a change set, e.g. "2 articles edited, 1 article added". */
|
/** Short human summary of a change set, e.g. "2 articles edited, 1 article added". */
|
||||||
export function summarizeChanges(changes: ContractDocumentChange[]): string {
|
export function summarizeChanges(changes: ContractDocumentChange[]): string {
|
||||||
if (changes.length === 0) return 'No changes';
|
if (changes.length === 0) return 'No changes';
|
||||||
@@ -148,6 +213,7 @@ export function summarizeChanges(changes: ContractDocumentChange[]): string {
|
|||||||
|
|
||||||
const counts = new Map<string, number>();
|
const counts = new Map<string, number>();
|
||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
|
const fields: string[] = [];
|
||||||
|
|
||||||
for (const change of changes) {
|
for (const change of changes) {
|
||||||
const verb = articleVerbs[change.kind];
|
const verb = articleVerbs[change.kind];
|
||||||
@@ -157,9 +223,19 @@ export function summarizeChanges(changes: ContractDocumentChange[]): string {
|
|||||||
parts.push('document title changed');
|
parts.push('document title changed');
|
||||||
} else if (change.kind === 'WHEREAS_CHANGED') {
|
} else if (change.kind === 'WHEREAS_CHANGED') {
|
||||||
parts.push('recitals changed');
|
parts.push('recitals changed');
|
||||||
|
} else if (change.kind === 'FIELD_CHANGED') {
|
||||||
|
fields.push(change.label.toLowerCase());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (fields.length > 0) {
|
||||||
|
parts.push(
|
||||||
|
fields.length <= 3
|
||||||
|
? `${fields.join(', ')} changed`
|
||||||
|
: `${fields.length} contract fields changed`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const articleParts = [...counts.entries()].map(
|
const articleParts = [...counts.entries()].map(
|
||||||
([verb, count]) => `${count} article${count === 1 ? '' : 's'} ${verb}`,
|
([verb, count]) => `${count} article${count === 1 ? '' : 's'} ${verb}`,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { DataSource, Repository } from 'typeorm';
|
||||||
|
|
||||||
import { diffSnapshots, summarizeChanges } from './contract-document-diff.util';
|
import {
|
||||||
|
ContractDocumentChange,
|
||||||
|
diffSnapshots,
|
||||||
|
summarizeChanges,
|
||||||
|
} from './contract-document-diff.util';
|
||||||
import { ContractDocumentRevision } from './entities/contract-document-revision.entity';
|
import { ContractDocumentRevision } from './entities/contract-document-revision.entity';
|
||||||
import type { ContractDocumentSnapshot } from './entities/contract.entity';
|
import type { ContractDocumentSnapshot } from './entities/contract.entity';
|
||||||
|
|
||||||
@@ -12,6 +16,39 @@ export interface RecordRevisionInput {
|
|||||||
after: ContractDocumentSnapshot | null;
|
after: ContractDocumentSnapshot | null;
|
||||||
actorId?: string | null;
|
actorId?: string | null;
|
||||||
actorRole?: string | null;
|
actorRole?: string | null;
|
||||||
|
actorName?: string | null;
|
||||||
|
stepId?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `iam.users.name` is a localized object ({ en, am, … }), not a string — a
|
||||||
|
* plain `String(name)` there yields "[object Object]" in the audit trail.
|
||||||
|
*/
|
||||||
|
interface IamUserRow {
|
||||||
|
name?: Record<string, string> | string | null;
|
||||||
|
username?: string | null;
|
||||||
|
email?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Best display name for a user row: English label → any locale → login → email. */
|
||||||
|
function pickUserName(user: IamUserRow): string | null {
|
||||||
|
const { name } = user;
|
||||||
|
if (typeof name === 'string' && name.trim()) return name.trim();
|
||||||
|
if (name && typeof name === 'object') {
|
||||||
|
const localized =
|
||||||
|
name.en ?? Object.values(name).find((v) => typeof v === 'string' && v.trim());
|
||||||
|
if (localized?.trim()) return localized.trim();
|
||||||
|
}
|
||||||
|
return user.username?.trim() || user.email?.trim() || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pre-computed changes (contract fields), rather than a document diff. */
|
||||||
|
export interface RecordChangesInput {
|
||||||
|
contractId: string;
|
||||||
|
changes: ContractDocumentChange[];
|
||||||
|
actorId?: string | null;
|
||||||
|
actorRole?: string | null;
|
||||||
|
actorName?: string | null;
|
||||||
stepId?: string | null;
|
stepId?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -22,6 +59,7 @@ export class ContractDocumentHistoryService {
|
|||||||
constructor(
|
constructor(
|
||||||
@InjectRepository(ContractDocumentRevision)
|
@InjectRepository(ContractDocumentRevision)
|
||||||
private readonly revisionRepo: Repository<ContractDocumentRevision>,
|
private readonly revisionRepo: Repository<ContractDocumentRevision>,
|
||||||
|
@InjectDataSource() private readonly dataSource: DataSource,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -30,18 +68,32 @@ export class ContractDocumentHistoryService {
|
|||||||
* and swallowed. A no-op edit records nothing.
|
* and swallowed. A no-op edit records nothing.
|
||||||
*/
|
*/
|
||||||
async record(input: RecordRevisionInput): Promise<void> {
|
async record(input: RecordRevisionInput): Promise<void> {
|
||||||
|
return this.recordChanges({
|
||||||
|
...input,
|
||||||
|
changes: diffSnapshots(input.before, input.after),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Append a revision from an already-computed change set — the contract-field
|
||||||
|
* path, where there is no document snapshot to diff. Same best-effort
|
||||||
|
* contract as {@link record}: a no-op change set records nothing, and a
|
||||||
|
* failure here never breaks the edit that triggered it.
|
||||||
|
*/
|
||||||
|
async recordChanges(input: RecordChangesInput): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const changes = diffSnapshots(input.before, input.after);
|
if (input.changes.length === 0) return;
|
||||||
if (changes.length === 0) return;
|
|
||||||
|
|
||||||
await this.revisionRepo.save(
|
await this.revisionRepo.save(
|
||||||
this.revisionRepo.create({
|
this.revisionRepo.create({
|
||||||
contractId: input.contractId,
|
contractId: input.contractId,
|
||||||
actorId: input.actorId ?? null,
|
actorId: input.actorId ?? null,
|
||||||
actorRole: input.actorRole ?? null,
|
actorRole: input.actorRole ?? null,
|
||||||
|
actorName:
|
||||||
|
input.actorName ?? (await this.resolveActorName(input.actorId)),
|
||||||
stepId: input.stepId ?? null,
|
stepId: input.stepId ?? null,
|
||||||
summary: summarizeChanges(changes),
|
summary: summarizeChanges(input.changes),
|
||||||
changes,
|
changes: input.changes,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -51,11 +103,62 @@ export class ContractDocumentHistoryService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Name for the acting user. `iam.users` is owned by the auth system and has
|
||||||
|
* no entity here, so it is read directly; a miss is not an error — the trail
|
||||||
|
* still carries the id, role and timestamp.
|
||||||
|
*/
|
||||||
|
private async resolveActorName(
|
||||||
|
actorId?: string | null,
|
||||||
|
): Promise<string | null> {
|
||||||
|
if (!actorId) return null;
|
||||||
|
const names = await this.resolveActorNames([actorId]);
|
||||||
|
return names.get(actorId) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Batched {@link resolveActorName} — one query for a whole revision list. */
|
||||||
|
private async resolveActorNames(
|
||||||
|
actorIds: string[],
|
||||||
|
): Promise<Map<string, string>> {
|
||||||
|
const resolved = new Map<string, string>();
|
||||||
|
const ids = [...new Set(actorIds.filter(Boolean))];
|
||||||
|
if (ids.length === 0) return resolved;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const rows = (await this.dataSource.query(
|
||||||
|
`SELECT id, name, username, email FROM iam.users WHERE id = ANY($1::uuid[])`,
|
||||||
|
[ids],
|
||||||
|
)) as Array<IamUserRow & { id: string }>;
|
||||||
|
for (const row of rows) {
|
||||||
|
const name = pickUserName(row);
|
||||||
|
if (name) resolved.set(row.id, name);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`Could not resolve actor names: ${String(err)}`);
|
||||||
|
}
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
|
||||||
/** Revision history for a contract, newest first. */
|
/** Revision history for a contract, newest first. */
|
||||||
list(contractId: string): Promise<ContractDocumentRevision[]> {
|
async list(contractId: string): Promise<ContractDocumentRevision[]> {
|
||||||
return this.revisionRepo.find({
|
const revisions = await this.revisionRepo.find({
|
||||||
where: { contractId },
|
where: { contractId },
|
||||||
order: { createdAt: 'DESC' },
|
order: { createdAt: 'DESC' },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Rows written before actor_name existed still carry an actor_id — resolve
|
||||||
|
// those for display (one query for the whole list) rather than backfilling.
|
||||||
|
const missing = revisions
|
||||||
|
.filter((r) => !r.actorName && r.actorId)
|
||||||
|
.map((r) => r.actorId as string);
|
||||||
|
if (missing.length === 0) return revisions;
|
||||||
|
|
||||||
|
const names = await this.resolveActorNames(missing);
|
||||||
|
for (const revision of revisions) {
|
||||||
|
if (!revision.actorName && revision.actorId) {
|
||||||
|
revision.actorName = names.get(revision.actorId) ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return revisions;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import { BadRequestException } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { ContractClearanceService } from './contract-clearance.service';
|
||||||
|
import type { Contract } from './entities/contract.entity';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The duty advice → dispute → re-advice loop. GL Ethiopia advises an amount;
|
||||||
|
* the customer either pays it or sends it back with a reason. Sending it back
|
||||||
|
* reopens the advice milestone — that is what puts the Duty & tax step back in
|
||||||
|
* GL's hands — and the round can repeat until the amount is agreed.
|
||||||
|
*/
|
||||||
|
describe('ContractClearanceService — duty dispute', () => {
|
||||||
|
const contract = (over: Partial<Contract> = {}): Contract =>
|
||||||
|
({
|
||||||
|
id: 'ctr-1',
|
||||||
|
reference: 'CTR-2026-00042',
|
||||||
|
tradeDirection: 'IMPORT',
|
||||||
|
customsClearingEnabled: true,
|
||||||
|
contractKind: 'ONE_TIME',
|
||||||
|
...over,
|
||||||
|
}) as Contract;
|
||||||
|
|
||||||
|
const milestone = (code: string, status: string) =>
|
||||||
|
({ milestoneCode: code, status }) as never;
|
||||||
|
|
||||||
|
let repo: {
|
||||||
|
currentCycle: jest.Mock;
|
||||||
|
createReviewNote: jest.Mock;
|
||||||
|
updateCycle: jest.Mock;
|
||||||
|
findReviewNotes: jest.Mock;
|
||||||
|
};
|
||||||
|
let contractsService: { findById: jest.Mock };
|
||||||
|
let workflowService: { listMilestones: jest.Mock };
|
||||||
|
let milestoneService: { reopenForContract: jest.Mock };
|
||||||
|
let notifier: { dutyDisputed: jest.Mock };
|
||||||
|
let service: ContractClearanceService;
|
||||||
|
|
||||||
|
const build = (milestones: unknown[]) => {
|
||||||
|
workflowService.listMilestones.mockResolvedValue(milestones);
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
repo = {
|
||||||
|
currentCycle: jest.fn().mockResolvedValue({ id: 'cyc-1', dutyRequired: true }),
|
||||||
|
createReviewNote: jest.fn().mockResolvedValue(undefined),
|
||||||
|
updateCycle: jest.fn().mockResolvedValue(undefined),
|
||||||
|
findReviewNotes: jest.fn().mockResolvedValue([]),
|
||||||
|
};
|
||||||
|
contractsService = { findById: jest.fn().mockResolvedValue(contract()) };
|
||||||
|
workflowService = { listMilestones: jest.fn().mockResolvedValue([]) };
|
||||||
|
milestoneService = { reopenForContract: jest.fn().mockResolvedValue(undefined) };
|
||||||
|
notifier = { dutyDisputed: jest.fn() };
|
||||||
|
|
||||||
|
service = new ContractClearanceService(
|
||||||
|
repo as never,
|
||||||
|
contractsService as never,
|
||||||
|
{} as never, // bookingsService
|
||||||
|
{} as never, // filesService
|
||||||
|
{} as never, // fileUploadSettingsService
|
||||||
|
workflowService as never,
|
||||||
|
milestoneService as never,
|
||||||
|
{} as never, // dropdownSettingsService
|
||||||
|
{} as never, // glOperationsService
|
||||||
|
notifier as never,
|
||||||
|
);
|
||||||
|
build([
|
||||||
|
milestone('DUTY_TAXES_ADVISED', 'COMPLETED'),
|
||||||
|
milestone('DUTY_TAX_PAID', 'PENDING'),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('records the objection and hands the step back to GL', async () => {
|
||||||
|
await service.disputeDuty('ctr-1', ' Declared value is wrong ', 'user-1');
|
||||||
|
|
||||||
|
expect(repo.createReviewNote).toHaveBeenCalledWith(
|
||||||
|
'ctr-1',
|
||||||
|
'Declared value is wrong',
|
||||||
|
'DUTY_DISPUTE',
|
||||||
|
'user-1',
|
||||||
|
'CUSTOMER',
|
||||||
|
);
|
||||||
|
// Reopening the advice milestone is what re-arms the Duty & tax step.
|
||||||
|
expect(milestoneService.reopenForContract).toHaveBeenCalledWith(
|
||||||
|
'ctr-1',
|
||||||
|
'DUTY_TAXES_ADVISED',
|
||||||
|
);
|
||||||
|
expect(repo.updateCycle).toHaveBeenCalledWith('cyc-1', {
|
||||||
|
currentPhase: 'GL_ET_OUTPUT',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tells GL Ethiopia, not the customer', async () => {
|
||||||
|
await service.disputeDuty('ctr-1', 'Too high', 'user-1');
|
||||||
|
expect(notifier.dutyDisputed).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ id: 'ctr-1' }),
|
||||||
|
'Too high',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requires a reason — GL cannot correct an unexplained objection', async () => {
|
||||||
|
await expect(service.disputeDuty('ctr-1', ' ')).rejects.toBeInstanceOf(
|
||||||
|
BadRequestException,
|
||||||
|
);
|
||||||
|
expect(milestoneService.reopenForContract).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses when nothing has been advised yet', async () => {
|
||||||
|
build([milestone('DUTY_TAXES_ADVISED', 'PENDING')]);
|
||||||
|
await expect(service.disputeDuty('ctr-1', 'Too high')).rejects.toThrow(
|
||||||
|
/no advised duty amount/i,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses once the payment slip is in — that is a refund, not a re-advice', async () => {
|
||||||
|
build([
|
||||||
|
milestone('DUTY_TAXES_ADVISED', 'COMPLETED'),
|
||||||
|
milestone('DUTY_TAX_PAID', 'COMPLETED'),
|
||||||
|
]);
|
||||||
|
await expect(service.disputeDuty('ctr-1', 'Too high')).rejects.toThrow(
|
||||||
|
/already been submitted/i,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses when duty was never required for this clearance', async () => {
|
||||||
|
repo.currentCycle.mockResolvedValue({ id: 'cyc-1', dutyRequired: false });
|
||||||
|
await expect(service.disputeDuty('ctr-1', 'Too high')).rejects.toThrow(
|
||||||
|
/not required/i,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('the view', () => {
|
||||||
|
const buildDispute = (milestones: unknown[]) =>
|
||||||
|
(
|
||||||
|
service as unknown as {
|
||||||
|
buildDutyDispute: (id: string, m: unknown[]) => Promise<unknown>;
|
||||||
|
}
|
||||||
|
).buildDutyDispute('ctr-1', milestones);
|
||||||
|
|
||||||
|
it('shows the objection while GL still owes a corrected advice', async () => {
|
||||||
|
repo.findReviewNotes.mockResolvedValue([
|
||||||
|
{ body: 'Second look please', createdAt: new Date('2026-07-20T09:00:00Z') },
|
||||||
|
{ body: 'First objection', createdAt: new Date('2026-07-18T09:00:00Z') },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const dispute = await buildDispute([
|
||||||
|
milestone('DUTY_TAXES_ADVISED', 'PENDING'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(dispute).toMatchObject({ note: 'Second look please', rounds: 2 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears itself once GL re-advises', async () => {
|
||||||
|
repo.findReviewNotes.mockResolvedValue([
|
||||||
|
{ body: 'First objection', createdAt: new Date('2026-07-18T09:00:00Z') },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const dispute = await buildDispute([
|
||||||
|
milestone('DUTY_TAXES_ADVISED', 'COMPLETED'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(dispute).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import {
|
||||||
|
diffContractFields,
|
||||||
|
summarizeChanges,
|
||||||
|
} from './contract-document-diff.util';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The contract-field audit runs on the customer's own edits, so it has to be
|
||||||
|
* exact: never report a field the edit did not touch, and never render a value
|
||||||
|
* as "[object Object]" or "true" in the trail a reviewer reads.
|
||||||
|
*/
|
||||||
|
describe('diffContractFields', () => {
|
||||||
|
it('reports only the fields that actually changed', () => {
|
||||||
|
const changes = diffContractFields(
|
||||||
|
{ freightType: 'BULK', paymentCurrency: 'USD', isReefer: false },
|
||||||
|
{ freightType: 'CONTAINER', paymentCurrency: 'USD', isReefer: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(changes).toEqual([
|
||||||
|
{
|
||||||
|
kind: 'FIELD_CHANGED',
|
||||||
|
field: 'freightType',
|
||||||
|
label: 'Freight type',
|
||||||
|
from: 'BULK',
|
||||||
|
to: 'CONTAINER',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders booleans as Yes/No, not true/false', () => {
|
||||||
|
const [change] = diffContractFields({ isHazardous: false }, { isHazardous: true });
|
||||||
|
|
||||||
|
expect(change).toMatchObject({ label: 'Hazardous', from: 'No', to: 'Yes' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats null, undefined and empty string as "not set"', () => {
|
||||||
|
expect(diffContractFields({ unNumber: null }, { unNumber: '' })).toEqual([]);
|
||||||
|
expect(diffContractFields({ unNumber: undefined }, { unNumber: null })).toEqual([]);
|
||||||
|
|
||||||
|
const [set] = diffContractFields({ unNumber: null }, { unNumber: 'UN1234' });
|
||||||
|
expect(set).toMatchObject({ from: null, to: 'UN1234' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores fields absent from the update', () => {
|
||||||
|
// A partial edit must not report the fields it never sent.
|
||||||
|
expect(diffContractFields({ freightType: 'BULK', isReefer: true }, {})).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('records a route swap that keeps the same lane count', () => {
|
||||||
|
const [change] = diffContractFields(
|
||||||
|
{ routes: 'Nagad → Mojo' },
|
||||||
|
{ routes: 'Nagad → Adama' },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(change).toMatchObject({
|
||||||
|
label: 'Routes',
|
||||||
|
from: 'Nagad → Mojo',
|
||||||
|
to: 'Nagad → Adama',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('summarises field changes by name, and by count once there are many', () => {
|
||||||
|
const few = diffContractFields(
|
||||||
|
{ freightType: 'BULK', paymentCurrency: 'USD' },
|
||||||
|
{ freightType: 'CONTAINER', paymentCurrency: 'ETB' },
|
||||||
|
);
|
||||||
|
expect(summarizeChanges(few)).toBe('freight type, payment currency changed');
|
||||||
|
|
||||||
|
const many = diffContractFields(
|
||||||
|
{ a: '1', b: '1', c: '1', d: '1' },
|
||||||
|
{ a: '2', b: '2', c: '2', d: '2' },
|
||||||
|
);
|
||||||
|
expect(summarizeChanges(many)).toBe('4 contract fields changed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('summarises document and field changes together', () => {
|
||||||
|
const summary = summarizeChanges([
|
||||||
|
{ kind: 'ARTICLE_BODY_CHANGED', articleId: 'a-1', title: 'Article 1' },
|
||||||
|
{
|
||||||
|
kind: 'FIELD_CHANGED',
|
||||||
|
field: 'routes',
|
||||||
|
label: 'Routes',
|
||||||
|
from: 'A → B',
|
||||||
|
to: 'A → C',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(summary).toBe('1 article edited, routes changed');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -192,6 +192,56 @@ export class ContractNotifierService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GL Ethiopia asked Djibouti to name the transit officer. Staff-only, and
|
||||||
|
* deep-linked to the Djibouti clearance page where the name is entered — the
|
||||||
|
* customs declaration is blocked until they answer.
|
||||||
|
*/
|
||||||
|
transitAssigneeRequested(c: Contract, note: string | null): void {
|
||||||
|
const msg =
|
||||||
|
`GL Ethiopia needs a transit assignee for contract ${c.reference} before ` +
|
||||||
|
`the customs declaration can be filed.${note ? ` Note: "${note}"` : ''}`;
|
||||||
|
this.logger.log(`TRANSIT ASSIGNEE REQUESTED — ${c.reference}`);
|
||||||
|
this.inAppStaff(c, `Transit assignee needed — ${c.reference}`, msg, {
|
||||||
|
type: NotificationType.CLEARANCE_REVIEW,
|
||||||
|
link: `/dashboard/gl-djibouti/clearance/${c.id}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Djibouti named (or changed) the transit officer — Ethiopia can proceed. */
|
||||||
|
transitAssigneeAssigned(
|
||||||
|
c: Contract,
|
||||||
|
assignee: string,
|
||||||
|
previous: string | null,
|
||||||
|
): void {
|
||||||
|
const msg = previous
|
||||||
|
? `GL Djibouti changed the transit assignee for contract ${c.reference} from ` +
|
||||||
|
`"${previous}" to "${assignee}".`
|
||||||
|
: `GL Djibouti assigned ${assignee} to handle contract ${c.reference} in transit. ` +
|
||||||
|
`The customs declaration can now be filed.`;
|
||||||
|
this.logger.log(`TRANSIT ASSIGNEE ASSIGNED — ${c.reference}`);
|
||||||
|
this.inAppStaff(c, `Transit assignee set — ${c.reference}`, msg, {
|
||||||
|
type: NotificationType.CLEARANCE_REVIEW,
|
||||||
|
link: `/dashboard/contracts/clearance/${c.id}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The customer disputed the advised duty & tax. This goes to STAFF, not the
|
||||||
|
* customer: GL Ethiopia is the one who has to re-advise, and the clearance
|
||||||
|
* page is where they do it.
|
||||||
|
*/
|
||||||
|
dutyDisputed(c: Contract, note: string): void {
|
||||||
|
const msg =
|
||||||
|
`The customer disputed the duty & tax advised on contract ${c.reference}: ` +
|
||||||
|
`"${note}". Review and re-advise the amount on the clearance page.`;
|
||||||
|
this.logger.log(`DUTY DISPUTED — ${c.reference}`);
|
||||||
|
this.inAppStaff(c, `Duty disputed on ${c.reference}`, msg, {
|
||||||
|
type: NotificationType.CLEARANCE_REVIEW,
|
||||||
|
link: `/dashboard/contracts/clearance/${c.id}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/** A clearance document was queried — customer must re-upload it. */
|
/** A clearance document was queried — customer must re-upload it. */
|
||||||
clearanceDocumentQueried(c: Contract, fileKey: string, note: string): void {
|
clearanceDocumentQueried(c: Contract, fileKey: string, note: string): void {
|
||||||
const msg =
|
const msg =
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { ContractDocumentHistoryService } from './contract-document-history.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `iam.users.name` is a localized jsonb object, not a string. Reading it
|
||||||
|
* naively puts "[object Object]" in the audit trail — or, worse, throws and
|
||||||
|
* leaves every revision anonymous. These specs pin the resolution rules.
|
||||||
|
*/
|
||||||
|
describe('ContractDocumentHistoryService actor names', () => {
|
||||||
|
const build = (rows: unknown[]) => {
|
||||||
|
const saved: Array<Record<string, unknown>> = [];
|
||||||
|
const service = Object.create(
|
||||||
|
ContractDocumentHistoryService.prototype,
|
||||||
|
) as ContractDocumentHistoryService;
|
||||||
|
Object.assign(service, {
|
||||||
|
logger: { warn: jest.fn(), error: jest.fn() },
|
||||||
|
dataSource: { query: jest.fn().mockResolvedValue(rows) },
|
||||||
|
revisionRepo: {
|
||||||
|
create: (row: Record<string, unknown>) => row,
|
||||||
|
save: jest.fn((row: Record<string, unknown>) => {
|
||||||
|
saved.push(row);
|
||||||
|
return Promise.resolve(row);
|
||||||
|
}),
|
||||||
|
find: jest.fn().mockResolvedValue([]),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return { service, saved };
|
||||||
|
};
|
||||||
|
|
||||||
|
const change = {
|
||||||
|
kind: 'FIELD_CHANGED' as const,
|
||||||
|
field: 'routes',
|
||||||
|
label: 'Routes',
|
||||||
|
from: 'A → B',
|
||||||
|
to: 'A → C',
|
||||||
|
};
|
||||||
|
|
||||||
|
it('prefers the English label from the localized name object', async () => {
|
||||||
|
const { service, saved } = build([
|
||||||
|
{ id: 'u-1', name: { am: 'ሱፐር አድሚን', en: 'Super Admin' }, username: 'superadmin' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
await service.recordChanges({ contractId: 'c-1', changes: [change], actorId: 'u-1' });
|
||||||
|
|
||||||
|
expect(saved[0].actorName).toBe('Super Admin');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to another locale, then username, then email', async () => {
|
||||||
|
const onlyAmharic = build([{ id: 'u-1', name: { am: 'ሱፐር' }, username: 'x' }]);
|
||||||
|
await onlyAmharic.service.recordChanges({
|
||||||
|
contractId: 'c-1',
|
||||||
|
changes: [change],
|
||||||
|
actorId: 'u-1',
|
||||||
|
});
|
||||||
|
expect(onlyAmharic.saved[0].actorName).toBe('ሱፐር');
|
||||||
|
|
||||||
|
const noName = build([{ id: 'u-1', name: null, username: 'operator', email: 'o@edr' }]);
|
||||||
|
await noName.service.recordChanges({
|
||||||
|
contractId: 'c-1',
|
||||||
|
changes: [change],
|
||||||
|
actorId: 'u-1',
|
||||||
|
});
|
||||||
|
expect(noName.saved[0].actorName).toBe('operator');
|
||||||
|
|
||||||
|
const emailOnly = build([{ id: 'u-1', name: {}, username: null, email: 'o@edr.local' }]);
|
||||||
|
await emailOnly.service.recordChanges({
|
||||||
|
contractId: 'c-1',
|
||||||
|
changes: [change],
|
||||||
|
actorId: 'u-1',
|
||||||
|
});
|
||||||
|
expect(emailOnly.saved[0].actorName).toBe('o@edr.local');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never writes "[object Object]" as the actor name', async () => {
|
||||||
|
const { service, saved } = build([{ id: 'u-1', name: { en: 'Real Name' } }]);
|
||||||
|
|
||||||
|
await service.recordChanges({ contractId: 'c-1', changes: [change], actorId: 'u-1' });
|
||||||
|
|
||||||
|
expect(String(saved[0].actorName)).not.toContain('object Object');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('records nothing when the change set is empty', async () => {
|
||||||
|
const { service, saved } = build([]);
|
||||||
|
|
||||||
|
await service.recordChanges({ contractId: 'c-1', changes: [], actorId: 'u-1' });
|
||||||
|
|
||||||
|
expect(saved).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still records the revision when the user lookup fails', async () => {
|
||||||
|
const { service, saved } = build([]);
|
||||||
|
Object.assign(service, {
|
||||||
|
dataSource: { query: jest.fn().mockRejectedValue(new Error('iam down')) },
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.recordChanges({ contractId: 'c-1', changes: [change], actorId: 'u-1' });
|
||||||
|
|
||||||
|
expect(saved).toHaveLength(1);
|
||||||
|
expect(saved[0].actorName).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves names for legacy rows that predate the actor_name column', async () => {
|
||||||
|
const { service } = build([{ id: 'u-1', name: { en: 'Abenezer Haile' } }]);
|
||||||
|
Object.assign(service, {
|
||||||
|
revisionRepo: {
|
||||||
|
find: jest
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue([{ id: 'r-1', actorId: 'u-1', actorName: null }]),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const [revision] = await service.list('c-1');
|
||||||
|
|
||||||
|
expect(revision.actorName).toBe('Abenezer Haile');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -274,6 +274,20 @@ export class ContractTransitionService {
|
|||||||
// shared six templates are never written here.
|
// shared six templates are never written here.
|
||||||
const snapshot = await this.resolveDocumentSnapshot(contract, documentSnapshot);
|
const snapshot = await this.resolveDocumentSnapshot(contract, documentSnapshot);
|
||||||
|
|
||||||
|
// Audit whatever staff changed in the accept dialog. The baseline is the
|
||||||
|
// template this contract would otherwise have frozen as-is, so an untouched
|
||||||
|
// accept diffs to nothing and records no revision.
|
||||||
|
if (documentSnapshot) {
|
||||||
|
const baseline = await this.resolveDocumentSnapshot(contract);
|
||||||
|
await this.documentHistory.record({
|
||||||
|
contractId,
|
||||||
|
before: baseline,
|
||||||
|
after: snapshot,
|
||||||
|
actorId,
|
||||||
|
actorRole: 'Reviewing staff',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
await this.contractsRepository.update(contractId, {
|
await this.contractsRepository.update(contractId, {
|
||||||
status: 'PENDING_APPROVAL',
|
status: 'PENDING_APPROVAL',
|
||||||
approvedByStaffId: actorId,
|
approvedByStaffId: actorId,
|
||||||
|
|||||||
@@ -303,8 +303,10 @@ export class ContractsController {
|
|||||||
@Param('id', ParseUUIDPipe) id: string,
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
@Body() dto: UpdateContractDto,
|
@Body() dto: UpdateContractDto,
|
||||||
@UploadedFiles() files: Express.Multer.File[],
|
@UploadedFiles() files: Express.Multer.File[],
|
||||||
|
// Recorded on the edit's audit revision — who changed the contract.
|
||||||
|
@CurrentUser() user?: TCurrentUser,
|
||||||
) {
|
) {
|
||||||
return this.contractsService.update(id, dto, files ?? []);
|
return this.contractsService.update(id, dto, files ?? [], user?.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@@ -738,6 +740,92 @@ export class ContractsController {
|
|||||||
return this.clearanceService.finalizePreClearance(id);
|
return this.clearanceService.finalizePreClearance(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post(':id/clearance/transit-assignee/request')
|
||||||
|
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
|
||||||
|
@ApiOperation({
|
||||||
|
summary:
|
||||||
|
'GL ET asks GL Djibouti to name the transit officer — required before the customs declaration',
|
||||||
|
})
|
||||||
|
requestTransitAssignee(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body('note') note: string | undefined,
|
||||||
|
@CurrentUser() user: AuthUserPayload,
|
||||||
|
) {
|
||||||
|
return this.clearanceService.requestTransitAssignee(
|
||||||
|
id,
|
||||||
|
note,
|
||||||
|
resolveAuthUserId(user),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/clearance/transit-assignee/assign')
|
||||||
|
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
|
||||||
|
@ApiOperation({
|
||||||
|
summary:
|
||||||
|
'GL Djibouti names the transit officer (free text) — unblocks the customs declaration; calling again reassigns',
|
||||||
|
})
|
||||||
|
assignTransitAssignee(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body('assignee') assignee: string,
|
||||||
|
@CurrentUser() user: AuthUserPayload,
|
||||||
|
) {
|
||||||
|
return this.clearanceService.assignTransitAssignee(
|
||||||
|
id,
|
||||||
|
assignee,
|
||||||
|
resolveAuthUserId(user),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id/clearance/documents/:fileKey/versions')
|
||||||
|
@BookingStaff(FREIGHT_PERMS.contracts.clearanceReview)
|
||||||
|
@ApiOperation({
|
||||||
|
summary:
|
||||||
|
'Version history of one clearance document — the customer original plus every staff replacement',
|
||||||
|
})
|
||||||
|
documentVersions(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Param('fileKey') fileKey: string,
|
||||||
|
) {
|
||||||
|
return this.clearanceService.documentVersions(id, fileKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/clearance/documents/:fileKey/replace')
|
||||||
|
@BookingStaff(FREIGHT_PERMS.contracts.clearanceReview)
|
||||||
|
@UseInterceptors(FileInterceptor('file'))
|
||||||
|
@ApiConsumes('multipart/form-data')
|
||||||
|
@ApiOperation({
|
||||||
|
summary:
|
||||||
|
'GL replaces a clearance document in place (reason required) — the previous version is kept in the file history and the new one needs approving',
|
||||||
|
})
|
||||||
|
replaceClearanceDocument(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Param('fileKey') fileKey: string,
|
||||||
|
@UploadedFile() file: Express.Multer.File,
|
||||||
|
@Body('reason') reason: string,
|
||||||
|
@CurrentUser() user: AuthUserPayload,
|
||||||
|
) {
|
||||||
|
return this.clearanceService.replaceDocument(
|
||||||
|
id,
|
||||||
|
fileKey,
|
||||||
|
file,
|
||||||
|
resolveAuthUserId(user),
|
||||||
|
reason,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/clearance/duty/dispute')
|
||||||
|
@ApiOperation({
|
||||||
|
summary:
|
||||||
|
'Customer disputes the advised duty/tax with a reason — reopens the step so GL Ethiopia can re-advise (repeatable)',
|
||||||
|
})
|
||||||
|
disputeContractDuty(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body('note') note: string,
|
||||||
|
@CurrentUser() user: AuthUserPayload,
|
||||||
|
) {
|
||||||
|
return this.clearanceService.disputeDuty(id, note, resolveAuthUserId(user));
|
||||||
|
}
|
||||||
|
|
||||||
@Post(':id/clearance/duty-slip')
|
@Post(':id/clearance/duty-slip')
|
||||||
@UseInterceptors(FileInterceptor('file'))
|
@UseInterceptors(FileInterceptor('file'))
|
||||||
@ApiConsumes('multipart/form-data')
|
@ApiConsumes('multipart/form-data')
|
||||||
@@ -766,19 +854,21 @@ export class ContractsController {
|
|||||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
|
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
|
||||||
@UseInterceptors(FileInterceptor('file'))
|
@UseInterceptors(FileInterceptor('file'))
|
||||||
@ApiConsumes('multipart/form-data')
|
@ApiConsumes('multipart/form-data')
|
||||||
@ApiOperation({ summary: 'GL DJ uploads Delivery Order (import)' })
|
@ApiOperation({
|
||||||
|
summary:
|
||||||
|
'GL DJ uploads Delivery Order (import) with vessel arrival + DO collected dates',
|
||||||
|
})
|
||||||
uploadDeliveryOrder(
|
uploadDeliveryOrder(
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
@UploadedFile() file: Express.Multer.File,
|
@UploadedFile() file: Express.Multer.File,
|
||||||
@Body('vesselDepartureDate') vesselDepartureDate: string | undefined,
|
@Body('vesselArrivalDate') vesselArrivalDate: string | undefined,
|
||||||
|
@Body('doCollectedDate') doCollectedDate: string | undefined,
|
||||||
@CurrentUser() user: AuthUserPayload,
|
@CurrentUser() user: AuthUserPayload,
|
||||||
) {
|
) {
|
||||||
return this.clearanceService.uploadDeliveryOrder(
|
return this.clearanceService.uploadDeliveryOrder(id, file, resolveAuthUserId(user), {
|
||||||
id,
|
vesselArrivalDate,
|
||||||
file,
|
doCollectedDate,
|
||||||
resolveAuthUserId(user),
|
});
|
||||||
vesselDepartureDate,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/clearance/release-order')
|
@Post(':id/clearance/release-order')
|
||||||
|
|||||||
@@ -86,6 +86,17 @@ export class ContractsRepository extends BaseRepository<Contract> {
|
|||||||
.andWhere('contract.status NOT IN (:...terminal)', {
|
.andWhere('contract.status NOT IN (:...terminal)', {
|
||||||
terminal: TERMINAL_CONTRACT_STATUSES,
|
terminal: TERMINAL_CONTRACT_STATUSES,
|
||||||
})
|
})
|
||||||
|
// A ONE_TIME contract allows a single booking, so once that booking
|
||||||
|
// exists the contract is spent and can never carry another shipment.
|
||||||
|
// Without this it kept blocking new requests on the same service type +
|
||||||
|
// route until its validity lapsed — locking a customer out of a lane for
|
||||||
|
// the rest of the term after one completed shipment.
|
||||||
|
.andWhere(
|
||||||
|
`(contract.contract_kind <> 'ONE_TIME' OR NOT EXISTS (
|
||||||
|
SELECT 1 FROM freight.bookings b
|
||||||
|
WHERE b.contract_id = contract.id AND b.deleted_at IS NULL
|
||||||
|
))`,
|
||||||
|
)
|
||||||
.getMany();
|
.getMany();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,7 +164,9 @@ export class ContractsRepository extends BaseRepository<Contract> {
|
|||||||
'contract.files',
|
'contract.files',
|
||||||
FileRecord,
|
FileRecord,
|
||||||
'file',
|
'file',
|
||||||
"file.resource_id = contract.id AND file.resource = 'contracts'",
|
// Superseded versions are soft-deleted, not dropped — keep them out of
|
||||||
|
// the live file list (a manual join condition is not filtered for us).
|
||||||
|
"file.resource_id = contract.id AND file.resource = 'contracts' AND file.deleted_at IS NULL",
|
||||||
)
|
)
|
||||||
.getOne();
|
.getOne();
|
||||||
|
|
||||||
@@ -547,6 +560,17 @@ export class ContractsRepository extends BaseRepository<Contract> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Review notes of one type, newest first — the duty advice/dispute rounds. */
|
||||||
|
async findReviewNotes(
|
||||||
|
contractId: string,
|
||||||
|
noteType: ContractReviewNoteType,
|
||||||
|
): Promise<ContractReviewNote[]> {
|
||||||
|
return this.dataSource.getRepository(ContractReviewNote).find({
|
||||||
|
where: { contractId, noteType },
|
||||||
|
order: { createdAt: 'DESC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async findLatestReviewNote(
|
async findLatestReviewNote(
|
||||||
contractId: string,
|
contractId: string,
|
||||||
noteType?: ContractReviewNoteType,
|
noteType?: ContractReviewNoteType,
|
||||||
@@ -714,12 +738,20 @@ export class ContractsRepository extends BaseRepository<Contract> {
|
|||||||
ContractClearanceCycle,
|
ContractClearanceCycle,
|
||||||
| 'dutyRequired'
|
| 'dutyRequired'
|
||||||
| 'vesselDepartureDate'
|
| 'vesselDepartureDate'
|
||||||
|
| 'vesselArrivalDate'
|
||||||
|
| 'doCollectedDate'
|
||||||
| 'roAmendmentRequestedAt'
|
| 'roAmendmentRequestedAt'
|
||||||
| 'roHoldReason'
|
| 'roHoldReason'
|
||||||
| 'currentPhase'
|
| 'currentPhase'
|
||||||
| 'status'
|
| 'status'
|
||||||
| 'preClearanceFinalizedAt'
|
| 'preClearanceFinalizedAt'
|
||||||
| 'completedAt'
|
| 'completedAt'
|
||||||
|
| 'transitAssigneeRequestedAt'
|
||||||
|
| 'transitAssigneeRequestedByUserId'
|
||||||
|
| 'transitAssigneeRequestNote'
|
||||||
|
| 'transitAssigneeName'
|
||||||
|
| 'transitAssigneeAssignedAt'
|
||||||
|
| 'transitAssigneeAssignedByUserId'
|
||||||
>
|
>
|
||||||
>,
|
>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ import { Contract, CONTRACT_STATUSES, CONTRACT_CUSTOMER_EDITABLE_STATUSES } from
|
|||||||
import { ContractRoute } from './entities/contract-route.entity';
|
import { ContractRoute } from './entities/contract-route.entity';
|
||||||
import { ContractCargoScope } from './entities/contract-cargo-scope.entity';
|
import { ContractCargoScope } from './entities/contract-cargo-scope.entity';
|
||||||
import { isEffectivelyExpired } from './utils/contract-expiry.util';
|
import { isEffectivelyExpired } from './utils/contract-expiry.util';
|
||||||
|
import { diffContractFields } from './contract-document-diff.util';
|
||||||
|
import { ContractDocumentHistoryService } from './contract-document-history.service';
|
||||||
import { FileRecord } from '../files/entities/file.entity';
|
import { FileRecord } from '../files/entities/file.entity';
|
||||||
|
|
||||||
/** Paginated contract list: flat `total` (backoffice) + `meta` block (portal). */
|
/** Paginated contract list: flat `total` (backoffice) + `meta` block (portal). */
|
||||||
@@ -42,6 +44,35 @@ export interface PaginatedContracts {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Route list as a readable lane string, e.g. "Nagad → Mojo, Mojo → Adama". */
|
||||||
|
function describeRoutes(routes?: ContractRoute[]): string | null {
|
||||||
|
if (!routes?.length) return null;
|
||||||
|
return [...routes]
|
||||||
|
.sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0))
|
||||||
|
.map(
|
||||||
|
(r) =>
|
||||||
|
`${r.originYard?.label ?? r.originYardId} → ${r.destinationYard?.label ?? r.destinationYardId}`,
|
||||||
|
)
|
||||||
|
.join(', ');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cargo scope as a readable string, e.g. "20ft ×2, 40ft ×1" or "Wheat ×500". */
|
||||||
|
function describeCargoScope(scope?: ContractCargoScope[]): string | null {
|
||||||
|
if (!scope?.length) return null;
|
||||||
|
return scope
|
||||||
|
.map((row) => {
|
||||||
|
const label =
|
||||||
|
row.containerSize ??
|
||||||
|
row.cargoType?.cargoTypeName ??
|
||||||
|
row.cargoFreeText ??
|
||||||
|
row.cargoTypeId ??
|
||||||
|
'cargo';
|
||||||
|
return row.quantityCap != null ? `${label} ×${row.quantityCap}` : String(label);
|
||||||
|
})
|
||||||
|
.sort()
|
||||||
|
.join(', ');
|
||||||
|
}
|
||||||
|
|
||||||
const NEEDS_ACTION_STATUSES = [
|
const NEEDS_ACTION_STATUSES = [
|
||||||
'SUBMITTED',
|
'SUBMITTED',
|
||||||
'PENDING_APPROVAL',
|
'PENDING_APPROVAL',
|
||||||
@@ -57,6 +88,7 @@ export class ContractsService {
|
|||||||
private readonly companiesService: CompaniesService,
|
private readonly companiesService: CompaniesService,
|
||||||
private readonly filesService: FilesService,
|
private readonly filesService: FilesService,
|
||||||
private readonly minioService: MinioService,
|
private readonly minioService: MinioService,
|
||||||
|
private readonly documentHistory: ContractDocumentHistoryService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/** Generate a unique contract reference number (CTR-YYYY-NNNNN). */
|
/** Generate a unique contract reference number (CTR-YYYY-NNNNN). */
|
||||||
@@ -332,7 +364,11 @@ export class ContractsService {
|
|||||||
tradeDirection: dto.tradeDirection,
|
tradeDirection: dto.tradeDirection,
|
||||||
freightType: dto.freightType,
|
freightType: dto.freightType,
|
||||||
serviceTypeId: dto.serviceTypeId,
|
serviceTypeId: dto.serviceTypeId,
|
||||||
paymentCurrency: dto.paymentCurrency,
|
// A contract is always QUOTED in USD — the billing currency is chosen per
|
||||||
|
// booking (or on the shipment request when GL books for the customer), so
|
||||||
|
// any client-supplied currency here is ignored. Contracts created before
|
||||||
|
// this rule keep whatever they stored; update() never rewrites it.
|
||||||
|
paymentCurrency: 'USD',
|
||||||
customsClearingEnabled: includesCustoms,
|
customsClearingEnabled: includesCustoms,
|
||||||
customsClearingAgent: includesCustoms ? null : (dto.customsClearingAgent ?? null),
|
customsClearingAgent: includesCustoms ? null : (dto.customsClearingAgent ?? null),
|
||||||
equipmentReturn: dto.equipmentReturn ?? null,
|
equipmentReturn: dto.equipmentReturn ?? null,
|
||||||
@@ -490,6 +526,7 @@ export class ContractsService {
|
|||||||
id: string,
|
id: string,
|
||||||
dto: UpdateContractDto,
|
dto: UpdateContractDto,
|
||||||
files: Express.Multer.File[],
|
files: Express.Multer.File[],
|
||||||
|
actorId?: string,
|
||||||
): Promise<{ contract: Contract; warnings: string[] }> {
|
): Promise<{ contract: Contract; warnings: string[] }> {
|
||||||
const existing = await this.findById(id);
|
const existing = await this.findById(id);
|
||||||
if (!CONTRACT_CUSTOMER_EDITABLE_STATUSES.includes(existing.status as never)) {
|
if (!CONTRACT_CUSTOMER_EDITABLE_STATUSES.includes(existing.status as never)) {
|
||||||
@@ -516,7 +553,9 @@ export class ContractsService {
|
|||||||
tradeDirection: dto.tradeDirection ?? existing.tradeDirection,
|
tradeDirection: dto.tradeDirection ?? existing.tradeDirection,
|
||||||
freightType,
|
freightType,
|
||||||
serviceTypeId: dto.serviceTypeId ?? existing.serviceTypeId,
|
serviceTypeId: dto.serviceTypeId ?? existing.serviceTypeId,
|
||||||
paymentCurrency: dto.paymentCurrency ?? existing.paymentCurrency,
|
// Never rewritten: grandfathered contracts keep the currency (and frozen
|
||||||
|
// snapshots) they were signed with.
|
||||||
|
paymentCurrency: existing.paymentCurrency,
|
||||||
isHazardous: dto.isHazardous ?? existing.isHazardous,
|
isHazardous: dto.isHazardous ?? existing.isHazardous,
|
||||||
isReefer: dto.isReefer ?? existing.isReefer,
|
isReefer: dto.isReefer ?? existing.isReefer,
|
||||||
// Same rule as create: clearing the flag clears the declaration with it.
|
// Same rule as create: clearing the flag clears the declaration with it.
|
||||||
@@ -576,7 +615,52 @@ export class ContractsService {
|
|||||||
existing.companyProfileId ?? null,
|
existing.companyProfileId ?? null,
|
||||||
);
|
);
|
||||||
|
|
||||||
return { contract: await this.findById(id), warnings };
|
const updated = await this.findById(id);
|
||||||
|
// Audit what this edit actually changed. Runs after the writes so the
|
||||||
|
// "after" side is read back from the contract rather than from the DTO.
|
||||||
|
await this.recordFieldRevision(existing, updated, actorId);
|
||||||
|
|
||||||
|
return { contract: updated, warnings };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fields worth auditing on a customer edit, read off a loaded contract. */
|
||||||
|
private auditableFields(contract: Contract): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
contractKind: contract.contractKind,
|
||||||
|
tradeDirection: contract.tradeDirection,
|
||||||
|
freightType: contract.freightType,
|
||||||
|
serviceType: contract.serviceType?.serviceName ?? contract.serviceTypeId,
|
||||||
|
paymentCurrency: contract.paymentCurrency,
|
||||||
|
contractType: contract.contractType,
|
||||||
|
isHazardous: contract.isHazardous,
|
||||||
|
hazardClass: contract.hazardClass,
|
||||||
|
unNumber: contract.unNumber,
|
||||||
|
isReefer: contract.isReefer,
|
||||||
|
equipmentReturn: contract.equipmentReturn,
|
||||||
|
customsClearingAgent: contract.customsClearingAgent,
|
||||||
|
firstMilePickupAddress: contract.firstMilePickupAddress,
|
||||||
|
lastMileDeliveryAddress: contract.lastMileDeliveryAddress,
|
||||||
|
routes: describeRoutes(contract.routes),
|
||||||
|
cargoScope: describeCargoScope(contract.cargoScope),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Append a revision describing a customer's edit to the contract itself. */
|
||||||
|
private async recordFieldRevision(
|
||||||
|
before: Contract,
|
||||||
|
after: Contract,
|
||||||
|
actorId?: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const changes = diffContractFields(
|
||||||
|
this.auditableFields(before),
|
||||||
|
this.auditableFields(after),
|
||||||
|
);
|
||||||
|
await this.documentHistory.recordChanges({
|
||||||
|
contractId: after.id,
|
||||||
|
changes,
|
||||||
|
actorId: actorId ?? null,
|
||||||
|
actorRole: 'Customer',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Parse comma-separated or repeated status query values. */
|
/** Parse comma-separated or repeated status query values. */
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { BadRequestException } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { assertDoCollectionDates } from './contract-clearance.util';
|
||||||
|
|
||||||
|
describe('assertDoCollectionDates', () => {
|
||||||
|
it('requires both dates', () => {
|
||||||
|
expect(() => assertDoCollectionDates(undefined)).toThrow(BadRequestException);
|
||||||
|
expect(() =>
|
||||||
|
assertDoCollectionDates({ vesselArrivalDate: '2026-07-01' }),
|
||||||
|
).toThrow(/DO collected date is required/);
|
||||||
|
expect(() =>
|
||||||
|
assertDoCollectionDates({ doCollectedDate: '2026-07-01' }),
|
||||||
|
).toThrow(/Vessel arrival date is required/);
|
||||||
|
// Whitespace is not a date.
|
||||||
|
expect(() =>
|
||||||
|
assertDoCollectionDates({ vesselArrivalDate: ' ', doCollectedDate: ' ' }),
|
||||||
|
).toThrow(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a DO collected before the vessel arrived', () => {
|
||||||
|
expect(() =>
|
||||||
|
assertDoCollectionDates({
|
||||||
|
vesselArrivalDate: '2026-07-10',
|
||||||
|
doCollectedDate: '2026-07-09',
|
||||||
|
}),
|
||||||
|
).toThrow(/cannot be earlier than the vessel arrival date/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalizes an ISO datetime down to its date part', () => {
|
||||||
|
expect(
|
||||||
|
assertDoCollectionDates({
|
||||||
|
vesselArrivalDate: '2026-07-10T21:00:00.000Z',
|
||||||
|
doCollectedDate: '2026-07-10T05:00:00.000Z',
|
||||||
|
}),
|
||||||
|
).toEqual({ vesselArrivalDate: '2026-07-10', doCollectedDate: '2026-07-10' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a malformed date', () => {
|
||||||
|
expect(() =>
|
||||||
|
assertDoCollectionDates({
|
||||||
|
vesselArrivalDate: '10/07/2026',
|
||||||
|
doCollectedDate: '2026-07-10',
|
||||||
|
}),
|
||||||
|
).toThrow(/not a valid date/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,6 +2,7 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
|||||||
import { Transform, Type } from 'class-transformer';
|
import { Transform, Type } from 'class-transformer';
|
||||||
import {
|
import {
|
||||||
IsArray,
|
IsArray,
|
||||||
|
IsIn,
|
||||||
IsInt,
|
IsInt,
|
||||||
IsNumber,
|
IsNumber,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
@@ -91,6 +92,15 @@ export class CreateBookingRequestDto {
|
|||||||
@Type(() => RequestBulkLineDto)
|
@Type(() => RequestBulkLineDto)
|
||||||
bulk?: RequestBulkLineDto;
|
bulk?: RequestBulkLineDto;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
enum: ['ETB', 'USD'],
|
||||||
|
description:
|
||||||
|
'Billing currency for the shipment GL will book. Intercity is always ETB.',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(['ETB', 'USD'])
|
||||||
|
paymentCurrency?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional()
|
@ApiPropertyOptional()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import {
|
|||||||
ValidateNested,
|
ValidateNested,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
|
|
||||||
|
import { PAYMENT_CURRENCIES } from './create-contract.dto';
|
||||||
|
|
||||||
/** Per-shipment equipment return — "NA" stays contract-level only. */
|
/** Per-shipment equipment return — "NA" stays contract-level only. */
|
||||||
const SHIPMENT_EQUIPMENT_RETURNS = ['WITH_RETURN', 'WITHOUT_RETURN'] as const;
|
const SHIPMENT_EQUIPMENT_RETURNS = ['WITH_RETURN', 'WITHOUT_RETURN'] as const;
|
||||||
|
|
||||||
@@ -150,6 +152,20 @@ export class CreateBookingUnderContractDto {
|
|||||||
@IsUUID()
|
@IsUUID()
|
||||||
contractRouteId?: string;
|
contractRouteId?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The contract quotes in USD; the customer picks the billing currency here.
|
||||||
|
* Omitted → the contract's own currency (USD for contracts created under the
|
||||||
|
* current rule, the grandfathered currency for older ones). Intercity is
|
||||||
|
* forced to ETB by the service regardless of what is sent.
|
||||||
|
*/
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
enum: PAYMENT_CURRENCIES,
|
||||||
|
description: 'Billing currency for this shipment. Intercity is always ETB.',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn([...PAYMENT_CURRENCIES])
|
||||||
|
paymentCurrency?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
description:
|
description:
|
||||||
'Binding shipment day. Omitted for intercity (DOMESTIC) bookings — staff assign a passing train later.',
|
'Binding shipment day. Omitted for intercity (DOMESTIC) bookings — staff assign a passing train later.',
|
||||||
|
|||||||
@@ -158,9 +158,20 @@ export class CreateContractDto {
|
|||||||
@IsUUID()
|
@IsUUID()
|
||||||
serviceTypeId!: string;
|
serviceTypeId!: string;
|
||||||
|
|
||||||
@ApiProperty({ enum: PAYMENT_CURRENCIES })
|
/**
|
||||||
|
* Deprecated at the contract level. A contract now always quotes in USD; the
|
||||||
|
* customer picks the billing currency per booking (or on the shipment request
|
||||||
|
* when GL books on their behalf). Accepted but ignored on create so older
|
||||||
|
* clients don't break — the service forces USD.
|
||||||
|
*/
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
enum: PAYMENT_CURRENCIES,
|
||||||
|
deprecated: true,
|
||||||
|
description: 'Ignored — contracts always quote in USD. Choose currency at booking.',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
@IsIn([...PAYMENT_CURRENCIES])
|
@IsIn([...PAYMENT_CURRENCIES])
|
||||||
paymentCurrency!: string;
|
paymentCurrency?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: 'Whether EDR/GL handles customs clearance' })
|
@ApiPropertyOptional({ description: 'Whether EDR/GL handles customs clearance' })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
|
|||||||
@@ -43,6 +43,14 @@ export class BookingRequest extends BaseEntity {
|
|||||||
@Column({ name: 'requested_lines', type: 'jsonb', default: () => "'{}'::jsonb" })
|
@Column({ name: 'requested_lines', type: 'jsonb', default: () => "'{}'::jsonb" })
|
||||||
requestedLines!: Freight.RequestedShipmentLines;
|
requestedLines!: Freight.RequestedShipmentLines;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Billing currency the customer chose for this shipment. The contract quotes
|
||||||
|
* in USD; on a customs contract GL creates the booking, so this is where the
|
||||||
|
* customer states which currency to be invoiced in.
|
||||||
|
*/
|
||||||
|
@Column({ name: 'payment_currency', type: 'varchar', length: 5, nullable: true })
|
||||||
|
paymentCurrency?: string | null;
|
||||||
|
|
||||||
@Column({ name: 'notes', type: 'text', nullable: true })
|
@Column({ name: 'notes', type: 'text', nullable: true })
|
||||||
notes?: string | null;
|
notes?: string | null;
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,41 @@ export class ContractClearanceCycle extends BaseEntity {
|
|||||||
@Column({ name: 'vessel_departure_date', type: 'date', nullable: true })
|
@Column({ name: 'vessel_departure_date', type: 'date', nullable: true })
|
||||||
vesselDepartureDate?: string | null;
|
vesselDepartureDate?: string | null;
|
||||||
|
|
||||||
|
/** Import DO: when the vessel arrived in Djibouti. Required on DO upload. */
|
||||||
|
@Column({ name: 'vessel_arrival_date', type: 'date', nullable: true })
|
||||||
|
vesselArrivalDate?: string | null;
|
||||||
|
|
||||||
|
/** Import DO: when GL Djibouti collected the DO. Required on DO upload. */
|
||||||
|
@Column({ name: 'do_collected_date', type: 'date', nullable: true })
|
||||||
|
doCollectedDate?: string | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transit-assignee handshake that runs BEFORE the customs declaration: GL
|
||||||
|
* Ethiopia asks Djibouti for the officer who will handle the shipment in
|
||||||
|
* transit, and Djibouti answers with a name. The declaration step stays shut
|
||||||
|
* until `transitAssigneeName` is set; Djibouti may overwrite it later
|
||||||
|
* (reassignment) and the newer name simply wins.
|
||||||
|
*/
|
||||||
|
@Column({ name: 'transit_assignee_requested_at', type: 'timestamptz', nullable: true })
|
||||||
|
transitAssigneeRequestedAt?: Date | null;
|
||||||
|
|
||||||
|
@Column({ name: 'transit_assignee_requested_by_user_id', type: 'uuid', nullable: true })
|
||||||
|
transitAssigneeRequestedByUserId?: string | null;
|
||||||
|
|
||||||
|
/** What GL Ethiopia asked for — shown on the Djibouti queue. */
|
||||||
|
@Column({ name: 'transit_assignee_request_note', type: 'text', nullable: true })
|
||||||
|
transitAssigneeRequestNote?: string | null;
|
||||||
|
|
||||||
|
/** The officer Djibouti named — free text, no user directory to bind to. */
|
||||||
|
@Column({ name: 'transit_assignee_name', type: 'text', nullable: true })
|
||||||
|
transitAssigneeName?: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'transit_assignee_assigned_at', type: 'timestamptz', nullable: true })
|
||||||
|
transitAssigneeAssignedAt?: Date | null;
|
||||||
|
|
||||||
|
@Column({ name: 'transit_assignee_assigned_by_user_id', type: 'uuid', nullable: true })
|
||||||
|
transitAssigneeAssignedByUserId?: string | null;
|
||||||
|
|
||||||
@Column({ name: 'ro_amendment_requested_at', type: 'timestamptz', nullable: true })
|
@Column({ name: 'ro_amendment_requested_at', type: 'timestamptz', nullable: true })
|
||||||
roAmendmentRequestedAt?: Date | null;
|
roAmendmentRequestedAt?: Date | null;
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,13 @@ export class ContractDocumentRevision extends BaseEntity {
|
|||||||
@Column({ name: 'actor_id', type: 'uuid', nullable: true })
|
@Column({ name: 'actor_id', type: 'uuid', nullable: true })
|
||||||
actorId?: string | null;
|
actorId?: string | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Who made the edit, captured at the time. Denormalised so the trail still
|
||||||
|
* names them after a rename or a deactivated account.
|
||||||
|
*/
|
||||||
|
@Column({ name: 'actor_name', type: 'varchar', length: 200, nullable: true })
|
||||||
|
actorName?: string | null;
|
||||||
|
|
||||||
/** The approval step's required role at the time of the edit. */
|
/** The approval step's required role at the time of the edit. */
|
||||||
@Column({ name: 'actor_role', type: 'varchar', length: 64, nullable: true })
|
@Column({ name: 'actor_role', type: 'varchar', length: 64, nullable: true })
|
||||||
actorRole?: string | null;
|
actorRole?: string | null;
|
||||||
|
|||||||
@@ -8,6 +8,11 @@ export const CONTRACT_REVIEW_NOTE_TYPES = [
|
|||||||
'STAFF_NOTE',
|
'STAFF_NOTE',
|
||||||
'CUSTOMER_NOTE',
|
'CUSTOMER_NOTE',
|
||||||
'AMENDMENT',
|
'AMENDMENT',
|
||||||
|
/**
|
||||||
|
* The customer disputed the advised duty & tax and asked GL Ethiopia to
|
||||||
|
* correct it. One row per round — the advice/dispute loop can repeat.
|
||||||
|
*/
|
||||||
|
'DUTY_DISPUTE',
|
||||||
] as const;
|
] as const;
|
||||||
export type ContractReviewNoteType =
|
export type ContractReviewNoteType =
|
||||||
(typeof CONTRACT_REVIEW_NOTE_TYPES)[number];
|
(typeof CONTRACT_REVIEW_NOTE_TYPES)[number];
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { ContractBookingService } from './contract-booking.service';
|
||||||
|
import { BookingPricingService } from '../bookings/booking-pricing.service';
|
||||||
|
import type { Contract } from './entities/contract.entity';
|
||||||
|
import type { ContractRateSnapshot } from './entities/contract-rate-snapshot.entity';
|
||||||
|
|
||||||
|
const contract = (over: Partial<Contract>): Contract =>
|
||||||
|
({ tradeDirection: 'IMPORT', paymentCurrency: 'USD', ...over }) as Contract;
|
||||||
|
|
||||||
|
/** The private resolver, reached without standing up the whole Nest graph. */
|
||||||
|
const resolveCurrency = (c: Contract, requested?: string | null): string =>
|
||||||
|
(
|
||||||
|
ContractBookingService.prototype as unknown as {
|
||||||
|
resolveShipmentCurrency: (c: Contract, r?: string | null) => string;
|
||||||
|
}
|
||||||
|
).resolveShipmentCurrency(c, requested);
|
||||||
|
|
||||||
|
const snapshot = (currency: string, unitPrice: number): ContractRateSnapshot =>
|
||||||
|
({ rateCode: 'CONTAINER_20FT', currency, unitPrice }) as ContractRateSnapshot;
|
||||||
|
|
||||||
|
const frozenByCode = (
|
||||||
|
snap: ContractRateSnapshot | null,
|
||||||
|
bookingCurrency: string,
|
||||||
|
usdToEtb: number,
|
||||||
|
): ContractRateSnapshot | null =>
|
||||||
|
(
|
||||||
|
BookingPricingService.prototype as unknown as {
|
||||||
|
frozenRateByCode: (
|
||||||
|
m: Map<string, ContractRateSnapshot> | null,
|
||||||
|
code: string,
|
||||||
|
bookingCurrency: string,
|
||||||
|
usdToEtb: number,
|
||||||
|
) => ContractRateSnapshot | null;
|
||||||
|
}
|
||||||
|
).frozenRateByCode(
|
||||||
|
snap ? new Map([['CONTAINER_20FT', snap]]) : null,
|
||||||
|
'CONTAINER_20FT',
|
||||||
|
bookingCurrency,
|
||||||
|
usdToEtb,
|
||||||
|
);
|
||||||
|
|
||||||
|
describe('per-shipment billing currency', () => {
|
||||||
|
it('takes the customer choice over the contract', () => {
|
||||||
|
expect(resolveCurrency(contract({}), 'ETB')).toBe('ETB');
|
||||||
|
expect(resolveCurrency(contract({}), 'USD')).toBe('USD');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to the contract currency when none is chosen', () => {
|
||||||
|
// Grandfathered ETB contract with no explicit choice.
|
||||||
|
expect(resolveCurrency(contract({ paymentCurrency: 'ETB' }))).toBe('ETB');
|
||||||
|
expect(resolveCurrency(contract({}), ' ')).toBe('USD');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forces ETB on intercity whatever was requested', () => {
|
||||||
|
const domestic = contract({ tradeDirection: 'DOMESTIC' });
|
||||||
|
expect(resolveCurrency(domestic, 'USD')).toBe('ETB');
|
||||||
|
expect(resolveCurrency(domestic)).toBe('ETB');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('frozen contract rate in the booking currency', () => {
|
||||||
|
it('converts a USD snapshot for an ETB booking instead of dropping it', () => {
|
||||||
|
// The old behaviour returned null here, which silently re-priced the
|
||||||
|
// booking at live rates and lost the agreed contract price.
|
||||||
|
expect(frozenByCode(snapshot('USD', 400), 'ETB', 150)?.unitPrice).toBe(60_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('converts a grandfathered ETB snapshot back for a USD booking', () => {
|
||||||
|
expect(frozenByCode(snapshot('ETB', 60_000), 'USD', 150)?.unitPrice).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes a matching-currency snapshot through untouched', () => {
|
||||||
|
const snap = snapshot('USD', 400);
|
||||||
|
expect(frozenByCode(snap, 'USD', 1)).toBe(snap);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses to price off an unusable exchange rate', () => {
|
||||||
|
// Converting with 0 would zero the whole line.
|
||||||
|
expect(frozenByCode(snapshot('USD', 400), 'ETB', 0)).toBeNull();
|
||||||
|
expect(frozenByCode(snapshot('USD', 400), 'ETB', Number.NaN)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when there is no snapshot', () => {
|
||||||
|
expect(frozenByCode(null, 'ETB', 150)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
import { BadRequestException } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { ContractClearanceService } from './contract-clearance.service';
|
||||||
|
import type { Contract } from './entities/contract.entity';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pre-declaration transit-assignee handshake. GL Ethiopia asks Djibouti who will
|
||||||
|
* handle the shipment in transit; Djibouti answers with a name. The customs
|
||||||
|
* declaration stays shut until that name exists, and Djibouti may send a
|
||||||
|
* different one later.
|
||||||
|
*/
|
||||||
|
describe('ContractClearanceService — transit assignee', () => {
|
||||||
|
const contract = (over: Partial<Contract> = {}): Contract =>
|
||||||
|
({
|
||||||
|
id: 'ctr-1',
|
||||||
|
reference: 'CTR-2026-00042',
|
||||||
|
tradeDirection: 'IMPORT',
|
||||||
|
customsClearingEnabled: true,
|
||||||
|
contractKind: 'ONE_TIME',
|
||||||
|
...over,
|
||||||
|
}) as Contract;
|
||||||
|
|
||||||
|
let repo: { currentCycle: jest.Mock; updateCycle: jest.Mock };
|
||||||
|
let contractsService: { findById: jest.Mock };
|
||||||
|
let notifier: {
|
||||||
|
transitAssigneeRequested: jest.Mock;
|
||||||
|
transitAssigneeAssigned: jest.Mock;
|
||||||
|
};
|
||||||
|
let service: ContractClearanceService;
|
||||||
|
|
||||||
|
const cycle = (over: Record<string, unknown> = {}) => ({
|
||||||
|
id: 'cyc-1',
|
||||||
|
transitAssigneeRequestedAt: null,
|
||||||
|
transitAssigneeName: null,
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
repo = {
|
||||||
|
currentCycle: jest.fn().mockResolvedValue(cycle()),
|
||||||
|
updateCycle: jest.fn().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
|
contractsService = { findById: jest.fn().mockResolvedValue(contract()) };
|
||||||
|
notifier = {
|
||||||
|
transitAssigneeRequested: jest.fn(),
|
||||||
|
transitAssigneeAssigned: jest.fn(),
|
||||||
|
};
|
||||||
|
service = new ContractClearanceService(
|
||||||
|
repo as never,
|
||||||
|
contractsService as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
notifier as never,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('request (GL Ethiopia)', () => {
|
||||||
|
it('stamps the ask and pings Djibouti', async () => {
|
||||||
|
await service.requestTransitAssignee('ctr-1', ' Reefer, needs a cold-chain officer ', 'et-1');
|
||||||
|
|
||||||
|
const patch = repo.updateCycle.mock.calls[0][1];
|
||||||
|
expect(patch.transitAssigneeRequestedAt).toBeInstanceOf(Date);
|
||||||
|
expect(patch.transitAssigneeRequestedByUserId).toBe('et-1');
|
||||||
|
expect(patch.transitAssigneeRequestNote).toBe(
|
||||||
|
'Reefer, needs a cold-chain officer',
|
||||||
|
);
|
||||||
|
expect(notifier.transitAssigneeRequested).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('assign (GL Djibouti)', () => {
|
||||||
|
it('records the officer and tells Ethiopia they can proceed', async () => {
|
||||||
|
repo.currentCycle.mockResolvedValue(
|
||||||
|
cycle({ transitAssigneeRequestedAt: new Date() }),
|
||||||
|
);
|
||||||
|
|
||||||
|
await service.assignTransitAssignee('ctr-1', ' Ahmed Bourhan ', 'dj-1');
|
||||||
|
|
||||||
|
const patch = repo.updateCycle.mock.calls[0][1];
|
||||||
|
expect(patch.transitAssigneeName).toBe('Ahmed Bourhan');
|
||||||
|
expect(patch.transitAssigneeAssignedByUserId).toBe('dj-1');
|
||||||
|
expect(notifier.transitAssigneeAssigned).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ id: 'ctr-1' }),
|
||||||
|
'Ahmed Bourhan',
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reassigns, carrying the previous name into the notice', async () => {
|
||||||
|
repo.currentCycle.mockResolvedValue(
|
||||||
|
cycle({
|
||||||
|
transitAssigneeRequestedAt: new Date(),
|
||||||
|
transitAssigneeName: 'Ahmed Bourhan',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await service.assignTransitAssignee('ctr-1', 'Fatouma Ali', 'dj-1');
|
||||||
|
|
||||||
|
expect(notifier.transitAssigneeAssigned).toHaveBeenCalledWith(
|
||||||
|
expect.anything(),
|
||||||
|
'Fatouma Ali',
|
||||||
|
'Ahmed Bourhan',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses an empty name', async () => {
|
||||||
|
repo.currentCycle.mockResolvedValue(
|
||||||
|
cycle({ transitAssigneeRequestedAt: new Date() }),
|
||||||
|
);
|
||||||
|
await expect(
|
||||||
|
service.assignTransitAssignee('ctr-1', ' ', 'dj-1'),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses before Ethiopia has asked', async () => {
|
||||||
|
await expect(
|
||||||
|
service.assignTransitAssignee('ctr-1', 'Ahmed Bourhan', 'dj-1'),
|
||||||
|
).rejects.toThrow(/not requested/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('declaration gate', () => {
|
||||||
|
const ensure = (c: Contract) =>
|
||||||
|
(
|
||||||
|
service as unknown as {
|
||||||
|
ensureDeclarationPrerequisites: (id: string, c: Contract) => Promise<void>;
|
||||||
|
}
|
||||||
|
).ensureDeclarationPrerequisites('ctr-1', c);
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Documents are approved; only the assignee decides the outcome here.
|
||||||
|
(
|
||||||
|
service as unknown as { isClearanceFullyApproved: unknown }
|
||||||
|
).isClearanceFullyApproved = jest.fn().mockResolvedValue(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tells GL to raise the request when none exists', async () => {
|
||||||
|
await expect(ensure(contract())).rejects.toThrow(
|
||||||
|
/Request a transit assignee/i,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tells GL to wait when Djibouti has not answered', async () => {
|
||||||
|
repo.currentCycle.mockResolvedValue(
|
||||||
|
cycle({ transitAssigneeRequestedAt: new Date() }),
|
||||||
|
);
|
||||||
|
await expect(ensure(contract())).rejects.toThrow(/has not assigned/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lets the declaration through once the officer is named', async () => {
|
||||||
|
repo.currentCycle.mockResolvedValue(
|
||||||
|
cycle({
|
||||||
|
transitAssigneeRequestedAt: new Date(),
|
||||||
|
transitAssigneeName: 'Ahmed Bourhan',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
(
|
||||||
|
service as unknown as { workflowService: unknown }
|
||||||
|
).workflowService = {
|
||||||
|
listMilestones: jest
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue([
|
||||||
|
{ milestoneCode: 'DOCUMENTS_APPROVED', status: 'COMPLETED' },
|
||||||
|
]),
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(ensure(contract())).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -53,4 +53,16 @@ export class FileRecord extends BaseEntity {
|
|||||||
|
|
||||||
@Column({ name: "reviewed_at", type: "timestamptz", nullable: true })
|
@Column({ name: "reviewed_at", type: "timestamptz", nullable: true })
|
||||||
reviewedAt!: Date | null;
|
reviewedAt!: Date | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Who replaced this version, when a newer file took its place. Superseded
|
||||||
|
* versions are soft-deleted rather than dropped, so the original a customer
|
||||||
|
* uploaded survives a staff correction and the two can be compared.
|
||||||
|
*/
|
||||||
|
@Column({ name: "replaced_by_user_id", type: "uuid", nullable: true })
|
||||||
|
replacedByUserId!: string | null;
|
||||||
|
|
||||||
|
/** Why the file was replaced — shown on the document's version history. */
|
||||||
|
@Column({ name: "replace_reason", type: "text", nullable: true })
|
||||||
|
replaceReason!: string | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,12 +41,47 @@ export class FilesRepository extends BaseRepository<FileRecord> {
|
|||||||
return this.repository.findOne({ where: { resourceId, resource, code } });
|
return this.repository.findOne({ where: { resourceId, resource, code } });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retire the live version(s) of a document code. SOFT delete on purpose: the
|
||||||
|
* bytes and the row stay so the original upload can still be read back from
|
||||||
|
* the version history after staff replace it. Every normal read already
|
||||||
|
* filters soft-deleted rows, so callers see only the current version.
|
||||||
|
*
|
||||||
|
* `replacedBy` / `reason` are stamped on the retired row when a newer file is
|
||||||
|
* taking its place (as opposed to a plain removal).
|
||||||
|
*/
|
||||||
async deleteByCode(
|
async deleteByCode(
|
||||||
resourceId: string,
|
resourceId: string,
|
||||||
resource: string,
|
resource: string,
|
||||||
code: string,
|
code: string,
|
||||||
|
replacedBy?: { userId?: string | null; reason?: string | null },
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await this.repository.delete({ resourceId, resource, code });
|
if (replacedBy) {
|
||||||
|
await this.repository.update(
|
||||||
|
{ resourceId, resource, code },
|
||||||
|
{
|
||||||
|
replacedByUserId: replacedBy.userId ?? null,
|
||||||
|
replaceReason: replacedBy.reason ?? null,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await this.repository.softDelete({ resourceId, resource, code });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every version of one document code, newest first — superseded versions
|
||||||
|
* included. The only read that deliberately looks past the soft-delete filter.
|
||||||
|
*/
|
||||||
|
findVersionHistory(
|
||||||
|
resourceId: string,
|
||||||
|
resource: string,
|
||||||
|
code: string,
|
||||||
|
): Promise<FileRecord[]> {
|
||||||
|
return this.repository.find({
|
||||||
|
where: { resourceId, resource, code },
|
||||||
|
withDeleted: true,
|
||||||
|
order: { createdAt: "DESC" },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
112
apps/edr-freight-api/src/modules/files/files.service.spec.ts
Normal file
112
apps/edr-freight-api/src/modules/files/files.service.spec.ts
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import { FilesService } from './files.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replacing a stored document must never destroy the previous one: the customer
|
||||||
|
* uploaded it, and a staff correction has to stay auditable against it. The old
|
||||||
|
* row is soft-deleted (so every normal read still returns exactly the current
|
||||||
|
* version) and stamped with who replaced it and why.
|
||||||
|
*/
|
||||||
|
describe('FilesService — document versions', () => {
|
||||||
|
const file = {
|
||||||
|
originalname: 'bill-of-lading.pdf',
|
||||||
|
size: 1234,
|
||||||
|
mimetype: 'application/pdf',
|
||||||
|
buffer: Buffer.from('x'),
|
||||||
|
} as Express.Multer.File;
|
||||||
|
|
||||||
|
let filesRepository: {
|
||||||
|
deleteByCode: jest.Mock;
|
||||||
|
create: jest.Mock;
|
||||||
|
findVersionHistory: jest.Mock;
|
||||||
|
};
|
||||||
|
let service: FilesService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
filesRepository = {
|
||||||
|
deleteByCode: jest.fn().mockResolvedValue(undefined),
|
||||||
|
create: jest.fn(async (row) => ({ id: 'file-new', ...row })),
|
||||||
|
findVersionHistory: jest.fn().mockResolvedValue([]),
|
||||||
|
};
|
||||||
|
service = new FilesService(
|
||||||
|
filesRepository as never,
|
||||||
|
{
|
||||||
|
uploadFile: jest.fn().mockResolvedValue('https://minio/bucket/new.pdf'),
|
||||||
|
getObjectNameFromUrl: (u: string) => u,
|
||||||
|
getSignedUrl: jest.fn(),
|
||||||
|
} as never,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stamps the retired version with who replaced it and why', async () => {
|
||||||
|
await service.upsertByCode(
|
||||||
|
{ resourceId: 'ctr-1', resource: 'contracts', code: 'bill_of_lading', file },
|
||||||
|
{ userId: 'gl-user-1', reason: 'Customer sent page 2 only' },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(filesRepository.deleteByCode).toHaveBeenCalledWith(
|
||||||
|
'ctr-1',
|
||||||
|
'contracts',
|
||||||
|
'bill_of_lading',
|
||||||
|
{ userId: 'gl-user-1', reason: 'Customer sent page 2 only' },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still replaces silently when no replacer is given (system overwrites)', async () => {
|
||||||
|
await service.upsertByCode({
|
||||||
|
resourceId: 'ctr-1',
|
||||||
|
resource: 'contracts',
|
||||||
|
code: 'contract_pdf',
|
||||||
|
file,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(filesRepository.deleteByCode).toHaveBeenCalledWith(
|
||||||
|
'ctr-1',
|
||||||
|
'contracts',
|
||||||
|
'contract_pdf',
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks the live row current and the soft-deleted ones superseded', async () => {
|
||||||
|
filesRepository.findVersionHistory.mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: 'v2',
|
||||||
|
name: 'corrected.pdf',
|
||||||
|
url: 'u2',
|
||||||
|
size: 2,
|
||||||
|
mimeType: 'application/pdf',
|
||||||
|
createdAt: new Date('2026-07-20T10:00:00Z'),
|
||||||
|
deletedAt: null,
|
||||||
|
replacedByUserId: null,
|
||||||
|
replaceReason: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'v1',
|
||||||
|
name: 'original.pdf',
|
||||||
|
url: 'u1',
|
||||||
|
size: 1,
|
||||||
|
mimeType: 'application/pdf',
|
||||||
|
createdAt: new Date('2026-07-18T10:00:00Z'),
|
||||||
|
deletedAt: new Date('2026-07-20T10:00:00Z'),
|
||||||
|
replacedByUserId: 'gl-user-1',
|
||||||
|
replaceReason: 'Wrong page order',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const versions = await service.versionHistory(
|
||||||
|
'ctr-1',
|
||||||
|
'contracts',
|
||||||
|
'bill_of_lading',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(versions[0]).toMatchObject({ id: 'v2', isCurrent: true, replacedAt: null });
|
||||||
|
expect(versions[1]).toMatchObject({
|
||||||
|
id: 'v1',
|
||||||
|
isCurrent: false,
|
||||||
|
replacedByUserId: 'gl-user-1',
|
||||||
|
replaceReason: 'Wrong page order',
|
||||||
|
});
|
||||||
|
// The customer's original is still readable — that is the whole point.
|
||||||
|
expect(versions[1].url).toBe('u1');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -104,13 +104,66 @@ export class FilesService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Replace existing file row for the same resource + code (e.g. contract PDF). */
|
/**
|
||||||
async upsertByCode(input: CreateFileInput): Promise<FileRecord> {
|
* Replace the file stored under a resource + code (e.g. contract PDF). The
|
||||||
|
* previous version is retired, not destroyed — pass `replacedBy` to record who
|
||||||
|
* swapped it and why, which is what the version history shows.
|
||||||
|
*/
|
||||||
|
async upsertByCode(
|
||||||
|
input: CreateFileInput,
|
||||||
|
replacedBy?: { userId?: string | null; reason?: string | null },
|
||||||
|
): Promise<FileRecord> {
|
||||||
const { resourceId, resource, code } = input;
|
const { resourceId, resource, code } = input;
|
||||||
await this.filesRepository.deleteByCode(resourceId, resource, code);
|
await this.filesRepository.deleteByCode(
|
||||||
|
resourceId,
|
||||||
|
resource,
|
||||||
|
code,
|
||||||
|
replacedBy,
|
||||||
|
);
|
||||||
return this.upload(input);
|
return this.upload(input);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every stored version of one document, newest first. `isCurrent` marks the
|
||||||
|
* live row; the rest are superseded uploads kept for audit.
|
||||||
|
*/
|
||||||
|
async versionHistory(
|
||||||
|
resourceId: string,
|
||||||
|
resource: string,
|
||||||
|
code: string,
|
||||||
|
): Promise<
|
||||||
|
Array<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
size: number;
|
||||||
|
mimeType: string;
|
||||||
|
uploadedAt: string;
|
||||||
|
isCurrent: boolean;
|
||||||
|
replacedAt: string | null;
|
||||||
|
replacedByUserId: string | null;
|
||||||
|
replaceReason: string | null;
|
||||||
|
}>
|
||||||
|
> {
|
||||||
|
const rows = await this.filesRepository.findVersionHistory(
|
||||||
|
resourceId,
|
||||||
|
resource,
|
||||||
|
code,
|
||||||
|
);
|
||||||
|
return rows.map((row) => ({
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
url: row.url,
|
||||||
|
size: row.size,
|
||||||
|
mimeType: row.mimeType,
|
||||||
|
uploadedAt: row.createdAt.toISOString(),
|
||||||
|
isCurrent: row.deletedAt == null,
|
||||||
|
replacedAt: row.deletedAt ? row.deletedAt.toISOString() : null,
|
||||||
|
replacedByUserId: row.replacedByUserId,
|
||||||
|
replaceReason: row.replaceReason,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
async deleteByCode(
|
async deleteByCode(
|
||||||
resourceId: string,
|
resourceId: string,
|
||||||
resource: string,
|
resource: string,
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { orderConsistWagons } from './consist-order.util';
|
||||||
|
|
||||||
|
// Built train: A-B-C-D coupled in that order. Slots are created by the wagon
|
||||||
|
// PLAN, so their sequenceNo says nothing about where the wagon actually sits.
|
||||||
|
const TRAIN = ['A', 'B', 'C', 'D'];
|
||||||
|
|
||||||
|
const slot = (sequenceNo: number, physicalWagonId: string | null) => ({
|
||||||
|
sequenceNo,
|
||||||
|
physicalWagonId,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('orderConsistWagons', () => {
|
||||||
|
it('draws slots in the train coupling order, not slot order', () => {
|
||||||
|
// Plan order says D then B; the train says B sits ahead of D.
|
||||||
|
const drawn = orderConsistWagons([slot(1, 'D'), slot(2, 'B')], {
|
||||||
|
physicalWagonIdsInOrder: TRAIN,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(drawn.map((w) => w.physicalWagonId)).toEqual(['B', 'D']);
|
||||||
|
expect(drawn.map((w) => w.position)).toEqual([1, 2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('interleaves empty consist wagons in their real place', () => {
|
||||||
|
// Loaded slots on A and C; B and D ride along empty. The empties used to be
|
||||||
|
// appended after every loaded slot, so the drawing was never the train.
|
||||||
|
const drawn = orderConsistWagons(
|
||||||
|
[slot(1, 'A'), slot(2, 'C'), slot(98, 'B'), slot(99, 'D')],
|
||||||
|
{ physicalWagonIdsInOrder: TRAIN },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(drawn.map((w) => w.physicalWagonId)).toEqual(['A', 'B', 'C', 'D']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps every wagon in place when a load moves between wagons', () => {
|
||||||
|
// Load sat on A (slot 1); staff drag it onto empty D. The move repins the
|
||||||
|
// slot, so the SAME slot now reads as wagon D and A falls back to empty.
|
||||||
|
const before = orderConsistWagons([slot(1, 'A'), slot(98, 'D')], {
|
||||||
|
physicalWagonIdsInOrder: TRAIN,
|
||||||
|
});
|
||||||
|
const after = orderConsistWagons([slot(1, 'D'), slot(98, 'A')], {
|
||||||
|
physicalWagonIdsInOrder: TRAIN,
|
||||||
|
});
|
||||||
|
|
||||||
|
// A is drawn first and D last, before and after — the train did not shuffle.
|
||||||
|
expect(before.map((w) => w.physicalWagonId)).toEqual(['A', 'D']);
|
||||||
|
expect(after.map((w) => w.physicalWagonId)).toEqual(['A', 'D']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('follows a train-builder reorder without touching any slot row', () => {
|
||||||
|
const slots = [slot(1, 'A'), slot(2, 'B')];
|
||||||
|
|
||||||
|
// Builder swaps the coupling order; the slots are untouched.
|
||||||
|
const drawn = orderConsistWagons(slots, {
|
||||||
|
physicalWagonIdsInOrder: ['B', 'A', 'C', 'D'],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(drawn.map((w) => w.physicalWagonId)).toEqual(['B', 'A']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('draws back-to-front when the caller reverses the train', () => {
|
||||||
|
const drawn = orderConsistWagons([slot(1, 'A'), slot(2, 'C')], {
|
||||||
|
physicalWagonIdsInOrder: [...TRAIN].reverse(),
|
||||||
|
reverseWagonOrder: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(drawn.map((w) => w.physicalWagonId)).toEqual(['C', 'A']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parks unpinned slots last, in slot order', () => {
|
||||||
|
const drawn = orderConsistWagons([slot(9, null), slot(4, null), slot(1, 'C')], {
|
||||||
|
physicalWagonIdsInOrder: TRAIN,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(drawn.map((w) => [w.physicalWagonId, w.sequenceNo])).toEqual([
|
||||||
|
['C', 1],
|
||||||
|
[null, 4],
|
||||||
|
[null, 9],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to slot order when there is no built train', () => {
|
||||||
|
// Frozen schedules and loose-wagon schedules pass no physical order.
|
||||||
|
const drawn = orderConsistWagons([slot(2, 'X'), slot(1, 'Y')], {
|
||||||
|
physicalWagonIdsInOrder: [],
|
||||||
|
});
|
||||||
|
expect(drawn.map((w) => w.sequenceNo)).toEqual([1, 2]);
|
||||||
|
|
||||||
|
const reversed = orderConsistWagons([slot(1, 'X'), slot(2, 'Y')], {
|
||||||
|
physicalWagonIdsInOrder: [],
|
||||||
|
reverseWagonOrder: true,
|
||||||
|
});
|
||||||
|
expect(reversed.map((w) => w.sequenceNo)).toEqual([2, 1]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
/**
|
||||||
|
* Draw order for a schedule's consist.
|
||||||
|
*
|
||||||
|
* A slot's stored `sequenceNo` is its place in the wagon PLAN, not its place in
|
||||||
|
* the train. The train's real coupling order lives on the physical wagons
|
||||||
|
* (`wagons.sequence_number`), which the caller passes in already ordered — ASC
|
||||||
|
* normally, DESC for a `reverseWagonOrder` schedule.
|
||||||
|
*
|
||||||
|
* Ordering by the physical wagon is what keeps the drawing honest:
|
||||||
|
* - moving a load between wagons repaints WHICH wagon is loaded and never
|
||||||
|
* shuffles the train, because each slot is drawn wherever its wagon sits;
|
||||||
|
* - a train-builder reorder lands on the next read, allocations included,
|
||||||
|
* since the order is derived on every read instead of copied at pin time.
|
||||||
|
*
|
||||||
|
* Slots with no physical wagon (not pinned yet, or a schedule that isn't tied
|
||||||
|
* to a built train) have no place in the consist — they keep slot order, last.
|
||||||
|
*/
|
||||||
|
export interface ConsistOrderable {
|
||||||
|
sequenceNo: number;
|
||||||
|
physicalWagonId?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConsistOrderOptions {
|
||||||
|
/**
|
||||||
|
* Every wagon coupled to the built train, in real coupling order (already
|
||||||
|
* reversed by the caller for a `reverseWagonOrder` schedule). Empty for a
|
||||||
|
* frozen schedule or one with no built train — the consist then keeps slot
|
||||||
|
* order.
|
||||||
|
*/
|
||||||
|
physicalWagonIdsInOrder: string[];
|
||||||
|
reverseWagonOrder?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const orderConsistWagons = <T extends ConsistOrderable>(
|
||||||
|
wagons: T[],
|
||||||
|
{ physicalWagonIdsInOrder, reverseWagonOrder }: ConsistOrderOptions,
|
||||||
|
): (T & { position: number })[] => {
|
||||||
|
const physicalOrder = new Map(physicalWagonIdsInOrder.map((id, index) => [id, index]));
|
||||||
|
const bySlotSequence = (a: T, b: T) =>
|
||||||
|
reverseWagonOrder ? b.sequenceNo - a.sequenceNo : a.sequenceNo - b.sequenceNo;
|
||||||
|
|
||||||
|
const ordered = physicalOrder.size
|
||||||
|
? [...wagons].sort((a, b) => {
|
||||||
|
const ai = a.physicalWagonId ? physicalOrder.get(a.physicalWagonId) : undefined;
|
||||||
|
const bi = b.physicalWagonId ? physicalOrder.get(b.physicalWagonId) : undefined;
|
||||||
|
if (ai == null && bi == null) return bySlotSequence(a, b);
|
||||||
|
if (ai == null) return 1;
|
||||||
|
if (bi == null) return -1;
|
||||||
|
return ai - bi;
|
||||||
|
})
|
||||||
|
: [...wagons].sort(bySlotSequence);
|
||||||
|
|
||||||
|
return ordered.map((wagon, index) => ({ ...wagon, position: index + 1 }));
|
||||||
|
};
|
||||||
@@ -147,6 +147,7 @@ import {
|
|||||||
DEFAULT_CONTAINER_WAGON_LENGTH_METERS,
|
DEFAULT_CONTAINER_WAGON_LENGTH_METERS,
|
||||||
DEFAULT_CONTAINER_WAGON_TARE_TONS,
|
DEFAULT_CONTAINER_WAGON_TARE_TONS,
|
||||||
} from './booking-batch.constants';
|
} from './booking-batch.constants';
|
||||||
|
import { orderConsistWagons } from './consist-order.util';
|
||||||
import {
|
import {
|
||||||
computeExportWindowTimes,
|
computeExportWindowTimes,
|
||||||
computeImportWindowTimes,
|
computeImportWindowTimes,
|
||||||
@@ -6696,6 +6697,18 @@ export class TrainSchedulingService {
|
|||||||
consistOnly: true,
|
consistOnly: true,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// The consist is DRAWN in the built train's real coupling order (rawConsistWagons
|
||||||
|
// is already ASC/DESC per reverseWagonOrder), not in slot order — see
|
||||||
|
// consist-order.util. `position` is the drawn place, 1..n; `sequenceNo` stays
|
||||||
|
// the slot's own stored value.
|
||||||
|
const drawConsist = <T extends { sequenceNo: number; physicalWagonId: string | null }>(
|
||||||
|
list: T[],
|
||||||
|
) =>
|
||||||
|
orderConsistWagons(list, {
|
||||||
|
physicalWagonIdsInOrder: rawConsistWagons.map((wagon) => wagon.id),
|
||||||
|
reverseWagonOrder: schedule.reverseWagonOrder,
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: schedule.id,
|
id: schedule.id,
|
||||||
reference: schedule.reference ?? null,
|
reference: schedule.reference ?? null,
|
||||||
@@ -6785,7 +6798,8 @@ export class TrainSchedulingService {
|
|||||||
maxPullWeightTons: roundTons(Number(loco.maxPullWeightTons)),
|
maxPullWeightTons: roundTons(Number(loco.maxPullWeightTons)),
|
||||||
maxTrainLengthMeters: roundTons(Number(loco.maxTrainLengthMeters)),
|
maxTrainLengthMeters: roundTons(Number(loco.maxTrainLengthMeters)),
|
||||||
})),
|
})),
|
||||||
wagons: (schedule.trainSet.wagons ?? [])
|
wagons: drawConsist(
|
||||||
|
(schedule.trainSet.wagons ?? [])
|
||||||
.map((wagon) => {
|
.map((wagon) => {
|
||||||
// Frozen schedules read the wagon number + allocations from the
|
// Frozen schedules read the wagon number + allocations from the
|
||||||
// snapshot slot; the immutable slot geometry (capacity/type) still
|
// snapshot slot; the immutable slot geometry (capacity/type) still
|
||||||
@@ -6875,12 +6889,8 @@ export class TrainSchedulingService {
|
|||||||
})) ?? [],
|
})) ?? [],
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.concat(emptyConsistWagons)
|
.concat(emptyConsistWagons),
|
||||||
.sort((a, b) =>
|
),
|
||||||
schedule.reverseWagonOrder
|
|
||||||
? b.sequenceNo - a.sequenceNo
|
|
||||||
: a.sequenceNo - b.sequenceNo,
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
bookings:
|
bookings:
|
||||||
@@ -7458,8 +7468,12 @@ export class TrainSchedulingService {
|
|||||||
];
|
];
|
||||||
const cargoOf = (allocs: WagonBookingAllocation[]) =>
|
const cargoOf = (allocs: WagonBookingAllocation[]) =>
|
||||||
allocs.reduce((sum, a) => sum + Number(a.allocatedWeightTons || 0), 0);
|
allocs.reduce((sum, a) => sum + Number(a.allocatedWeightTons || 0), 0);
|
||||||
const wagonLabel = (slot: { sequenceNo: number } | null, wagon: Wagon | null) =>
|
// Name wagons by their physical number — the consist is drawn in the train's
|
||||||
slot ? `#${slot.sequenceNo}` : (wagon?.wagonNumber ?? 'the target wagon');
|
// coupling order, so a slot's sequenceNo is not the position staff can see.
|
||||||
|
const slotLabel = (slot: TrainSetWagon) =>
|
||||||
|
slot.physicalWagon?.wagonNumber ?? `#${slot.sequenceNo}`;
|
||||||
|
const wagonLabel = (slot: TrainSetWagon | null, wagon: Wagon | null) =>
|
||||||
|
slot ? slotLabel(slot) : (wagon?.wagonNumber ?? 'the target wagon');
|
||||||
const checkReceives = (
|
const checkReceives = (
|
||||||
allocs: WagonBookingAllocation[],
|
allocs: WagonBookingAllocation[],
|
||||||
label: string,
|
label: string,
|
||||||
@@ -7501,7 +7515,7 @@ export class TrainSchedulingService {
|
|||||||
if (targetAllocs.length) {
|
if (targetAllocs.length) {
|
||||||
checkReceives(
|
checkReceives(
|
||||||
targetAllocs,
|
targetAllocs,
|
||||||
`#${source.sequenceNo}`,
|
slotLabel(source),
|
||||||
source.wagonType,
|
source.wagonType,
|
||||||
Number(source.capacityTons),
|
Number(source.capacityTons),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { IsOptional, IsString, MaxLength } from 'class-validator';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OCC ends a transfer request with fewer wagons than asked for. The note is
|
||||||
|
* carried into the requester's notification — it is what tells them WHY the
|
||||||
|
* yard could not give the rest.
|
||||||
|
*/
|
||||||
|
export class CloseShortTransferRequestDto {
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'Why the source yard cannot supply the remainder',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(2000)
|
||||||
|
note?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { WagonTransferRequestStatus } from '@edr/types';
|
||||||
|
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { Transform } from 'class-transformer';
|
||||||
|
import { IsIn, IsOptional, IsUUID } from 'class-validator';
|
||||||
|
|
||||||
|
import { PaginationQueryDto } from '../../../common/dto/pagination-query.dto';
|
||||||
|
|
||||||
|
const SORT_FIELDS = ['createdAt', 'quantity', 'status'] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transfer-desk list query. `status` accepts a comma-separated list so the
|
||||||
|
* "Open" tab can ask for PENDING + PARTIALLY_FULFILLED in one call.
|
||||||
|
*/
|
||||||
|
export class ListTransferRequestsQueryDto extends PaginationQueryDto {
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'One status or a comma-separated list',
|
||||||
|
enum: WagonTransferRequestStatus,
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(({ value }) =>
|
||||||
|
typeof value === 'string' && value.trim() ? value.trim() : undefined,
|
||||||
|
)
|
||||||
|
status?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Source yard' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
fromYardId?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Destination yard' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
toYardId?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Wagon type' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
wagonTypeId?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ enum: SORT_FIELDS, default: 'createdAt' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn([...SORT_FIELDS])
|
||||||
|
sortBy?: (typeof SORT_FIELDS)[number];
|
||||||
|
}
|
||||||
@@ -40,6 +40,14 @@ export class WagonTransferRequest extends BaseEntity {
|
|||||||
@Column({ name: 'quantity', type: 'int' })
|
@Column({ name: 'quantity', type: 'int' })
|
||||||
quantity!: number;
|
quantity!: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How many have actually moved so far. OCC sends what the yard can spare,
|
||||||
|
* whenever it can — the request stays open until this reaches `quantity` or
|
||||||
|
* OCC closes it short.
|
||||||
|
*/
|
||||||
|
@Column({ name: 'fulfilled_quantity', type: 'int', default: 0 })
|
||||||
|
fulfilledQuantity!: number;
|
||||||
|
|
||||||
@Column({
|
@Column({
|
||||||
name: 'status',
|
name: 'status',
|
||||||
type: 'varchar',
|
type: 'varchar',
|
||||||
@@ -54,9 +62,17 @@ export class WagonTransferRequest extends BaseEntity {
|
|||||||
@Column({ name: 'fulfilled_by_user_id', type: 'uuid', nullable: true })
|
@Column({ name: 'fulfilled_by_user_id', type: 'uuid', nullable: true })
|
||||||
fulfilledByUserId?: string | null;
|
fulfilledByUserId?: string | null;
|
||||||
|
|
||||||
|
/** When the LAST transfer against this request ran (not necessarily the full count). */
|
||||||
@Column({ name: 'fulfilled_at', type: 'timestamptz', nullable: true })
|
@Column({ name: 'fulfilled_at', type: 'timestamptz', nullable: true })
|
||||||
fulfilledAt?: Date | null;
|
fulfilledAt?: Date | null;
|
||||||
|
|
||||||
|
/** Set when OCC ended the request with fewer wagons than asked for. */
|
||||||
|
@Column({ name: 'closed_short_at', type: 'timestamptz', nullable: true })
|
||||||
|
closedShortAt?: Date | null;
|
||||||
|
|
||||||
|
@Column({ name: 'closed_short_by_user_id', type: 'uuid', nullable: true })
|
||||||
|
closedShortByUserId?: string | null;
|
||||||
|
|
||||||
@Column({ name: 'note', type: 'text', nullable: true })
|
@Column({ name: 'note', type: 'text', nullable: true })
|
||||||
note?: string | null;
|
note?: string | null;
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { WagonTransferRequestStatus } from '@edr/types';
|
|
||||||
import {
|
import {
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
@@ -13,26 +12,36 @@ import { CurrentUser } from '@edr/api-common';
|
|||||||
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
FleetManage,
|
WagonTransferCancel,
|
||||||
FleetView,
|
WagonTransferCloseShort,
|
||||||
WagonTransferFulfill,
|
WagonTransferFulfill,
|
||||||
WagonTransferHistoryAll,
|
WagonTransferHistoryAll,
|
||||||
WagonTransferRequest,
|
WagonTransferRequest,
|
||||||
|
WagonTransferView,
|
||||||
} from '../../common/booking-guards';
|
} from '../../common/booking-guards';
|
||||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
|
||||||
import { BulkFulfillTransferRequestsDto } from './dto/bulk-fulfill-transfer-requests.dto';
|
import { BulkFulfillTransferRequestsDto } from './dto/bulk-fulfill-transfer-requests.dto';
|
||||||
|
import { CloseShortTransferRequestDto } from './dto/close-short-transfer-request.dto';
|
||||||
import { CreateTransferRequestDto } from './dto/create-transfer-request.dto';
|
import { CreateTransferRequestDto } from './dto/create-transfer-request.dto';
|
||||||
import { FulfillTransferRequestDto } from './dto/fulfill-transfer-request.dto';
|
import { FulfillTransferRequestDto } from './dto/fulfill-transfer-request.dto';
|
||||||
|
import { ListTransferRequestsQueryDto } from './dto/list-transfer-requests-query.dto';
|
||||||
import { WagonTransferRequestsService } from './wagon-transfer-requests.service';
|
import { WagonTransferRequestsService } from './wagon-transfer-requests.service';
|
||||||
|
|
||||||
|
/** Query-string number, or undefined when absent/garbage (service defaults it). */
|
||||||
|
const toInt = (value?: string): number | undefined => {
|
||||||
|
const n = Number.parseInt(String(value ?? ''), 10);
|
||||||
|
return Number.isFinite(n) && n > 0 ? n : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Two-person wagon-transfer queue. Requester (transfer_request perm) files a
|
* The wagon-transfer desk. A requester (transfer_request) files a count-only
|
||||||
* count-only request; OCC (transfer_fulfill perm) picks the wagons and executes
|
* request; OCC (transfer_fulfill) moves wagons against it in as many
|
||||||
* the move. Separate top-level path so it never collides with `wagons/:id`.
|
* instalments as the source yard allows, and closes it short
|
||||||
|
* (transfer_close_short) when the yard has no more to give. Separate top-level
|
||||||
|
* path so it never collides with `wagons/:id`.
|
||||||
*/
|
*/
|
||||||
@ApiTags('wagon-transfer-requests')
|
@ApiTags('wagon-transfer-requests')
|
||||||
@Controller('wagon-transfer-requests')
|
@Controller('wagon-transfer-requests')
|
||||||
@FleetView(FREIGHT_PERMS.wagons.view)
|
@WagonTransferView()
|
||||||
export class WagonTransferRequestsController {
|
export class WagonTransferRequestsController {
|
||||||
constructor(private readonly service: WagonTransferRequestsService) {}
|
constructor(private readonly service: WagonTransferRequestsService) {}
|
||||||
|
|
||||||
@@ -47,10 +56,12 @@ export class WagonTransferRequestsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@ApiQuery({ name: 'status', required: false, enum: WagonTransferRequestStatus })
|
@ApiOperation({
|
||||||
@ApiOperation({ summary: 'List transfer requests (OCC queue: status=PENDING)' })
|
summary:
|
||||||
list(@Query('status') status?: WagonTransferRequestStatus) {
|
'Transfer desk list — paginated, filterable by status (comma-separated), yards and wagon type',
|
||||||
return this.service.listRequests(status);
|
})
|
||||||
|
list(@Query() query: ListTransferRequestsQueryDto) {
|
||||||
|
return this.service.listRequests(query);
|
||||||
}
|
}
|
||||||
|
|
||||||
// NOTE: static routes (`history`, `bulk-fulfill`) MUST stay above `@Get(':id')`
|
// NOTE: static routes (`history`, `bulk-fulfill`) MUST stay above `@Get(':id')`
|
||||||
@@ -73,24 +84,48 @@ export class WagonTransferRequestsController {
|
|||||||
// matches in declaration order, so `/history` would otherwise be captured by
|
// matches in declaration order, so `/history` would otherwise be captured by
|
||||||
// the `:id` param route (and rejected by ParseUUIDPipe).
|
// the `:id` param route (and rejected by ParseUUIDPipe).
|
||||||
@Get('history')
|
@Get('history')
|
||||||
|
@ApiQuery({ name: 'page', required: false })
|
||||||
|
@ApiQuery({ name: 'pageSize', required: false })
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Caller's own transfer history (requests filed/fulfilled + wagons moved)",
|
summary: "Caller's own transfer history (requests filed/fulfilled + wagons moved)",
|
||||||
})
|
})
|
||||||
myHistory(@CurrentUser() user: TCurrentUser) {
|
myHistory(
|
||||||
|
@CurrentUser() user: TCurrentUser,
|
||||||
|
@Query('page') page?: string,
|
||||||
|
@Query('pageSize') pageSize?: string,
|
||||||
|
) {
|
||||||
// Never fall through to the all-staff view: getHistory(undefined) means
|
// Never fall through to the all-staff view: getHistory(undefined) means
|
||||||
// "everyone", so a missing caller id must return empty, not leak scope.
|
// "everyone", so a missing caller id must return empty, not leak scope.
|
||||||
if (!user?.id) return { requests: [], movements: [] };
|
if (!user?.id) {
|
||||||
return this.service.getHistory(user.id);
|
return {
|
||||||
|
requests: [],
|
||||||
|
movements: [],
|
||||||
|
meta: {
|
||||||
|
page: 1,
|
||||||
|
pageSize: 20,
|
||||||
|
requestsTotal: 0,
|
||||||
|
movementsTotal: 0,
|
||||||
|
totalPages: 1,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return this.service.getHistory(user.id, toInt(page), toInt(pageSize));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('history/all')
|
@Get('history/all')
|
||||||
@WagonTransferHistoryAll()
|
@WagonTransferHistoryAll()
|
||||||
@ApiQuery({ name: 'userId', required: false })
|
@ApiQuery({ name: 'userId', required: false })
|
||||||
|
@ApiQuery({ name: 'page', required: false })
|
||||||
|
@ApiQuery({ name: 'pageSize', required: false })
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Admin: any/all staff's transfer history (optional ?userId filter)",
|
summary: "Admin: any/all staff's transfer history (optional ?userId filter)",
|
||||||
})
|
})
|
||||||
allHistory(@Query('userId') userId?: string) {
|
allHistory(
|
||||||
return this.service.getHistory(userId);
|
@Query('userId') userId?: string,
|
||||||
|
@Query('page') page?: string,
|
||||||
|
@Query('pageSize') pageSize?: string,
|
||||||
|
) {
|
||||||
|
return this.service.getHistory(userId, toInt(page), toInt(pageSize));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
@@ -110,9 +145,26 @@ export class WagonTransferRequestsController {
|
|||||||
return this.service.fulfillRequest(id, dto, user?.id);
|
return this.service.fulfillRequest(id, dto, user?.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post(':id/close-short')
|
||||||
|
@WagonTransferCloseShort()
|
||||||
|
@ApiOperation({
|
||||||
|
summary:
|
||||||
|
'OCC: end the request with fewer wagons than asked for — what moved stays, the requester is told the shortfall',
|
||||||
|
})
|
||||||
|
closeShort(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body() dto: CloseShortTransferRequestDto,
|
||||||
|
@CurrentUser() user: TCurrentUser,
|
||||||
|
) {
|
||||||
|
return this.service.closeShort(id, dto, user?.id);
|
||||||
|
}
|
||||||
|
|
||||||
@Post(':id/cancel')
|
@Post(':id/cancel')
|
||||||
@FleetManage(FREIGHT_PERMS.wagons.transferRequest)
|
@WagonTransferCancel()
|
||||||
@ApiOperation({ summary: 'Withdraw a pending transfer request' })
|
@ApiOperation({
|
||||||
|
summary:
|
||||||
|
'Withdraw a request that has not moved any wagon yet (use close-short once wagons have moved)',
|
||||||
|
})
|
||||||
cancel(@Param('id', ParseUUIDPipe) id: string) {
|
cancel(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
return this.service.cancelRequest(id);
|
return this.service.cancelRequest(id);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,235 @@
|
|||||||
|
import { WagonTransferRequestStatus } from '@edr/types';
|
||||||
|
import { ConflictException, BadRequestException } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { WagonTransferRequestsService } from './wagon-transfer-requests.service';
|
||||||
|
import type { WagonTransferRequest } from './entities/wagon-transfer-request.entity';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Instalment fulfilment: a request for 50 wagons is met with whatever the source
|
||||||
|
* yard can spare, whenever it can spare it. It stays open until the full count
|
||||||
|
* lands or OCC closes it short — which is what tells the requester to go ask
|
||||||
|
* another yard.
|
||||||
|
*/
|
||||||
|
describe('WagonTransferRequestsService — partial fulfilment', () => {
|
||||||
|
const request = (over: Partial<WagonTransferRequest> = {}): WagonTransferRequest =>
|
||||||
|
({
|
||||||
|
id: 'req-1',
|
||||||
|
fromYardId: 'yard-a',
|
||||||
|
toYardId: 'yard-b',
|
||||||
|
wagonTypeId: 'type-1',
|
||||||
|
quantity: 50,
|
||||||
|
fulfilledQuantity: 0,
|
||||||
|
status: WagonTransferRequestStatus.Pending,
|
||||||
|
requestedByUserId: 'user-1',
|
||||||
|
...over,
|
||||||
|
}) as WagonTransferRequest;
|
||||||
|
|
||||||
|
let requestRepo: {
|
||||||
|
findOne: jest.Mock;
|
||||||
|
find: jest.Mock;
|
||||||
|
save: jest.Mock;
|
||||||
|
create: jest.Mock;
|
||||||
|
createQueryBuilder: jest.Mock;
|
||||||
|
};
|
||||||
|
let wagonRepo: { find: jest.Mock; count: jest.Mock };
|
||||||
|
let wagonsService: { bulkTransfer: jest.Mock };
|
||||||
|
let inbox: { notify: jest.Mock };
|
||||||
|
let service: WagonTransferRequestsService;
|
||||||
|
let stored: WagonTransferRequest;
|
||||||
|
|
||||||
|
const flush = () => new Promise((resolve) => setImmediate(resolve));
|
||||||
|
|
||||||
|
const build = (row: WagonTransferRequest) => {
|
||||||
|
stored = row;
|
||||||
|
requestRepo.findOne.mockImplementation(async () => stored);
|
||||||
|
requestRepo.save.mockImplementation(async (r: WagonTransferRequest) => {
|
||||||
|
stored = r;
|
||||||
|
return r;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
requestRepo = {
|
||||||
|
findOne: jest.fn(),
|
||||||
|
find: jest.fn().mockResolvedValue([]),
|
||||||
|
save: jest.fn(),
|
||||||
|
create: jest.fn((r) => r),
|
||||||
|
createQueryBuilder: jest.fn(),
|
||||||
|
};
|
||||||
|
wagonRepo = { find: jest.fn().mockResolvedValue([]), count: jest.fn() };
|
||||||
|
wagonsService = { bulkTransfer: jest.fn().mockResolvedValue(undefined) };
|
||||||
|
inbox = { notify: jest.fn().mockResolvedValue(undefined) };
|
||||||
|
service = new WagonTransferRequestsService(
|
||||||
|
requestRepo as never,
|
||||||
|
wagonRepo as never,
|
||||||
|
{ find: jest.fn(), findAndCount: jest.fn() } as never,
|
||||||
|
wagonsService as never,
|
||||||
|
inbox as never,
|
||||||
|
);
|
||||||
|
build(request());
|
||||||
|
});
|
||||||
|
|
||||||
|
const availableWagons = (n: number) =>
|
||||||
|
Array.from({ length: n }, (_, i) => ({
|
||||||
|
id: `w-${i}`,
|
||||||
|
wagonNumber: `100${i}`,
|
||||||
|
currentYardId: 'yard-a',
|
||||||
|
wagonTypeId: 'type-1',
|
||||||
|
status: 'AVAILABLE',
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('fulfillRequest', () => {
|
||||||
|
it('books an instalment and keeps the request open', async () => {
|
||||||
|
wagonRepo.find.mockResolvedValue(availableWagons(20));
|
||||||
|
|
||||||
|
await service.fulfillRequest('req-1', {
|
||||||
|
wagonIds: availableWagons(20).map((w) => w.id),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(stored.fulfilledQuantity).toBe(20);
|
||||||
|
expect(stored.status).toBe(WagonTransferRequestStatus.PartiallyFulfilled);
|
||||||
|
expect(wagonsService.bulkTransfer).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('completes the request when the last instalment lands', async () => {
|
||||||
|
build(request({ fulfilledQuantity: 30, status: WagonTransferRequestStatus.PartiallyFulfilled }));
|
||||||
|
wagonRepo.find.mockResolvedValue(availableWagons(20));
|
||||||
|
|
||||||
|
await service.fulfillRequest('req-1', {
|
||||||
|
wagonIds: availableWagons(20).map((w) => w.id),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(stored.fulfilledQuantity).toBe(50);
|
||||||
|
expect(stored.status).toBe(WagonTransferRequestStatus.Fulfilled);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses to move more than is still owed', async () => {
|
||||||
|
build(request({ fulfilledQuantity: 45, status: WagonTransferRequestStatus.PartiallyFulfilled }));
|
||||||
|
wagonRepo.find.mockResolvedValue(availableWagons(10));
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.fulfillRequest('req-1', {
|
||||||
|
wagonIds: availableWagons(10).map((w) => w.id),
|
||||||
|
}),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
expect(wagonsService.bulkTransfer).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses to touch a request that is already closed', async () => {
|
||||||
|
build(request({ status: WagonTransferRequestStatus.ClosedShort, fulfilledQuantity: 20 }));
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.fulfillRequest('req-1', { wagonIds: ['w-0'] }),
|
||||||
|
).rejects.toBeInstanceOf(ConflictException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tells the requester what landed and what is still owed', async () => {
|
||||||
|
wagonRepo.find.mockResolvedValue(availableWagons(20));
|
||||||
|
|
||||||
|
await service.fulfillRequest('req-1', {
|
||||||
|
wagonIds: availableWagons(20).map((w) => w.id),
|
||||||
|
});
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
const sent = inbox.notify.mock.calls[0][0];
|
||||||
|
expect(sent.recipients).toEqual({ userIds: ['user-1'] });
|
||||||
|
expect(sent.body).toContain('20 wagon(s) have arrived');
|
||||||
|
expect(sent.body).toContain('30 of 50 still to come');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('bulkFulfill', () => {
|
||||||
|
it('sends what the yard has instead of skipping a short request', async () => {
|
||||||
|
wagonRepo.find.mockResolvedValue(availableWagons(20));
|
||||||
|
|
||||||
|
const result = await service.bulkFulfill(['req-1']);
|
||||||
|
|
||||||
|
expect(stored.fulfilledQuantity).toBe(20);
|
||||||
|
expect(stored.status).toBe(WagonTransferRequestStatus.PartiallyFulfilled);
|
||||||
|
expect(result.skipped).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips only when the yard has nothing to give', async () => {
|
||||||
|
wagonRepo.find.mockResolvedValue([]);
|
||||||
|
|
||||||
|
const result = await service.bulkFulfill(['req-1']);
|
||||||
|
|
||||||
|
expect(wagonsService.bulkTransfer).not.toHaveBeenCalled();
|
||||||
|
expect(result.skipped[0].reason).toContain('No available wagons');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('closeShort', () => {
|
||||||
|
it('ends the request and tells the requester to ask another yard', async () => {
|
||||||
|
build(request({ fulfilledQuantity: 20, status: WagonTransferRequestStatus.PartiallyFulfilled }));
|
||||||
|
|
||||||
|
await service.closeShort('req-1', { note: 'Yard is empty until Friday' });
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
expect(stored.status).toBe(WagonTransferRequestStatus.ClosedShort);
|
||||||
|
expect(stored.closedShortAt).toBeInstanceOf(Date);
|
||||||
|
const sent = inbox.notify.mock.calls[0][0];
|
||||||
|
expect(sent.body).toContain('Only 20 of the 50');
|
||||||
|
expect(sent.body).toContain('Yard is empty until Friday');
|
||||||
|
expect(sent.body).toContain('Request the remaining 30');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses when the request is already fully supplied', async () => {
|
||||||
|
build(request({ fulfilledQuantity: 50, status: WagonTransferRequestStatus.PartiallyFulfilled }));
|
||||||
|
|
||||||
|
await expect(service.closeShort('req-1', {})).rejects.toBeInstanceOf(
|
||||||
|
ConflictException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('cancelRequest', () => {
|
||||||
|
it('withdraws a request that never moved a wagon', async () => {
|
||||||
|
await service.cancelRequest('req-1');
|
||||||
|
expect(stored.status).toBe(WagonTransferRequestStatus.Cancelled);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses once wagons have moved — close it short instead', async () => {
|
||||||
|
build(request({ fulfilledQuantity: 20, status: WagonTransferRequestStatus.PartiallyFulfilled }));
|
||||||
|
|
||||||
|
await expect(service.cancelRequest('req-1')).rejects.toThrow(
|
||||||
|
/close it short/i,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createRequest', () => {
|
||||||
|
it('accepts a count larger than what the yard holds today', async () => {
|
||||||
|
wagonRepo.count.mockResolvedValue(20);
|
||||||
|
|
||||||
|
await service.createRequest(
|
||||||
|
{
|
||||||
|
fromYardId: 'yard-a',
|
||||||
|
toYardId: 'yard-b',
|
||||||
|
wagonTypeId: 'type-1',
|
||||||
|
quantity: 50,
|
||||||
|
reason: 'Grain campaign',
|
||||||
|
},
|
||||||
|
'user-1',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(requestRepo.save).toHaveBeenCalled();
|
||||||
|
expect(stored.quantity).toBe(50);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still refuses a same-yard move', async () => {
|
||||||
|
await expect(
|
||||||
|
service.createRequest(
|
||||||
|
{
|
||||||
|
fromYardId: 'yard-a',
|
||||||
|
toYardId: 'yard-a',
|
||||||
|
wagonTypeId: 'type-1',
|
||||||
|
quantity: 5,
|
||||||
|
reason: 'x',
|
||||||
|
},
|
||||||
|
'user-1',
|
||||||
|
),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,15 +1,27 @@
|
|||||||
import { WagonStatus, WagonTransferRequestStatus } from '@edr/types';
|
import {
|
||||||
|
NotificationAudience,
|
||||||
|
NotificationType,
|
||||||
|
OPEN_WAGON_TRANSFER_STATUSES,
|
||||||
|
PaginatedResponse,
|
||||||
|
WagonStatus,
|
||||||
|
WagonTransferRequestStatus,
|
||||||
|
} from '@edr/types';
|
||||||
import {
|
import {
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
ConflictException,
|
ConflictException,
|
||||||
Injectable,
|
Injectable,
|
||||||
|
Logger,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { In, IsNull, Not, Repository } from 'typeorm';
|
import { In, IsNull, Not, Repository } from 'typeorm';
|
||||||
|
|
||||||
|
import { paginateQuery } from '../../common/utils/pagination.util';
|
||||||
|
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
|
||||||
|
import { CloseShortTransferRequestDto } from './dto/close-short-transfer-request.dto';
|
||||||
import { CreateTransferRequestDto } from './dto/create-transfer-request.dto';
|
import { CreateTransferRequestDto } from './dto/create-transfer-request.dto';
|
||||||
import { FulfillTransferRequestDto } from './dto/fulfill-transfer-request.dto';
|
import { FulfillTransferRequestDto } from './dto/fulfill-transfer-request.dto';
|
||||||
|
import { ListTransferRequestsQueryDto } from './dto/list-transfer-requests-query.dto';
|
||||||
import { Wagon } from './entities/wagon.entity';
|
import { Wagon } from './entities/wagon.entity';
|
||||||
import { WagonMovement } from './entities/wagon-movement.entity';
|
import { WagonMovement } from './entities/wagon-movement.entity';
|
||||||
import { WagonTransferRequest } from './entities/wagon-transfer-request.entity';
|
import { WagonTransferRequest } from './entities/wagon-transfer-request.entity';
|
||||||
@@ -19,10 +31,21 @@ import { WagonsService } from './wagons.service';
|
|||||||
export interface TransferHistory {
|
export interface TransferHistory {
|
||||||
requests: WagonTransferRequest[];
|
requests: WagonTransferRequest[];
|
||||||
movements: WagonMovement[];
|
movements: WagonMovement[];
|
||||||
|
/**
|
||||||
|
* One pager drives both lists (they are shown side by side), so it carries a
|
||||||
|
* total per list and the page count of the longer one.
|
||||||
|
*/
|
||||||
|
meta: {
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
requestsTotal: number;
|
||||||
|
movementsTotal: number;
|
||||||
|
totalPages: number;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** How many ledger rows the history returns at most (newest first). */
|
/** Hard ceiling on a single history page, whatever the client asks for. */
|
||||||
const HISTORY_LIMIT = 500;
|
const HISTORY_LIMIT = 100;
|
||||||
|
|
||||||
const REQUEST_RELATIONS = {
|
const REQUEST_RELATIONS = {
|
||||||
fromYard: true,
|
fromYard: true,
|
||||||
@@ -38,6 +61,8 @@ const REQUEST_RELATIONS = {
|
|||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class WagonTransferRequestsService {
|
export class WagonTransferRequestsService {
|
||||||
|
private readonly logger = new Logger(WagonTransferRequestsService.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@InjectRepository(WagonTransferRequest)
|
@InjectRepository(WagonTransferRequest)
|
||||||
private readonly requestRepo: Repository<WagonTransferRequest>,
|
private readonly requestRepo: Repository<WagonTransferRequest>,
|
||||||
@@ -46,13 +71,14 @@ export class WagonTransferRequestsService {
|
|||||||
@InjectRepository(WagonMovement)
|
@InjectRepository(WagonMovement)
|
||||||
private readonly movementRepo: Repository<WagonMovement>,
|
private readonly movementRepo: Repository<WagonMovement>,
|
||||||
private readonly wagonsService: WagonsService,
|
private readonly wagonsService: WagonsService,
|
||||||
|
private readonly inbox: NotificationInboxService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Record a PENDING request. Count-only — no wagons are picked here, but the
|
* Record a PENDING request. Count-only — no wagons are picked here, and the
|
||||||
* count is capped at the AVAILABLE wagons of that type currently sitting in
|
* count is NOT capped by what the source yard holds today: OCC fulfils in
|
||||||
* the source yard: staff may only ask for wagons that are actually there to
|
* instalments, so asking for 50 while only 20 sit there is a normal, useful
|
||||||
* give. A reason is mandatory and is shown on the OCC queue.
|
* request. A reason is mandatory and is shown on the OCC queue.
|
||||||
*/
|
*/
|
||||||
async createRequest(
|
async createRequest(
|
||||||
dto: CreateTransferRequestDto,
|
dto: CreateTransferRequestDto,
|
||||||
@@ -63,14 +89,6 @@ export class WagonTransferRequestsService {
|
|||||||
'Source and destination yard must be different',
|
'Source and destination yard must be different',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const available = await this.countAvailable(dto.fromYardId, dto.wagonTypeId);
|
|
||||||
if (available < dto.quantity) {
|
|
||||||
throw new BadRequestException(
|
|
||||||
available === 0
|
|
||||||
? 'No available wagons of this type in the source yard'
|
|
||||||
: `Only ${available} available wagon(s) of this type in the source yard — request at most ${available}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const request = this.requestRepo.create({
|
const request = this.requestRepo.create({
|
||||||
fromYardId: dto.fromYardId,
|
fromYardId: dto.fromYardId,
|
||||||
toYardId: dto.toYardId,
|
toYardId: dto.toYardId,
|
||||||
@@ -85,8 +103,12 @@ export class WagonTransferRequestsService {
|
|||||||
return this.findById(saved.id);
|
return this.findById(saved.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** AVAILABLE wagons of `wagonTypeId` currently in `yardId`. */
|
/**
|
||||||
private countAvailable(yardId: string, wagonTypeId: string): Promise<number> {
|
* AVAILABLE wagons of `wagonTypeId` currently in `yardId` — what OCC can move
|
||||||
|
* right now. Shown on the desk beside the outstanding count so staff see at a
|
||||||
|
* glance how much of a request the yard can cover today.
|
||||||
|
*/
|
||||||
|
countAvailable(yardId: string, wagonTypeId: string): Promise<number> {
|
||||||
return this.wagonRepo.count({
|
return this.wagonRepo.count({
|
||||||
where: {
|
where: {
|
||||||
currentYardId: yardId,
|
currentYardId: yardId,
|
||||||
@@ -96,15 +118,51 @@ export class WagonTransferRequestsService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Requests, newest first, optionally filtered by status (OCC queue = PENDING). */
|
/**
|
||||||
|
* The transfer desk list: paginated, newest first, filterable by status (one
|
||||||
|
* or a comma-separated set — the "Open" tab asks for PENDING +
|
||||||
|
* PARTIALLY_FULFILLED), yards and wagon type. Search matches the reason text.
|
||||||
|
*/
|
||||||
async listRequests(
|
async listRequests(
|
||||||
status?: WagonTransferRequestStatus,
|
query: ListTransferRequestsQueryDto,
|
||||||
): Promise<WagonTransferRequest[]> {
|
): Promise<PaginatedResponse<WagonTransferRequest>> {
|
||||||
return this.requestRepo.find({
|
const qb = this.requestRepo
|
||||||
where: status ? { status } : {},
|
.createQueryBuilder('r')
|
||||||
relations: REQUEST_RELATIONS,
|
.leftJoinAndSelect('r.fromYard', 'fromYard')
|
||||||
order: { createdAt: 'DESC' },
|
.leftJoinAndSelect('r.toYard', 'toYard')
|
||||||
});
|
.leftJoinAndSelect('r.wagonType', 'wagonType');
|
||||||
|
|
||||||
|
const statuses = (query.status ?? '')
|
||||||
|
.split(',')
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
if (statuses.length) {
|
||||||
|
qb.andWhere('r.status IN (:...statuses)', { statuses });
|
||||||
|
}
|
||||||
|
if (query.fromYardId) {
|
||||||
|
qb.andWhere('r.from_yard_id = :fromYardId', { fromYardId: query.fromYardId });
|
||||||
|
}
|
||||||
|
if (query.toYardId) {
|
||||||
|
qb.andWhere('r.to_yard_id = :toYardId', { toYardId: query.toYardId });
|
||||||
|
}
|
||||||
|
if (query.wagonTypeId) {
|
||||||
|
qb.andWhere('r.wagon_type_id = :wagonTypeId', {
|
||||||
|
wagonTypeId: query.wagonTypeId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (query.search) {
|
||||||
|
qb.andWhere('r.reason ILIKE :search', { search: `%${query.search}%` });
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortColumn =
|
||||||
|
query.sortBy === 'quantity'
|
||||||
|
? 'r.quantity'
|
||||||
|
: query.sortBy === 'status'
|
||||||
|
? 'r.status'
|
||||||
|
: 'r.created_at';
|
||||||
|
qb.orderBy(sortColumn, query.sortOrder ?? 'DESC');
|
||||||
|
|
||||||
|
return paginateQuery(qb, { page: query.page, pageSize: query.pageSize });
|
||||||
}
|
}
|
||||||
|
|
||||||
async findById(id: string): Promise<WagonTransferRequest> {
|
async findById(id: string): Promise<WagonTransferRequest> {
|
||||||
@@ -116,11 +174,23 @@ export class WagonTransferRequestsService {
|
|||||||
return request;
|
return request;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Wagons still owed on an open request. */
|
||||||
|
private remainingOn(request: WagonTransferRequest): number {
|
||||||
|
return Math.max(0, request.quantity - (request.fulfilledQuantity ?? 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True while OCC can still move wagons against this request. */
|
||||||
|
private isOpen(request: WagonTransferRequest): boolean {
|
||||||
|
return OPEN_WAGON_TRANSFER_STATUSES.includes(request.status);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* OCC fulfils a PENDING request with hand-picked wagons. Every wagon must sit
|
* OCC moves hand-picked wagons against an open request. Any number from 1 up
|
||||||
* in the request's source yard, match its wagon type, and the count must equal
|
* to whatever is still owed — the yard rarely has the whole ask at once, so a
|
||||||
* the requested quantity — then the transfer runs and the request is marked
|
* request for 50 can be met 20 now, 30 later. Every wagon must sit in the
|
||||||
* FULFILLED.
|
* source yard, match the type and be available. The request completes on its
|
||||||
|
* own once the full count has moved; short of that it stays open as
|
||||||
|
* PARTIALLY_FULFILLED and the requester is told what landed.
|
||||||
*/
|
*/
|
||||||
async fulfillRequest(
|
async fulfillRequest(
|
||||||
id: string,
|
id: string,
|
||||||
@@ -128,16 +198,17 @@ export class WagonTransferRequestsService {
|
|||||||
userId?: string | null,
|
userId?: string | null,
|
||||||
): Promise<WagonTransferRequest> {
|
): Promise<WagonTransferRequest> {
|
||||||
const request = await this.findById(id);
|
const request = await this.findById(id);
|
||||||
if (request.status !== WagonTransferRequestStatus.Pending) {
|
if (!this.isOpen(request)) {
|
||||||
throw new ConflictException(
|
throw new ConflictException(
|
||||||
`Request is already ${request.status.toLowerCase()}`,
|
`Request is already ${request.status.toLowerCase().replace(/_/g, ' ')}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const wagonIds = [...new Set(dto.wagonIds)];
|
const wagonIds = [...new Set(dto.wagonIds)];
|
||||||
if (wagonIds.length !== request.quantity) {
|
const remaining = this.remainingOn(request);
|
||||||
|
if (wagonIds.length > remaining) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
`Select exactly ${request.quantity} wagon(s); you selected ${wagonIds.length}`,
|
`Only ${remaining} wagon(s) still owed on this request; you selected ${wagonIds.length}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,21 +249,124 @@ export class WagonTransferRequestsService {
|
|||||||
{ transferRequestId: request.id },
|
{ transferRequestId: request.id },
|
||||||
);
|
);
|
||||||
|
|
||||||
request.status = WagonTransferRequestStatus.Fulfilled;
|
await this.recordDelivery(request, wagonIds.length, userId);
|
||||||
request.fulfilledByUserId = userId ?? null;
|
|
||||||
request.fulfilledAt = new Date();
|
|
||||||
await this.requestRepo.save(request);
|
|
||||||
return this.findById(id);
|
return this.findById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* OCC accepts AND executes a subset of pending requests in one action. For
|
* Book an instalment against a request: bump the delivered count, complete it
|
||||||
* each selected request the system auto-picks the required number of
|
* when the full ask has landed, and tell the requester what moved. Shared by
|
||||||
* AVAILABLE wagons of the requested type from the source yard (lowest wagon
|
* the hand-picked and auto-picked (bulk) fulfilment paths.
|
||||||
* number first) and runs the audited transfer. A request that cannot be
|
*/
|
||||||
* executed — already decided, or not enough available wagons left after the
|
private async recordDelivery(
|
||||||
* ones processed before it — is SKIPPED and simply stays PENDING, visible to
|
request: WagonTransferRequest,
|
||||||
* both teams; nothing is rolled back for the others.
|
moved: number,
|
||||||
|
userId?: string | null,
|
||||||
|
): Promise<void> {
|
||||||
|
request.fulfilledQuantity = (request.fulfilledQuantity ?? 0) + moved;
|
||||||
|
request.status =
|
||||||
|
request.fulfilledQuantity >= request.quantity
|
||||||
|
? WagonTransferRequestStatus.Fulfilled
|
||||||
|
: WagonTransferRequestStatus.PartiallyFulfilled;
|
||||||
|
request.fulfilledByUserId = userId ?? null;
|
||||||
|
request.fulfilledAt = new Date();
|
||||||
|
await this.requestRepo.save(request);
|
||||||
|
this.notifyRequester(request, moved);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tell the requester what landed. Fire-and-forget: a notification failure must
|
||||||
|
* never undo a transfer that already moved wagons.
|
||||||
|
*/
|
||||||
|
private notifyRequester(
|
||||||
|
request: WagonTransferRequest,
|
||||||
|
moved: number,
|
||||||
|
closedShortNote?: string | null,
|
||||||
|
): void {
|
||||||
|
if (!request.requestedByUserId) return;
|
||||||
|
const outstanding = this.remainingOn(request);
|
||||||
|
const complete = request.status === WagonTransferRequestStatus.Fulfilled;
|
||||||
|
const closedShort =
|
||||||
|
request.status === WagonTransferRequestStatus.ClosedShort;
|
||||||
|
|
||||||
|
const title = complete
|
||||||
|
? `All ${request.quantity} wagon(s) transferred`
|
||||||
|
: closedShort
|
||||||
|
? `Transfer closed short — ${request.fulfilledQuantity} of ${request.quantity} wagon(s)`
|
||||||
|
: `${moved} of ${request.quantity} wagon(s) transferred`;
|
||||||
|
|
||||||
|
const body = complete
|
||||||
|
? `Your wagon transfer request is complete — all ${request.quantity} wagon(s) have arrived.`
|
||||||
|
: closedShort
|
||||||
|
? `Only ${request.fulfilledQuantity} of the ${request.quantity} wagon(s) you asked for could be supplied` +
|
||||||
|
`${closedShortNote ? `: ${closedShortNote}` : '.'} ` +
|
||||||
|
`Request the remaining ${outstanding} from another yard.`
|
||||||
|
: `${moved} wagon(s) have arrived against your request. ` +
|
||||||
|
`${outstanding} of ${request.quantity} still to come.`;
|
||||||
|
|
||||||
|
void this.inbox
|
||||||
|
.notify({
|
||||||
|
recipients: { userIds: [request.requestedByUserId] },
|
||||||
|
audience: NotificationAudience.BACKOFFICE,
|
||||||
|
type: NotificationType.GENERIC,
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
link: `/dashboard/wagon-transfers/${request.id}`,
|
||||||
|
data: {
|
||||||
|
transferRequestId: request.id,
|
||||||
|
delivered: request.fulfilledQuantity,
|
||||||
|
requested: request.quantity,
|
||||||
|
outstanding,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.catch((err) =>
|
||||||
|
this.logger.warn(
|
||||||
|
`Transfer notification failed for ${request.id}: ${(err as Error).message}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OCC ends a request with fewer wagons than asked for — the source yard has
|
||||||
|
* nothing more to give. What already moved stays moved; the requester is told
|
||||||
|
* the shortfall so they can raise it against another yard. Cancelling is for
|
||||||
|
* requests that never moved anything; this is the close for ones that did.
|
||||||
|
*/
|
||||||
|
async closeShort(
|
||||||
|
id: string,
|
||||||
|
dto: CloseShortTransferRequestDto,
|
||||||
|
userId?: string | null,
|
||||||
|
): Promise<WagonTransferRequest> {
|
||||||
|
const request = await this.findById(id);
|
||||||
|
if (!this.isOpen(request)) {
|
||||||
|
throw new ConflictException(
|
||||||
|
`Request is already ${request.status.toLowerCase().replace(/_/g, ' ')}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (this.remainingOn(request) === 0) {
|
||||||
|
throw new ConflictException(
|
||||||
|
'Nothing outstanding — this request is already fully supplied',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
request.status = WagonTransferRequestStatus.ClosedShort;
|
||||||
|
request.closedShortAt = new Date();
|
||||||
|
request.closedShortByUserId = userId ?? null;
|
||||||
|
if (dto.note?.trim()) {
|
||||||
|
request.note = dto.note.trim();
|
||||||
|
}
|
||||||
|
await this.requestRepo.save(request);
|
||||||
|
this.notifyRequester(request, 0, dto.note ?? null);
|
||||||
|
return this.findById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OCC executes a set of open requests in one action, auto-picking AVAILABLE
|
||||||
|
* wagons of the requested type from each source yard (lowest wagon number
|
||||||
|
* first). A yard that cannot cover the whole ask still sends what it has —
|
||||||
|
* the request stays open for the rest rather than being skipped, which is the
|
||||||
|
* whole point of instalments. Only a request with NOTHING available is
|
||||||
|
* skipped, and nothing is rolled back for the others.
|
||||||
*/
|
*/
|
||||||
async bulkFulfill(
|
async bulkFulfill(
|
||||||
requestIds: string[],
|
requestIds: string[],
|
||||||
@@ -212,13 +386,14 @@ export class WagonTransferRequestsService {
|
|||||||
skipped.push({ id, reason: 'Request not found' });
|
skipped.push({ id, reason: 'Request not found' });
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (request.status !== WagonTransferRequestStatus.Pending) {
|
if (!this.isOpen(request)) {
|
||||||
skipped.push({
|
skipped.push({
|
||||||
id,
|
id,
|
||||||
reason: `Already ${request.status.toLowerCase()}`,
|
reason: `Already ${request.status.toLowerCase().replace(/_/g, ' ')}`,
|
||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
const remaining = this.remainingOn(request);
|
||||||
const wagons = await this.wagonRepo.find({
|
const wagons = await this.wagonRepo.find({
|
||||||
where: {
|
where: {
|
||||||
currentYardId: request.fromYardId,
|
currentYardId: request.fromYardId,
|
||||||
@@ -226,12 +401,12 @@ export class WagonTransferRequestsService {
|
|||||||
status: WagonStatus.Available,
|
status: WagonStatus.Available,
|
||||||
},
|
},
|
||||||
order: { wagonNumber: 'ASC' },
|
order: { wagonNumber: 'ASC' },
|
||||||
take: request.quantity,
|
take: remaining,
|
||||||
});
|
});
|
||||||
if (wagons.length < request.quantity) {
|
if (wagons.length === 0) {
|
||||||
skipped.push({
|
skipped.push({
|
||||||
id,
|
id,
|
||||||
reason: `Only ${wagons.length} of ${request.quantity} wagon(s) available in the source yard — left pending`,
|
reason: 'No available wagons of this type in the source yard — left open',
|
||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -240,10 +415,7 @@ export class WagonTransferRequestsService {
|
|||||||
userId,
|
userId,
|
||||||
{ transferRequestId: request.id },
|
{ transferRequestId: request.id },
|
||||||
);
|
);
|
||||||
request.status = WagonTransferRequestStatus.Fulfilled;
|
await this.recordDelivery(request, wagons.length, userId);
|
||||||
request.fulfilledByUserId = userId ?? null;
|
|
||||||
request.fulfilledAt = new Date();
|
|
||||||
await this.requestRepo.save(request);
|
|
||||||
fulfilled.push(await this.findById(id));
|
fulfilled.push(await this.findById(id));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -258,17 +430,25 @@ export class WagonTransferRequestsService {
|
|||||||
* (the controller passes the caller's id unless they hold the history-all
|
* (the controller passes the caller's id unless they hold the history-all
|
||||||
* permission) — this method trusts its argument.
|
* permission) — this method trusts its argument.
|
||||||
*/
|
*/
|
||||||
async getHistory(userId?: string | null): Promise<TransferHistory> {
|
async getHistory(
|
||||||
const requests = await this.requestRepo.find({
|
userId?: string | null,
|
||||||
|
page?: number,
|
||||||
|
pageSize?: number,
|
||||||
|
): Promise<TransferHistory> {
|
||||||
|
const take = Math.min(pageSize ?? 20, HISTORY_LIMIT);
|
||||||
|
const skip = ((page ?? 1) - 1) * take;
|
||||||
|
|
||||||
|
const [requests, requestsTotal] = await this.requestRepo.findAndCount({
|
||||||
where: userId
|
where: userId
|
||||||
? [{ requestedByUserId: userId }, { fulfilledByUserId: userId }]
|
? [{ requestedByUserId: userId }, { fulfilledByUserId: userId }]
|
||||||
: {},
|
: {},
|
||||||
relations: REQUEST_RELATIONS,
|
relations: REQUEST_RELATIONS,
|
||||||
order: { createdAt: 'DESC' },
|
order: { createdAt: 'DESC' },
|
||||||
take: HISTORY_LIMIT,
|
skip,
|
||||||
|
take,
|
||||||
});
|
});
|
||||||
|
|
||||||
const movements = await this.movementRepo.find({
|
const [movements, movementsTotal] = await this.movementRepo.findAndCount({
|
||||||
// Own view: moves I made. All view: every user-attributed move (skip the
|
// Own view: moves I made. All view: every user-attributed move (skip the
|
||||||
// system-written loaded/reposition legs that carry no mover).
|
// system-written loaded/reposition legs that carry no mover).
|
||||||
where: userId
|
where: userId
|
||||||
@@ -276,18 +456,39 @@ export class WagonTransferRequestsService {
|
|||||||
: { movedByUserId: Not(IsNull()) },
|
: { movedByUserId: Not(IsNull()) },
|
||||||
relations: { wagon: true, fromYard: true, toYard: true, transferRequest: true },
|
relations: { wagon: true, fromYard: true, toYard: true, transferRequest: true },
|
||||||
order: { occurredAt: 'DESC' },
|
order: { occurredAt: 'DESC' },
|
||||||
take: HISTORY_LIMIT,
|
skip,
|
||||||
|
take,
|
||||||
});
|
});
|
||||||
|
|
||||||
return { requests, movements };
|
return {
|
||||||
|
requests,
|
||||||
|
movements,
|
||||||
|
meta: {
|
||||||
|
page: page ?? 1,
|
||||||
|
pageSize: take,
|
||||||
|
requestsTotal,
|
||||||
|
movementsTotal,
|
||||||
|
// Whichever list is longer decides how far the pager can go.
|
||||||
|
totalPages: Math.max(
|
||||||
|
1,
|
||||||
|
Math.ceil(Math.max(requestsTotal, movementsTotal) / take),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Withdraw a still-PENDING request. */
|
/**
|
||||||
|
* Withdraw a request before anything moved. Once wagons have been delivered
|
||||||
|
* the request can only be completed or closed short — cancelling would erase
|
||||||
|
* the fact that a transfer happened.
|
||||||
|
*/
|
||||||
async cancelRequest(id: string): Promise<WagonTransferRequest> {
|
async cancelRequest(id: string): Promise<WagonTransferRequest> {
|
||||||
const request = await this.findById(id);
|
const request = await this.findById(id);
|
||||||
if (request.status !== WagonTransferRequestStatus.Pending) {
|
if (request.status !== WagonTransferRequestStatus.Pending) {
|
||||||
throw new ConflictException(
|
throw new ConflictException(
|
||||||
`Only pending requests can be cancelled (this one is ${request.status.toLowerCase()})`,
|
request.status === WagonTransferRequestStatus.PartiallyFulfilled
|
||||||
|
? 'Wagons have already moved against this request — close it short instead of cancelling'
|
||||||
|
: `Only pending requests can be cancelled (this one is ${request.status.toLowerCase().replace(/_/g, ' ')})`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
request.status = WagonTransferRequestStatus.Cancelled;
|
request.status = WagonTransferRequestStatus.Cancelled;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { WagonMovement } from './entities/wagon-movement.entity';
|
|||||||
import { WagonTransferRequest } from './entities/wagon-transfer-request.entity';
|
import { WagonTransferRequest } from './entities/wagon-transfer-request.entity';
|
||||||
import { Train } from '../trains/entities/train.entity';
|
import { Train } from '../trains/entities/train.entity';
|
||||||
import { Yard } from '../rule-engine/entities/yard.entity';
|
import { Yard } from '../rule-engine/entities/yard.entity';
|
||||||
|
import { NotificationInboxModule } from '../notification-inbox/notification-inbox.module';
|
||||||
import { WagonsController } from './wagons.controller';
|
import { WagonsController } from './wagons.controller';
|
||||||
import { WagonTransferRequestsController } from './wagon-transfer-requests.controller';
|
import { WagonTransferRequestsController } from './wagon-transfer-requests.controller';
|
||||||
import { WagonsService } from './wagons.service';
|
import { WagonsService } from './wagons.service';
|
||||||
@@ -19,6 +20,8 @@ import { WagonTransferRequestsService } from './wagon-transfer-requests.service'
|
|||||||
Train,
|
Train,
|
||||||
Yard,
|
Yard,
|
||||||
]),
|
]),
|
||||||
|
// The transfer desk notifies the requester as instalments land.
|
||||||
|
NotificationInboxModule,
|
||||||
],
|
],
|
||||||
controllers: [
|
controllers: [
|
||||||
WagonsController,
|
WagonsController,
|
||||||
|
|||||||
@@ -233,6 +233,12 @@ export const FLEET_RAIL_PERMISSIONS: FreightPermissionSeed[] = [
|
|||||||
perm('e1b00001-0001-4000-8000-000000000005', 'edr_freight_app:wagons:transfer_request', 'Request wagon transfer'),
|
perm('e1b00001-0001-4000-8000-000000000005', 'edr_freight_app:wagons:transfer_request', 'Request wagon transfer'),
|
||||||
perm('e1b00001-0001-4000-8000-000000000006', 'edr_freight_app:wagons:transfer_fulfill', 'Fulfil wagon transfer (OCC)'),
|
perm('e1b00001-0001-4000-8000-000000000006', 'edr_freight_app:wagons:transfer_fulfill', 'Fulfil wagon transfer (OCC)'),
|
||||||
perm('e1b00001-0001-4000-8000-000000000007', 'edr_freight_app:wagons:transfer_history_all', "View all staff's transfer history"),
|
perm('e1b00001-0001-4000-8000-000000000007', 'edr_freight_app:wagons:transfer_history_all', "View all staff's transfer history"),
|
||||||
|
// The transfer desk is its own screen, so it carries its own per-action keys —
|
||||||
|
// seeing the queue, withdrawing a request and short-closing one are separate
|
||||||
|
// grants from filing or fulfilling.
|
||||||
|
perm('e1b00001-0001-4000-8000-000000000008', 'edr_freight_app:wagons:transfer_view', 'View wagon transfer requests'),
|
||||||
|
perm('e1b00001-0001-4000-8000-000000000009', 'edr_freight_app:wagons:transfer_cancel', 'Withdraw a wagon transfer request'),
|
||||||
|
perm('e1b00001-0001-4000-8000-00000000000a', 'edr_freight_app:wagons:transfer_close_short', 'Close a transfer request short of the requested count'),
|
||||||
perm('e1c00001-0001-4000-8000-000000000001', 'edr_freight_app:trains:view', 'View trains'),
|
perm('e1c00001-0001-4000-8000-000000000001', 'edr_freight_app:trains:view', 'View trains'),
|
||||||
perm('e1c00001-0001-4000-8000-000000000002', 'edr_freight_app:trains:create', 'Create train'),
|
perm('e1c00001-0001-4000-8000-000000000002', 'edr_freight_app:trains:create', 'Create train'),
|
||||||
perm('e1c00001-0001-4000-8000-000000000003', 'edr_freight_app:trains:update', 'Update train'),
|
perm('e1c00001-0001-4000-8000-000000000003', 'edr_freight_app:trains:update', 'Update train'),
|
||||||
@@ -524,6 +530,12 @@ export const FREIGHT_PERMS = {
|
|||||||
// executes the move). Distinct keys so OCC can hold fulfil without request.
|
// executes the move). Distinct keys so OCC can hold fulfil without request.
|
||||||
transferRequest: 'edr_freight_app:wagons:transfer_request',
|
transferRequest: 'edr_freight_app:wagons:transfer_request',
|
||||||
transferFulfill: 'edr_freight_app:wagons:transfer_fulfill',
|
transferFulfill: 'edr_freight_app:wagons:transfer_fulfill',
|
||||||
|
/** Open the transfer-requests desk (list + detail). */
|
||||||
|
transferView: 'edr_freight_app:wagons:transfer_view',
|
||||||
|
/** Withdraw a request that has not moved any wagon yet. */
|
||||||
|
transferCancel: 'edr_freight_app:wagons:transfer_cancel',
|
||||||
|
/** End a request short — anyone who can fulfil may also do this. */
|
||||||
|
transferCloseShort: 'edr_freight_app:wagons:transfer_close_short',
|
||||||
// Admin: read every staffer's transfer history. Without it, a user only sees
|
// Admin: read every staffer's transfer history. Without it, a user only sees
|
||||||
// their own (the /history endpoint uses the caller id, backend-enforced).
|
// their own (the /history endpoint uses the caller id, backend-enforced).
|
||||||
transferHistoryAll: 'edr_freight_app:wagons:transfer_history_all',
|
transferHistoryAll: 'edr_freight_app:wagons:transfer_history_all',
|
||||||
@@ -905,6 +917,12 @@ export const POSITION_PERMISSION_PRESETS = {
|
|||||||
...ROLE_PERMISSION_PRESETS.director,
|
...ROLE_PERMISSION_PRESETS.director,
|
||||||
...ROLE_PERMISSION_PRESETS.operationsOfficer,
|
...ROLE_PERMISSION_PRESETS.operationsOfficer,
|
||||||
FREIGHT_PERMS.allocation.manage,
|
FREIGHT_PERMS.allocation.manage,
|
||||||
|
// Customer desk: onboarding intake lands on the chief — open the customer
|
||||||
|
// list and approve/suspend a submitted profile. Deliberately NOT granted:
|
||||||
|
// create, update and password reset, which stay with the customer admins.
|
||||||
|
FREIGHT_PERMS.customers.view,
|
||||||
|
FREIGHT_PERMS.customers.verify,
|
||||||
|
FREIGHT_PERMS.customers.deactivate,
|
||||||
]),
|
]),
|
||||||
director: dedupe([...ROLE_PERMISSION_PRESETS.director]),
|
director: dedupe([...ROLE_PERMISSION_PRESETS.director]),
|
||||||
ceo: dedupe([...ROLE_PERMISSION_PRESETS.ceo]),
|
ceo: dedupe([...ROLE_PERMISSION_PRESETS.ceo]),
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
ArrowLeftRight,
|
||||||
Boxes,
|
Boxes,
|
||||||
Building2,
|
Building2,
|
||||||
Container,
|
Container,
|
||||||
@@ -86,6 +87,7 @@ import DropdownSettingsPage from "./pages/dropdown_settings/DropdownSettingsPage
|
|||||||
import ContractTemplatesPage from "./pages/contract_templates/ContractTemplatesPage";
|
import ContractTemplatesPage from "./pages/contract_templates/ContractTemplatesPage";
|
||||||
import ContractTemplateEditorPage from "./pages/contract_templates/ContractTemplateEditorPage";
|
import ContractTemplateEditorPage from "./pages/contract_templates/ContractTemplateEditorPage";
|
||||||
import FleetResourcePage from "./pages/fleet/FleetResourcePage";
|
import FleetResourcePage from "./pages/fleet/FleetResourcePage";
|
||||||
|
import WagonTransfersPage from "./pages/wagons/WagonTransfersPage";
|
||||||
import VehicleDetailPage from "./pages/fleet/VehicleDetailPage";
|
import VehicleDetailPage from "./pages/fleet/VehicleDetailPage";
|
||||||
import DriverDetailPage from "./pages/fleet/DriverDetailPage";
|
import DriverDetailPage from "./pages/fleet/DriverDetailPage";
|
||||||
import RoutesPage from "./pages/fleet/RoutesPage";
|
import RoutesPage from "./pages/fleet/RoutesPage";
|
||||||
@@ -297,6 +299,15 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
|||||||
icon: <Truck />,
|
icon: <Truck />,
|
||||||
permission: [FREIGHT_PERMS.wagons.view, FREIGHT_PERMS.fleet.view],
|
permission: [FREIGHT_PERMS.wagons.view, FREIGHT_PERMS.fleet.view],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: "Wagon Transfers",
|
||||||
|
href: "/dashboard/wagon-transfers",
|
||||||
|
icon: <ArrowLeftRight />,
|
||||||
|
permission: [
|
||||||
|
FREIGHT_PERMS.wagons.transferView,
|
||||||
|
FREIGHT_PERMS.wagons.view,
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: "Vehicles",
|
label: "Vehicles",
|
||||||
href: "/dashboard/vehicles",
|
href: "/dashboard/vehicles",
|
||||||
@@ -1180,6 +1191,19 @@ const App = () => {
|
|||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="wagon-transfers"
|
||||||
|
element={
|
||||||
|
<RequirePermission
|
||||||
|
permission={[
|
||||||
|
FREIGHT_PERMS.wagons.transferView,
|
||||||
|
FREIGHT_PERMS.wagons.view,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<WagonTransfersPage />
|
||||||
|
</RequirePermission>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="containers"
|
path="containers"
|
||||||
element={
|
element={
|
||||||
@@ -1410,6 +1434,19 @@ const App = () => {
|
|||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="wagon-transfers"
|
||||||
|
element={
|
||||||
|
<RequirePermission
|
||||||
|
permission={[
|
||||||
|
FREIGHT_PERMS.wagons.transferView,
|
||||||
|
FREIGHT_PERMS.wagons.view,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<WagonTransfersPage />
|
||||||
|
</RequirePermission>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="containers"
|
path="containers"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -0,0 +1,237 @@
|
|||||||
|
import {
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
Group,
|
||||||
|
Loader,
|
||||||
|
Modal,
|
||||||
|
Paper,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
Textarea,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { Download, Eye, History, Upload } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import toast from "react-hot-toast";
|
||||||
|
|
||||||
|
import { PhasedFileDropzone } from "@/components/contracts/PhasedFileDropzone";
|
||||||
|
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
|
||||||
|
import { contractsService } from "@/services/contracts.service";
|
||||||
|
import { isViewable } from "@edr/ui-common";
|
||||||
|
|
||||||
|
import { downloadBookingFile, fetchViewableFile } from "@/services/files.service";
|
||||||
|
|
||||||
|
export interface ClearanceDocumentVersionsModalProps {
|
||||||
|
contractId: string;
|
||||||
|
/** The document being inspected; null closes the modal. */
|
||||||
|
doc: { fileKey: string; label: string } | null;
|
||||||
|
onClose: () => void;
|
||||||
|
/** Hide the replace form (finalized clearance, read-only viewers). */
|
||||||
|
canReplace?: boolean;
|
||||||
|
onReplaced?: () => void;
|
||||||
|
onView?: (file: { name: string; url: string }) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fmt = (iso: string) =>
|
||||||
|
new Date(iso).toLocaleString("en-GB", {
|
||||||
|
day: "numeric",
|
||||||
|
month: "short",
|
||||||
|
year: "numeric",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
hour12: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Version history of one clearance document, and the way to add a version.
|
||||||
|
*
|
||||||
|
* Staff can correct a document without bouncing it back to the customer, but
|
||||||
|
* the customer's original is never overwritten — it drops down this list as a
|
||||||
|
* superseded version, stamped with who replaced it and why. The corrected file
|
||||||
|
* comes back unreviewed, so it still has to be approved before finalizing.
|
||||||
|
*/
|
||||||
|
export function ClearanceDocumentVersionsModal({
|
||||||
|
contractId,
|
||||||
|
doc,
|
||||||
|
onClose,
|
||||||
|
canReplace = false,
|
||||||
|
onReplaced,
|
||||||
|
onView,
|
||||||
|
}: ClearanceDocumentVersionsModalProps) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [file, setFile] = useState<File | null>(null);
|
||||||
|
const [reason, setReason] = useState("");
|
||||||
|
|
||||||
|
const { data: versions = [], isLoading } = useQuery({
|
||||||
|
queryKey: ["contracts", "clearance-doc-versions", contractId, doc?.fileKey],
|
||||||
|
queryFn: () =>
|
||||||
|
contractsService.getClearanceDocumentVersions(contractId, doc!.fileKey),
|
||||||
|
enabled: Boolean(doc),
|
||||||
|
});
|
||||||
|
|
||||||
|
const replace = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
contractsService.replaceClearanceDocument(
|
||||||
|
contractId,
|
||||||
|
doc!.fileKey,
|
||||||
|
file!,
|
||||||
|
reason.trim(),
|
||||||
|
),
|
||||||
|
onSuccess: async () => {
|
||||||
|
toast.success("Document replaced — the previous version is kept on file");
|
||||||
|
setFile(null);
|
||||||
|
setReason("");
|
||||||
|
await queryClient.invalidateQueries({
|
||||||
|
queryKey: ["contracts", "clearance-doc-versions", contractId, doc?.fileKey],
|
||||||
|
});
|
||||||
|
await queryClient.invalidateQueries({
|
||||||
|
queryKey: QUERY_KEYS.CONTRACTS.clearance(contractId),
|
||||||
|
});
|
||||||
|
onReplaced?.();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const close = () => {
|
||||||
|
setFile(null);
|
||||||
|
setReason("");
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
opened={Boolean(doc)}
|
||||||
|
onClose={close}
|
||||||
|
size="lg"
|
||||||
|
radius="md"
|
||||||
|
title={
|
||||||
|
<Group gap={8}>
|
||||||
|
<History size={16} />
|
||||||
|
<Text fw={700}>{doc?.label ?? "Document"} — version history</Text>
|
||||||
|
</Group>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Stack gap="md">
|
||||||
|
{isLoading ? (
|
||||||
|
<Group justify="center" py="lg">
|
||||||
|
<Loader size="sm" />
|
||||||
|
</Group>
|
||||||
|
) : versions.length === 0 ? (
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
Nothing uploaded under this document yet.
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<Stack gap={8}>
|
||||||
|
{versions.map((v, index) => (
|
||||||
|
<Paper
|
||||||
|
key={v.id}
|
||||||
|
withBorder
|
||||||
|
radius="md"
|
||||||
|
p="sm"
|
||||||
|
bg={v.isCurrent ? "var(--mantine-color-edr-green-0)" : undefined}
|
||||||
|
>
|
||||||
|
<Group justify="space-between" wrap="nowrap" align="flex-start">
|
||||||
|
<Stack gap={2} style={{ minWidth: 0 }}>
|
||||||
|
<Group gap={6} wrap="nowrap">
|
||||||
|
<Text size="sm" fw={600} truncate>
|
||||||
|
{v.name}
|
||||||
|
</Text>
|
||||||
|
{v.isCurrent ? (
|
||||||
|
<Badge size="xs" color="edr-green" variant="light">
|
||||||
|
Current
|
||||||
|
</Badge>
|
||||||
|
) : index === versions.length - 1 ? (
|
||||||
|
<Badge size="xs" color="blue" variant="light">
|
||||||
|
Original
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge size="xs" color="gray" variant="light">
|
||||||
|
Superseded
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
Uploaded {fmt(v.uploadedAt)}
|
||||||
|
{v.replacedAt ? ` · replaced ${fmt(v.replacedAt)}` : ""}
|
||||||
|
</Text>
|
||||||
|
{v.replaceReason ? (
|
||||||
|
<Text size="xs" c="orange.8">
|
||||||
|
Reason: {v.replaceReason}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</Stack>
|
||||||
|
<Group gap={6} wrap="nowrap">
|
||||||
|
{isViewable({ name: v.name, url: "" }) && onView ? (
|
||||||
|
<Button
|
||||||
|
size="compact-xs"
|
||||||
|
variant="default"
|
||||||
|
radius="md"
|
||||||
|
leftSection={<Eye size={13} />}
|
||||||
|
onClick={() =>
|
||||||
|
void fetchViewableFile(v.id, v.name).then(onView)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
View
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
<Button
|
||||||
|
size="compact-xs"
|
||||||
|
variant="default"
|
||||||
|
radius="md"
|
||||||
|
leftSection={<Download size={13} />}
|
||||||
|
onClick={() => void downloadBookingFile(v.id, v.name)}
|
||||||
|
>
|
||||||
|
Download
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
</Paper>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{canReplace ? (
|
||||||
|
<Paper withBorder radius="md" p="md" bg="var(--mantine-color-gray-0)">
|
||||||
|
<Stack gap="sm">
|
||||||
|
<Text size="sm" fw={700}>
|
||||||
|
Replace this document
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
Use this for a correction you can make yourself. The customer's
|
||||||
|
copy stays in the history above, and the new file has to be
|
||||||
|
approved before clearance is finalized.
|
||||||
|
</Text>
|
||||||
|
<PhasedFileDropzone
|
||||||
|
label="Corrected document"
|
||||||
|
value={file}
|
||||||
|
onChange={setFile}
|
||||||
|
replaceMode
|
||||||
|
/>
|
||||||
|
<Textarea
|
||||||
|
label="Why is it being replaced?"
|
||||||
|
placeholder="e.g. customer sent page 2 only — attached the full signed copy"
|
||||||
|
value={reason}
|
||||||
|
onChange={(e) => setReason(e.currentTarget.value)}
|
||||||
|
autosize
|
||||||
|
minRows={2}
|
||||||
|
/>
|
||||||
|
<Group justify="flex-end">
|
||||||
|
<Button
|
||||||
|
color="edr-green"
|
||||||
|
radius="md"
|
||||||
|
leftSection={<Upload size={15} />}
|
||||||
|
loading={replace.isPending}
|
||||||
|
disabled={!file || !reason.trim()}
|
||||||
|
onClick={() => replace.mutate()}
|
||||||
|
>
|
||||||
|
Replace document
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
) : null}
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ClearanceDocumentVersionsModal;
|
||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
Eye,
|
Eye,
|
||||||
FileCheck2,
|
FileCheck2,
|
||||||
FileText,
|
FileText,
|
||||||
|
History,
|
||||||
MessageSquareWarning,
|
MessageSquareWarning,
|
||||||
Upload,
|
Upload,
|
||||||
UserCheck,
|
UserCheck,
|
||||||
@@ -31,6 +32,7 @@ import {
|
|||||||
import type { Freight } from "@edr/types";
|
import type { Freight } from "@edr/types";
|
||||||
import { isViewable } from "@edr/ui-common";
|
import { isViewable } from "@edr/ui-common";
|
||||||
|
|
||||||
|
import { ClearanceDocumentVersionsModal } from "@/components/contracts/ClearanceDocumentVersionsModal";
|
||||||
import { SectionCard } from "@/components/bookings/detail/SectionCard";
|
import { SectionCard } from "@/components/bookings/detail/SectionCard";
|
||||||
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
|
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
|
||||||
import { contractsService } from "@/services/contracts.service";
|
import { contractsService } from "@/services/contracts.service";
|
||||||
@@ -114,6 +116,11 @@ export function ContractClearanceReviewSection({
|
|||||||
const [queryNotes, setQueryNotes] = useState<Record<string, string>>({});
|
const [queryNotes, setQueryNotes] = useState<Record<string, string>>({});
|
||||||
const [openQuery, setOpenQuery] = useState<Record<string, boolean>>({});
|
const [openQuery, setOpenQuery] = useState<Record<string, boolean>>({});
|
||||||
const [uploadingKey, setUploadingKey] = useState<string | null>(null);
|
const [uploadingKey, setUploadingKey] = useState<string | null>(null);
|
||||||
|
// Document whose version history is open (also hosts the replace form).
|
||||||
|
const [historyDoc, setHistoryDoc] = useState<{
|
||||||
|
fileKey: string;
|
||||||
|
label: string;
|
||||||
|
} | null>(null);
|
||||||
const { view, viewer } = useFileViewer();
|
const { view, viewer } = useFileViewer();
|
||||||
|
|
||||||
const reviewerTeam = selfClear ? "Operations" : "Global Logistics";
|
const reviewerTeam = selfClear ? "Operations" : "Global Logistics";
|
||||||
@@ -264,6 +271,9 @@ export function ContractClearanceReviewSection({
|
|||||||
handleReview(doc.fileKey, "QUERIED", queryNotes[doc.fileKey])
|
handleReview(doc.fileKey, "QUERIED", queryNotes[doc.fileKey])
|
||||||
}
|
}
|
||||||
onView={view}
|
onView={view}
|
||||||
|
onHistory={() =>
|
||||||
|
setHistoryDoc({ fileKey: doc.fileKey, label: doc.label })
|
||||||
|
}
|
||||||
busy={reviewDocument.isPending}
|
busy={reviewDocument.isPending}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
@@ -487,6 +497,18 @@ export function ContractClearanceReviewSection({
|
|||||||
</Group>
|
</Group>
|
||||||
</Paper>
|
</Paper>
|
||||||
)}
|
)}
|
||||||
|
{/* Version history + in-place correction. Replacing is blocked in the same
|
||||||
|
situations queries are (finalized clearance / read-only audit view) —
|
||||||
|
documents must not move once the gate has closed. */}
|
||||||
|
<ClearanceDocumentVersionsModal
|
||||||
|
contractId={contractId}
|
||||||
|
doc={historyDoc}
|
||||||
|
canReplace={!readOnly && !queriesLocked}
|
||||||
|
onClose={() => setHistoryDoc(null)}
|
||||||
|
onReplaced={onChanged}
|
||||||
|
onView={view}
|
||||||
|
/>
|
||||||
|
|
||||||
{viewer}
|
{viewer}
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
@@ -534,6 +556,7 @@ function DocReviewCard({
|
|||||||
onApprove,
|
onApprove,
|
||||||
onQuery,
|
onQuery,
|
||||||
onView,
|
onView,
|
||||||
|
onHistory,
|
||||||
busy,
|
busy,
|
||||||
}: {
|
}: {
|
||||||
doc: Freight.ContractClearanceDocument;
|
doc: Freight.ContractClearanceDocument;
|
||||||
@@ -548,6 +571,7 @@ function DocReviewCard({
|
|||||||
onApprove: () => void;
|
onApprove: () => void;
|
||||||
onQuery: () => void;
|
onQuery: () => void;
|
||||||
onView: (file: { name: string; url: string }) => void;
|
onView: (file: { name: string; url: string }) => void;
|
||||||
|
onHistory: () => void;
|
||||||
busy: boolean;
|
busy: boolean;
|
||||||
}) {
|
}) {
|
||||||
const status = doc.reviewStatus ?? "PENDING";
|
const status = doc.reviewStatus ?? "PENDING";
|
||||||
@@ -660,6 +684,21 @@ function DocReviewCard({
|
|||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
|
{/* Every version ever stored under this key — the customer original
|
||||||
|
plus any staff correction — and where a correction is made. */}
|
||||||
|
{hasFile && (
|
||||||
|
<Tooltip label="Versions & replace">
|
||||||
|
<Button
|
||||||
|
size="compact-xs"
|
||||||
|
variant="default"
|
||||||
|
radius="md"
|
||||||
|
leftSection={<History size={13} />}
|
||||||
|
onClick={onHistory}
|
||||||
|
>
|
||||||
|
History
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,15 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { History } from "lucide-react";
|
import { History, User } from "lucide-react";
|
||||||
import { Badge, Group, Loader, Stack, Text, Timeline } from "@mantine/core";
|
import {
|
||||||
|
Avatar,
|
||||||
|
Badge,
|
||||||
|
Group,
|
||||||
|
Loader,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
Timeline,
|
||||||
|
Tooltip,
|
||||||
|
} from "@mantine/core";
|
||||||
import type { Freight } from "@edr/types";
|
import type { Freight } from "@edr/types";
|
||||||
|
|
||||||
import { contractsService } from "@/services/contracts.service";
|
import { contractsService } from "@/services/contracts.service";
|
||||||
@@ -8,6 +17,8 @@ import { SectionCard } from "@/components/bookings/detail/SectionCard";
|
|||||||
|
|
||||||
interface ContractRevisionTimelineProps {
|
interface ContractRevisionTimelineProps {
|
||||||
contractId: string;
|
contractId: string;
|
||||||
|
/** Rendered as a plain block instead of a SectionCard (own-tab layout). */
|
||||||
|
bare?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
type Change = Freight.IContractDocumentChange;
|
type Change = Freight.IContractDocumentChange;
|
||||||
@@ -21,8 +32,26 @@ const CHANGE_STYLES: Record<Change["kind"], { color: string; label: string }> =
|
|||||||
ARTICLE_REORDERED: { color: "gray", label: "Reordered" },
|
ARTICLE_REORDERED: { color: "gray", label: "Reordered" },
|
||||||
DOCUMENT_TITLE_CHANGED: { color: "grape", label: "Title" },
|
DOCUMENT_TITLE_CHANGED: { color: "grape", label: "Title" },
|
||||||
WHEREAS_CHANGED: { color: "teal", label: "Recitals" },
|
WHEREAS_CHANGED: { color: "teal", label: "Recitals" },
|
||||||
|
FIELD_CHANGED: { color: "orange", label: "Field" },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Initials for the actor avatar — "Abenezer Haile" → "AH". */
|
||||||
|
function initials(name: string): string {
|
||||||
|
return name
|
||||||
|
.split(/\s+/)
|
||||||
|
.filter(Boolean)
|
||||||
|
.slice(0, 2)
|
||||||
|
.map((part) => part[0]?.toUpperCase() ?? "")
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Role slugs arrive like "-marketing-director-"; render them readably. */
|
||||||
|
function prettyRole(role: string): string {
|
||||||
|
const cleaned = role.replace(/^-+|-+$/g, "").replace(/[-_]+/g, " ").trim();
|
||||||
|
if (!cleaned) return role;
|
||||||
|
return cleaned.charAt(0).toUpperCase() + cleaned.slice(1).toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
/** What the change applies to — an article title, or the document itself. */
|
/** What the change applies to — an article title, or the document itself. */
|
||||||
function changeSubject(change: Change): string {
|
function changeSubject(change: Change): string {
|
||||||
switch (change.kind) {
|
switch (change.kind) {
|
||||||
@@ -40,6 +69,8 @@ function changeSubject(change: Change): string {
|
|||||||
return `“${change.fromTitle}” → “${change.title}”`;
|
return `“${change.fromTitle}” → “${change.title}”`;
|
||||||
case "ARTICLE_REORDERED":
|
case "ARTICLE_REORDERED":
|
||||||
return `${change.title} (${change.fromOrder} → ${change.toOrder})`;
|
return `${change.title} (${change.fromOrder} → ${change.toOrder})`;
|
||||||
|
case "FIELD_CHANGED":
|
||||||
|
return `${change.label}: ${change.from ?? "—"} → ${change.to ?? "—"}`;
|
||||||
default:
|
default:
|
||||||
return change.title;
|
return change.title;
|
||||||
}
|
}
|
||||||
@@ -53,82 +84,131 @@ function formatWhen(iso: string): string {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** "3 hours ago" — the at-a-glance read; the exact stamp sits beside it. */
|
||||||
|
function formatAgo(iso: string): string {
|
||||||
|
const seconds = Math.round((Date.now() - new Date(iso).getTime()) / 1000);
|
||||||
|
if (seconds < 60) return "just now";
|
||||||
|
const units: Array<[Intl.RelativeTimeFormatUnit, number]> = [
|
||||||
|
["year", 31536000],
|
||||||
|
["month", 2592000],
|
||||||
|
["day", 86400],
|
||||||
|
["hour", 3600],
|
||||||
|
["minute", 60],
|
||||||
|
];
|
||||||
|
const rtf = new Intl.RelativeTimeFormat(undefined, { numeric: "auto" });
|
||||||
|
for (const [unit, secondsPerUnit] of units) {
|
||||||
|
if (seconds >= secondsPerUnit) {
|
||||||
|
return rtf.format(-Math.floor(seconds / secondsPerUnit), unit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "just now";
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Audit trail of edits to the contract document. The document stays editable
|
* Audit trail of edits to the contract document. The document stays editable
|
||||||
* through the approval chain, so this is the record of who changed what.
|
* through the approval chain, so this is the record of who changed what.
|
||||||
*/
|
*/
|
||||||
export function ContractRevisionTimeline({
|
export function ContractRevisionTimeline({
|
||||||
contractId,
|
contractId,
|
||||||
|
bare = false,
|
||||||
}: ContractRevisionTimelineProps) {
|
}: ContractRevisionTimelineProps) {
|
||||||
const { data: revisions, isLoading } = useQuery({
|
const { data: revisions, isLoading } = useQuery({
|
||||||
queryKey: ["contracts", contractId, "document-revisions"],
|
queryKey: ["contracts", contractId, "document-revisions"],
|
||||||
queryFn: () => contractsService.getContractDocumentRevisions(contractId),
|
queryFn: () => contractsService.getContractDocumentRevisions(contractId),
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
const body = isLoading ? (
|
||||||
<SectionCard icon={History} title="Document history">
|
<Group gap="xs">
|
||||||
{isLoading ? (
|
<Loader size="xs" />
|
||||||
<Group gap="xs">
|
<Text size="sm" c="dimmed">
|
||||||
<Loader size="xs" />
|
Loading history…
|
||||||
<Text size="sm" c="dimmed">
|
</Text>
|
||||||
Loading history…
|
</Group>
|
||||||
</Text>
|
) : !revisions?.length ? (
|
||||||
</Group>
|
<Stack gap={4} align="center" py="xl">
|
||||||
) : !revisions?.length ? (
|
<History size={26} color="var(--mantine-color-gray-5)" />
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" fw={500}>
|
||||||
No edits recorded yet. Changes made to the contract articles during
|
No edits recorded yet
|
||||||
approval will appear here.
|
</Text>
|
||||||
</Text>
|
<Text size="xs" c="dimmed" ta="center" maw={420}>
|
||||||
) : (
|
Every change to this contract — its articles during review and approval,
|
||||||
<Timeline
|
or its details while the customer can still edit it — is logged here
|
||||||
active={revisions.length}
|
with who made it and when.
|
||||||
bulletSize={18}
|
</Text>
|
||||||
lineWidth={2}
|
</Stack>
|
||||||
color="edr-green"
|
) : (
|
||||||
>
|
<Timeline
|
||||||
{revisions.map((revision) => (
|
active={revisions.length}
|
||||||
<Timeline.Item
|
bulletSize={28}
|
||||||
key={revision.id}
|
lineWidth={2}
|
||||||
title={
|
color="edr-green"
|
||||||
<Group gap="xs" wrap="nowrap">
|
>
|
||||||
<Text size="sm" fw={600}>
|
{revisions.map((revision) => {
|
||||||
{revision.actorRole ?? "Staff"}
|
const who = revision.actorName?.trim();
|
||||||
</Text>
|
const role = revision.actorRole ? prettyRole(revision.actorRole) : null;
|
||||||
<Text size="xs" c="dimmed">
|
return (
|
||||||
{formatWhen(revision.createdAt)}
|
<Timeline.Item
|
||||||
</Text>
|
key={revision.id}
|
||||||
</Group>
|
bullet={
|
||||||
}
|
<Avatar size={26} radius="xl" color="edr-green" variant="light">
|
||||||
>
|
<Text size="10px" fw={700}>
|
||||||
<Stack gap={6} mt={4}>
|
{who ? initials(who) : <User size={13} />}
|
||||||
{revision.summary && (
|
</Text>
|
||||||
<Text size="xs" c="dimmed">
|
</Avatar>
|
||||||
{revision.summary}
|
}
|
||||||
</Text>
|
title={
|
||||||
|
<Group gap="xs" wrap="wrap" align="baseline">
|
||||||
|
<Text size="sm" fw={600}>
|
||||||
|
{who ?? role ?? "Unknown user"}
|
||||||
|
</Text>
|
||||||
|
{role && who && (
|
||||||
|
<Badge size="xs" variant="light" color="gray" radius="sm">
|
||||||
|
{role}
|
||||||
|
</Badge>
|
||||||
)}
|
)}
|
||||||
{revision.changes.map((change, index) => {
|
<Tooltip label={formatWhen(revision.createdAt)} withArrow>
|
||||||
const style = CHANGE_STYLES[change.kind];
|
<Text size="xs" c="dimmed">
|
||||||
return (
|
{formatAgo(revision.createdAt)}
|
||||||
<Group key={index} gap="xs" wrap="nowrap" align="flex-start">
|
</Text>
|
||||||
<Badge
|
</Tooltip>
|
||||||
size="xs"
|
</Group>
|
||||||
variant="light"
|
}
|
||||||
color={style?.color ?? "gray"}
|
>
|
||||||
style={{ flexShrink: 0 }}
|
<Stack gap={6} mt={6} pb="xs">
|
||||||
>
|
{revision.summary && (
|
||||||
{style?.label ?? change.kind}
|
<Text size="xs" c="dimmed">
|
||||||
</Badge>
|
{revision.summary}
|
||||||
<Text size="xs" style={{ lineHeight: 1.5 }}>
|
</Text>
|
||||||
{changeSubject(change)}
|
)}
|
||||||
</Text>
|
{revision.changes.map((change, index) => {
|
||||||
</Group>
|
const style = CHANGE_STYLES[change.kind];
|
||||||
);
|
return (
|
||||||
})}
|
<Group key={index} gap="xs" wrap="nowrap" align="flex-start">
|
||||||
</Stack>
|
<Badge
|
||||||
</Timeline.Item>
|
size="xs"
|
||||||
))}
|
variant="light"
|
||||||
</Timeline>
|
color={style?.color ?? "gray"}
|
||||||
)}
|
style={{ flexShrink: 0 }}
|
||||||
|
>
|
||||||
|
{style?.label ?? change.kind}
|
||||||
|
</Badge>
|
||||||
|
<Text size="xs" style={{ lineHeight: 1.5 }}>
|
||||||
|
{changeSubject(change)}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Stack>
|
||||||
|
</Timeline.Item>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Timeline>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (bare) return body;
|
||||||
|
return (
|
||||||
|
<SectionCard icon={History} title="Change history">
|
||||||
|
{body}
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Group } from "@mantine/core";
|
||||||
|
import { DateInput } from "@mantine/dates";
|
||||||
|
|
||||||
|
/** `YYYY-MM-DD` in local time — the API column is a DATE, so no UTC shift. */
|
||||||
|
export function toIsoDate(value: Date | null): string | null {
|
||||||
|
if (!value) return null;
|
||||||
|
const tz = value.getTimezoneOffset() * 60000;
|
||||||
|
return new Date(value.getTime() - tz).toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DoCollectionDates {
|
||||||
|
vesselArrival: Date | null;
|
||||||
|
doCollected: Date | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Both dates present and the DO not collected before the vessel docked. */
|
||||||
|
export function doDatesComplete(dates: DoCollectionDates): boolean {
|
||||||
|
if (!dates.vesselArrival || !dates.doCollected) return false;
|
||||||
|
return (toIsoDate(dates.doCollected) ?? "") >= (toIsoDate(dates.vesselArrival) ?? "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The two mandatory dates Djibouti GL records with a Delivery Order. Shared by
|
||||||
|
* the DO upload modal and the inline DO step so neither surface can post an
|
||||||
|
* upload the API will reject.
|
||||||
|
*/
|
||||||
|
export function DoCollectionDateFields({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
value: DoCollectionDates;
|
||||||
|
onChange: (next: DoCollectionDates) => void;
|
||||||
|
}) {
|
||||||
|
// Only complain once the user has actually entered the earlier date.
|
||||||
|
const outOfOrder =
|
||||||
|
Boolean(value.vesselArrival && value.doCollected) && !doDatesComplete(value);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Group grow align="flex-start" gap="sm" wrap="wrap">
|
||||||
|
<DateInput
|
||||||
|
label="Vessel arrival date"
|
||||||
|
placeholder="Select date"
|
||||||
|
value={value.vesselArrival}
|
||||||
|
onChange={(v) =>
|
||||||
|
onChange({ ...value, vesselArrival: v ? new Date(v) : null })
|
||||||
|
}
|
||||||
|
maxDate={new Date()}
|
||||||
|
size="sm"
|
||||||
|
required
|
||||||
|
withAsterisk
|
||||||
|
/>
|
||||||
|
<DateInput
|
||||||
|
label="DO collected date"
|
||||||
|
placeholder="Select date"
|
||||||
|
value={value.doCollected}
|
||||||
|
onChange={(v) =>
|
||||||
|
onChange({ ...value, doCollected: v ? new Date(v) : null })
|
||||||
|
}
|
||||||
|
minDate={value.vesselArrival ?? undefined}
|
||||||
|
maxDate={new Date()}
|
||||||
|
size="sm"
|
||||||
|
required
|
||||||
|
withAsterisk
|
||||||
|
error={outOfOrder ? "Cannot be before the vessel arrival date." : undefined}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Local state helper — both surfaces need the same seed-from-server logic. */
|
||||||
|
export function useDoCollectionDates(seed?: {
|
||||||
|
vesselArrivalDate?: string | null;
|
||||||
|
doCollectedDate?: string | null;
|
||||||
|
}) {
|
||||||
|
return useState<DoCollectionDates>(() => ({
|
||||||
|
vesselArrival: seed?.vesselArrivalDate ? new Date(seed.vesselArrivalDate) : null,
|
||||||
|
doCollected: seed?.doCollectedDate ? new Date(seed.doCollectedDate) : null,
|
||||||
|
}));
|
||||||
|
}
|
||||||
@@ -4,6 +4,12 @@ import { DateInput } from "@mantine/dates";
|
|||||||
import { Ship, Upload } from "lucide-react";
|
import { Ship, Upload } from "lucide-react";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
|
|
||||||
|
import {
|
||||||
|
DoCollectionDateFields,
|
||||||
|
doDatesComplete,
|
||||||
|
toIsoDate,
|
||||||
|
useDoCollectionDates,
|
||||||
|
} from "@/components/contracts/DoCollectionDateFields";
|
||||||
import { PhasedFileDropzone } from "@/components/contracts/PhasedFileDropzone";
|
import { PhasedFileDropzone } from "@/components/contracts/PhasedFileDropzone";
|
||||||
import { findWorkflowFile } from "@/components/contracts/PhasedUploadedFileRow";
|
import { findWorkflowFile } from "@/components/contracts/PhasedUploadedFileRow";
|
||||||
import { contractsService } from "@/services/contracts.service";
|
import { contractsService } from "@/services/contracts.service";
|
||||||
@@ -20,6 +26,9 @@ export interface GlClearanceUploadModalProps {
|
|||||||
isBooking: boolean;
|
isBooking: boolean;
|
||||||
workflowFiles?: Freight.ClearanceWorkflowFile[];
|
workflowFiles?: Freight.ClearanceWorkflowFile[];
|
||||||
vesselDepartureDate?: string | null;
|
vesselDepartureDate?: string | null;
|
||||||
|
/** Previously recorded DO dates, so a replace opens pre-filled. */
|
||||||
|
vesselArrivalDate?: string | null;
|
||||||
|
doCollectedDate?: string | null;
|
||||||
onSuccess?: () => void;
|
onSuccess?: () => void;
|
||||||
onPreview?: (file: { name: string; url: string }) => void;
|
onPreview?: (file: { name: string; url: string }) => void;
|
||||||
}
|
}
|
||||||
@@ -32,6 +41,8 @@ export function GlClearanceUploadModal({
|
|||||||
isBooking,
|
isBooking,
|
||||||
workflowFiles = [],
|
workflowFiles = [],
|
||||||
vesselDepartureDate,
|
vesselDepartureDate,
|
||||||
|
vesselArrivalDate,
|
||||||
|
doCollectedDate,
|
||||||
onSuccess,
|
onSuccess,
|
||||||
onPreview,
|
onPreview,
|
||||||
}: GlClearanceUploadModalProps) {
|
}: GlClearanceUploadModalProps) {
|
||||||
@@ -39,6 +50,10 @@ export function GlClearanceUploadModal({
|
|||||||
const [vesselDate, setVesselDate] = useState<Date | null>(
|
const [vesselDate, setVesselDate] = useState<Date | null>(
|
||||||
vesselDepartureDate ? new Date(vesselDepartureDate) : null,
|
vesselDepartureDate ? new Date(vesselDepartureDate) : null,
|
||||||
);
|
);
|
||||||
|
const [doDates, setDoDates] = useDoCollectionDates({
|
||||||
|
vesselArrivalDate,
|
||||||
|
doCollectedDate,
|
||||||
|
});
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
// Earliest selectable vessel date (today, local) — refreshed on each open.
|
// Earliest selectable vessel date (today, local) — refreshed on each open.
|
||||||
const todayISODate = useMemo(() => {
|
const todayISODate = useMemo(() => {
|
||||||
@@ -65,15 +80,22 @@ export function GlClearanceUploadModal({
|
|||||||
toast.error("Vessel departure date is required.");
|
toast.error("Vessel departure date is required.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (isDo && !doDatesComplete(doDates)) {
|
||||||
|
toast.error("Vessel arrival date and DO collected date are both required.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
if (isDo) {
|
if (isDo) {
|
||||||
const iso = vesselDate ? vesselDate.toISOString().slice(0, 10) : undefined;
|
const dates = {
|
||||||
|
vesselArrivalDate: toIsoDate(doDates.vesselArrival)!,
|
||||||
|
doCollectedDate: toIsoDate(doDates.doCollected)!,
|
||||||
|
};
|
||||||
if (isBooking) {
|
if (isBooking) {
|
||||||
await bookingsService.uploadDeliveryOrder(entityId, file, iso);
|
await bookingsService.uploadDeliveryOrder(entityId, file, dates);
|
||||||
} else {
|
} else {
|
||||||
await contractsService.uploadDeliveryOrder(entityId, file, iso);
|
await contractsService.uploadDeliveryOrder(entityId, file, dates);
|
||||||
}
|
}
|
||||||
toast.success(replaceMode ? "Delivery Order updated" : "Delivery Order uploaded");
|
toast.success(replaceMode ? "Delivery Order updated" : "Delivery Order uploaded");
|
||||||
} else {
|
} else {
|
||||||
@@ -113,7 +135,7 @@ export function GlClearanceUploadModal({
|
|||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">
|
||||||
{isDo
|
{isDo
|
||||||
? "Upload the Djibouti Delivery Order (DO) for this import shipment."
|
? "Upload the Djibouti Delivery Order (DO) and record when the vessel arrived and when the DO was collected. Both dates are required."
|
||||||
: "Upload the Release Order and confirm the vessel departure date."}
|
: "Upload the Release Order and confirm the vessel departure date."}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
@@ -127,14 +149,7 @@ export function GlClearanceUploadModal({
|
|||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<DateInput
|
<DoCollectionDateFields value={doDates} onChange={setDoDates} />
|
||||||
label="Vessel arrival date (optional)"
|
|
||||||
value={vesselDate}
|
|
||||||
onChange={(v) => setVesselDate(v ? new Date(v) : null)}
|
|
||||||
minDate={todayISODate}
|
|
||||||
size="sm"
|
|
||||||
clearable
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<PhasedFileDropzone
|
<PhasedFileDropzone
|
||||||
@@ -154,7 +169,11 @@ export function GlClearanceUploadModal({
|
|||||||
<Button
|
<Button
|
||||||
color="edr-green"
|
color="edr-green"
|
||||||
loading={loading}
|
loading={loading}
|
||||||
disabled={!file || (isRo && !vesselDate)}
|
disabled={
|
||||||
|
!file ||
|
||||||
|
(isRo && !vesselDate) ||
|
||||||
|
(isDo && !doDatesComplete(doDates))
|
||||||
|
}
|
||||||
leftSection={<Upload size={16} />}
|
leftSection={<Upload size={16} />}
|
||||||
onClick={() => void submit()}
|
onClick={() => void submit()}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
Loader,
|
Loader,
|
||||||
Modal,
|
Modal,
|
||||||
Paper,
|
Paper,
|
||||||
|
SegmentedControl,
|
||||||
Select,
|
Select,
|
||||||
Stack,
|
Stack,
|
||||||
Switch,
|
Switch,
|
||||||
@@ -269,6 +270,9 @@ export default function GlCreateBookingForm() {
|
|||||||
const [scheduledDate, setScheduledDate] = useState("");
|
const [scheduledDate, setScheduledDate] = useState("");
|
||||||
const [contractRouteId, setContractRouteId] = useState<string | null>(null);
|
const [contractRouteId, setContractRouteId] = useState<string | null>(null);
|
||||||
const [notes, setNotes] = useState("");
|
const [notes, setNotes] = useState("");
|
||||||
|
// The customer states the billing currency on their shipment request — GL
|
||||||
|
// books in it. Intercity is always ETB (the API enforces this too).
|
||||||
|
const [paymentCurrency, setPaymentCurrency] = useState<"USD" | "ETB">("USD");
|
||||||
// What the containers carry — captured per booking (moved off the contract).
|
// What the containers carry — captured per booking (moved off the contract).
|
||||||
const [cargoDescription, setCargoDescription] = useState("");
|
const [cargoDescription, setCargoDescription] = useState("");
|
||||||
const [containerLines, setContainerLines] = useState<ContainerLineDraft[]>([]);
|
const [containerLines, setContainerLines] = useState<ContainerLineDraft[]>([]);
|
||||||
@@ -439,6 +443,9 @@ export default function GlCreateBookingForm() {
|
|||||||
}
|
}
|
||||||
if (bookingRequest.contractRouteId)
|
if (bookingRequest.contractRouteId)
|
||||||
setContractRouteId(bookingRequest.contractRouteId);
|
setContractRouteId(bookingRequest.contractRouteId);
|
||||||
|
if (bookingRequest.paymentCurrency === "USD" || bookingRequest.paymentCurrency === "ETB") {
|
||||||
|
setPaymentCurrency(bookingRequest.paymentCurrency);
|
||||||
|
}
|
||||||
if (bookingRequest.notes) setNotes(bookingRequest.notes);
|
if (bookingRequest.notes) setNotes(bookingRequest.notes);
|
||||||
}, [bookingRequest, prefilled]);
|
}, [bookingRequest, prefilled]);
|
||||||
|
|
||||||
@@ -834,6 +841,7 @@ export default function GlCreateBookingForm() {
|
|||||||
|
|
||||||
const payload: Freight.CreateBookingUnderContractDto = {
|
const payload: Freight.CreateBookingUnderContractDto = {
|
||||||
...(contractRouteId ? { contractRouteId } : {}),
|
...(contractRouteId ? { contractRouteId } : {}),
|
||||||
|
paymentCurrency,
|
||||||
// Intercity bookings carry no date — staff assign a passing train later.
|
// Intercity bookings carry no date — staff assign a passing train later.
|
||||||
...(scheduledDate
|
...(scheduledDate
|
||||||
? { scheduledDate: new Date(scheduledDate).toISOString() }
|
? { scheduledDate: new Date(scheduledDate).toISOString() }
|
||||||
@@ -1672,6 +1680,30 @@ export default function GlCreateBookingForm() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<StepCard>
|
<StepCard>
|
||||||
|
<Box mb="md">
|
||||||
|
<Text size="sm" fw={600} mb={4}>
|
||||||
|
Billing currency
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed" mb={8}>
|
||||||
|
{isIntercity
|
||||||
|
? "Intercity shipments are invoiced in ETB."
|
||||||
|
: bookingRequest?.paymentCurrency
|
||||||
|
? "Requested by the customer on their shipment request."
|
||||||
|
: "The contract is quoted in USD — pick the currency this shipment is invoiced in."}
|
||||||
|
</Text>
|
||||||
|
<SegmentedControl
|
||||||
|
value={isIntercity ? "ETB" : paymentCurrency}
|
||||||
|
onChange={(v) => setPaymentCurrency(v as "USD" | "ETB")}
|
||||||
|
disabled={isIntercity}
|
||||||
|
data={[
|
||||||
|
{ label: "USD", value: "USD" },
|
||||||
|
{ label: "ETB", value: "ETB" },
|
||||||
|
]}
|
||||||
|
color="edr-green"
|
||||||
|
radius={10}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
<Textarea
|
<Textarea
|
||||||
label="Additional notes"
|
label="Additional notes"
|
||||||
placeholder="Any special instructions for this shipment…"
|
placeholder="Any special instructions for this shipment…"
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
TextInput,
|
TextInput,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { PhasedFileDropzone, PhasedMultiFileDropzone } from "@/components/contracts/PhasedFileDropzone";
|
import { PhasedFileDropzone, PhasedMultiFileDropzone } from "@/components/contracts/PhasedFileDropzone";
|
||||||
|
import { TransitAssigneePanel } from "@/components/contracts/TransitAssigneePanel";
|
||||||
import {
|
import {
|
||||||
TransitPermitMultiUpload,
|
TransitPermitMultiUpload,
|
||||||
type TransitPermitUploadedRow,
|
type TransitPermitUploadedRow,
|
||||||
@@ -24,6 +25,7 @@ import {
|
|||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
Clock,
|
Clock,
|
||||||
FileText,
|
FileText,
|
||||||
|
MessageSquareWarning,
|
||||||
PackageCheck,
|
PackageCheck,
|
||||||
Receipt,
|
Receipt,
|
||||||
ShieldAlert,
|
ShieldAlert,
|
||||||
@@ -37,6 +39,12 @@ import toast from "react-hot-toast";
|
|||||||
import { SectionCard } from "@/components/bookings/detail/SectionCard";
|
import { SectionCard } from "@/components/bookings/detail/SectionCard";
|
||||||
import { ExportClearanceStepper } from "@/components/contracts/ExportClearanceStepper";
|
import { ExportClearanceStepper } from "@/components/contracts/ExportClearanceStepper";
|
||||||
import { PhasedDocumentUploadField } from "@/components/contracts/PhasedDocumentUploadField";
|
import { PhasedDocumentUploadField } from "@/components/contracts/PhasedDocumentUploadField";
|
||||||
|
import {
|
||||||
|
DoCollectionDateFields,
|
||||||
|
doDatesComplete,
|
||||||
|
toIsoDate,
|
||||||
|
useDoCollectionDates,
|
||||||
|
} from "@/components/contracts/DoCollectionDateFields";
|
||||||
import {
|
import {
|
||||||
findWorkflowFile,
|
findWorkflowFile,
|
||||||
PhasedUploadedFileRow,
|
PhasedUploadedFileRow,
|
||||||
@@ -53,6 +61,8 @@ export type ClearanceViewLike = Pick<
|
|||||||
| "nextAction"
|
| "nextAction"
|
||||||
| "dutyRequired"
|
| "dutyRequired"
|
||||||
| "dutyAdvice"
|
| "dutyAdvice"
|
||||||
|
| "dutyDispute"
|
||||||
|
| "transitAssignee"
|
||||||
| "roHold"
|
| "roHold"
|
||||||
| "roHoldReason"
|
| "roHoldReason"
|
||||||
| "milestones"
|
| "milestones"
|
||||||
@@ -69,6 +79,8 @@ export type ClearanceViewLike = Pick<
|
|||||||
| "offloaded"
|
| "offloaded"
|
||||||
| "finalInvoice"
|
| "finalInvoice"
|
||||||
| "vesselDepartureDate"
|
| "vesselDepartureDate"
|
||||||
|
| "vesselArrivalDate"
|
||||||
|
| "doCollectedDate"
|
||||||
| "linkedBookingId"
|
| "linkedBookingId"
|
||||||
| "riskLevel"
|
| "riskLevel"
|
||||||
| "riskAssignedAt"
|
| "riskAssignedAt"
|
||||||
@@ -332,8 +344,22 @@ export function PhasedClearanceActionPanel({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
{/* Djibouti must name the transit officer first — the declaration
|
||||||
|
is filed against whoever handles the shipment there, and the
|
||||||
|
API refuses the upload until the name is in. */}
|
||||||
{showEt &&
|
{showEt &&
|
||||||
canEt &&
|
canEt &&
|
||||||
|
!isBooking &&
|
||||||
|
!clearance.transitAssignee?.name &&
|
||||||
|
!isMilestoneDone(clearance.milestones, "DECLARED") ? (
|
||||||
|
<TransitAssigneePanel
|
||||||
|
contractId={entityId}
|
||||||
|
transitAssignee={clearance.transitAssignee}
|
||||||
|
side="ET"
|
||||||
|
onChanged={onChanged}
|
||||||
|
/>
|
||||||
|
) : showEt &&
|
||||||
|
canEt &&
|
||||||
!clearance.bookingReady &&
|
!clearance.bookingReady &&
|
||||||
(activeStep >= 1 ||
|
(activeStep >= 1 ||
|
||||||
isMilestoneDone(clearance.milestones, "DECLARED")) ? (
|
isMilestoneDone(clearance.milestones, "DECLARED")) ? (
|
||||||
@@ -504,6 +530,8 @@ export function PhasedClearanceActionPanel({
|
|||||||
isBooking={isBooking}
|
isBooking={isBooking}
|
||||||
workflowFiles={workflowFiles}
|
workflowFiles={workflowFiles}
|
||||||
replaceMode={isMilestoneDone(clearance.milestones, "DO_COLLECTED")}
|
replaceMode={isMilestoneDone(clearance.milestones, "DO_COLLECTED")}
|
||||||
|
vesselArrivalDate={clearance.vesselArrivalDate}
|
||||||
|
doCollectedDate={clearance.doCollectedDate}
|
||||||
onChanged={onChanged}
|
onChanged={onChanged}
|
||||||
onViewFile={onViewFile}
|
onViewFile={onViewFile}
|
||||||
onDownloadFile={onDownloadFile}
|
onDownloadFile={onDownloadFile}
|
||||||
@@ -1569,9 +1597,35 @@ function DutyStep({
|
|||||||
const noticeFile = findWorkflowFile(workflowFiles, "duty_tax_notice");
|
const noticeFile = findWorkflowFile(workflowFiles, "duty_tax_notice");
|
||||||
|
|
||||||
const hasExistingNotice = Boolean(noticeFile);
|
const hasExistingNotice = Boolean(noticeFile);
|
||||||
|
const dispute = clearance.dutyDispute;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
|
{/* The customer rejected the last advice — their words drive the
|
||||||
|
correction, so they lead the step. */}
|
||||||
|
{dispute ? (
|
||||||
|
<Alert
|
||||||
|
color="orange"
|
||||||
|
radius="md"
|
||||||
|
icon={<MessageSquareWarning size={16} />}
|
||||||
|
title={
|
||||||
|
dispute.rounds > 1
|
||||||
|
? `Customer asked for a correction (round ${dispute.rounds})`
|
||||||
|
: "Customer asked for a correction"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Stack gap={4}>
|
||||||
|
<Text size="sm" style={{ whiteSpace: "pre-wrap" }}>
|
||||||
|
{dispute.note}
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
Raised {new Date(dispute.raisedAt).toLocaleString()} — re-advise
|
||||||
|
below to send a corrected notice.
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{noticeFile ? (
|
{noticeFile ? (
|
||||||
<Stack gap={8}>
|
<Stack gap={8}>
|
||||||
<Text size="xs" fw={700} c="dimmed" tt="uppercase">
|
<Text size="xs" fw={700} c="dimmed" tt="uppercase">
|
||||||
@@ -1765,6 +1819,8 @@ function DeliveryOrderStep({
|
|||||||
onChanged,
|
onChanged,
|
||||||
workflowFiles = [],
|
workflowFiles = [],
|
||||||
replaceMode = false,
|
replaceMode = false,
|
||||||
|
vesselArrivalDate,
|
||||||
|
doCollectedDate,
|
||||||
onViewFile,
|
onViewFile,
|
||||||
onDownloadFile,
|
onDownloadFile,
|
||||||
}: {
|
}: {
|
||||||
@@ -1773,12 +1829,18 @@ function DeliveryOrderStep({
|
|||||||
onChanged?: () => void;
|
onChanged?: () => void;
|
||||||
workflowFiles?: Freight.ClearanceWorkflowFile[];
|
workflowFiles?: Freight.ClearanceWorkflowFile[];
|
||||||
replaceMode?: boolean;
|
replaceMode?: boolean;
|
||||||
|
vesselArrivalDate?: string | null;
|
||||||
|
doCollectedDate?: string | null;
|
||||||
onViewFile?: (file: { name: string; url: string }) => void;
|
onViewFile?: (file: { name: string; url: string }) => void;
|
||||||
onDownloadFile?: (file: { id: string; name: string }) => void;
|
onDownloadFile?: (file: { id: string; name: string }) => void;
|
||||||
}) {
|
}) {
|
||||||
const [files, setFiles] = useState<Record<string, File | null>>({
|
const [files, setFiles] = useState<Record<string, File | null>>({
|
||||||
delivery_order: null,
|
delivery_order: null,
|
||||||
});
|
});
|
||||||
|
const [doDates, setDoDates] = useDoCollectionDates({
|
||||||
|
vesselArrivalDate,
|
||||||
|
doCollectedDate,
|
||||||
|
});
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const hasFile = Boolean(files.delivery_order);
|
const hasFile = Boolean(files.delivery_order);
|
||||||
|
|
||||||
@@ -1790,20 +1852,27 @@ function DeliveryOrderStep({
|
|||||||
workflowFiles={workflowFiles}
|
workflowFiles={workflowFiles}
|
||||||
replaceMode={replaceMode}
|
replaceMode={replaceMode}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
disabled={!hasFile}
|
disabled={!hasFile || !doDatesComplete(doDates)}
|
||||||
helperText="Upload the Djibouti Delivery Order (DO) for this shipment."
|
helperText="Upload the Djibouti Delivery Order (DO) and record when the vessel arrived and when the DO was collected."
|
||||||
submitLabel={replaceMode ? "Replace DO" : "Upload DO"}
|
submitLabel={replaceMode ? "Replace DO" : "Upload DO"}
|
||||||
|
extraFields={
|
||||||
|
<DoCollectionDateFields value={doDates} onChange={setDoDates} />
|
||||||
|
}
|
||||||
onViewFile={onViewFile}
|
onViewFile={onViewFile}
|
||||||
onDownloadFile={onDownloadFile}
|
onDownloadFile={onDownloadFile}
|
||||||
onSubmit={async () => {
|
onSubmit={async () => {
|
||||||
const file = files.delivery_order;
|
const file = files.delivery_order;
|
||||||
if (!file) return;
|
if (!file || !doDatesComplete(doDates)) return;
|
||||||
|
const dates = {
|
||||||
|
vesselArrivalDate: toIsoDate(doDates.vesselArrival)!,
|
||||||
|
doCollectedDate: toIsoDate(doDates.doCollected)!,
|
||||||
|
};
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
if (isBooking) {
|
if (isBooking) {
|
||||||
await bookingsService.uploadDeliveryOrder(entityId, file);
|
await bookingsService.uploadDeliveryOrder(entityId, file, dates);
|
||||||
} else {
|
} else {
|
||||||
await contractsService.uploadDeliveryOrder(entityId, file);
|
await contractsService.uploadDeliveryOrder(entityId, file, dates);
|
||||||
}
|
}
|
||||||
setFiles({ delivery_order: null });
|
setFiles({ delivery_order: null });
|
||||||
toast.success(replaceMode ? "Delivery Order updated" : "Delivery Order uploaded");
|
toast.success(replaceMode ? "Delivery Order updated" : "Delivery Order uploaded");
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Box, Button, Group, Paper, Stack, Text } from "@mantine/core";
|
import { Box, Button, Group, Paper, Stack, Text } from "@mantine/core";
|
||||||
import { FileText, Upload } from "lucide-react";
|
import { FileText, Upload } from "lucide-react";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
import type { Freight } from "@edr/types";
|
import type { Freight } from "@edr/types";
|
||||||
|
|
||||||
import { PhasedFileDropzone } from "@/components/contracts/PhasedFileDropzone";
|
import { PhasedFileDropzone } from "@/components/contracts/PhasedFileDropzone";
|
||||||
@@ -11,6 +12,8 @@ export interface PhasedDocumentUploadFieldProps {
|
|||||||
onChange: (key: string, file: File | null) => void;
|
onChange: (key: string, file: File | null) => void;
|
||||||
workflowFiles?: Freight.ClearanceWorkflowFile[];
|
workflowFiles?: Freight.ClearanceWorkflowFile[];
|
||||||
helperText: string;
|
helperText: string;
|
||||||
|
/** Extra inputs rendered above the dropzones (e.g. required DO dates). */
|
||||||
|
extraFields?: ReactNode;
|
||||||
replaceMode?: boolean;
|
replaceMode?: boolean;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
@@ -27,6 +30,7 @@ export function PhasedDocumentUploadField({
|
|||||||
onChange,
|
onChange,
|
||||||
workflowFiles = [],
|
workflowFiles = [],
|
||||||
helperText,
|
helperText,
|
||||||
|
extraFields,
|
||||||
replaceMode = false,
|
replaceMode = false,
|
||||||
loading = false,
|
loading = false,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
@@ -87,6 +91,7 @@ export function PhasedDocumentUploadField({
|
|||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
|
{extraFields}
|
||||||
{fields.map((f) => (
|
{fields.map((f) => (
|
||||||
<PhasedFileDropzone
|
<PhasedFileDropzone
|
||||||
key={f.key}
|
key={f.key}
|
||||||
|
|||||||
@@ -0,0 +1,246 @@
|
|||||||
|
import type { Freight } from "@edr/types";
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
Group,
|
||||||
|
Paper,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
Textarea,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { useMutation } from "@tanstack/react-query";
|
||||||
|
import { CheckCircle2, Clock, Send, UserCheck } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import toast from "react-hot-toast";
|
||||||
|
|
||||||
|
import { contractsService } from "@/services/contracts.service";
|
||||||
|
|
||||||
|
export interface TransitAssigneePanelProps {
|
||||||
|
contractId: string;
|
||||||
|
transitAssignee: Freight.ContractClearanceView["transitAssignee"];
|
||||||
|
/**
|
||||||
|
* ET asks and waits; DJ answers with a name. The same state renders from both
|
||||||
|
* desks — only the action on offer differs.
|
||||||
|
*/
|
||||||
|
side: "ET" | "DJ";
|
||||||
|
/** Hide the action (read-only audit view, or the user lacks the permission). */
|
||||||
|
readOnly?: boolean;
|
||||||
|
onChanged?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fmt = (iso?: string | null) =>
|
||||||
|
iso
|
||||||
|
? new Date(iso).toLocaleString("en-GB", {
|
||||||
|
day: "numeric",
|
||||||
|
month: "short",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
hour12: false,
|
||||||
|
})
|
||||||
|
: "—";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The transit-assignee handshake that gates the customs declaration.
|
||||||
|
*
|
||||||
|
* GL Ethiopia cannot file a declaration until Djibouti says who will handle the
|
||||||
|
* shipment on their side, so ET raises the ask here and Djibouti answers with a
|
||||||
|
* name (free text — the officer is not a platform user). Djibouti can send a
|
||||||
|
* different name later; the newest one wins and Ethiopia is notified again.
|
||||||
|
*/
|
||||||
|
export function TransitAssigneePanel({
|
||||||
|
contractId,
|
||||||
|
transitAssignee,
|
||||||
|
side,
|
||||||
|
readOnly = false,
|
||||||
|
onChanged,
|
||||||
|
}: TransitAssigneePanelProps) {
|
||||||
|
const [note, setNote] = useState("");
|
||||||
|
const [assignee, setAssignee] = useState(transitAssignee?.name ?? "");
|
||||||
|
const [changing, setChanging] = useState(false);
|
||||||
|
|
||||||
|
const request = useMutation({
|
||||||
|
mutationFn: () => contractsService.requestTransitAssignee(contractId, note.trim()),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("Request sent to GL Djibouti");
|
||||||
|
setNote("");
|
||||||
|
onChanged?.();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const assign = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
contractsService.assignTransitAssignee(contractId, assignee.trim()),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("Transit assignee sent to GL Ethiopia");
|
||||||
|
setChanging(false);
|
||||||
|
onChanged?.();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const requested = Boolean(transitAssignee?.requestedAt);
|
||||||
|
const assigned = Boolean(transitAssignee?.name);
|
||||||
|
|
||||||
|
// Settled state — both desks see the same line; DJ keeps a way to change it.
|
||||||
|
if (assigned && !changing) {
|
||||||
|
return (
|
||||||
|
<Alert
|
||||||
|
color="edr-green"
|
||||||
|
radius="md"
|
||||||
|
icon={<CheckCircle2 size={16} />}
|
||||||
|
title="Transit assignee confirmed"
|
||||||
|
>
|
||||||
|
<Group justify="space-between" wrap="wrap" gap="sm">
|
||||||
|
<Text size="sm">
|
||||||
|
<Text span fw={700}>
|
||||||
|
{transitAssignee!.name}
|
||||||
|
</Text>{" "}
|
||||||
|
will handle this shipment in transit · assigned{" "}
|
||||||
|
{fmt(transitAssignee!.assignedAt)}
|
||||||
|
</Text>
|
||||||
|
{side === "DJ" && !readOnly ? (
|
||||||
|
<Button
|
||||||
|
size="compact-xs"
|
||||||
|
variant="light"
|
||||||
|
radius="md"
|
||||||
|
onClick={() => {
|
||||||
|
setAssignee(transitAssignee!.name ?? "");
|
||||||
|
setChanging(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Change
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</Group>
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Djibouti's desk: answer the ask.
|
||||||
|
if (side === "DJ") {
|
||||||
|
if (!requested) {
|
||||||
|
return (
|
||||||
|
<Alert color="gray" radius="md" icon={<Clock size={16} />}>
|
||||||
|
GL Ethiopia has not requested a transit assignee for this clearance yet.
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Paper withBorder radius="md" p="md">
|
||||||
|
<Stack gap="sm">
|
||||||
|
<Group gap={8}>
|
||||||
|
<UserCheck size={16} />
|
||||||
|
<Text fw={700} size="sm">
|
||||||
|
{changing ? "Change the transit assignee" : "Assign a transit officer"}
|
||||||
|
</Text>
|
||||||
|
<Badge size="xs" color="orange" variant="light">
|
||||||
|
Requested {fmt(transitAssignee?.requestedAt)}
|
||||||
|
</Badge>
|
||||||
|
</Group>
|
||||||
|
{transitAssignee?.requestNote ? (
|
||||||
|
<Text size="sm" c="dimmed" style={{ whiteSpace: "pre-wrap" }}>
|
||||||
|
GL Ethiopia: {transitAssignee.requestNote}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
<TextInput
|
||||||
|
label="Transit officer"
|
||||||
|
description="Name of the person handling this shipment in Djibouti"
|
||||||
|
placeholder="e.g. Ahmed Bourhan"
|
||||||
|
value={assignee}
|
||||||
|
onChange={(e) => setAssignee(e.currentTarget.value)}
|
||||||
|
disabled={readOnly}
|
||||||
|
/>
|
||||||
|
<Group justify="flex-end" gap="sm">
|
||||||
|
{changing ? (
|
||||||
|
<Button variant="default" radius="md" onClick={() => setChanging(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
<Button
|
||||||
|
color="edr-green"
|
||||||
|
radius="md"
|
||||||
|
leftSection={<Send size={15} />}
|
||||||
|
loading={assign.isPending}
|
||||||
|
disabled={readOnly || !assignee.trim()}
|
||||||
|
onClick={() => assign.mutate()}
|
||||||
|
>
|
||||||
|
Send assignment
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ethiopia's desk: raise the ask, then wait.
|
||||||
|
if (requested) {
|
||||||
|
return (
|
||||||
|
<Alert
|
||||||
|
color="orange"
|
||||||
|
radius="md"
|
||||||
|
icon={<Clock size={16} />}
|
||||||
|
title="Waiting for GL Djibouti"
|
||||||
|
>
|
||||||
|
<Stack gap="sm" align="flex-start">
|
||||||
|
<Text size="sm">
|
||||||
|
Requested {fmt(transitAssignee?.requestedAt)}. The customs declaration
|
||||||
|
opens once Djibouti names the transit officer.
|
||||||
|
</Text>
|
||||||
|
{!readOnly ? (
|
||||||
|
<Button
|
||||||
|
size="compact-xs"
|
||||||
|
variant="light"
|
||||||
|
color="orange"
|
||||||
|
radius="md"
|
||||||
|
loading={request.isPending}
|
||||||
|
onClick={() => request.mutate()}
|
||||||
|
>
|
||||||
|
Send a reminder
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</Stack>
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper withBorder radius="md" p="md">
|
||||||
|
<Stack gap="sm">
|
||||||
|
<Group gap={8}>
|
||||||
|
<UserCheck size={16} />
|
||||||
|
<Text fw={700} size="sm">
|
||||||
|
Request a transit assignee
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
GL Djibouti must name the officer handling this shipment in transit
|
||||||
|
before the customs declaration can be filed.
|
||||||
|
</Text>
|
||||||
|
<Textarea
|
||||||
|
label="Note for GL Djibouti (optional)"
|
||||||
|
placeholder="Anything they need to know to pick the right officer"
|
||||||
|
value={note}
|
||||||
|
onChange={(e) => setNote(e.currentTarget.value)}
|
||||||
|
autosize
|
||||||
|
minRows={2}
|
||||||
|
disabled={readOnly}
|
||||||
|
/>
|
||||||
|
<Group justify="flex-end">
|
||||||
|
<Button
|
||||||
|
color="edr-green"
|
||||||
|
radius="md"
|
||||||
|
leftSection={<Send size={15} />}
|
||||||
|
loading={request.isPending}
|
||||||
|
disabled={readOnly}
|
||||||
|
onClick={() => request.mutate()}
|
||||||
|
>
|
||||||
|
Request assignee
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TransitAssigneePanel;
|
||||||
@@ -147,7 +147,7 @@ export const BookingDetailModal = ({
|
|||||||
<Group gap={4} justify="flex-end">
|
<Group gap={4} justify="flex-end">
|
||||||
{bookingWagons.map((w) => (
|
{bookingWagons.map((w) => (
|
||||||
<Badge key={w.id} size="sm" variant="outline" color="edr-green" radius="sm">
|
<Badge key={w.id} size="sm" variant="outline" color="edr-green" radius="sm">
|
||||||
#{w.sequenceNo}
|
#{w.position ?? w.sequenceNo}
|
||||||
</Badge>
|
</Badge>
|
||||||
))}
|
))}
|
||||||
</Group>
|
</Group>
|
||||||
|
|||||||
@@ -281,7 +281,7 @@ function WagonCar({
|
|||||||
{/* header */}
|
{/* header */}
|
||||||
<Group justify="space-between" px={7} pt={3} wrap="nowrap">
|
<Group justify="space-between" px={7} pt={3} wrap="nowrap">
|
||||||
<Text size="10px" fw={800} c="gray.7">
|
<Text size="10px" fw={800} c="gray.7">
|
||||||
#{wagon.sequenceNo}
|
#{wagon.position ?? wagon.sequenceNo}
|
||||||
</Text>
|
</Text>
|
||||||
{isEmpty ? (
|
{isEmpty ? (
|
||||||
<Text size="8px" c="dimmed" fw={700} style={{ letterSpacing: 0.5 }}>
|
<Text size="8px" c="dimmed" fw={700} style={{ letterSpacing: 0.5 }}>
|
||||||
@@ -425,7 +425,7 @@ function WagonCar({
|
|||||||
</Box>
|
</Box>
|
||||||
<div>
|
<div>
|
||||||
<Text size="sm" fw={800}>
|
<Text size="sm" fw={800}>
|
||||||
Wagon #{wagon.sequenceNo}
|
Wagon #{wagon.position ?? wagon.sequenceNo}
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="10px" c="dimmed">
|
<Text size="10px" c="dimmed">
|
||||||
{wagon.physicalWagonNumber ?? wagon.wagonType?.code ?? "Unassigned"}
|
{wagon.physicalWagonNumber ?? wagon.wagonType?.code ?? "Unassigned"}
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ export const RemoveBookingModal = ({
|
|||||||
<strong>Gross weight:</strong> {grossTons.toFixed(2)} T
|
<strong>Gross weight:</strong> {grossTons.toFixed(2)} T
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="sm">
|
<Text size="sm">
|
||||||
<strong>Wagon Slot:</strong> #{wagon.sequenceNo}
|
<strong>Wagon Slot:</strong> #{wagon.position ?? wagon.sequenceNo}
|
||||||
</Text>
|
</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -217,7 +217,7 @@ export const TrainConsistView = ({
|
|||||||
<Box>
|
<Box>
|
||||||
<Group gap={6} mb={6} wrap="nowrap">
|
<Group gap={6} mb={6} wrap="nowrap">
|
||||||
<Badge variant="light" color="edr-green" radius="sm">
|
<Badge variant="light" color="edr-green" radius="sm">
|
||||||
Editing wagon #{selectedWagon.sequenceNo}
|
Editing wagon #{selectedWagon.position ?? selectedWagon.sequenceNo}
|
||||||
</Badge>
|
</Badge>
|
||||||
<Text size="xs" c="dimmed">
|
<Text size="xs" c="dimmed">
|
||||||
Update container numbers, move containers to another wagon, or remove the booking
|
Update container numbers, move containers to another wagon, or remove the booking
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ export const WagonCard = ({
|
|||||||
<div>
|
<div>
|
||||||
<Group gap={4}>
|
<Group gap={4}>
|
||||||
<Text size="sm" fw={800}>
|
<Text size="sm" fw={800}>
|
||||||
Wagon #{wagon.sequenceNo}
|
Wagon #{wagon.position ?? wagon.sequenceNo}
|
||||||
</Text>
|
</Text>
|
||||||
<Badge size="xs" variant="light" color="edr-green">
|
<Badge size="xs" variant="light" color="edr-green">
|
||||||
{wagonType}
|
{wagonType}
|
||||||
@@ -205,7 +205,7 @@ export const WagonCard = ({
|
|||||||
>
|
>
|
||||||
<Group gap={6} wrap="nowrap" justify="space-between">
|
<Group gap={6} wrap="nowrap" justify="space-between">
|
||||||
<Text size="xs" fw={600} truncate>
|
<Text size="xs" fw={600} truncate>
|
||||||
#{w.sequenceNo} ·{" "}
|
#{w.position ?? w.sequenceNo} ·{" "}
|
||||||
{w.physicalWagonNumber ?? w.wagonType?.code ?? "Wagon"}
|
{w.physicalWagonNumber ?? w.wagonType?.code ?? "Wagon"}
|
||||||
</Text>
|
</Text>
|
||||||
<Badge
|
<Badge
|
||||||
|
|||||||
@@ -1,613 +0,0 @@
|
|||||||
import { Freight } from "@edr/types";
|
|
||||||
import {
|
|
||||||
Badge,
|
|
||||||
Button,
|
|
||||||
Card,
|
|
||||||
Checkbox,
|
|
||||||
Divider,
|
|
||||||
Group,
|
|
||||||
Loader,
|
|
||||||
Modal,
|
|
||||||
ScrollArea,
|
|
||||||
Stack,
|
|
||||||
Switch,
|
|
||||||
Tabs,
|
|
||||||
Text,
|
|
||||||
ThemeIcon,
|
|
||||||
} from "@mantine/core";
|
|
||||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
|
||||||
import {
|
|
||||||
ArrowRight,
|
|
||||||
ChevronLeft,
|
|
||||||
History,
|
|
||||||
Inbox,
|
|
||||||
PackageCheck,
|
|
||||||
Warehouse,
|
|
||||||
X,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { useMemo, useState } from "react";
|
|
||||||
|
|
||||||
import { api } from "@/services/api";
|
|
||||||
import { useAuth } from "@/auth/useAuth";
|
|
||||||
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
|
||||||
import { useToast } from "@/hooks/use-toast";
|
|
||||||
import type {
|
|
||||||
WagonMovementRecord,
|
|
||||||
WagonTransferRequest,
|
|
||||||
} from "@/services/wagon.service";
|
|
||||||
|
|
||||||
export interface WagonTransferRequestsModalProps {
|
|
||||||
opened: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const PENDING = Freight.WagonTransferRequestStatus.Pending;
|
|
||||||
const AVAILABLE = Freight.WagonStatus.Available;
|
|
||||||
|
|
||||||
const yardLabel = (y?: { label?: string; code?: string } | null) =>
|
|
||||||
y?.label || y?.code || "—";
|
|
||||||
const typeLabel = (t?: { code?: string; name?: string } | null) =>
|
|
||||||
t ? `${t.code ?? ""}${t.name ? ` · ${t.name}` : ""}` : "—";
|
|
||||||
|
|
||||||
/** Requester → destination + type + count summary line, reused in list and picker. */
|
|
||||||
const RequestSummary = ({ r }: { r: WagonTransferRequest }) => (
|
|
||||||
<Group gap={8} wrap="nowrap">
|
|
||||||
<Text fw={600} size="sm" truncate>
|
|
||||||
{yardLabel(r.fromYard)}
|
|
||||||
</Text>
|
|
||||||
<ArrowRight size={14} style={{ flexShrink: 0 }} />
|
|
||||||
<Text fw={600} size="sm" truncate>
|
|
||||||
{yardLabel(r.toYard)}
|
|
||||||
</Text>
|
|
||||||
<Badge variant="light" color="grape" radius="sm">
|
|
||||||
{r.quantity}× {typeLabel(r.wagonType)}
|
|
||||||
</Badge>
|
|
||||||
</Group>
|
|
||||||
);
|
|
||||||
|
|
||||||
const STATUS_COLOR: Record<string, string> = {
|
|
||||||
PENDING: "gray",
|
|
||||||
FULFILLED: "teal",
|
|
||||||
CANCELLED: "red",
|
|
||||||
};
|
|
||||||
|
|
||||||
const fmtDateTime = (iso: string) =>
|
|
||||||
new Date(iso).toLocaleString("en-GB", {
|
|
||||||
day: "numeric",
|
|
||||||
month: "short",
|
|
||||||
hour: "2-digit",
|
|
||||||
minute: "2-digit",
|
|
||||||
hour12: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Per-user transfer history. A staffer sees their OWN activity — the requests
|
|
||||||
* they filed or fulfilled, and the individual wagons they moved. Holders of
|
|
||||||
* `transfer_history_all` get an "All staff" toggle that widens the view; the
|
|
||||||
* backend enforces the scope regardless of the toggle.
|
|
||||||
*/
|
|
||||||
function HistoryPanel({ opened }: { opened: boolean }) {
|
|
||||||
const { user } = useAuth();
|
|
||||||
const canSeeAll = hasPermission(
|
|
||||||
user,
|
|
||||||
FREIGHT_PERMS.wagons.transferHistoryAll,
|
|
||||||
);
|
|
||||||
const myId = (user as { id?: string } | null | undefined)?.id;
|
|
||||||
const [allStaff, setAllStaff] = useState(false);
|
|
||||||
const scopeAll = canSeeAll && allStaff;
|
|
||||||
|
|
||||||
const mine = useQuery({
|
|
||||||
...api.wagonTransferRequests.history.queryOptions(),
|
|
||||||
enabled: opened && !scopeAll,
|
|
||||||
});
|
|
||||||
const all = useQuery({
|
|
||||||
...api.wagonTransferRequests.historyAll.queryOptions({ input: {} }),
|
|
||||||
enabled: opened && scopeAll,
|
|
||||||
});
|
|
||||||
const source = scopeAll ? all : mine;
|
|
||||||
const requests = source.data?.requests ?? [];
|
|
||||||
const movements: WagonMovementRecord[] = source.data?.movements ?? [];
|
|
||||||
|
|
||||||
const roleBadge = (r: WagonTransferRequest) => {
|
|
||||||
if (myId && r.fulfilledByUserId === myId)
|
|
||||||
return (
|
|
||||||
<Badge size="xs" variant="light" color="blue">
|
|
||||||
fulfilled
|
|
||||||
</Badge>
|
|
||||||
);
|
|
||||||
if (myId && r.requestedByUserId === myId)
|
|
||||||
return (
|
|
||||||
<Badge size="xs" variant="light" color="grape">
|
|
||||||
requested
|
|
||||||
</Badge>
|
|
||||||
);
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Stack gap="lg">
|
|
||||||
{canSeeAll ? (
|
|
||||||
<Group justify="flex-end">
|
|
||||||
<Switch
|
|
||||||
checked={allStaff}
|
|
||||||
onChange={(e) => setAllStaff(e.currentTarget.checked)}
|
|
||||||
label="All staff"
|
|
||||||
color="edr-green"
|
|
||||||
/>
|
|
||||||
</Group>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{source.isLoading ? (
|
|
||||||
<Group justify="center" p="xl">
|
|
||||||
<Loader />
|
|
||||||
</Group>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div>
|
|
||||||
<Text fw={700} size="sm" mb={8}>
|
|
||||||
Requests{scopeAll ? "" : " you touched"}
|
|
||||||
</Text>
|
|
||||||
{requests.length === 0 ? (
|
|
||||||
<Text size="sm" c="dimmed">
|
|
||||||
No requests yet.
|
|
||||||
</Text>
|
|
||||||
) : (
|
|
||||||
<Stack gap={6}>
|
|
||||||
{requests.map((r) => (
|
|
||||||
<Card key={r.id} withBorder radius="md" padding="xs">
|
|
||||||
<Group justify="space-between" wrap="nowrap">
|
|
||||||
<RequestSummary r={r} />
|
|
||||||
<Group gap={8} wrap="nowrap">
|
|
||||||
{roleBadge(r)}
|
|
||||||
<Badge
|
|
||||||
size="sm"
|
|
||||||
variant="light"
|
|
||||||
color={STATUS_COLOR[r.status] ?? "gray"}
|
|
||||||
>
|
|
||||||
{r.status.toLowerCase()}
|
|
||||||
</Badge>
|
|
||||||
</Group>
|
|
||||||
</Group>
|
|
||||||
{r.reason ? (
|
|
||||||
<Text size="xs" c="dimmed" mt={4}>
|
|
||||||
Reason: {r.reason}
|
|
||||||
</Text>
|
|
||||||
) : null}
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</Stack>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Divider />
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Text fw={700} size="sm" mb={8}>
|
|
||||||
Wagons moved
|
|
||||||
</Text>
|
|
||||||
{movements.length === 0 ? (
|
|
||||||
<Text size="sm" c="dimmed">
|
|
||||||
No wagon moves yet.
|
|
||||||
</Text>
|
|
||||||
) : (
|
|
||||||
<ScrollArea.Autosize mah={260}>
|
|
||||||
<Stack gap={6}>
|
|
||||||
{movements.map((m) => (
|
|
||||||
<Card key={m.id} withBorder radius="md" padding="xs">
|
|
||||||
<Group justify="space-between" wrap="nowrap">
|
|
||||||
<Group gap={8} wrap="nowrap" style={{ minWidth: 0 }}>
|
|
||||||
<Text fw={600} size="sm">
|
|
||||||
{m.wagon?.wagonNumber ?? "Wagon"}
|
|
||||||
</Text>
|
|
||||||
<Text size="xs" c="dimmed" truncate>
|
|
||||||
{yardLabel(m.fromYard)} → {yardLabel(m.toYard)}
|
|
||||||
</Text>
|
|
||||||
{m.transferRequestId ? (
|
|
||||||
<Badge size="xs" variant="light" color="teal">
|
|
||||||
from request
|
|
||||||
</Badge>
|
|
||||||
) : null}
|
|
||||||
</Group>
|
|
||||||
<Text size="xs" c="dimmed" style={{ flexShrink: 0 }}>
|
|
||||||
{fmtDateTime(m.occurredAt)}
|
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</Stack>
|
|
||||||
</ScrollArea.Autosize>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* OCC fulfilment queue for wagon-transfer requests. Lists PENDING requests; open
|
|
||||||
* one to hand-pick exactly the requested number of wagons from the source yard
|
|
||||||
* (of the requested type) and execute the move, or cancel the request.
|
|
||||||
* A second tab shows per-user transfer history.
|
|
||||||
*/
|
|
||||||
const WagonTransferRequestsModal = ({
|
|
||||||
opened,
|
|
||||||
onClose,
|
|
||||||
}: WagonTransferRequestsModalProps) => {
|
|
||||||
const { toast } = useToast();
|
|
||||||
const [tab, setTab] = useState<string | null>("queue");
|
|
||||||
const [active, setActive] = useState<WagonTransferRequest | null>(null);
|
|
||||||
const [picked, setPicked] = useState<Set<string>>(new Set());
|
|
||||||
// Bulk accept-and-execute: the subset of pending requests OCC ticked.
|
|
||||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
|
||||||
|
|
||||||
const { data: requests = [], isLoading } = useQuery({
|
|
||||||
...api.wagonTransferRequests.list.queryOptions({ input: { status: PENDING } }),
|
|
||||||
enabled: opened,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Available wagons of the requested type sitting in the request's source yard.
|
|
||||||
const { data: wagons = [], isLoading: wagonsLoading } = useQuery({
|
|
||||||
...api.wagons.list.queryOptions({
|
|
||||||
input: {
|
|
||||||
filters: active
|
|
||||||
? {
|
|
||||||
currentYardId: active.fromYardId,
|
|
||||||
wagonTypeId: active.wagonTypeId,
|
|
||||||
status: AVAILABLE,
|
|
||||||
}
|
|
||||||
: {},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
enabled: opened && Boolean(active),
|
|
||||||
});
|
|
||||||
|
|
||||||
const fulfill = useMutation(api.wagonTransferRequests.fulfill.mutationOptions());
|
|
||||||
const bulkFulfill = useMutation(
|
|
||||||
api.wagonTransferRequests.bulkFulfill.mutationOptions(),
|
|
||||||
);
|
|
||||||
const cancel = useMutation(api.wagonTransferRequests.cancel.mutationOptions());
|
|
||||||
|
|
||||||
const showError = (err: unknown, fallback: string) => {
|
|
||||||
const message =
|
|
||||||
(err as { response?: { data?: { message?: string } } })?.response?.data
|
|
||||||
?.message ?? fallback;
|
|
||||||
toast({ title: fallback, description: String(message), variant: "destructive" });
|
|
||||||
};
|
|
||||||
|
|
||||||
const openPicker = (r: WagonTransferRequest) => {
|
|
||||||
setActive(r);
|
|
||||||
setPicked(new Set());
|
|
||||||
};
|
|
||||||
const closePicker = () => {
|
|
||||||
setActive(null);
|
|
||||||
setPicked(new Set());
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggle = (id: string) =>
|
|
||||||
setPicked((prev) => {
|
|
||||||
const next = new Set(prev);
|
|
||||||
if (next.has(id)) next.delete(id);
|
|
||||||
else if (active && next.size >= active.quantity) return prev; // cap at quantity
|
|
||||||
else next.add(id);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
|
|
||||||
const need = active?.quantity ?? 0;
|
|
||||||
const shortfall = active ? Math.max(0, need - wagons.length) : 0;
|
|
||||||
|
|
||||||
const handleFulfill = async () => {
|
|
||||||
if (!active || picked.size !== need) return;
|
|
||||||
try {
|
|
||||||
await fulfill.mutateAsync({ id: active.id, wagonIds: [...picked] });
|
|
||||||
toast({
|
|
||||||
title: `Transferred ${need} wagon(s) · ${yardLabel(active.fromYard)} → ${yardLabel(
|
|
||||||
active.toYard,
|
|
||||||
)}`,
|
|
||||||
});
|
|
||||||
closePicker();
|
|
||||||
} catch (err) {
|
|
||||||
showError(err, "Transfer failed");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCancel = async (r: WagonTransferRequest) => {
|
|
||||||
try {
|
|
||||||
await cancel.mutateAsync({ id: r.id });
|
|
||||||
toast({ title: "Request cancelled" });
|
|
||||||
} catch (err) {
|
|
||||||
showError(err, "Cancel failed");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleSelected = (id: string) =>
|
|
||||||
setSelected((prev) => {
|
|
||||||
const next = new Set(prev);
|
|
||||||
if (next.has(id)) next.delete(id);
|
|
||||||
else next.add(id);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Execute the ticked subset; whatever cannot run (not enough available
|
|
||||||
// wagons, already decided) is reported and simply stays PENDING.
|
|
||||||
const handleBulkFulfill = async () => {
|
|
||||||
if (selected.size === 0) return;
|
|
||||||
try {
|
|
||||||
const res = await bulkFulfill.mutateAsync({ requestIds: [...selected] });
|
|
||||||
setSelected(new Set());
|
|
||||||
const skippedNote = res.skipped.length
|
|
||||||
? ` · ${res.skipped.length} left pending (${res.skipped
|
|
||||||
.map((s) => s.reason)
|
|
||||||
.join('; ')})`
|
|
||||||
: "";
|
|
||||||
toast({
|
|
||||||
title: `Executed ${res.fulfilled.length} transfer request(s)`,
|
|
||||||
description: skippedNote || undefined,
|
|
||||||
variant: res.fulfilled.length === 0 ? "destructive" : undefined,
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
showError(err, "Bulk execute failed");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const sortedWagons = useMemo(
|
|
||||||
() => [...wagons].sort((a, b) => a.wagonNumber.localeCompare(b.wagonNumber)),
|
|
||||||
[wagons],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
opened={opened}
|
|
||||||
onClose={onClose}
|
|
||||||
size="min(760px, 96vw)"
|
|
||||||
radius="lg"
|
|
||||||
centered
|
|
||||||
overlayProps={{ blur: 2 }}
|
|
||||||
title={
|
|
||||||
<Group gap="sm">
|
|
||||||
<ThemeIcon variant="light" color="edr-green" radius="md" size="lg">
|
|
||||||
<Inbox size={18} />
|
|
||||||
</ThemeIcon>
|
|
||||||
<div>
|
|
||||||
<Text fw={700}>Wagon Transfer Requests</Text>
|
|
||||||
<Text size="xs" c="dimmed">
|
|
||||||
{active
|
|
||||||
? "Pick the wagons to move, then transfer"
|
|
||||||
: "OCC queue — pick wagons and complete each move"}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
</Group>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Tabs value={tab} onChange={setTab} keepMounted={false}>
|
|
||||||
<Tabs.List mb="md">
|
|
||||||
<Tabs.Tab value="queue" leftSection={<Inbox size={14} />}>
|
|
||||||
Queue
|
|
||||||
</Tabs.Tab>
|
|
||||||
<Tabs.Tab value="history" leftSection={<History size={14} />}>
|
|
||||||
History
|
|
||||||
</Tabs.Tab>
|
|
||||||
</Tabs.List>
|
|
||||||
|
|
||||||
<Tabs.Panel value="queue">
|
|
||||||
{!active ? (
|
|
||||||
// ---- Pending queue ----
|
|
||||||
isLoading ? (
|
|
||||||
<Group justify="center" p="xl">
|
|
||||||
<Loader />
|
|
||||||
</Group>
|
|
||||||
) : requests.length === 0 ? (
|
|
||||||
<Card withBorder radius="md" padding="xl">
|
|
||||||
<Stack align="center" gap={6}>
|
|
||||||
<ThemeIcon variant="light" color="gray" radius="xl" size={48}>
|
|
||||||
<Inbox size={22} />
|
|
||||||
</ThemeIcon>
|
|
||||||
<Text fw={600}>No pending transfer requests</Text>
|
|
||||||
<Text size="sm" c="dimmed" ta="center" maw={420}>
|
|
||||||
When staff request a yard-to-yard wagon move, it appears here for
|
|
||||||
you to fulfil.
|
|
||||||
</Text>
|
|
||||||
</Stack>
|
|
||||||
</Card>
|
|
||||||
) : (
|
|
||||||
<Stack gap="sm">
|
|
||||||
{/* Bulk accept-and-execute action bar: tick a subset, run it, and
|
|
||||||
everything unticked (or unexecutable) stays PENDING. */}
|
|
||||||
<Group justify="space-between" wrap="nowrap">
|
|
||||||
<Checkbox
|
|
||||||
label={
|
|
||||||
selected.size > 0
|
|
||||||
? `${selected.size} of ${requests.length} selected`
|
|
||||||
: "Select all"
|
|
||||||
}
|
|
||||||
checked={selected.size === requests.length && requests.length > 0}
|
|
||||||
indeterminate={selected.size > 0 && selected.size < requests.length}
|
|
||||||
onChange={() =>
|
|
||||||
setSelected(
|
|
||||||
selected.size === requests.length
|
|
||||||
? new Set()
|
|
||||||
: new Set(requests.map((r) => r.id)),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
color="edr-green"
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
size="compact-sm"
|
|
||||||
color="edr-green"
|
|
||||||
leftSection={<PackageCheck size={14} />}
|
|
||||||
loading={bulkFulfill.isPending}
|
|
||||||
disabled={selected.size === 0}
|
|
||||||
onClick={handleBulkFulfill}
|
|
||||||
>
|
|
||||||
Accept & execute {selected.size > 0 ? `(${selected.size})` : ""}
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
{requests.map((r) => (
|
|
||||||
<Card key={r.id} withBorder radius="md" padding="md">
|
|
||||||
<Group justify="space-between" wrap="nowrap" align="flex-start">
|
|
||||||
<Group gap="sm" wrap="nowrap" align="flex-start" style={{ minWidth: 0 }}>
|
|
||||||
<Checkbox
|
|
||||||
checked={selected.has(r.id)}
|
|
||||||
onChange={() => toggleSelected(r.id)}
|
|
||||||
color="edr-green"
|
|
||||||
mt={2}
|
|
||||||
/>
|
|
||||||
<Stack gap={6} style={{ minWidth: 0 }}>
|
|
||||||
<RequestSummary r={r} />
|
|
||||||
{r.reason ? (
|
|
||||||
<Text size="xs">
|
|
||||||
<Text span fw={600}>
|
|
||||||
Reason:
|
|
||||||
</Text>{" "}
|
|
||||||
{r.reason}
|
|
||||||
</Text>
|
|
||||||
) : null}
|
|
||||||
{r.note ? (
|
|
||||||
<Text size="xs" c="dimmed">
|
|
||||||
“{r.note}”
|
|
||||||
</Text>
|
|
||||||
) : null}
|
|
||||||
</Stack>
|
|
||||||
</Group>
|
|
||||||
<Group gap={8} wrap="nowrap">
|
|
||||||
<Button
|
|
||||||
size="compact-sm"
|
|
||||||
variant="subtle"
|
|
||||||
color="gray"
|
|
||||||
leftSection={<X size={14} />}
|
|
||||||
loading={cancel.isPending}
|
|
||||||
onClick={() => handleCancel(r)}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="compact-sm"
|
|
||||||
color="edr-green"
|
|
||||||
leftSection={<PackageCheck size={14} />}
|
|
||||||
onClick={() => openPicker(r)}
|
|
||||||
>
|
|
||||||
Fulfil
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
</Group>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</Stack>
|
|
||||||
)
|
|
||||||
) : (
|
|
||||||
// ---- Wagon picker for the active request ----
|
|
||||||
<Stack gap="md">
|
|
||||||
<Card withBorder radius="md" padding="sm" bg="var(--mantine-color-gray-0)">
|
|
||||||
<RequestSummary r={active} />
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Group justify="space-between">
|
|
||||||
<Text size="sm" fw={600}>
|
|
||||||
Select wagons in {yardLabel(active.fromYard)}
|
|
||||||
</Text>
|
|
||||||
<Badge
|
|
||||||
color={picked.size === need ? "teal" : "gray"}
|
|
||||||
variant={picked.size === need ? "filled" : "light"}
|
|
||||||
>
|
|
||||||
{picked.size} / {need} selected
|
|
||||||
</Badge>
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
{wagonsLoading ? (
|
|
||||||
<Group justify="center" p="lg">
|
|
||||||
<Loader size="sm" />
|
|
||||||
</Group>
|
|
||||||
) : sortedWagons.length === 0 ? (
|
|
||||||
<Card withBorder radius="md" padding="lg">
|
|
||||||
<Group gap={8} justify="center">
|
|
||||||
<Warehouse size={16} />
|
|
||||||
<Text size="sm" c="dimmed">
|
|
||||||
No available wagons of this type in {yardLabel(active.fromYard)}.
|
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
</Card>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{shortfall > 0 ? (
|
|
||||||
<Text size="xs" c="orange.7">
|
|
||||||
Only {sortedWagons.length} available — {shortfall} short of the{" "}
|
|
||||||
{need} requested.
|
|
||||||
</Text>
|
|
||||||
) : null}
|
|
||||||
<ScrollArea.Autosize mah={320}>
|
|
||||||
<Stack gap={6}>
|
|
||||||
{sortedWagons.map((w) => {
|
|
||||||
const checked = picked.has(w.id);
|
|
||||||
const atCap = !checked && picked.size >= need;
|
|
||||||
return (
|
|
||||||
<Card
|
|
||||||
key={w.id}
|
|
||||||
withBorder
|
|
||||||
radius="md"
|
|
||||||
padding="xs"
|
|
||||||
onClick={() => !atCap && toggle(w.id)}
|
|
||||||
style={{
|
|
||||||
cursor: atCap ? "not-allowed" : "pointer",
|
|
||||||
borderColor: checked
|
|
||||||
? "var(--mantine-color-edr-green-4)"
|
|
||||||
: undefined,
|
|
||||||
opacity: atCap ? 0.55 : 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Group gap="sm" wrap="nowrap">
|
|
||||||
{/* Visual only — the Card's onClick owns the toggle so a
|
|
||||||
click on the box doesn't fire both and cancel out. */}
|
|
||||||
<Checkbox
|
|
||||||
checked={checked}
|
|
||||||
readOnly
|
|
||||||
disabled={atCap}
|
|
||||||
color="edr-green"
|
|
||||||
tabIndex={-1}
|
|
||||||
aria-hidden
|
|
||||||
/>
|
|
||||||
<Text fw={600} size="sm">
|
|
||||||
{w.wagonNumber}
|
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Stack>
|
|
||||||
</ScrollArea.Autosize>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Divider />
|
|
||||||
<Group justify="space-between">
|
|
||||||
<Button
|
|
||||||
variant="subtle"
|
|
||||||
color="gray"
|
|
||||||
leftSection={<ChevronLeft size={16} />}
|
|
||||||
onClick={closePicker}
|
|
||||||
>
|
|
||||||
Back to queue
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
color="edr-green"
|
|
||||||
leftSection={<PackageCheck size={16} />}
|
|
||||||
loading={fulfill.isPending}
|
|
||||||
disabled={picked.size !== need}
|
|
||||||
onClick={handleFulfill}
|
|
||||||
>
|
|
||||||
Transfer {need} wagon{need === 1 ? "" : "s"}
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
</Stack>
|
|
||||||
)}
|
|
||||||
</Tabs.Panel>
|
|
||||||
|
|
||||||
<Tabs.Panel value="history">
|
|
||||||
<HistoryPanel opened={opened} />
|
|
||||||
</Tabs.Panel>
|
|
||||||
</Tabs>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default WagonTransferRequestsModal;
|
|
||||||
@@ -40,7 +40,12 @@ const clampInt = (v: number | string, max: number): number => {
|
|||||||
return Math.min(Math.floor(n), max);
|
return Math.min(Math.floor(n), max);
|
||||||
};
|
};
|
||||||
|
|
||||||
/** NumberInput + Slider + All/Half presets, kept in sync and bounded to `max`. */
|
/**
|
||||||
|
* NumberInput + Slider + All/Half presets, kept in sync. `max` bounds the field
|
||||||
|
* for actions that move real wagons; omit it for a transfer REQUEST, which may
|
||||||
|
* legitimately ask for more than the yard holds today (OCC fulfils it in
|
||||||
|
* instalments) — the slider then just tracks the current value.
|
||||||
|
*/
|
||||||
const QuantityField = ({
|
const QuantityField = ({
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
@@ -49,10 +54,11 @@ const QuantityField = ({
|
|||||||
}: {
|
}: {
|
||||||
value: number;
|
value: number;
|
||||||
onChange: (n: number) => void;
|
onChange: (n: number) => void;
|
||||||
max: number;
|
max?: number;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
const set = (v: number | string) => onChange(clampInt(v, max));
|
const capped = max ?? Number.MAX_SAFE_INTEGER;
|
||||||
|
const set = (v: number | string) => onChange(clampInt(v, capped));
|
||||||
const off = disabled || max === 0;
|
const off = disabled || max === 0;
|
||||||
return (
|
return (
|
||||||
<Stack gap={8}>
|
<Stack gap={8}>
|
||||||
@@ -73,19 +79,25 @@ const QuantityField = ({
|
|||||||
value={value}
|
value={value}
|
||||||
onChange={set}
|
onChange={set}
|
||||||
min={0}
|
min={0}
|
||||||
max={Math.max(max, 1)}
|
max={Math.max(max ?? Math.max(value, 10), 1)}
|
||||||
disabled={off}
|
disabled={off}
|
||||||
label={(v) => `${v}`}
|
label={(v) => `${v}`}
|
||||||
color="edr-green"
|
color="edr-green"
|
||||||
/>
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
<Group gap={6}>
|
<Group gap={6}>
|
||||||
<Button size="compact-xs" variant="light" color="gray" disabled={off} onClick={() => set(Math.ceil(max / 2))}>
|
{/* Presets only make sense against a real ceiling — an uncapped request
|
||||||
Half
|
field (transfer ask) shows the manual input alone. */}
|
||||||
</Button>
|
{max != null ? (
|
||||||
<Button size="compact-xs" variant="light" color="gray" disabled={off} onClick={() => set(max)}>
|
<>
|
||||||
All ({max})
|
<Button size="compact-xs" variant="light" color="gray" disabled={off} onClick={() => set(Math.ceil(max / 2))}>
|
||||||
</Button>
|
Half
|
||||||
|
</Button>
|
||||||
|
<Button size="compact-xs" variant="light" color="gray" disabled={off} onClick={() => set(max)}>
|
||||||
|
All ({max})
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
{value > 0 ? (
|
{value > 0 ? (
|
||||||
<Button size="compact-xs" variant="subtle" color="gray" onClick={() => set(0)}>
|
<Button size="compact-xs" variant="subtle" color="gray" onClick={() => set(0)}>
|
||||||
Clear
|
Clear
|
||||||
@@ -242,9 +254,10 @@ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalPro
|
|||||||
}
|
}
|
||||||
}, [opened]);
|
}, [opened]);
|
||||||
|
|
||||||
// Keep quantities within bounds as counts shift after each action. Transfers
|
// Keep quantities within bounds as counts shift after each action. A TRANSFER
|
||||||
// may only ask for AVAILABLE wagons, so the request cap is availableCount.
|
// REQUEST is deliberately uncapped: OCC delivers in instalments, so asking for
|
||||||
useEffect(() => setTransferQty((q) => Math.min(q, availableCount)), [availableCount]);
|
// 50 where 20 sit today is normal — only the status flips below are bounded by
|
||||||
|
// what is physically in the yard.
|
||||||
useEffect(() => setToAssignedQty((q) => Math.min(q, availableCount)), [availableCount]);
|
useEffect(() => setToAssignedQty((q) => Math.min(q, availableCount)), [availableCount]);
|
||||||
useEffect(() => setToAvailableQty((q) => Math.min(q, assignedCount)), [assignedCount]);
|
useEffect(() => setToAvailableQty((q) => Math.min(q, assignedCount)), [assignedCount]);
|
||||||
|
|
||||||
@@ -453,11 +466,9 @@ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalPro
|
|||||||
{availableCount} available
|
{availableCount} available
|
||||||
</Badge>
|
</Badge>
|
||||||
</Group>
|
</Group>
|
||||||
<QuantityField
|
{/* No max: the request may exceed what the yard holds
|
||||||
value={transferQty}
|
today — OCC fulfils it in instalments. */}
|
||||||
onChange={setTransferQty}
|
<QuantityField value={transferQty} onChange={setTransferQty} />
|
||||||
max={availableCount}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<Select
|
<Select
|
||||||
label="Destination yard"
|
label="Destination yard"
|
||||||
|
|||||||
@@ -186,6 +186,16 @@ export const URL_CONSTANTS = {
|
|||||||
CLEARANCE_QUEUE: "/contracts/clearance/queue",
|
CLEARANCE_QUEUE: "/contracts/clearance/queue",
|
||||||
CLEARANCE: (id: string) => `/contracts/${id}/clearance`,
|
CLEARANCE: (id: string) => `/contracts/${id}/clearance`,
|
||||||
CLEARANCE_REVIEW: (id: string) => `/contracts/${id}/clearance/review`,
|
CLEARANCE_REVIEW: (id: string) => `/contracts/${id}/clearance/review`,
|
||||||
|
/** Pre-declaration transit-assignee handshake (ET asks, DJ answers). */
|
||||||
|
CLEARANCE_TRANSIT_ASSIGNEE_REQUEST: (id: string) =>
|
||||||
|
`/contracts/${id}/clearance/transit-assignee/request`,
|
||||||
|
CLEARANCE_TRANSIT_ASSIGNEE_ASSIGN: (id: string) =>
|
||||||
|
`/contracts/${id}/clearance/transit-assignee/assign`,
|
||||||
|
/** Staff corrects a clearance document in place; the old version is kept. */
|
||||||
|
CLEARANCE_DOC_REPLACE: (id: string, fileKey: string) =>
|
||||||
|
`/contracts/${id}/clearance/documents/${encodeURIComponent(fileKey)}/replace`,
|
||||||
|
CLEARANCE_DOC_VERSIONS: (id: string, fileKey: string) =>
|
||||||
|
`/contracts/${id}/clearance/documents/${encodeURIComponent(fileKey)}/versions`,
|
||||||
CLEARANCE_OUTPUT_DOCUMENTS: (id: string) =>
|
CLEARANCE_OUTPUT_DOCUMENTS: (id: string) =>
|
||||||
`/contracts/${id}/clearance/output-documents`,
|
`/contracts/${id}/clearance/output-documents`,
|
||||||
CLEARANCE_FINALIZE: (id: string) => `/contracts/${id}/clearance/finalize`,
|
CLEARANCE_FINALIZE: (id: string) => `/contracts/${id}/clearance/finalize`,
|
||||||
|
|||||||
@@ -130,6 +130,10 @@ export const FREIGHT_PERMS = {
|
|||||||
transferRequest: "edr_freight_app:wagons:transfer_request",
|
transferRequest: "edr_freight_app:wagons:transfer_request",
|
||||||
transferFulfill: "edr_freight_app:wagons:transfer_fulfill",
|
transferFulfill: "edr_freight_app:wagons:transfer_fulfill",
|
||||||
transferHistoryAll: "edr_freight_app:wagons:transfer_history_all",
|
transferHistoryAll: "edr_freight_app:wagons:transfer_history_all",
|
||||||
|
/** Open the transfer desk. `wagons:view` is accepted as a fallback. */
|
||||||
|
transferView: "edr_freight_app:wagons:transfer_view",
|
||||||
|
transferCancel: "edr_freight_app:wagons:transfer_cancel",
|
||||||
|
transferCloseShort: "edr_freight_app:wagons:transfer_close_short",
|
||||||
},
|
},
|
||||||
trains: {
|
trains: {
|
||||||
view: "edr_freight_app:trains:view",
|
view: "edr_freight_app:trains:view",
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
FileText,
|
FileText,
|
||||||
Files,
|
Files,
|
||||||
Flame,
|
Flame,
|
||||||
|
History,
|
||||||
LayoutGrid,
|
LayoutGrid,
|
||||||
Package,
|
Package,
|
||||||
Receipt,
|
Receipt,
|
||||||
@@ -295,7 +296,9 @@ export default function ContractRequestDetailPage() {
|
|||||||
? "documents"
|
? "documents"
|
||||||
: requestedTab === "customer"
|
: requestedTab === "customer"
|
||||||
? "customer"
|
? "customer"
|
||||||
: "details";
|
: requestedTab === "history"
|
||||||
|
? "history"
|
||||||
|
: "details";
|
||||||
|
|
||||||
const customerLabel = contract.isGovernment
|
const customerLabel = contract.isGovernment
|
||||||
? (contract.governmentInstitution ?? "Government")
|
? (contract.governmentInstitution ?? "Government")
|
||||||
@@ -475,6 +478,9 @@ export default function ContractRequestDetailPage() {
|
|||||||
<Tabs.Tab value="customer" leftSection={<Users size={16} />}>
|
<Tabs.Tab value="customer" leftSection={<Users size={16} />}>
|
||||||
Customer
|
Customer
|
||||||
</Tabs.Tab>
|
</Tabs.Tab>
|
||||||
|
<Tabs.Tab value="history" leftSection={<History size={16} />}>
|
||||||
|
History
|
||||||
|
</Tabs.Tab>
|
||||||
</Tabs.List>
|
</Tabs.List>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
@@ -488,7 +494,6 @@ export default function ContractRequestDetailPage() {
|
|||||||
onView={handleViewFile}
|
onView={handleViewFile}
|
||||||
onDownload={handleDownloadFile}
|
onDownload={handleDownloadFile}
|
||||||
/>
|
/>
|
||||||
<ContractRevisionTimeline contractId={contract.id} />
|
|
||||||
<ContractDocumentsCard
|
<ContractDocumentsCard
|
||||||
files={profileDocuments}
|
files={profileDocuments}
|
||||||
title="Customer profile documents"
|
title="Customer profile documents"
|
||||||
@@ -509,6 +514,14 @@ export default function ContractRequestDetailPage() {
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
) : currentTab === "history" ? (
|
||||||
|
<SectionCard
|
||||||
|
icon={History}
|
||||||
|
title="Change history"
|
||||||
|
subtitle="Every recorded edit to this contract — who changed what, and when."
|
||||||
|
>
|
||||||
|
<ContractRevisionTimeline contractId={contract.id} bare />
|
||||||
|
</SectionCard>
|
||||||
) : currentTab === "customer" ? (
|
) : currentTab === "customer" ? (
|
||||||
<ContractCustomerCard contract={contract} />
|
<ContractCustomerCard contract={contract} />
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import {
|
|||||||
type GlClearanceUploadKind,
|
type GlClearanceUploadKind,
|
||||||
} from "@/components/contracts/GlClearanceUploadModal";
|
} from "@/components/contracts/GlClearanceUploadModal";
|
||||||
import { PhasedClearanceActionPanel } from "@/components/contracts/PhasedClearanceActionPanel";
|
import { PhasedClearanceActionPanel } from "@/components/contracts/PhasedClearanceActionPanel";
|
||||||
|
import { TransitAssigneePanel } from "@/components/contracts/TransitAssigneePanel";
|
||||||
import { SectionCard } from "@/components/bookings/detail/SectionCard";
|
import { SectionCard } from "@/components/bookings/detail/SectionCard";
|
||||||
import { IncidentReportCard } from "@/components/contracts/gl-actions/IncidentReportCard";
|
import { IncidentReportCard } from "@/components/contracts/gl-actions/IncidentReportCard";
|
||||||
import { findWorkflowFile } from "@/components/contracts/PhasedUploadedFileRow";
|
import { findWorkflowFile } from "@/components/contracts/PhasedUploadedFileRow";
|
||||||
@@ -235,6 +236,23 @@ export default function GlClearanceDetailPage() {
|
|||||||
</Tabs.List>
|
</Tabs.List>
|
||||||
|
|
||||||
<Tabs.Panel value="workflow">
|
<Tabs.Panel value="workflow">
|
||||||
|
{/* GL Ethiopia cannot file the customs declaration until this desk
|
||||||
|
names the officer handling the shipment in transit, so the ask
|
||||||
|
sits above everything else on the page. */}
|
||||||
|
{data.kind === "contract" ? (
|
||||||
|
<Box mb="md">
|
||||||
|
<TransitAssigneePanel
|
||||||
|
contractId={id!}
|
||||||
|
transitAssignee={data.clearance.transitAssignee}
|
||||||
|
side="DJ"
|
||||||
|
readOnly={
|
||||||
|
!hasPermission(user, FREIGHT_PERMS.contracts.clearanceDjActions)
|
||||||
|
}
|
||||||
|
onChanged={() => void refetch()}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<Grid>
|
<Grid>
|
||||||
<Grid.Col span={{ base: 12, lg: 7 }}>
|
<Grid.Col span={{ base: 12, lg: 7 }}>
|
||||||
{data.kind === "booking" ? (
|
{data.kind === "booking" ? (
|
||||||
@@ -345,6 +363,8 @@ export default function GlClearanceDetailPage() {
|
|||||||
isBooking={data.kind === "booking"}
|
isBooking={data.kind === "booking"}
|
||||||
workflowFiles={workflowFiles}
|
workflowFiles={workflowFiles}
|
||||||
vesselDepartureDate={vesselDepartureDate}
|
vesselDepartureDate={vesselDepartureDate}
|
||||||
|
vesselArrivalDate={data.clearance.vesselArrivalDate}
|
||||||
|
doCollectedDate={data.clearance.doCollectedDate}
|
||||||
onSuccess={() => void refetch()}
|
onSuccess={() => void refetch()}
|
||||||
onPreview={view}
|
onPreview={view}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { canFleetAction, hasPermission, FREIGHT_PERMS } from "@/lib/permissions"
|
|||||||
import Breadcrumbs from "@/components/ui/Breadcrumbs";
|
import Breadcrumbs from "@/components/ui/Breadcrumbs";
|
||||||
import { Inbox, Plus, Warehouse } from "lucide-react";
|
import { Inbox, Plus, Warehouse } from "lucide-react";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { Navigate, useLocation } from "react-router-dom";
|
import { Link, Navigate, useLocation } from "react-router-dom";
|
||||||
|
|
||||||
import FleetCardGrid from "@/components/fleet/FleetCardGrid";
|
import FleetCardGrid from "@/components/fleet/FleetCardGrid";
|
||||||
import FleetFormDialog from "@/components/fleet/FleetFormDialog";
|
import FleetFormDialog from "@/components/fleet/FleetFormDialog";
|
||||||
@@ -19,7 +19,6 @@ import FleetToolbar from "@/components/fleet/FleetToolbar";
|
|||||||
import { matchesDayRange } from "@/hooks/useListControls";
|
import { matchesDayRange } from "@/hooks/useListControls";
|
||||||
import WagonMovementHistoryModal from "@/components/fleet/WagonMovementHistoryModal";
|
import WagonMovementHistoryModal from "@/components/fleet/WagonMovementHistoryModal";
|
||||||
import WagonYardWorkspaceModal from "@/components/wagons/WagonYardWorkspaceModal";
|
import WagonYardWorkspaceModal from "@/components/wagons/WagonYardWorkspaceModal";
|
||||||
import WagonTransferRequestsModal from "@/components/wagons/WagonTransferRequestsModal";
|
|
||||||
import { formatFleetCell, registerFleetOptionLabels } from "@/components/fleet/fleetFormat";
|
import { formatFleetCell, registerFleetOptionLabels } from "@/components/fleet/fleetFormat";
|
||||||
import { useFleetViewMode } from "@/components/fleet/useFleetViewMode";
|
import { useFleetViewMode } from "@/components/fleet/useFleetViewMode";
|
||||||
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
|
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
|
||||||
@@ -67,7 +66,6 @@ const FleetResourcePage = () => {
|
|||||||
const [historyTarget, setHistoryTarget] = useState<FleetRecord | null>(null);
|
const [historyTarget, setHistoryTarget] = useState<FleetRecord | null>(null);
|
||||||
const [selectedDriver, setSelectedDriver] = useState<string>("");
|
const [selectedDriver, setSelectedDriver] = useState<string>("");
|
||||||
const [wagonWorkspaceOpen, setWagonWorkspaceOpen] = useState(false);
|
const [wagonWorkspaceOpen, setWagonWorkspaceOpen] = useState(false);
|
||||||
const [transferRequestsOpen, setTransferRequestsOpen] = useState(false);
|
|
||||||
const { viewMode, setViewMode } = useFleetViewMode(slug);
|
const { viewMode, setViewMode } = useFleetViewMode(slug);
|
||||||
|
|
||||||
const serverListFilters = useMemo((): FleetListFilters | undefined => {
|
const serverListFilters = useMemo((): FleetListFilters | undefined => {
|
||||||
@@ -90,6 +88,11 @@ const FleetResourcePage = () => {
|
|||||||
if (trainNumber && trainNumber !== "ALL") {
|
if (trainNumber && trainNumber !== "ALL") {
|
||||||
(filters as { trainNumber?: string }).trainNumber = trainNumber;
|
(filters as { trainNumber?: string }).trainNumber = trainNumber;
|
||||||
}
|
}
|
||||||
|
// Wagons only: narrow the fleet to one wagon type (the API filters on it).
|
||||||
|
const wagonTypeId = listFilterValues.wagonTypeId;
|
||||||
|
if (wagonTypeId && wagonTypeId !== "ALL") {
|
||||||
|
(filters as { wagonTypeId?: string }).wagonTypeId = wagonTypeId;
|
||||||
|
}
|
||||||
if (slug !== "locomotives" && search.trim()) {
|
if (slug !== "locomotives" && search.trim()) {
|
||||||
filters.search = search.trim();
|
filters.search = search.trim();
|
||||||
}
|
}
|
||||||
@@ -449,12 +452,15 @@ const FleetResourcePage = () => {
|
|||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
{canTransfer ? (
|
{canTransfer ? (
|
||||||
|
// The desk is its own page now (list + fulfil + history with
|
||||||
|
// pagination); this is just the way in from the fleet list.
|
||||||
<Button
|
<Button
|
||||||
|
component={Link}
|
||||||
|
to="/dashboard/wagon-transfers"
|
||||||
variant="light"
|
variant="light"
|
||||||
color="grape"
|
color="grape"
|
||||||
leftSection={<Inbox size={16} />}
|
leftSection={<Inbox size={16} />}
|
||||||
styles={{ label: { fontWeight: 500 } }}
|
styles={{ label: { fontWeight: 500 } }}
|
||||||
onClick={() => setTransferRequestsOpen(true)}
|
|
||||||
>
|
>
|
||||||
Transfer Requests
|
Transfer Requests
|
||||||
</Button>
|
</Button>
|
||||||
@@ -713,13 +719,6 @@ const FleetResourcePage = () => {
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{slug === "wagons" ? (
|
|
||||||
<WagonTransferRequestsModal
|
|
||||||
opened={transferRequestsOpen}
|
|
||||||
onClose={() => setTransferRequestsOpen(false)}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{slug === "wagons" ? (
|
{slug === "wagons" ? (
|
||||||
<WagonMovementHistoryModal
|
<WagonMovementHistoryModal
|
||||||
opened={Boolean(historyTarget)}
|
opened={Boolean(historyTarget)}
|
||||||
|
|||||||
@@ -305,6 +305,12 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [
|
|||||||
allLabel: "All statuses",
|
allLabel: "All statuses",
|
||||||
options: WAGON_STATUS_OPTIONS,
|
options: WAGON_STATUS_OPTIONS,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: "wagonTypeId",
|
||||||
|
label: "Wagon type",
|
||||||
|
allLabel: "All types",
|
||||||
|
dynamicOptions: "wagonTypes",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: "currentYardId",
|
key: "currentYardId",
|
||||||
label: "Current Yard",
|
label: "Current Yard",
|
||||||
|
|||||||
@@ -0,0 +1,195 @@
|
|||||||
|
import { Freight } from "@edr/types";
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Button,
|
||||||
|
Checkbox,
|
||||||
|
Group,
|
||||||
|
Loader,
|
||||||
|
Modal,
|
||||||
|
ScrollArea,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
|
import { AlertTriangle, ArrowRight, PackageCheck } from "lucide-react";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import toast from "react-hot-toast";
|
||||||
|
|
||||||
|
import { api } from "@/services/api";
|
||||||
|
import type { WagonTransferRequest } from "@/services/wagon.service";
|
||||||
|
|
||||||
|
import { outstandingOn, wagonTypeLabel, yardLabel } from "./wagon-transfer-ui";
|
||||||
|
|
||||||
|
export interface TransferFulfillModalProps {
|
||||||
|
request: WagonTransferRequest | null;
|
||||||
|
onClose: () => void;
|
||||||
|
onDone?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Move wagons against an open request. Any number from one up to whatever is
|
||||||
|
* still owed — a yard that can only spare 20 of 50 sends 20 now and the request
|
||||||
|
* stays open for the rest, so the picker caps at the OUTSTANDING count, not the
|
||||||
|
* originally requested one.
|
||||||
|
*/
|
||||||
|
export function TransferFulfillModal({
|
||||||
|
request,
|
||||||
|
onClose,
|
||||||
|
onDone,
|
||||||
|
}: TransferFulfillModalProps) {
|
||||||
|
const [picked, setPicked] = useState<Set<string>>(new Set());
|
||||||
|
const outstanding = request ? outstandingOn(request) : 0;
|
||||||
|
|
||||||
|
const { data: wagons = [], isLoading } = useQuery({
|
||||||
|
...api.wagons.list.queryOptions({
|
||||||
|
input: {
|
||||||
|
filters: request
|
||||||
|
? {
|
||||||
|
currentYardId: request.fromYardId,
|
||||||
|
wagonTypeId: request.wagonTypeId,
|
||||||
|
status: Freight.WagonStatus.Available,
|
||||||
|
}
|
||||||
|
: {},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
enabled: Boolean(request),
|
||||||
|
});
|
||||||
|
|
||||||
|
const fulfill = useMutation(api.wagonTransferRequests.fulfill.mutationOptions());
|
||||||
|
|
||||||
|
const canTake = useMemo(
|
||||||
|
() => Math.min(outstanding, wagons.length),
|
||||||
|
[outstanding, wagons.length],
|
||||||
|
);
|
||||||
|
|
||||||
|
const toggle = (id: string) =>
|
||||||
|
setPicked((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(id)) next.delete(id);
|
||||||
|
// Never let staff pick more than is still owed — the API rejects it too.
|
||||||
|
else if (next.size >= outstanding) return prev;
|
||||||
|
else next.add(id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
|
const takeAllAvailable = () =>
|
||||||
|
setPicked(new Set(wagons.slice(0, canTake).map((w) => w.id)));
|
||||||
|
|
||||||
|
const close = () => {
|
||||||
|
setPicked(new Set());
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
if (!request || picked.size === 0) return;
|
||||||
|
try {
|
||||||
|
const moved = picked.size;
|
||||||
|
await fulfill.mutateAsync({ id: request.id, wagonIds: [...picked] });
|
||||||
|
toast.success(
|
||||||
|
moved >= outstanding
|
||||||
|
? `Request complete — ${moved} wagon(s) transferred`
|
||||||
|
: `${moved} wagon(s) transferred · ${outstanding - moved} still owed`,
|
||||||
|
);
|
||||||
|
close();
|
||||||
|
onDone?.();
|
||||||
|
} catch {
|
||||||
|
// The http interceptor surfaces the server's reason.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
opened={Boolean(request)}
|
||||||
|
onClose={close}
|
||||||
|
size="lg"
|
||||||
|
radius="md"
|
||||||
|
title={
|
||||||
|
request ? (
|
||||||
|
<Group gap={8} wrap="nowrap">
|
||||||
|
<Text fw={700}>{yardLabel(request.fromYard)}</Text>
|
||||||
|
<ArrowRight size={15} />
|
||||||
|
<Text fw={700}>{yardLabel(request.toYard)}</Text>
|
||||||
|
<Text c="dimmed" size="sm">
|
||||||
|
{wagonTypeLabel(request.wagonType)}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{!request ? null : (
|
||||||
|
<Stack gap="sm">
|
||||||
|
<Group justify="space-between" wrap="wrap">
|
||||||
|
<Text size="sm">
|
||||||
|
<Text span fw={700}>
|
||||||
|
{outstanding}
|
||||||
|
</Text>{" "}
|
||||||
|
wagon(s) still owed ·{" "}
|
||||||
|
<Text span fw={700}>
|
||||||
|
{wagons.length}
|
||||||
|
</Text>{" "}
|
||||||
|
available in {yardLabel(request.fromYard)}
|
||||||
|
</Text>
|
||||||
|
<Button
|
||||||
|
variant="light"
|
||||||
|
size="xs"
|
||||||
|
radius="md"
|
||||||
|
disabled={canTake === 0}
|
||||||
|
onClick={takeAllAvailable}
|
||||||
|
>
|
||||||
|
Select {canTake}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{wagons.length < outstanding ? (
|
||||||
|
<Alert color="yellow" radius="md" icon={<AlertTriangle size={15} />}>
|
||||||
|
This yard can only cover {wagons.length} of the {outstanding}{" "}
|
||||||
|
outstanding. Send what is here — the request stays open for the
|
||||||
|
rest, or close it short so the requester can ask another yard.
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<Group justify="center" py="lg">
|
||||||
|
<Loader size="sm" />
|
||||||
|
</Group>
|
||||||
|
) : wagons.length === 0 ? (
|
||||||
|
<Text c="dimmed" size="sm" py="md">
|
||||||
|
No available wagons of this type in the source yard right now.
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<ScrollArea.Autosize mah={320}>
|
||||||
|
<Stack gap={4}>
|
||||||
|
{wagons.map((w) => (
|
||||||
|
<Checkbox
|
||||||
|
key={w.id}
|
||||||
|
checked={picked.has(w.id)}
|
||||||
|
onChange={() => toggle(w.id)}
|
||||||
|
label={w.wagonNumber}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</ScrollArea.Autosize>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Group justify="flex-end" gap="sm">
|
||||||
|
<Button variant="default" radius="md" onClick={close}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="edr-green"
|
||||||
|
radius="md"
|
||||||
|
leftSection={<PackageCheck size={15} />}
|
||||||
|
loading={fulfill.isPending}
|
||||||
|
disabled={picked.size === 0}
|
||||||
|
onClick={() => void submit()}
|
||||||
|
>
|
||||||
|
Transfer {picked.size || ""}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TransferFulfillModal;
|
||||||
@@ -0,0 +1,276 @@
|
|||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Button,
|
||||||
|
Group,
|
||||||
|
Modal,
|
||||||
|
NumberInput,
|
||||||
|
Select,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
Textarea,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
|
import { AlertTriangle, Send, XCircle } from "lucide-react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import toast from "react-hot-toast";
|
||||||
|
|
||||||
|
import { api } from "@/services/api";
|
||||||
|
import type { WagonTransferRequest } from "@/services/wagon.service";
|
||||||
|
|
||||||
|
import { outstandingOn, wagonTypeLabel, yardLabel } from "./wagon-transfer-ui";
|
||||||
|
|
||||||
|
/** Yard + wagon-type option lists, shared by both modals. */
|
||||||
|
function useTransferOptions(enabled: boolean) {
|
||||||
|
const { data: yards = [] } = useQuery({
|
||||||
|
...api.routes.yards.queryOptions({ staleTime: 5 * 60_000 }),
|
||||||
|
enabled,
|
||||||
|
});
|
||||||
|
const { data: wagonTypes = [] } = useQuery({
|
||||||
|
...api.wagonTypes.list.queryOptions(),
|
||||||
|
enabled,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
yardOptions: yards.map((y) => ({
|
||||||
|
value: y.id,
|
||||||
|
label: y.label ?? y.code ?? y.id,
|
||||||
|
})),
|
||||||
|
typeOptions: wagonTypes.map((t) => ({
|
||||||
|
value: t.id,
|
||||||
|
label: [t.code, t.name].filter(Boolean).join(" · "),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TransferRequestFormModalProps {
|
||||||
|
opened: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
/**
|
||||||
|
* Carry-over from a request that could not be met in full: the destination,
|
||||||
|
* type, outstanding count and reason are pre-filled and the user only picks
|
||||||
|
* WHICH other yard to ask. Undefined for a plain new request.
|
||||||
|
*/
|
||||||
|
prefillFrom?: WagonTransferRequest | null;
|
||||||
|
onCreated?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* File a wagon-transfer request. The count is deliberately NOT capped by what
|
||||||
|
* the source yard holds today — OCC fulfils in instalments, so asking for 50
|
||||||
|
* where 20 sit is a normal request.
|
||||||
|
*/
|
||||||
|
export function TransferRequestFormModal({
|
||||||
|
opened,
|
||||||
|
onClose,
|
||||||
|
prefillFrom,
|
||||||
|
onCreated,
|
||||||
|
}: TransferRequestFormModalProps) {
|
||||||
|
const { yardOptions, typeOptions } = useTransferOptions(opened);
|
||||||
|
const [fromYardId, setFromYardId] = useState<string | null>(null);
|
||||||
|
const [toYardId, setToYardId] = useState<string | null>(null);
|
||||||
|
const [wagonTypeId, setWagonTypeId] = useState<string | null>(null);
|
||||||
|
const [quantity, setQuantity] = useState<number | string>(1);
|
||||||
|
const [reason, setReason] = useState("");
|
||||||
|
|
||||||
|
// Re-seed on every open so a carry-over never leaks into the next request.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!opened) return;
|
||||||
|
setFromYardId(null); // always chosen fresh — that is the point of a re-ask
|
||||||
|
setToYardId(prefillFrom?.toYardId ?? null);
|
||||||
|
setWagonTypeId(prefillFrom?.wagonTypeId ?? null);
|
||||||
|
setQuantity(prefillFrom ? outstandingOn(prefillFrom) : 1);
|
||||||
|
setReason(prefillFrom?.reason ?? "");
|
||||||
|
}, [opened, prefillFrom]);
|
||||||
|
|
||||||
|
const create = useMutation(api.wagonTransferRequests.create.mutationOptions());
|
||||||
|
|
||||||
|
const sameYard = Boolean(fromYardId && fromYardId === toYardId);
|
||||||
|
const valid =
|
||||||
|
Boolean(fromYardId && toYardId && wagonTypeId && reason.trim()) &&
|
||||||
|
!sameYard &&
|
||||||
|
Number(quantity) >= 1;
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
if (!valid) return;
|
||||||
|
try {
|
||||||
|
await create.mutateAsync({
|
||||||
|
fromYardId: fromYardId!,
|
||||||
|
toYardId: toYardId!,
|
||||||
|
wagonTypeId: wagonTypeId!,
|
||||||
|
quantity: Number(quantity),
|
||||||
|
reason: reason.trim(),
|
||||||
|
});
|
||||||
|
toast.success("Transfer request filed");
|
||||||
|
onClose();
|
||||||
|
onCreated?.();
|
||||||
|
} catch {
|
||||||
|
// Server reason is surfaced by the http interceptor.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
opened={opened}
|
||||||
|
onClose={onClose}
|
||||||
|
radius="md"
|
||||||
|
size="md"
|
||||||
|
title={prefillFrom ? "Request the rest from another yard" : "New transfer request"}
|
||||||
|
>
|
||||||
|
<Stack gap="sm">
|
||||||
|
{prefillFrom ? (
|
||||||
|
<Alert color="orange" radius="md" icon={<AlertTriangle size={15} />}>
|
||||||
|
{yardLabel(prefillFrom.fromYard)} supplied{" "}
|
||||||
|
{prefillFrom.fulfilledQuantity} of {prefillFrom.quantity}. Pick
|
||||||
|
another yard to cover the remaining {outstandingOn(prefillFrom)}.
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Select
|
||||||
|
label="Source yard"
|
||||||
|
description={prefillFrom ? "Which yard should supply the rest" : undefined}
|
||||||
|
placeholder="Where the wagons come from"
|
||||||
|
data={yardOptions}
|
||||||
|
value={fromYardId}
|
||||||
|
onChange={setFromYardId}
|
||||||
|
searchable
|
||||||
|
required
|
||||||
|
error={sameYard ? "Source and destination must differ" : undefined}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label="Destination yard"
|
||||||
|
placeholder="Where they are needed"
|
||||||
|
data={yardOptions}
|
||||||
|
value={toYardId}
|
||||||
|
onChange={setToYardId}
|
||||||
|
searchable
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label="Wagon type"
|
||||||
|
placeholder="Type of wagon"
|
||||||
|
data={typeOptions}
|
||||||
|
value={wagonTypeId}
|
||||||
|
onChange={setWagonTypeId}
|
||||||
|
searchable
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<NumberInput
|
||||||
|
label="How many"
|
||||||
|
description="Can exceed what the yard holds today — OCC delivers in instalments"
|
||||||
|
min={1}
|
||||||
|
value={quantity}
|
||||||
|
onChange={setQuantity}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Textarea
|
||||||
|
label="Reason"
|
||||||
|
placeholder="Why the wagons are needed"
|
||||||
|
value={reason}
|
||||||
|
onChange={(e) => setReason(e.currentTarget.value)}
|
||||||
|
autosize
|
||||||
|
minRows={2}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Group justify="flex-end" gap="sm">
|
||||||
|
<Button variant="default" radius="md" onClick={onClose}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="edr-green"
|
||||||
|
radius="md"
|
||||||
|
leftSection={<Send size={15} />}
|
||||||
|
loading={create.isPending}
|
||||||
|
disabled={!valid}
|
||||||
|
onClick={() => void submit()}
|
||||||
|
>
|
||||||
|
File request
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TransferCloseShortModalProps {
|
||||||
|
request: WagonTransferRequest | null;
|
||||||
|
onClose: () => void;
|
||||||
|
onClosed?: (request: WagonTransferRequest) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* End a request the source yard cannot finish. What already moved stays moved;
|
||||||
|
* the requester is notified of the shortfall so they can raise it elsewhere.
|
||||||
|
*/
|
||||||
|
export function TransferCloseShortModal({
|
||||||
|
request,
|
||||||
|
onClose,
|
||||||
|
onClosed,
|
||||||
|
}: TransferCloseShortModalProps) {
|
||||||
|
const [note, setNote] = useState("");
|
||||||
|
const closeShort = useMutation(
|
||||||
|
api.wagonTransferRequests.closeShort.mutationOptions(),
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (request) setNote("");
|
||||||
|
}, [request]);
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
if (!request) return;
|
||||||
|
try {
|
||||||
|
await closeShort.mutateAsync({ id: request.id, note: note.trim() || undefined });
|
||||||
|
toast.success("Request closed — the requester has been notified");
|
||||||
|
onClose();
|
||||||
|
onClosed?.(request);
|
||||||
|
} catch {
|
||||||
|
// Server reason surfaced by the http interceptor.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const outstanding = request ? outstandingOn(request) : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
opened={Boolean(request)}
|
||||||
|
onClose={onClose}
|
||||||
|
radius="md"
|
||||||
|
title="Close this request short"
|
||||||
|
>
|
||||||
|
{!request ? null : (
|
||||||
|
<Stack gap="sm">
|
||||||
|
<Text size="sm">
|
||||||
|
{yardLabel(request.fromYard)} → {yardLabel(request.toYard)} ·{" "}
|
||||||
|
{wagonTypeLabel(request.wagonType)}
|
||||||
|
</Text>
|
||||||
|
<Alert color="orange" radius="md" icon={<AlertTriangle size={15} />}>
|
||||||
|
{request.fulfilledQuantity} of {request.quantity} wagon(s) have been
|
||||||
|
supplied. Closing leaves {outstanding} undelivered — the requester is
|
||||||
|
told to ask another yard.
|
||||||
|
</Alert>
|
||||||
|
<Textarea
|
||||||
|
label="Why can't the yard supply the rest?"
|
||||||
|
description="Included in the requester's notification"
|
||||||
|
value={note}
|
||||||
|
onChange={(e) => setNote(e.currentTarget.value)}
|
||||||
|
autosize
|
||||||
|
minRows={2}
|
||||||
|
/>
|
||||||
|
<Group justify="flex-end" gap="sm">
|
||||||
|
<Button variant="default" radius="md" onClick={onClose}>
|
||||||
|
Keep it open
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="orange"
|
||||||
|
radius="md"
|
||||||
|
leftSection={<XCircle size={15} />}
|
||||||
|
loading={closeShort.isPending}
|
||||||
|
onClick={() => void submit()}
|
||||||
|
>
|
||||||
|
Close short
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,625 @@
|
|||||||
|
import { Freight } from "@edr/types";
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Group,
|
||||||
|
Loader,
|
||||||
|
Select,
|
||||||
|
Stack,
|
||||||
|
Switch,
|
||||||
|
Tabs,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { useDebouncedValue } from "@mantine/hooks";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
ArrowRight,
|
||||||
|
History,
|
||||||
|
Inbox,
|
||||||
|
PackageCheck,
|
||||||
|
Plus,
|
||||||
|
RefreshCw,
|
||||||
|
Search,
|
||||||
|
Send,
|
||||||
|
Truck,
|
||||||
|
XCircle,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import toast from "react-hot-toast";
|
||||||
|
import { useMutation } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
import { useAuth } from "@/auth/useAuth";
|
||||||
|
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
|
||||||
|
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||||
|
import { api } from "@/services/api";
|
||||||
|
import type {
|
||||||
|
TransferRequestListFilter,
|
||||||
|
WagonTransferRequest,
|
||||||
|
} from "@/services/wagon.service";
|
||||||
|
import {
|
||||||
|
DataTable,
|
||||||
|
DataTableFooter,
|
||||||
|
usePagination,
|
||||||
|
type ColumnDef,
|
||||||
|
} from "@edr/ui-common";
|
||||||
|
|
||||||
|
import TransferFulfillModal from "./TransferFulfillModal";
|
||||||
|
import {
|
||||||
|
TransferCloseShortModal,
|
||||||
|
TransferRequestFormModal,
|
||||||
|
} from "./TransferRequestModals";
|
||||||
|
import {
|
||||||
|
TransferProgress,
|
||||||
|
TransferStatusBadge,
|
||||||
|
fmtDateTime,
|
||||||
|
isOpenRequest,
|
||||||
|
outstandingOn,
|
||||||
|
wagonTypeLabel,
|
||||||
|
yardLabel,
|
||||||
|
} from "./wagon-transfer-ui";
|
||||||
|
|
||||||
|
const S = Freight.WagonTransferRequestStatus;
|
||||||
|
|
||||||
|
/** "Open" is the working set: nothing delivered yet OR part-delivered. */
|
||||||
|
const OPEN_STATUSES = `${S.Pending},${S.PartiallyFulfilled}`;
|
||||||
|
|
||||||
|
const STATUS_FILTER_OPTIONS = [
|
||||||
|
{ value: OPEN_STATUSES, label: "Open (awaiting wagons)" },
|
||||||
|
{ value: S.Pending, label: "Not started" },
|
||||||
|
{ value: S.PartiallyFulfilled, label: "Partly delivered" },
|
||||||
|
{ value: S.Fulfilled, label: "Complete" },
|
||||||
|
{ value: S.ClosedShort, label: "Closed short" },
|
||||||
|
{ value: S.Cancelled, label: "Cancelled" },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The wagon-transfer desk.
|
||||||
|
*
|
||||||
|
* A request is a count, not a wagon list: someone asks for 50 gondolas from
|
||||||
|
* Dire Dawa, and OCC sends whatever that yard can spare, whenever it can. The
|
||||||
|
* table is built around that — every row shows delivered-vs-asked, and a
|
||||||
|
* request only leaves the queue when it is fully supplied or explicitly closed
|
||||||
|
* short (which tells the requester to try another yard).
|
||||||
|
*/
|
||||||
|
export default function WagonTransfersPage() {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const canRequest = hasPermission(user, FREIGHT_PERMS.wagons.transferRequest);
|
||||||
|
const canFulfil = hasPermission(user, FREIGHT_PERMS.wagons.transferFulfill);
|
||||||
|
const canCloseShort =
|
||||||
|
canFulfil || hasPermission(user, FREIGHT_PERMS.wagons.transferCloseShort);
|
||||||
|
const canCancel =
|
||||||
|
canRequest || hasPermission(user, FREIGHT_PERMS.wagons.transferCancel);
|
||||||
|
|
||||||
|
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
||||||
|
const [status, setStatus] = useState<string | null>(OPEN_STATUSES);
|
||||||
|
const [fromYardId, setFromYardId] = useState<string | null>(null);
|
||||||
|
const [toYardId, setToYardId] = useState<string | null>(null);
|
||||||
|
const [wagonTypeId, setWagonTypeId] = useState<string | null>(null);
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [debouncedSearch] = useDebouncedValue(search, 300);
|
||||||
|
|
||||||
|
const [formOpen, setFormOpen] = useState(false);
|
||||||
|
const [carryOver, setCarryOver] = useState<WagonTransferRequest | null>(null);
|
||||||
|
const [fulfilling, setFulfilling] = useState<WagonTransferRequest | null>(null);
|
||||||
|
const [closingShort, setClosingShort] = useState<WagonTransferRequest | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
const filter: TransferRequestListFilter = useMemo(
|
||||||
|
() => ({
|
||||||
|
page: pagination.pageIndex + 1,
|
||||||
|
pageSize: pagination.pageSize,
|
||||||
|
...(status ? { status } : {}),
|
||||||
|
...(fromYardId ? { fromYardId } : {}),
|
||||||
|
...(toYardId ? { toYardId } : {}),
|
||||||
|
...(wagonTypeId ? { wagonTypeId } : {}),
|
||||||
|
...(debouncedSearch.trim() ? { search: debouncedSearch.trim() } : {}),
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
pagination.pageIndex,
|
||||||
|
pagination.pageSize,
|
||||||
|
status,
|
||||||
|
fromYardId,
|
||||||
|
toYardId,
|
||||||
|
wagonTypeId,
|
||||||
|
debouncedSearch,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data, isLoading, isError, refetch, isFetching } = useQuery(
|
||||||
|
api.wagonTransferRequests.list.queryOptions({ input: { filter } }),
|
||||||
|
);
|
||||||
|
const rows = data?.items ?? [];
|
||||||
|
const meta = data?.meta;
|
||||||
|
|
||||||
|
const { data: yards = [] } = useQuery(
|
||||||
|
api.routes.yards.queryOptions({ staleTime: 5 * 60_000 }),
|
||||||
|
);
|
||||||
|
const { data: wagonTypes = [] } = useQuery(api.wagonTypes.list.queryOptions());
|
||||||
|
const yardOptions = yards.map((y) => ({
|
||||||
|
value: y.id,
|
||||||
|
label: y.label ?? y.code ?? y.id,
|
||||||
|
}));
|
||||||
|
const typeOptions = wagonTypes.map((t) => ({
|
||||||
|
value: t.id,
|
||||||
|
label: [t.code, t.name].filter(Boolean).join(" · "),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const cancel = useMutation(api.wagonTransferRequests.cancel.mutationOptions());
|
||||||
|
|
||||||
|
const resetPage = () =>
|
||||||
|
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
|
||||||
|
|
||||||
|
const clearFilters = () => {
|
||||||
|
setStatus(OPEN_STATUSES);
|
||||||
|
setFromYardId(null);
|
||||||
|
setToYardId(null);
|
||||||
|
setWagonTypeId(null);
|
||||||
|
setSearch("");
|
||||||
|
resetPage();
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns: ColumnDef<WagonTransferRequest>[] = [
|
||||||
|
{
|
||||||
|
id: "route",
|
||||||
|
header: () => <span>Route</span>,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const r = row.original;
|
||||||
|
return (
|
||||||
|
<Group gap={6} wrap="nowrap">
|
||||||
|
<Text size="sm" fw={600}>
|
||||||
|
{yardLabel(r.fromYard)}
|
||||||
|
</Text>
|
||||||
|
<ArrowRight size={13} className="shrink-0 opacity-60" />
|
||||||
|
<Text size="sm" fw={600}>
|
||||||
|
{yardLabel(r.toYard)}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "type",
|
||||||
|
header: () => <span>Wagon type</span>,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Text size="sm">{wagonTypeLabel(row.original.wagonType)}</Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "progress",
|
||||||
|
header: () => <span>Delivered</span>,
|
||||||
|
cell: ({ row }) => <TransferProgress request={row.original} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "reason",
|
||||||
|
header: () => <span>Reason</span>,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Text size="sm" c="dimmed" lineClamp={2} maw={260}>
|
||||||
|
{row.original.reason || "—"}
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "filed",
|
||||||
|
header: () => <span>Filed</span>,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{fmtDateTime(row.original.createdAt)}
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "status",
|
||||||
|
header: () => <span>Status</span>,
|
||||||
|
cell: ({ row }) => <TransferStatusBadge status={row.original.status} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "actions",
|
||||||
|
header: () => <span />,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const r = row.original;
|
||||||
|
const open = isOpenRequest(r);
|
||||||
|
const short =
|
||||||
|
r.status === S.ClosedShort && outstandingOn(r) > 0;
|
||||||
|
return (
|
||||||
|
<Group gap={6} justify="flex-end" wrap="nowrap">
|
||||||
|
{open && canFulfil ? (
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
radius="md"
|
||||||
|
color="edr-green"
|
||||||
|
leftSection={<Truck size={13} />}
|
||||||
|
onClick={() => setFulfilling(r)}
|
||||||
|
>
|
||||||
|
Transfer
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{open && r.fulfilledQuantity > 0 && canCloseShort ? (
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
radius="md"
|
||||||
|
variant="light"
|
||||||
|
color="orange"
|
||||||
|
leftSection={<XCircle size={13} />}
|
||||||
|
onClick={() => setClosingShort(r)}
|
||||||
|
>
|
||||||
|
Close short
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{short && canRequest ? (
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
radius="md"
|
||||||
|
variant="light"
|
||||||
|
color="grape"
|
||||||
|
leftSection={<Send size={13} />}
|
||||||
|
onClick={() => {
|
||||||
|
setCarryOver(r);
|
||||||
|
setFormOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Ask another yard
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{r.status === S.Pending && canCancel ? (
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
radius="md"
|
||||||
|
variant="subtle"
|
||||||
|
color="red"
|
||||||
|
loading={cancel.isPending}
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
await cancel.mutateAsync({ id: r.id });
|
||||||
|
toast.success("Request withdrawn");
|
||||||
|
} catch {
|
||||||
|
// interceptor surfaces the reason
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Withdraw
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const openCount = rows.filter(isOpenRequest).length;
|
||||||
|
const outstandingWagons = rows.reduce(
|
||||||
|
(sum: number, r: WagonTransferRequest) =>
|
||||||
|
sum + (isOpenRequest(r) ? outstandingOn(r) : 0),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageContainer>
|
||||||
|
<Stack gap="lg">
|
||||||
|
<PageHeader
|
||||||
|
title="Wagon transfers"
|
||||||
|
subtitle="Requests for wagons to move between yards — delivered in instalments until the full count is met"
|
||||||
|
breadcrumbs={[
|
||||||
|
{ label: "Wagons", href: "/dashboard/wagons" },
|
||||||
|
{ label: "Transfers" },
|
||||||
|
]}
|
||||||
|
action={
|
||||||
|
<Group gap="sm">
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
radius="md"
|
||||||
|
leftSection={<RefreshCw size={15} />}
|
||||||
|
loading={isFetching}
|
||||||
|
onClick={() => void refetch()}
|
||||||
|
>
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
{canRequest ? (
|
||||||
|
<Button
|
||||||
|
color="edr-green"
|
||||||
|
radius="md"
|
||||||
|
leftSection={<Plus size={15} />}
|
||||||
|
onClick={() => {
|
||||||
|
setCarryOver(null);
|
||||||
|
setFormOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
New request
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</Group>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<KpiStrip
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
label: "Open on this page",
|
||||||
|
value: openCount,
|
||||||
|
icon: Inbox,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Wagons still owed",
|
||||||
|
value: outstandingWagons,
|
||||||
|
icon: Truck,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Requests matched",
|
||||||
|
value: meta?.total ?? 0,
|
||||||
|
icon: PackageCheck,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Tabs defaultValue="requests" keepMounted={false}>
|
||||||
|
<Tabs.List>
|
||||||
|
<Tabs.Tab value="requests" leftSection={<Inbox size={15} />}>
|
||||||
|
Requests
|
||||||
|
</Tabs.Tab>
|
||||||
|
<Tabs.Tab value="history" leftSection={<History size={15} />}>
|
||||||
|
History
|
||||||
|
</Tabs.Tab>
|
||||||
|
</Tabs.List>
|
||||||
|
|
||||||
|
<Tabs.Panel value="requests" pt="md">
|
||||||
|
<Card withBorder radius="md" p="md">
|
||||||
|
<Stack gap="md">
|
||||||
|
<Group gap="sm" wrap="wrap">
|
||||||
|
<TextInput
|
||||||
|
placeholder="Search the reason…"
|
||||||
|
leftSection={<Search size={15} />}
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSearch(e.currentTarget.value);
|
||||||
|
resetPage();
|
||||||
|
}}
|
||||||
|
w={240}
|
||||||
|
radius="md"
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
placeholder="Status"
|
||||||
|
data={STATUS_FILTER_OPTIONS}
|
||||||
|
value={status}
|
||||||
|
onChange={(v) => {
|
||||||
|
setStatus(v);
|
||||||
|
resetPage();
|
||||||
|
}}
|
||||||
|
clearable
|
||||||
|
w={200}
|
||||||
|
radius="md"
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
placeholder="From yard"
|
||||||
|
data={yardOptions}
|
||||||
|
value={fromYardId}
|
||||||
|
onChange={(v) => {
|
||||||
|
setFromYardId(v);
|
||||||
|
resetPage();
|
||||||
|
}}
|
||||||
|
searchable
|
||||||
|
clearable
|
||||||
|
w={180}
|
||||||
|
radius="md"
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
placeholder="To yard"
|
||||||
|
data={yardOptions}
|
||||||
|
value={toYardId}
|
||||||
|
onChange={(v) => {
|
||||||
|
setToYardId(v);
|
||||||
|
resetPage();
|
||||||
|
}}
|
||||||
|
searchable
|
||||||
|
clearable
|
||||||
|
w={180}
|
||||||
|
radius="md"
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
placeholder="Wagon type"
|
||||||
|
data={typeOptions}
|
||||||
|
value={wagonTypeId}
|
||||||
|
onChange={(v) => {
|
||||||
|
setWagonTypeId(v);
|
||||||
|
resetPage();
|
||||||
|
}}
|
||||||
|
searchable
|
||||||
|
clearable
|
||||||
|
w={180}
|
||||||
|
radius="md"
|
||||||
|
/>
|
||||||
|
<Button variant="subtle" radius="md" onClick={clearFilters}>
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Box style={{ overflowX: "auto" }} w="100%">
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
data={rows}
|
||||||
|
status={
|
||||||
|
isLoading ? "loading" : isError ? "error" : "success"
|
||||||
|
}
|
||||||
|
pagination={{
|
||||||
|
pageIndex: pagination.pageIndex,
|
||||||
|
pageSize: pagination.pageSize,
|
||||||
|
pageCount: meta?.totalPages ?? 1,
|
||||||
|
totalCount: meta?.total ?? 0,
|
||||||
|
}}
|
||||||
|
tableOptions={{
|
||||||
|
state: { pagination },
|
||||||
|
onPaginationChange: setPagination,
|
||||||
|
manualPagination: true,
|
||||||
|
pageCount: meta?.totalPages ?? 1,
|
||||||
|
}}
|
||||||
|
containerClassName="border-0 shadow-none bg-transparent"
|
||||||
|
footer={DataTableFooter}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
</Tabs.Panel>
|
||||||
|
|
||||||
|
<Tabs.Panel value="history" pt="md">
|
||||||
|
<TransferHistoryPanel />
|
||||||
|
</Tabs.Panel>
|
||||||
|
</Tabs>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<TransferRequestFormModal
|
||||||
|
opened={formOpen}
|
||||||
|
prefillFrom={carryOver}
|
||||||
|
onClose={() => {
|
||||||
|
setFormOpen(false);
|
||||||
|
setCarryOver(null);
|
||||||
|
}}
|
||||||
|
onCreated={() => void refetch()}
|
||||||
|
/>
|
||||||
|
<TransferFulfillModal
|
||||||
|
request={fulfilling}
|
||||||
|
onClose={() => setFulfilling(null)}
|
||||||
|
onDone={() => void refetch()}
|
||||||
|
/>
|
||||||
|
<TransferCloseShortModal
|
||||||
|
request={closingShort}
|
||||||
|
onClose={() => setClosingShort(null)}
|
||||||
|
onClosed={(r) => {
|
||||||
|
void refetch();
|
||||||
|
// Straight into the re-ask: the shortfall is the whole reason this
|
||||||
|
// request was closed, so offer the other-yard form immediately.
|
||||||
|
if (canRequest) {
|
||||||
|
setCarryOver(r);
|
||||||
|
setFormOpen(true);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Who moved what. A staffer sees their own activity; holders of
|
||||||
|
* `transfer_history_all` can widen it to every staffer (the backend enforces
|
||||||
|
* the scope regardless of the toggle).
|
||||||
|
*/
|
||||||
|
function TransferHistoryPanel() {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const canSeeAll = hasPermission(user, FREIGHT_PERMS.wagons.transferHistoryAll);
|
||||||
|
const [allStaff, setAllStaff] = useState(false);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const scopeAll = canSeeAll && allStaff;
|
||||||
|
|
||||||
|
const mine = useQuery({
|
||||||
|
...api.wagonTransferRequests.history.queryOptions({
|
||||||
|
input: { page, pageSize: 20 },
|
||||||
|
}),
|
||||||
|
enabled: !scopeAll,
|
||||||
|
});
|
||||||
|
const all = useQuery({
|
||||||
|
...api.wagonTransferRequests.historyAll.queryOptions({
|
||||||
|
input: { page, pageSize: 20 },
|
||||||
|
}),
|
||||||
|
enabled: scopeAll,
|
||||||
|
});
|
||||||
|
const source = scopeAll ? all : mine;
|
||||||
|
const requests = source.data?.requests ?? [];
|
||||||
|
const movements = source.data?.movements ?? [];
|
||||||
|
const meta = source.data?.meta;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card withBorder radius="md" p="md">
|
||||||
|
<Stack gap="md">
|
||||||
|
<Group justify="space-between" wrap="wrap">
|
||||||
|
<Text fw={600}>Transfer history</Text>
|
||||||
|
{canSeeAll ? (
|
||||||
|
<Switch
|
||||||
|
label="All staff"
|
||||||
|
checked={allStaff}
|
||||||
|
onChange={(e) => {
|
||||||
|
setAllStaff(e.currentTarget.checked);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{source.isLoading ? (
|
||||||
|
<Group justify="center" py="lg">
|
||||||
|
<Loader size="sm" />
|
||||||
|
</Group>
|
||||||
|
) : (
|
||||||
|
<Group align="flex-start" grow gap="lg" wrap="wrap">
|
||||||
|
<Stack gap={6} miw={280}>
|
||||||
|
<Text size="sm" fw={700} c="dimmed" tt="uppercase">
|
||||||
|
Requests ({meta?.requestsTotal ?? 0})
|
||||||
|
</Text>
|
||||||
|
{requests.length === 0 ? (
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
Nothing yet.
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
requests.map((r) => (
|
||||||
|
<Group key={r.id} gap={8} wrap="nowrap" justify="space-between">
|
||||||
|
<Text size="sm" truncate>
|
||||||
|
{yardLabel(r.fromYard)} → {yardLabel(r.toYard)} ·{" "}
|
||||||
|
{r.fulfilledQuantity}/{r.quantity}
|
||||||
|
</Text>
|
||||||
|
<TransferStatusBadge status={r.status} />
|
||||||
|
</Group>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Stack gap={6} miw={280}>
|
||||||
|
<Text size="sm" fw={700} c="dimmed" tt="uppercase">
|
||||||
|
Wagons moved ({meta?.movementsTotal ?? 0})
|
||||||
|
</Text>
|
||||||
|
{movements.length === 0 ? (
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
Nothing yet.
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
movements.map((m) => (
|
||||||
|
<Group key={m.id} gap={8} wrap="nowrap" justify="space-between">
|
||||||
|
<Text size="sm" truncate>
|
||||||
|
{m.wagon?.wagonNumber ?? "Wagon"} · {yardLabel(m.fromYard)} →{" "}
|
||||||
|
{yardLabel(m.toYard)}
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{fmtDateTime(m.occurredAt)}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Group justify="center" gap="sm">
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
size="xs"
|
||||||
|
radius="md"
|
||||||
|
disabled={page <= 1}
|
||||||
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</Button>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
Page {meta?.page ?? page} of {meta?.totalPages ?? 1}
|
||||||
|
</Text>
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
size="xs"
|
||||||
|
radius="md"
|
||||||
|
disabled={page >= (meta?.totalPages ?? 1)}
|
||||||
|
onClick={() => setPage((p) => p + 1)}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { Freight } from "@edr/types";
|
||||||
|
import { Badge, Box, Group, Progress, Text, Tooltip } from "@mantine/core";
|
||||||
|
|
||||||
|
import type { WagonTransferRequest } from "@/services/wagon.service";
|
||||||
|
|
||||||
|
export const yardLabel = (y?: { label?: string; code?: string } | null) =>
|
||||||
|
y?.label || y?.code || "—";
|
||||||
|
|
||||||
|
export const wagonTypeLabel = (t?: { code?: string; name?: string } | null) =>
|
||||||
|
t ? [t.code, t.name].filter(Boolean).join(" · ") : "—";
|
||||||
|
|
||||||
|
/** Wagons still owed on a request (0 once it is complete or closed). */
|
||||||
|
export const outstandingOn = (r: WagonTransferRequest): number =>
|
||||||
|
Math.max(0, r.quantity - (r.fulfilledQuantity ?? 0));
|
||||||
|
|
||||||
|
/** A request OCC can still move wagons against. */
|
||||||
|
export const isOpenRequest = (r: WagonTransferRequest): boolean =>
|
||||||
|
r.status === Freight.WagonTransferRequestStatus.Pending ||
|
||||||
|
r.status === Freight.WagonTransferRequestStatus.PartiallyFulfilled;
|
||||||
|
|
||||||
|
export const STATUS_META: Record<string, { label: string; color: string }> = {
|
||||||
|
PENDING: { label: "Awaiting wagons", color: "gray" },
|
||||||
|
PARTIALLY_FULFILLED: { label: "Partly delivered", color: "yellow" },
|
||||||
|
FULFILLED: { label: "Complete", color: "teal" },
|
||||||
|
CLOSED_SHORT: { label: "Closed short", color: "orange" },
|
||||||
|
CANCELLED: { label: "Cancelled", color: "red" },
|
||||||
|
};
|
||||||
|
|
||||||
|
export function TransferStatusBadge({ status }: { status: string }) {
|
||||||
|
const meta = STATUS_META[status] ?? { label: status, color: "gray" };
|
||||||
|
return (
|
||||||
|
<Badge variant="light" color={meta.color} radius="sm">
|
||||||
|
{meta.label}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delivered-vs-asked bar. The number is what staff actually need — the bar just
|
||||||
|
* makes "nearly there" vs "barely started" readable at a glance across a page
|
||||||
|
* of requests.
|
||||||
|
*/
|
||||||
|
export function TransferProgress({ request }: { request: WagonTransferRequest }) {
|
||||||
|
const delivered = request.fulfilledQuantity ?? 0;
|
||||||
|
const percent = request.quantity > 0 ? (delivered / request.quantity) * 100 : 0;
|
||||||
|
const outstanding = outstandingOn(request);
|
||||||
|
const complete = delivered >= request.quantity;
|
||||||
|
const closedShort =
|
||||||
|
request.status === Freight.WagonTransferRequestStatus.ClosedShort;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip
|
||||||
|
withArrow
|
||||||
|
label={
|
||||||
|
complete
|
||||||
|
? "Fully supplied"
|
||||||
|
: closedShort
|
||||||
|
? `Closed ${outstanding} wagon(s) short`
|
||||||
|
: `${outstanding} wagon(s) still to come`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Box miw={110}>
|
||||||
|
<Group gap={6} justify="space-between" wrap="nowrap" mb={4}>
|
||||||
|
<Text size="sm" fw={700} style={{ fontVariantNumeric: "tabular-nums" }}>
|
||||||
|
{delivered} / {request.quantity}
|
||||||
|
</Text>
|
||||||
|
{!complete && !closedShort ? (
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{outstanding} left
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</Group>
|
||||||
|
<Progress
|
||||||
|
value={percent}
|
||||||
|
size="sm"
|
||||||
|
radius="xl"
|
||||||
|
color={complete ? "teal" : closedShort ? "orange" : "yellow"}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const fmtDateTime = (iso?: string | null) =>
|
||||||
|
iso
|
||||||
|
? new Date(iso).toLocaleString("en-GB", {
|
||||||
|
day: "numeric",
|
||||||
|
month: "short",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
hour12: false,
|
||||||
|
})
|
||||||
|
: "—";
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type { PaginatedResponse } from "@edr/types";
|
||||||
|
|
||||||
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
|
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
|
||||||
import type { FleetResourceSlug } from "@/pages/fleet/config/resources";
|
import type { FleetResourceSlug } from "@/pages/fleet/config/resources";
|
||||||
import type { BookingDetail } from "@/types/booking";
|
import type { BookingDetail } from "@/types/booking";
|
||||||
@@ -208,6 +210,7 @@ import {
|
|||||||
type CreateTransferRequestPayload,
|
type CreateTransferRequestPayload,
|
||||||
type BulkFulfillResult,
|
type BulkFulfillResult,
|
||||||
type TransferHistory,
|
type TransferHistory,
|
||||||
|
type TransferRequestListFilter,
|
||||||
} from "./wagon.service";
|
} from "./wagon.service";
|
||||||
import { warehouseService } from "./warehouse.service";
|
import { warehouseService } from "./warehouse.service";
|
||||||
|
|
||||||
@@ -1719,14 +1722,14 @@ export const api = {
|
|||||||
|
|
||||||
wagonTransferRequests: {
|
wagonTransferRequests: {
|
||||||
list: endpoint<
|
list: endpoint<
|
||||||
{ status?: WagonTransferRequest["status"] },
|
{ filter?: TransferRequestListFilter },
|
||||||
WagonTransferRequest[]
|
PaginatedResponse<WagonTransferRequest>
|
||||||
>(
|
>(
|
||||||
"wagonTransferRequests",
|
"wagonTransferRequests",
|
||||||
"list",
|
"list",
|
||||||
({ status }) =>
|
({ filter }) =>
|
||||||
wagonTransferRequestService.list(status).then((r) => r.data),
|
wagonTransferRequestService.list(filter).then((r) => r.data),
|
||||||
({ status }) => ["wagonTransferRequests", "list", status ?? "ALL"],
|
({ filter }) => ["wagonTransferRequests", "list", filter ?? {}],
|
||||||
),
|
),
|
||||||
|
|
||||||
getById: endpoint<{ id: string }, WagonTransferRequest>(
|
getById: endpoint<{ id: string }, WagonTransferRequest>(
|
||||||
@@ -1774,19 +1777,47 @@ export const api = {
|
|||||||
() => [["wagonTransferRequests"]],
|
() => [["wagonTransferRequests"]],
|
||||||
),
|
),
|
||||||
|
|
||||||
history: endpoint<void, TransferHistory>(
|
closeShort: endpoint<{ id: string; note?: string }, WagonTransferRequest>(
|
||||||
"wagonTransferRequests",
|
"wagonTransferRequests",
|
||||||
"history",
|
"closeShort",
|
||||||
() => wagonTransferRequestService.myHistory().then((r) => r.data),
|
({ id, note }) =>
|
||||||
() => ["wagonTransferRequests", "history", "mine"],
|
wagonTransferRequestService.closeShort(id, note).then((r) => r.data),
|
||||||
|
undefined,
|
||||||
|
() => [["wagonTransferRequests"]],
|
||||||
),
|
),
|
||||||
|
|
||||||
historyAll: endpoint<{ userId?: string }, TransferHistory>(
|
history: endpoint<{ page?: number; pageSize?: number }, TransferHistory>(
|
||||||
|
"wagonTransferRequests",
|
||||||
|
"history",
|
||||||
|
({ page, pageSize }) =>
|
||||||
|
wagonTransferRequestService.myHistory(page, pageSize).then((r) => r.data),
|
||||||
|
({ page, pageSize }) => [
|
||||||
|
"wagonTransferRequests",
|
||||||
|
"history",
|
||||||
|
"mine",
|
||||||
|
page ?? 1,
|
||||||
|
pageSize ?? 20,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
|
||||||
|
historyAll: endpoint<
|
||||||
|
{ userId?: string; page?: number; pageSize?: number },
|
||||||
|
TransferHistory
|
||||||
|
>(
|
||||||
"wagonTransferRequests",
|
"wagonTransferRequests",
|
||||||
"historyAll",
|
"historyAll",
|
||||||
({ userId }) =>
|
({ userId, page, pageSize }) =>
|
||||||
wagonTransferRequestService.allHistory(userId).then((r) => r.data),
|
wagonTransferRequestService
|
||||||
({ userId }) => ["wagonTransferRequests", "history", "all", userId ?? ""],
|
.allHistory(userId, page, pageSize)
|
||||||
|
.then((r) => r.data),
|
||||||
|
({ userId, page, pageSize }) => [
|
||||||
|
"wagonTransferRequests",
|
||||||
|
"history",
|
||||||
|
"all",
|
||||||
|
userId ?? "",
|
||||||
|
page ?? 1,
|
||||||
|
pageSize ?? 20,
|
||||||
|
],
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -390,11 +390,12 @@ export const bookingsService = {
|
|||||||
uploadDeliveryOrder: async (
|
uploadDeliveryOrder: async (
|
||||||
id: string,
|
id: string,
|
||||||
file: File,
|
file: File,
|
||||||
vesselDepartureDate?: string,
|
dates: { vesselArrivalDate: string; doCollectedDate: string },
|
||||||
): Promise<BookingDetail> => {
|
): Promise<BookingDetail> => {
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
form.append("file", file);
|
form.append("file", file);
|
||||||
if (vesselDepartureDate) form.append("vesselDepartureDate", vesselDepartureDate);
|
form.append("vesselArrivalDate", dates.vesselArrivalDate);
|
||||||
|
form.append("doCollectedDate", dates.doCollectedDate);
|
||||||
const response = await client.post(B.CLEARANCE_DELIVERY_ORDER(id), form, {
|
const response = await client.post(B.CLEARANCE_DELIVERY_ORDER(id), form, {
|
||||||
headers: { "Content-Type": "multipart/form-data" },
|
headers: { "Content-Type": "multipart/form-data" },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -126,6 +126,23 @@ export interface SignContractPayload {
|
|||||||
consentText?: string;
|
consentText?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One stored version of a clearance document. The current row plus every
|
||||||
|
* superseded upload — staff corrections never erase what the customer sent.
|
||||||
|
*/
|
||||||
|
export interface ClearanceDocumentVersion {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
size: number;
|
||||||
|
mimeType: string;
|
||||||
|
uploadedAt: string;
|
||||||
|
isCurrent: boolean;
|
||||||
|
replacedAt: string | null;
|
||||||
|
replacedByUserId: string | null;
|
||||||
|
replaceReason: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
async function postContract<T>(url: string, body?: unknown): Promise<T> {
|
async function postContract<T>(url: string, body?: unknown): Promise<T> {
|
||||||
const response = await client.post<T>(url, body ?? {});
|
const response = await client.post<T>(url, body ?? {});
|
||||||
return unwrap(response.data);
|
return unwrap(response.data);
|
||||||
@@ -297,6 +314,50 @@ export const contractsService = {
|
|||||||
) =>
|
) =>
|
||||||
postContract<Freight.IContract>(C.CLEARANCE_REVIEW(id), payload),
|
postContract<Freight.IContract>(C.CLEARANCE_REVIEW(id), payload),
|
||||||
|
|
||||||
|
/** GL ET asks Djibouti to name the officer handling the shipment in transit. */
|
||||||
|
requestTransitAssignee: (id: string, note?: string) =>
|
||||||
|
postContract<Freight.IContract>(
|
||||||
|
C.CLEARANCE_TRANSIT_ASSIGNEE_REQUEST(id),
|
||||||
|
{ note },
|
||||||
|
),
|
||||||
|
|
||||||
|
/** GL Djibouti names (or changes) that officer — unblocks the declaration. */
|
||||||
|
assignTransitAssignee: (id: string, assignee: string) =>
|
||||||
|
postContract<Freight.IContract>(C.CLEARANCE_TRANSIT_ASSIGNEE_ASSIGN(id), {
|
||||||
|
assignee,
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace a clearance document in place. The customer's original is retired
|
||||||
|
* into the version history rather than overwritten, and the new file comes
|
||||||
|
* back unreviewed so it still has to be approved.
|
||||||
|
*/
|
||||||
|
replaceClearanceDocument: async (
|
||||||
|
id: string,
|
||||||
|
fileKey: string,
|
||||||
|
file: File,
|
||||||
|
reason: string,
|
||||||
|
): Promise<Freight.IContract> => {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append("file", file);
|
||||||
|
form.append("reason", reason);
|
||||||
|
const response = await client.post(
|
||||||
|
C.CLEARANCE_DOC_REPLACE(id, fileKey),
|
||||||
|
form,
|
||||||
|
{ headers: { "Content-Type": "multipart/form-data" } },
|
||||||
|
);
|
||||||
|
return unwrap(response.data) as Freight.IContract;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Every stored version of one clearance document, newest first. */
|
||||||
|
getClearanceDocumentVersions: async (
|
||||||
|
id: string,
|
||||||
|
fileKey: string,
|
||||||
|
): Promise<ClearanceDocumentVersion[]> => {
|
||||||
|
const response = await client.get(C.CLEARANCE_DOC_VERSIONS(id, fileKey));
|
||||||
|
return (unwrap(response.data) as ClearanceDocumentVersion[]) ?? [];
|
||||||
|
},
|
||||||
|
|
||||||
uploadClearanceOutput: async (
|
uploadClearanceOutput: async (
|
||||||
id: string,
|
id: string,
|
||||||
files: Record<string, File | null>,
|
files: Record<string, File | null>,
|
||||||
@@ -390,11 +451,12 @@ export const contractsService = {
|
|||||||
uploadDeliveryOrder: async (
|
uploadDeliveryOrder: async (
|
||||||
id: string,
|
id: string,
|
||||||
file: File,
|
file: File,
|
||||||
vesselDepartureDate?: string,
|
dates: { vesselArrivalDate: string; doCollectedDate: string },
|
||||||
): Promise<Freight.IContract> => {
|
): Promise<Freight.IContract> => {
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
form.append("file", file);
|
form.append("file", file);
|
||||||
if (vesselDepartureDate) form.append("vesselDepartureDate", vesselDepartureDate);
|
form.append("vesselArrivalDate", dates.vesselArrivalDate);
|
||||||
|
form.append("doCollectedDate", dates.doCollectedDate);
|
||||||
const response = await client.post(C.CLEARANCE_DELIVERY_ORDER(id), form, {
|
const response = await client.post(C.CLEARANCE_DELIVERY_ORDER(id), form, {
|
||||||
headers: { "Content-Type": "multipart/form-data" },
|
headers: { "Content-Type": "multipart/form-data" },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { Freight } from "@edr/types";
|
import type { Freight, PaginatedResponse } from "@edr/types";
|
||||||
|
|
||||||
import { api as apiClient } from "../auth/http";
|
import { api as apiClient } from "../auth/http";
|
||||||
|
|
||||||
@@ -109,10 +109,15 @@ export interface WagonTransferRequest {
|
|||||||
toYardId: string;
|
toYardId: string;
|
||||||
wagonTypeId: string;
|
wagonTypeId: string;
|
||||||
quantity: number;
|
quantity: number;
|
||||||
|
/** How many have actually moved so far — OCC delivers in instalments. */
|
||||||
|
fulfilledQuantity: number;
|
||||||
status: Freight.WagonTransferRequestStatus;
|
status: Freight.WagonTransferRequestStatus;
|
||||||
requestedByUserId: string | null;
|
requestedByUserId: string | null;
|
||||||
fulfilledByUserId: string | null;
|
fulfilledByUserId: string | null;
|
||||||
|
/** When the LAST instalment ran, not necessarily the full count. */
|
||||||
fulfilledAt: string | null;
|
fulfilledAt: string | null;
|
||||||
|
closedShortAt?: string | null;
|
||||||
|
closedShortByUserId?: string | null;
|
||||||
/** Why the wagons are needed — required for new requests, shown on the queue. */
|
/** Why the wagons are needed — required for new requests, shown on the queue. */
|
||||||
reason?: string | null;
|
reason?: string | null;
|
||||||
note: string | null;
|
note: string | null;
|
||||||
@@ -132,7 +137,7 @@ export interface CreateTransferRequestPayload {
|
|||||||
note?: string;
|
note?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Bulk accept-and-execute result: what ran, what stayed PENDING and why. */
|
/** Bulk accept-and-execute result: what ran, what stayed open and why. */
|
||||||
export interface BulkFulfillResult {
|
export interface BulkFulfillResult {
|
||||||
fulfilled: WagonTransferRequest[];
|
fulfilled: WagonTransferRequest[];
|
||||||
skipped: Array<{ id: string; reason: string }>;
|
skipped: Array<{ id: string; reason: string }>;
|
||||||
@@ -142,20 +147,52 @@ export interface BulkFulfillResult {
|
|||||||
export interface TransferHistory {
|
export interface TransferHistory {
|
||||||
requests: WagonTransferRequest[];
|
requests: WagonTransferRequest[];
|
||||||
movements: WagonMovementRecord[];
|
movements: WagonMovementRecord[];
|
||||||
|
meta: {
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
requestsTotal: number;
|
||||||
|
movementsTotal: number;
|
||||||
|
totalPages: number;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Desk list filters — `status` may be a comma-separated set ("open" tab). */
|
||||||
|
export interface TransferRequestListFilter {
|
||||||
|
status?: string;
|
||||||
|
fromYardId?: string;
|
||||||
|
toYardId?: string;
|
||||||
|
wagonTypeId?: string;
|
||||||
|
search?: string;
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const listQuery = (filter: TransferRequestListFilter = {}): string => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
for (const [key, value] of Object.entries(filter)) {
|
||||||
|
if (value !== undefined && value !== null && value !== '') {
|
||||||
|
params.set(key, String(value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const qs = params.toString();
|
||||||
|
return qs ? `?${qs}` : '';
|
||||||
|
};
|
||||||
|
|
||||||
export const wagonTransferRequestService = {
|
export const wagonTransferRequestService = {
|
||||||
list: (status?: Freight.WagonTransferRequestStatus) =>
|
list: (filter: TransferRequestListFilter = {}) =>
|
||||||
apiClient.get<WagonTransferRequest[]>(
|
apiClient.get<PaginatedResponse<WagonTransferRequest>>(
|
||||||
`/wagon-transfer-requests${status ? `?status=${status}` : ''}`,
|
`/wagon-transfer-requests${listQuery(filter)}`,
|
||||||
),
|
),
|
||||||
/** The caller's own history (both roles: requests they filed and fulfilled). */
|
/** The caller's own history (both roles: requests they filed and fulfilled). */
|
||||||
myHistory: () =>
|
myHistory: (page = 1, pageSize = 20) =>
|
||||||
apiClient.get<TransferHistory>('/wagon-transfer-requests/history'),
|
|
||||||
/** Admin: any/all staff's history (optional userId filter). */
|
|
||||||
allHistory: (userId?: string) =>
|
|
||||||
apiClient.get<TransferHistory>(
|
apiClient.get<TransferHistory>(
|
||||||
`/wagon-transfer-requests/history/all${userId ? `?userId=${userId}` : ''}`,
|
`/wagon-transfer-requests/history?page=${page}&pageSize=${pageSize}`,
|
||||||
|
),
|
||||||
|
/** Admin: any/all staff's history (optional userId filter). */
|
||||||
|
allHistory: (userId?: string, page = 1, pageSize = 20) =>
|
||||||
|
apiClient.get<TransferHistory>(
|
||||||
|
`/wagon-transfer-requests/history/all?page=${page}&pageSize=${pageSize}` +
|
||||||
|
`${userId ? `&userId=${userId}` : ''}`,
|
||||||
),
|
),
|
||||||
getById: (id: string) =>
|
getById: (id: string) =>
|
||||||
apiClient.get<WagonTransferRequest>(`/wagon-transfer-requests/${id}`),
|
apiClient.get<WagonTransferRequest>(`/wagon-transfer-requests/${id}`),
|
||||||
@@ -176,4 +213,10 @@ export const wagonTransferRequestService = {
|
|||||||
apiClient.post<WagonTransferRequest>(
|
apiClient.post<WagonTransferRequest>(
|
||||||
`/wagon-transfer-requests/${id}/cancel`,
|
`/wagon-transfer-requests/${id}/cancel`,
|
||||||
),
|
),
|
||||||
|
/** OCC: end the request short — the source yard has no more to give. */
|
||||||
|
closeShort: (id: string, note?: string) =>
|
||||||
|
apiClient.post<WagonTransferRequest>(
|
||||||
|
`/wagon-transfer-requests/${id}/close-short`,
|
||||||
|
{ note },
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -616,6 +616,12 @@ export interface TrainScheduleDetail {
|
|||||||
wagons: Array<{
|
wagons: Array<{
|
||||||
id: string;
|
id: string;
|
||||||
sequenceNo: number;
|
sequenceNo: number;
|
||||||
|
/**
|
||||||
|
* Place in the drawn consist, 1..n — the built train's real coupling
|
||||||
|
* order (reversed for a reverseWagonOrder schedule). Label wagons with
|
||||||
|
* this, not sequenceNo: a slot's stored sequenceNo is not its position.
|
||||||
|
*/
|
||||||
|
position?: number;
|
||||||
capacityTons: number;
|
capacityTons: number;
|
||||||
lengthMeters: number;
|
lengthMeters: number;
|
||||||
assignedWeightTons: number;
|
assignedWeightTons: number;
|
||||||
|
|||||||
@@ -153,6 +153,9 @@ export const URL_CONSTANTS = {
|
|||||||
CLEARANCE_DOCUMENTS: (id: string) =>
|
CLEARANCE_DOCUMENTS: (id: string) =>
|
||||||
`/api/contracts/${id}/clearance/documents`,
|
`/api/contracts/${id}/clearance/documents`,
|
||||||
CLEARANCE_DUTY_SLIP: (id: string) => `/api/contracts/${id}/clearance/duty-slip`,
|
CLEARANCE_DUTY_SLIP: (id: string) => `/api/contracts/${id}/clearance/duty-slip`,
|
||||||
|
/** Ask GL Ethiopia to correct the advised duty instead of paying it. */
|
||||||
|
CLEARANCE_DUTY_DISPUTE: (id: string) =>
|
||||||
|
`/api/contracts/${id}/clearance/duty/dispute`,
|
||||||
BOOKINGS: (id: string) => `/api/contracts/${id}/bookings`,
|
BOOKINGS: (id: string) => `/api/contracts/${id}/bookings`,
|
||||||
BOOKINGS_INITIATE: (id: string) => `/api/contracts/${id}/bookings/initiate`,
|
BOOKINGS_INITIATE: (id: string) => `/api/contracts/${id}/bookings/initiate`,
|
||||||
BOOKINGS_COMPLETE: (id: string, bookingId: string) =>
|
BOOKINGS_COMPLETE: (id: string, bookingId: string) =>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Alert, Box, Button, Group, Paper, Stack, Text } from "@mantine/core";
|
import { Alert, Box, Button, Group, Paper, Stack, Text, Textarea } from "@mantine/core";
|
||||||
import { AlertTriangle, ArrowRight, Download, PackageCheck, Receipt, Upload } from "lucide-react";
|
import { AlertTriangle, ArrowRight, Download, MessageSquareWarning, PackageCheck, Receipt, Upload } from "lucide-react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
import type { Freight } from "@edr/types";
|
import type { Freight } from "@edr/types";
|
||||||
@@ -68,7 +68,26 @@ export function ContractClearanceWorkflowBanner({
|
|||||||
</Alert>
|
</Alert>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{dutyPending && clearance.dutyAdvice ? (
|
{/* Duty is disputed: GL owes a corrected advice, so the pay/upload
|
||||||
|
panel is replaced by the waiting state until they re-send it. */}
|
||||||
|
{clearance.dutyDispute ? (
|
||||||
|
<Alert
|
||||||
|
color="orange"
|
||||||
|
variant="light"
|
||||||
|
icon={<MessageSquareWarning size={16} />}
|
||||||
|
title="Waiting for a corrected duty amount"
|
||||||
|
>
|
||||||
|
<Stack gap={4}>
|
||||||
|
<Text fz={13}>
|
||||||
|
You asked GL Ethiopia to review the advised duty & tax. They
|
||||||
|
will send a corrected notice — you will be notified.
|
||||||
|
</Text>
|
||||||
|
<Text fz={12} c="dimmed" style={{ whiteSpace: "pre-wrap" }}>
|
||||||
|
Your message: {clearance.dutyDispute.note}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</Alert>
|
||||||
|
) : dutyPending && clearance.dutyAdvice ? (
|
||||||
<DutyAdvicePanel
|
<DutyAdvicePanel
|
||||||
dutyAdvice={clearance.dutyAdvice}
|
dutyAdvice={clearance.dutyAdvice}
|
||||||
contractId={contract.id}
|
contractId={contract.id}
|
||||||
@@ -139,6 +158,11 @@ function DutyAdvicePanel({
|
|||||||
}) {
|
}) {
|
||||||
const [file, setFile] = useState<File | null>(null);
|
const [file, setFile] = useState<File | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
// Accept → pay and upload the slip. Query → say what is wrong and send it
|
||||||
|
// back to GL Ethiopia for a corrected notice.
|
||||||
|
const [disputing, setDisputing] = useState(false);
|
||||||
|
const [disputeNote, setDisputeNote] = useState("");
|
||||||
|
const [sendingDispute, setSendingDispute] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Paper
|
<Paper
|
||||||
@@ -216,41 +240,107 @@ function DutyAdvicePanel({
|
|||||||
) : null}
|
) : null}
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|
||||||
<Text fz={13} c="dimmed">
|
{disputing ? (
|
||||||
Pay the amount above, then upload your payment slip so clearance can continue.
|
<Stack gap="sm">
|
||||||
</Text>
|
<Text fz={13} c="dimmed">
|
||||||
|
Tell GL Ethiopia what is wrong with this amount. They will review
|
||||||
|
and send a corrected notice.
|
||||||
|
</Text>
|
||||||
|
<Textarea
|
||||||
|
label="What needs correcting?"
|
||||||
|
placeholder="e.g. the declared value is wrong — the invoice total is 412,000 ETB"
|
||||||
|
value={disputeNote}
|
||||||
|
onChange={(e) => setDisputeNote(e.currentTarget.value)}
|
||||||
|
autosize
|
||||||
|
minRows={3}
|
||||||
|
/>
|
||||||
|
<Group gap="sm" grow>
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
onClick={() => {
|
||||||
|
setDisputing(false);
|
||||||
|
setDisputeNote("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="orange"
|
||||||
|
loading={sendingDispute}
|
||||||
|
disabled={!disputeNote.trim()}
|
||||||
|
onClick={async () => {
|
||||||
|
setSendingDispute(true);
|
||||||
|
try {
|
||||||
|
await contractsService.disputeContractDuty(
|
||||||
|
contractId,
|
||||||
|
disputeNote.trim(),
|
||||||
|
);
|
||||||
|
toast.success("Sent back to GL Ethiopia for correction");
|
||||||
|
setDisputing(false);
|
||||||
|
setDisputeNote("");
|
||||||
|
onUploaded();
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : "Could not send");
|
||||||
|
} finally {
|
||||||
|
setSendingDispute(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Send for correction
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Text fz={13} c="dimmed">
|
||||||
|
Pay the amount above, then upload your payment slip so clearance can
|
||||||
|
continue. If the amount looks wrong, ask GL Ethiopia to correct it
|
||||||
|
before paying.
|
||||||
|
</Text>
|
||||||
|
|
||||||
<PortalFileDropzone
|
<PortalFileDropzone
|
||||||
label="Payment slip"
|
label="Payment slip"
|
||||||
description="Upload proof of duty/tax payment (PDF or image)."
|
description="Upload proof of duty/tax payment (PDF or image)."
|
||||||
value={file}
|
value={file}
|
||||||
onChange={setFile}
|
onChange={setFile}
|
||||||
onPreview={onPreview}
|
onPreview={onPreview}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
color="orange"
|
color="orange"
|
||||||
loading={loading}
|
loading={loading}
|
||||||
disabled={!file}
|
disabled={!file}
|
||||||
leftSection={<Upload size={16} />}
|
leftSection={<Upload size={16} />}
|
||||||
fullWidth
|
fullWidth
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
await contractsService.uploadContractDutySlip(contractId, file);
|
await contractsService.uploadContractDutySlip(contractId, file);
|
||||||
toast.success("Payment slip uploaded");
|
toast.success("Payment slip uploaded");
|
||||||
setFile(null);
|
setFile(null);
|
||||||
onUploaded();
|
onUploaded();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(e instanceof Error ? e.message : "Upload failed");
|
toast.error(e instanceof Error ? e.message : "Upload failed");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Submit payment slip
|
Accept & submit payment slip
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="subtle"
|
||||||
|
color="orange"
|
||||||
|
leftSection={<MessageSquareWarning size={15} />}
|
||||||
|
fullWidth
|
||||||
|
onClick={() => setDisputing(true)}
|
||||||
|
>
|
||||||
|
Request a change to this amount
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
</Paper>
|
</Paper>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -610,7 +610,6 @@ export default function NewContractPage({
|
|||||||
? Freight.ContractFreightType.Container
|
? Freight.ContractFreightType.Container
|
||||||
: Freight.ContractFreightType.Bulk,
|
: Freight.ContractFreightType.Bulk,
|
||||||
serviceTypeId: data.serviceTypeId,
|
serviceTypeId: data.serviceTypeId,
|
||||||
paymentCurrency: data.paymentCurrency,
|
|
||||||
// Empty-container return is a contract-level opt-in (container freight
|
// Empty-container return is a contract-level opt-in (container freight
|
||||||
// only) — like hazardous. Per-booking return quantities are still set at
|
// only) — like hazardous. Per-booking return quantities are still set at
|
||||||
// booking time, but only on contracts created WITH_RETURN.
|
// booking time, but only on contracts created WITH_RETURN.
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
Loader,
|
Loader,
|
||||||
Modal,
|
Modal,
|
||||||
Paper,
|
Paper,
|
||||||
|
SegmentedControl,
|
||||||
Stack,
|
Stack,
|
||||||
Switch,
|
Switch,
|
||||||
Text,
|
Text,
|
||||||
@@ -262,6 +263,10 @@ function NewShipmentBookingForm({
|
|||||||
// Seed the equipment-return toggle from the contract; the customer can
|
// Seed the equipment-return toggle from the contract; the customer can
|
||||||
// still flip it per shipment.
|
// still flip it per shipment.
|
||||||
withReturn: contract.equipmentReturn === "WITH_RETURN",
|
withReturn: contract.equipmentReturn === "WITH_RETURN",
|
||||||
|
// The contract quotes USD; the customer bills this shipment in the
|
||||||
|
// currency they pick here. Intercity is always ETB.
|
||||||
|
paymentCurrency:
|
||||||
|
contract.tradeDirection === "DOMESTIC" ? "ETB" : "USD",
|
||||||
},
|
},
|
||||||
resolver: zodResolver(
|
resolver: zodResolver(
|
||||||
createShipmentFormSchema({
|
createShipmentFormSchema({
|
||||||
@@ -332,6 +337,7 @@ function NewShipmentBookingForm({
|
|||||||
...(values.contractRouteId
|
...(values.contractRouteId
|
||||||
? { contractRouteId: values.contractRouteId }
|
? { contractRouteId: values.contractRouteId }
|
||||||
: {}),
|
: {}),
|
||||||
|
paymentCurrency: values.paymentCurrency,
|
||||||
// Intercity bookings carry no date — staff assign a passing train later.
|
// Intercity bookings carry no date — staff assign a passing train later.
|
||||||
...(values.scheduledDate
|
...(values.scheduledDate
|
||||||
? { scheduledDate: new Date(values.scheduledDate).toISOString() }
|
? { scheduledDate: new Date(values.scheduledDate).toISOString() }
|
||||||
@@ -1003,6 +1009,12 @@ function ScheduleStep({
|
|||||||
corridor. Operations assign it to a train with free capacity — you
|
corridor. Operations assign it to a train with free capacity — you
|
||||||
will be notified when it is accepted and payment is due.
|
will be notified when it is accepted and payment is due.
|
||||||
</Alert>
|
</Alert>
|
||||||
|
<Box mt="md">
|
||||||
|
<StepLabel>Billing currency</StepLabel>
|
||||||
|
<Text fz={13} c="dimmed" mt={6}>
|
||||||
|
Intercity shipments are invoiced in <strong>ETB</strong>.
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
</StepCard>
|
</StepCard>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1014,6 +1026,29 @@ function ScheduleStep({
|
|||||||
title="Schedule"
|
title="Schedule"
|
||||||
description="Pick the binding shipment day. Only days with an open train that has enough matching wagons for your cargo can be selected."
|
description="Pick the binding shipment day. Only days with an open train that has enough matching wagons for your cargo can be selected."
|
||||||
/>
|
/>
|
||||||
|
<Controller
|
||||||
|
name="paymentCurrency"
|
||||||
|
control={form.control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<Box mb="lg">
|
||||||
|
<StepLabel>Billing currency *</StepLabel>
|
||||||
|
<Text fz={12.5} c="dimmed" mt={4} mb={10}>
|
||||||
|
Your contract is quoted in USD. Pick the currency this shipment is
|
||||||
|
invoiced in — the total is converted for you.
|
||||||
|
</Text>
|
||||||
|
<SegmentedControl
|
||||||
|
value={field.value ?? "USD"}
|
||||||
|
onChange={(v) => field.onChange(v)}
|
||||||
|
data={[
|
||||||
|
{ label: "USD", value: "USD" },
|
||||||
|
{ label: "ETB", value: "ETB" },
|
||||||
|
]}
|
||||||
|
color="edr-green"
|
||||||
|
radius={10}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
{cargoQuery === null ? (
|
{cargoQuery === null ? (
|
||||||
<Alert color="yellow" variant="light" radius="md" icon={<AlertCircle size={16} />}>
|
<Alert color="yellow" variant="light" radius="md" icon={<AlertCircle size={16} />}>
|
||||||
Enter your cargo details first — available shipment days depend on the
|
Enter your cargo details first — available shipment days depend on the
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
Loader,
|
Loader,
|
||||||
NumberInput,
|
NumberInput,
|
||||||
Paper,
|
Paper,
|
||||||
|
SegmentedControl,
|
||||||
Stack,
|
Stack,
|
||||||
Text,
|
Text,
|
||||||
Textarea,
|
Textarea,
|
||||||
@@ -33,6 +34,9 @@ export default function NewShipmentRequestPage() {
|
|||||||
);
|
);
|
||||||
// Bulk contracts: a single amount — tons (PER_TON) or item count (PER_ITEM).
|
// Bulk contracts: a single amount — tons (PER_TON) or item count (PER_ITEM).
|
||||||
const [bulkAmount, setBulkAmount] = useState<number | string>("");
|
const [bulkAmount, setBulkAmount] = useState<number | string>("");
|
||||||
|
// GL books this shipment on the customer's behalf, so the currency they want
|
||||||
|
// to be invoiced in has to be stated here — the contract itself quotes USD.
|
||||||
|
const [paymentCurrency, setPaymentCurrency] = useState<"USD" | "ETB">("USD");
|
||||||
const [notes, setNotes] = useState("");
|
const [notes, setNotes] = useState("");
|
||||||
|
|
||||||
const { data: contract, isLoading } = useQuery({
|
const { data: contract, isLoading } = useQuery({
|
||||||
@@ -77,11 +81,12 @@ export default function NewShipmentRequestPage() {
|
|||||||
|
|
||||||
const isContainer = contract.freightType === "CONTAINER";
|
const isContainer = contract.freightType === "CONTAINER";
|
||||||
const route = contract.routes?.[0];
|
const route = contract.routes?.[0];
|
||||||
// GENERAL customs contracts: GL schedules the shipment during clearance —
|
// Customs contracts: GL schedules the shipment during clearance — the
|
||||||
// the customer only states the quantity, never picks a date.
|
// customer only states the quantity (and currency), never picks a date. This
|
||||||
|
// now covers ONE_TIME customs too, where GL likewise books on their behalf.
|
||||||
const hasCustoms =
|
const hasCustoms =
|
||||||
contract.contractKind === "GENERAL" &&
|
contract.serviceType?.includesCustoms ?? contract.customsClearingEnabled;
|
||||||
(contract.serviceType?.includesCustoms ?? contract.customsClearingEnabled);
|
const isIntercity = contract.tradeDirection === "DOMESTIC";
|
||||||
|
|
||||||
// Only the container sizes the contract was scoped for (20ft, 40ft, or both).
|
// Only the container sizes the contract was scoped for (20ft, 40ft, or both).
|
||||||
const SIZE_ORDER = ["20ft", "40ft"];
|
const SIZE_ORDER = ["20ft", "40ft"];
|
||||||
@@ -110,6 +115,7 @@ export default function NewShipmentRequestPage() {
|
|||||||
const dto: Freight.CreateBookingRequestDto = {
|
const dto: Freight.CreateBookingRequestDto = {
|
||||||
contractRouteId: route?.id,
|
contractRouteId: route?.id,
|
||||||
scheduledDate: hasCustoms ? undefined : scheduledDate || undefined,
|
scheduledDate: hasCustoms ? undefined : scheduledDate || undefined,
|
||||||
|
paymentCurrency: isIntercity ? "ETB" : paymentCurrency,
|
||||||
notes: notes.trim() || undefined,
|
notes: notes.trim() || undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -233,6 +239,28 @@ export default function NewShipmentRequestPage() {
|
|||||||
</Text>
|
</Text>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
<Box>
|
||||||
|
<Text size="sm" fw={600} mb={4}>
|
||||||
|
Billing currency *
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed" mb={8}>
|
||||||
|
{isIntercity
|
||||||
|
? "Intercity shipments are invoiced in ETB."
|
||||||
|
: "Your contract is quoted in USD. Global Logistics will book this shipment and invoice you in the currency you pick here."}
|
||||||
|
</Text>
|
||||||
|
<SegmentedControl
|
||||||
|
value={isIntercity ? "ETB" : paymentCurrency}
|
||||||
|
onChange={(v) => setPaymentCurrency(v as "USD" | "ETB")}
|
||||||
|
disabled={isIntercity}
|
||||||
|
data={[
|
||||||
|
{ label: "USD", value: "USD" },
|
||||||
|
{ label: "ETB", value: "ETB" },
|
||||||
|
]}
|
||||||
|
color="teal"
|
||||||
|
radius={10}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
<Textarea
|
<Textarea
|
||||||
label="Notes (optional)"
|
label="Notes (optional)"
|
||||||
value={notes}
|
value={notes}
|
||||||
|
|||||||
@@ -15,6 +15,18 @@ export const TERMINAL_BOOKING_STATUSES = [
|
|||||||
/** Path A statuses where a customer (no customs) may book against the contract. */
|
/** Path A statuses where a customer (no customs) may book against the contract. */
|
||||||
const PATH_A_BOOKABLE = ["FULLY_EXECUTED", "CONTRACT_ACTIVE"];
|
const PATH_A_BOOKABLE = ["FULLY_EXECUTED", "CONTRACT_ACTIVE"];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ONE_TIME + customs: statuses where the customer may still submit the shipment
|
||||||
|
* request GL books from. Mirrors the API source of truth in
|
||||||
|
* `booking-request.service.ts`.
|
||||||
|
*/
|
||||||
|
const ONE_TIME_CUSTOMS_REQUESTABLE = [
|
||||||
|
"FULLY_EXECUTED",
|
||||||
|
"AWAITING_CLEARANCE_DOCUMENTS",
|
||||||
|
"CLEARANCE_UNDER_REVIEW",
|
||||||
|
"CLEARANCE_READY_FOR_BOOKING",
|
||||||
|
];
|
||||||
|
|
||||||
/** Split bookings in these statuses released their quantity — no remainder to book. */
|
/** Split bookings in these statuses released their quantity — no remainder to book. */
|
||||||
const RELEASING_BOOKING_STATUSES = ["CANCELLED", "REJECTED", "EXPIRED"];
|
const RELEASING_BOOKING_STATUSES = ["CANCELLED", "REJECTED", "EXPIRED"];
|
||||||
|
|
||||||
@@ -49,20 +61,31 @@ export function getContractBookingAction(
|
|||||||
contract: Freight.IContract,
|
contract: Freight.IContract,
|
||||||
bookings: Freight.IBooking[],
|
bookings: Freight.IBooking[],
|
||||||
): ContractBookingAction {
|
): ContractBookingAction {
|
||||||
// GENERAL + customs: customer submits a shipment request; GL creates the booking.
|
// Customs: the customer never books directly — GL does it for them. The
|
||||||
if (
|
// shipment request is how they say what to ship and which currency to be
|
||||||
contract.customsClearingEnabled &&
|
// invoiced in (the contract itself quotes USD only).
|
||||||
contract.contractKind === "GENERAL" &&
|
if (contract.customsClearingEnabled) {
|
||||||
contract.status === "CONTRACT_ACTIVE"
|
const requestable =
|
||||||
) {
|
contract.contractKind === "GENERAL"
|
||||||
return {
|
? contract.status === "CONTRACT_ACTIVE"
|
||||||
kind: "request",
|
: // ONE_TIME: both signatures in, GL has not booked yet. Mirrors
|
||||||
to: `/contracts/${contract.id}/shipment-requests/new`,
|
// BookingRequestService.ONE_TIME_REQUESTABLE_STATUSES.
|
||||||
};
|
ONE_TIME_CUSTOMS_REQUESTABLE.includes(contract.status);
|
||||||
}
|
|
||||||
|
|
||||||
// Other customs (ONE_TIME): booked by GL — no customer action.
|
// One shipment, one open request — the API rejects a second one, so don't
|
||||||
if (contract.customsClearingEnabled) return { kind: "none", to: "" };
|
// offer the button while a request is still pending.
|
||||||
|
const hasOpenRequest =
|
||||||
|
contract.contractKind === "ONE_TIME" &&
|
||||||
|
bookings.some((b) => b.contractId === contract.id);
|
||||||
|
|
||||||
|
if (requestable && !hasOpenRequest) {
|
||||||
|
return {
|
||||||
|
kind: "request",
|
||||||
|
to: `/contracts/${contract.id}/shipment-requests/new`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { kind: "none", to: "" };
|
||||||
|
}
|
||||||
if (!PATH_A_BOOKABLE.includes(contract.status)) return { kind: "none", to: "" };
|
if (!PATH_A_BOOKABLE.includes(contract.status)) return { kind: "none", to: "" };
|
||||||
|
|
||||||
const to = `/contracts/${contract.id}/bookings/new`;
|
const to = `/contracts/${contract.id}/bookings/new`;
|
||||||
|
|||||||
@@ -91,8 +91,6 @@ export function contractToFormValues(
|
|||||||
previousContractRef: contract.renewalOfId ?? "",
|
previousContractRef: contract.renewalOfId ?? "",
|
||||||
|
|
||||||
serviceTypeId: contract.serviceTypeId,
|
serviceTypeId: contract.serviceTypeId,
|
||||||
paymentCurrency:
|
|
||||||
contract.paymentCurrency === "ETB" ? "ETB" : "USD",
|
|
||||||
|
|
||||||
firstMile: {
|
firstMile: {
|
||||||
enabled: hasFirstMile,
|
enabled: hasFirstMile,
|
||||||
|
|||||||
@@ -1,56 +0,0 @@
|
|||||||
import { Select, Text } from "@mantine/core";
|
|
||||||
import { Controller, type Control } from "react-hook-form";
|
|
||||||
import {
|
|
||||||
PAYMENT_CURRENCY_OPTIONS,
|
|
||||||
type ContractFormInputValues,
|
|
||||||
type ContractFormValues,
|
|
||||||
} from "./schema";
|
|
||||||
import { fieldStyles } from "./shared";
|
|
||||||
|
|
||||||
export function PaymentCurrencyField({
|
|
||||||
control,
|
|
||||||
etbOnly = false,
|
|
||||||
}: {
|
|
||||||
control: Control<ContractFormInputValues, any, ContractFormValues>;
|
|
||||||
/** Intercity (domestic) contracts are priced in ETB only. */
|
|
||||||
etbOnly?: boolean;
|
|
||||||
}) {
|
|
||||||
const options = etbOnly
|
|
||||||
? PAYMENT_CURRENCY_OPTIONS.filter((o) => o.value === "ETB")
|
|
||||||
: PAYMENT_CURRENCY_OPTIONS;
|
|
||||||
return (
|
|
||||||
<Controller
|
|
||||||
name="paymentCurrency"
|
|
||||||
control={control}
|
|
||||||
render={({ field, fieldState }) => {
|
|
||||||
const selected = options.find((o) => o.value === field.value);
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<Select
|
|
||||||
label="Payment Currency *"
|
|
||||||
placeholder="Select currency…"
|
|
||||||
data={options.map((o) => ({
|
|
||||||
value: o.value,
|
|
||||||
label: o.label,
|
|
||||||
}))}
|
|
||||||
value={field.value || null}
|
|
||||||
onChange={(v) => v && field.onChange(v)}
|
|
||||||
onBlur={field.onBlur}
|
|
||||||
error={fieldState.error?.message}
|
|
||||||
allowDeselect={false}
|
|
||||||
radius={10}
|
|
||||||
checkIconPosition="right"
|
|
||||||
comboboxProps={{ withinPortal: true, shadow: "md", radius: "md" }}
|
|
||||||
styles={fieldStyles}
|
|
||||||
/>
|
|
||||||
{selected && (
|
|
||||||
<Text fz={12} c="#6B7C8E" mt={6}>
|
|
||||||
{selected.description}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -125,7 +125,6 @@ export const contractFormSchema = z
|
|||||||
serviceTypeId: z
|
serviceTypeId: z
|
||||||
.string("Select a service type.")
|
.string("Select a service type.")
|
||||||
.min(1, "Select a service type."),
|
.min(1, "Select a service type."),
|
||||||
paymentCurrency: z.enum(PAYMENT_CURRENCIES, "Select a payment currency."),
|
|
||||||
|
|
||||||
firstMile: z
|
firstMile: z
|
||||||
.object({
|
.object({
|
||||||
@@ -220,14 +219,6 @@ export const contractFormSchema = z
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
.superRefine((data, ctx) => {
|
.superRefine((data, ctx) => {
|
||||||
// Intercity (domestic) contracts are priced and invoiced in ETB only.
|
|
||||||
if (data.operationType === "intercity" && data.paymentCurrency !== "ETB") {
|
|
||||||
ctx.addIssue({
|
|
||||||
code: "custom",
|
|
||||||
path: ["paymentCurrency"],
|
|
||||||
message: "Intercity contracts are priced in ETB.",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// Container scope needs no validation: both sizes are always in scope and
|
// Container scope needs no validation: both sizes are always in scope and
|
||||||
// the cargo description moved to booking time.
|
// the cargo description moved to booking time.
|
||||||
if (data.cargoType === "bulk") {
|
if (data.cargoType === "bulk") {
|
||||||
@@ -279,8 +270,6 @@ export const initialContractFormValues: DeepPartial<ContractFormValues> = {
|
|||||||
previousContractRef: "",
|
previousContractRef: "",
|
||||||
|
|
||||||
serviceTypeId: "",
|
serviceTypeId: "",
|
||||||
// No preselected currency — the customer must choose (intercity forces ETB).
|
|
||||||
paymentCurrency: undefined,
|
|
||||||
firstMile: { enabled: false, pickUpAddress: "", exactLocation: "", lat: null, lng: null },
|
firstMile: { enabled: false, pickUpAddress: "", exactLocation: "", lat: null, lng: null },
|
||||||
lastMile: { enabled: false, deliveryAddress: "", exactLocation: "", lat: null, lng: null },
|
lastMile: { enabled: false, deliveryAddress: "", exactLocation: "", lat: null, lng: null },
|
||||||
equipmentReturn: "without_return",
|
equipmentReturn: "without_return",
|
||||||
@@ -316,7 +305,6 @@ export const contractStepFields: Record<
|
|||||||
"contractType",
|
"contractType",
|
||||||
"previousContractRef",
|
"previousContractRef",
|
||||||
"serviceTypeId",
|
"serviceTypeId",
|
||||||
"paymentCurrency",
|
|
||||||
"equipmentReturn",
|
"equipmentReturn",
|
||||||
"customsClearingEnabled",
|
"customsClearingEnabled",
|
||||||
"customsClearingAgent",
|
"customsClearingAgent",
|
||||||
|
|||||||
@@ -104,12 +104,6 @@ export function Step1ContractType({
|
|||||||
// ── Service type ──
|
// ── Service type ──
|
||||||
const serviceId = contract.serviceTypeId;
|
const serviceId = contract.serviceTypeId;
|
||||||
if (serviceId) form.setValue("serviceTypeId", serviceId);
|
if (serviceId) form.setValue("serviceTypeId", serviceId);
|
||||||
if (contract.paymentCurrency) {
|
|
||||||
form.setValue(
|
|
||||||
"paymentCurrency",
|
|
||||||
contract.paymentCurrency as ContractFormValues["paymentCurrency"],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── First / last mile ──
|
// ── First / last mile ──
|
||||||
form.setValue("firstMile", {
|
form.setValue("firstMile", {
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import { Controller, type UseFormReturn } from "react-hook-form";
|
|||||||
import { ContractFormInputValues, type ContractFormValues } from "./schema";
|
import { ContractFormInputValues, type ContractFormValues } from "./schema";
|
||||||
import { filterBookableServices, operationToTradeDirection } from "./helpers";
|
import { filterBookableServices, operationToTradeDirection } from "./helpers";
|
||||||
import { fieldStyles, StepLabel } from "./shared";
|
import { fieldStyles, StepLabel } from "./shared";
|
||||||
import { PaymentCurrencyField } from "./payment-currency-field";
|
|
||||||
import { LocationPicker } from "@/pages/bookings/new-booking-form/LocationPicker";
|
import { LocationPicker } from "@/pages/bookings/new-booking-form/LocationPicker";
|
||||||
|
|
||||||
import type { Freight } from "@edr/types";
|
import type { Freight } from "@edr/types";
|
||||||
@@ -347,14 +346,11 @@ export function Step2ServiceType({
|
|||||||
}
|
}
|
||||||
}, [operationType, standaloneServices, form]);
|
}, [operationType, standaloneServices, form]);
|
||||||
|
|
||||||
// Intercity (domestic) contracts are priced in ETB only — force the currency
|
// A contract is always quoted in USD now — the billing currency is chosen per
|
||||||
// and let the field render just the ETB option. Also clears a stale USD from
|
// shipment (at booking, or on the shipment request when GL books). Intercity
|
||||||
// a restored draft or an operation-type switch.
|
// still bills in ETB, but that is applied at booking time, not here.
|
||||||
const isIntercity = operationType === "intercity";
|
const isIntercity = operationType === "intercity";
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isIntercity && form.getValues("paymentCurrency") !== "ETB") {
|
|
||||||
form.setValue("paymentCurrency", "ETB", { shouldValidate: true });
|
|
||||||
}
|
|
||||||
// The customs clearing agent field is hidden for intercity — drop any value
|
// The customs clearing agent field is hidden for intercity — drop any value
|
||||||
// carried over from a draft or an operation-type switch.
|
// carried over from a draft or an operation-type switch.
|
||||||
if (isIntercity && form.getValues("customsClearingAgent")) {
|
if (isIntercity && form.getValues("customsClearingAgent")) {
|
||||||
@@ -378,10 +374,6 @@ export function Step2ServiceType({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Box maw={420}>
|
|
||||||
<PaymentCurrencyField control={form.control} etbOnly={isIntercity} />
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{showServiceSections && (
|
{showServiceSections && (
|
||||||
<Stack gap={12}>
|
<Stack gap={12}>
|
||||||
<StepLabel>Trucking & customs options</StepLabel>
|
<StepLabel>Trucking & customs options</StepLabel>
|
||||||
|
|||||||
@@ -353,8 +353,15 @@ export function Step8Review({
|
|||||||
/>
|
/>
|
||||||
<SummaryItem
|
<SummaryItem
|
||||||
icon={<Coins size={18} />}
|
icon={<Coins size={18} />}
|
||||||
label="Payment currency"
|
label="Quotation currency"
|
||||||
value={values.paymentCurrency ?? "USD"}
|
value={
|
||||||
|
<>
|
||||||
|
USD
|
||||||
|
<Text fz="sm" c="dimmed" mt={4}>
|
||||||
|
You choose the billing currency on each shipment.
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<SummaryItem
|
<SummaryItem
|
||||||
icon={<Route size={18} />}
|
icon={<Route size={18} />}
|
||||||
@@ -500,10 +507,6 @@ export function Step8Review({
|
|||||||
done={Boolean(values.serviceTypeId)}
|
done={Boolean(values.serviceTypeId)}
|
||||||
label="Service configured"
|
label="Service configured"
|
||||||
/>
|
/>
|
||||||
<ReadinessItem
|
|
||||||
done={Boolean(values.paymentCurrency)}
|
|
||||||
label="Payment currency selected"
|
|
||||||
/>
|
|
||||||
<ReadinessItem
|
<ReadinessItem
|
||||||
done={Boolean(values.originYard && values.destinationYard)}
|
done={Boolean(values.originYard && values.destinationYard)}
|
||||||
label="Route selected"
|
label="Route selected"
|
||||||
|
|||||||
@@ -73,6 +73,9 @@ const containerLineSchema = z.object({
|
|||||||
const shipmentFormBase = z.object({
|
const shipmentFormBase = z.object({
|
||||||
contractRouteId: z.string().default(""),
|
contractRouteId: z.string().default(""),
|
||||||
scheduledDate: z.string().default(""),
|
scheduledDate: z.string().default(""),
|
||||||
|
// The contract quotes in USD; the customer picks the billing currency for
|
||||||
|
// THIS shipment. Intercity is forced to ETB (server-enforced too).
|
||||||
|
paymentCurrency: z.enum(["USD", "ETB"]).default("USD"),
|
||||||
// Container contracts only: return the empty container(s) to EDR after
|
// Container contracts only: return the empty container(s) to EDR after
|
||||||
// unloading. Seeded from the contract's equipment return; bulk ignores it.
|
// unloading. Seeded from the contract's equipment return; bulk ignores it.
|
||||||
withReturn: z.boolean().default(false),
|
withReturn: z.boolean().default(false),
|
||||||
|
|||||||
@@ -305,6 +305,18 @@ export const contractsService = {
|
|||||||
return data.data ?? data;
|
return data.data ?? data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reject the advised duty & tax with a reason. The clearance step goes back
|
||||||
|
* to GL Ethiopia, who re-advises a corrected amount; this can repeat.
|
||||||
|
*/
|
||||||
|
disputeContractDuty: async (
|
||||||
|
id: string,
|
||||||
|
note: string,
|
||||||
|
): Promise<Freight.IContract> => {
|
||||||
|
const { data } = await client.post(C.CLEARANCE_DUTY_DISPUTE(id), { note });
|
||||||
|
return data.data ?? data;
|
||||||
|
},
|
||||||
|
|
||||||
uploadContractDutySlip: async (
|
uploadContractDutySlip: async (
|
||||||
id: string,
|
id: string,
|
||||||
file: File,
|
file: File,
|
||||||
|
|||||||
@@ -41,7 +41,108 @@ export default defineConfig({
|
|||||||
process.env.E2E_DB_URL ??
|
process.env.E2E_DB_URL ??
|
||||||
"postgres://edr_e2e:edr_e2e@localhost:5533/edr_freight_e2e";
|
"postgres://edr_e2e:edr_e2e@localhost:5533/edr_freight_e2e";
|
||||||
|
|
||||||
|
// Shapes already retired in THIS cypress run. The Node plugin process
|
||||||
|
// outlives spec-bundle re-evaluation (Cypress re-runs the bundle — and
|
||||||
|
// so `before()` — on every cross-origin visit), so a run-scoped set here
|
||||||
|
// is what keeps the cleanup from firing a second time and cancelling the
|
||||||
|
// contract the spec had just created. Browser-side state cannot do this:
|
||||||
|
// Cypress.env() is reset by the reload.
|
||||||
|
const retiredContractShapes = new Set<string>();
|
||||||
|
/** Keys already executed by `db:queryOnce` in this cypress run. */
|
||||||
|
const onceRunKeys = new Set<string>();
|
||||||
|
// Stamped when the plugin loads, i.e. once per cypress run. Cleanup only
|
||||||
|
// ever touches rows older than this, so nothing the current run creates
|
||||||
|
// can be cancelled out from under it.
|
||||||
|
const runStartedAt = new Date().toISOString();
|
||||||
|
|
||||||
on("task", {
|
on("task", {
|
||||||
|
/**
|
||||||
|
* Cancel a previous run's contracts of one shape so a spec can run
|
||||||
|
* again against a warm DB (the API allows one active contract per
|
||||||
|
* customer + service type + route). Runs at most once per shape per
|
||||||
|
* cypress run — see `retiredContractShapes`.
|
||||||
|
*/
|
||||||
|
async "db:retireStaleContracts"({
|
||||||
|
tin,
|
||||||
|
kind,
|
||||||
|
direction,
|
||||||
|
}: {
|
||||||
|
tin: string;
|
||||||
|
kind: string;
|
||||||
|
direction: string;
|
||||||
|
}) {
|
||||||
|
const key = `${tin}:${kind}:${direction}`;
|
||||||
|
if (retiredContractShapes.has(key)) return { skipped: true };
|
||||||
|
retiredContractShapes.add(key);
|
||||||
|
|
||||||
|
const client = new Client({ connectionString: dbUrl });
|
||||||
|
await client.connect();
|
||||||
|
try {
|
||||||
|
const result = await client.query(
|
||||||
|
`UPDATE freight.contracts ct
|
||||||
|
SET status = 'CANCELLED'
|
||||||
|
FROM freight.companies c
|
||||||
|
WHERE c.id = ct.company_id
|
||||||
|
AND c.tin = $1
|
||||||
|
AND ct.deleted_at IS NULL
|
||||||
|
AND ct.contract_kind = $2
|
||||||
|
AND ct.trade_direction = $3
|
||||||
|
-- Only ever previous runs' rows.
|
||||||
|
AND ct.created_at < $4::timestamptz
|
||||||
|
-- Corridor fixtures are re-seeded by reference, so a
|
||||||
|
-- cancelled one would never come back — leave them alone.
|
||||||
|
AND ct.reference NOT LIKE 'CTR-IMP-%'
|
||||||
|
-- Segment fixtures are stamped per run and always booked by
|
||||||
|
-- their own spec. An UNBOOKED leftover is debris that still
|
||||||
|
-- holds the lane (one active contract per service + route),
|
||||||
|
-- which blocked the intercity spec from filing its own.
|
||||||
|
AND (
|
||||||
|
ct.reference NOT LIKE 'CTR-SEG-%'
|
||||||
|
OR NOT EXISTS (
|
||||||
|
SELECT 1 FROM freight.bookings b
|
||||||
|
WHERE b.contract_id = ct.id AND b.deleted_at IS NULL
|
||||||
|
)
|
||||||
|
)
|
||||||
|
AND ct.status NOT IN
|
||||||
|
('REJECTED','CANCELLED','CONTRACT_CLOSED','ARCHIVED','EXPIRED')`,
|
||||||
|
[tin, kind, direction, runStartedAt],
|
||||||
|
);
|
||||||
|
return { skipped: false, cancelled: result.rowCount };
|
||||||
|
} finally {
|
||||||
|
await client.end();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run a statement at most ONCE per cypress run, keyed by `key`.
|
||||||
|
*
|
||||||
|
* For arrange-data that must not repeat: Cypress re-evaluates the spec
|
||||||
|
* bundle on every cross-origin visit, so a `before()` hook fires again
|
||||||
|
* mid-spec and a plain cleanup would then wipe what the run had just
|
||||||
|
* created. The Node plugin process outlives those reloads, so the guard
|
||||||
|
* lives here rather than in the browser.
|
||||||
|
*/
|
||||||
|
async "db:queryOnce"({
|
||||||
|
key,
|
||||||
|
sql,
|
||||||
|
params = [],
|
||||||
|
}: {
|
||||||
|
key: string;
|
||||||
|
sql: string;
|
||||||
|
params?: unknown[];
|
||||||
|
}) {
|
||||||
|
if (onceRunKeys.has(key)) return { skipped: true };
|
||||||
|
onceRunKeys.add(key);
|
||||||
|
const client = new Client({ connectionString: dbUrl });
|
||||||
|
await client.connect();
|
||||||
|
try {
|
||||||
|
const result = await client.query(sql, params as never[]);
|
||||||
|
return { skipped: false, rowCount: result.rowCount };
|
||||||
|
} finally {
|
||||||
|
await client.end();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
/** Run an arbitrary SQL statement against the ephemeral e2e database. */
|
/** Run an arbitrary SQL statement against the ephemeral e2e database. */
|
||||||
async "db:query"({ sql, params = [] }: { sql: string; params?: unknown[] }) {
|
async "db:query"({ sql, params = [] }: { sql: string; params?: unknown[] }) {
|
||||||
const client = new Client({ connectionString: dbUrl });
|
const client = new Client({ connectionString: dbUrl });
|
||||||
|
|||||||
@@ -50,6 +50,20 @@ function expectStatus(expected: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("contract lifecycle: creation to finalization", { retries: 0 }, () => {
|
describe("contract lifecycle: creation to finalization", { retries: 0 }, () => {
|
||||||
|
before(() => {
|
||||||
|
// Re-runnable against a warm DB. The API allows one active contract per
|
||||||
|
// customer + service type + route, so this spec's own contract from an
|
||||||
|
// earlier run 409s the new one at creation — and since staff accept it
|
||||||
|
// with a year's validity, it would keep doing so for a year. Retire just
|
||||||
|
// the shape this spec creates (its own wizard-made GENERAL imports),
|
||||||
|
// leaving the seeded corridor/segment fixtures alone.
|
||||||
|
cy.retireStaleContracts({
|
||||||
|
tin: companyTin,
|
||||||
|
kind: "GENERAL",
|
||||||
|
direction: "IMPORT",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("customer creates and submits a GENERAL import container contract", () => {
|
it("customer creates and submits a GENERAL import container contract", () => {
|
||||||
cy.loginPortal(customer);
|
cy.loginPortal(customer);
|
||||||
cy.visitPortal("/contracts/new");
|
cy.visitPortal("/contracts/new");
|
||||||
@@ -95,7 +109,8 @@ describe("contract lifecycle: creation to finalization", { retries: 0 }, () => {
|
|||||||
withContract((c) => cy.visit(`/dashboard/contract-requests/${c.id}`));
|
withContract((c) => cy.visit(`/dashboard/contract-requests/${c.id}`));
|
||||||
|
|
||||||
cy.contains("button", "Accept for approval", { timeout: 20000 }).click();
|
cy.contains("button", "Accept for approval", { timeout: 20000 }).click();
|
||||||
// Validity defaults to the first configured option in the accept modal.
|
// The modal takes an explicit validity window and pre-fills neither date.
|
||||||
|
cy.acceptValidityWindow();
|
||||||
cy.contains("button", "Accept & start approval", { timeout: 20000 })
|
cy.contains("button", "Accept & start approval", { timeout: 20000 })
|
||||||
.should("not.be.disabled")
|
.should("not.be.disabled")
|
||||||
.click();
|
.click();
|
||||||
@@ -146,7 +161,11 @@ describe("contract lifecycle: creation to finalization", { retries: 0 }, () => {
|
|||||||
);
|
);
|
||||||
cy.wait(500).then(() => {
|
cy.wait(500).then(() => {
|
||||||
cy.get("body").then(($b) => {
|
cy.get("body").then(($b) => {
|
||||||
if ($b.text().includes("I have read the entire contract")) return;
|
// Probe the description that only renders once hasScrolledToBottom
|
||||||
|
// flips — the consent LABEL is always in the DOM (just its checkbox
|
||||||
|
// is disabled), so matching on it raced ahead of the scroll handler
|
||||||
|
// and left the Sign button disabled.
|
||||||
|
if ($b.text().includes("You may now sign the contract")) return;
|
||||||
expect(attempt, "consent bar unlocked").to.be.lessThan(20);
|
expect(attempt, "consent bar unlocked").to.be.lessThan(20);
|
||||||
unlockConsent(attempt + 1);
|
unlockConsent(attempt + 1);
|
||||||
});
|
});
|
||||||
@@ -154,7 +173,12 @@ describe("contract lifecycle: creation to finalization", { retries: 0 }, () => {
|
|||||||
};
|
};
|
||||||
unlockConsent(0);
|
unlockConsent(0);
|
||||||
|
|
||||||
cy.contains("I have read the entire contract", { timeout: 15000 }).click();
|
// Check the input itself — clicking the label text lands on the Checkbox's
|
||||||
|
// label span, which does not toggle it, leaving Sign disabled.
|
||||||
|
cy.contains("I have read the entire contract", { timeout: 15000 })
|
||||||
|
.closest(".mantine-Checkbox-root")
|
||||||
|
.find('input[type="checkbox"]')
|
||||||
|
.check({ force: true });
|
||||||
cy.contains("button", /^Sign contract$|^Approve & sign$/).click();
|
cy.contains("button", /^Sign contract$|^Approve & sign$/).click();
|
||||||
|
|
||||||
// Signature modal: name + drawn signature.
|
// Signature modal: name + drawn signature.
|
||||||
@@ -164,6 +188,7 @@ describe("contract lifecycle: creation to finalization", { retries: 0 }, () => {
|
|||||||
cy.get(`[id="${id}"]`).clear().type("Demo User");
|
cy.get(`[id="${id}"]`).clear().type("Demo User");
|
||||||
});
|
});
|
||||||
cy.drawSignature();
|
cy.drawSignature();
|
||||||
|
cy.uploadCompanyStamp();
|
||||||
cy.contains("button", "Continue to verification").click();
|
cy.contains("button", "Continue to verification").click();
|
||||||
|
|
||||||
// OTP modal — code goes to the signer's registered contacts (email only
|
// OTP modal — code goes to the signer's registered contacts (email only
|
||||||
@@ -191,6 +216,8 @@ describe("contract lifecycle: creation to finalization", { retries: 0 }, () => {
|
|||||||
cy.get(`[id="${id}"]`).clear().type("EDR Marketer");
|
cy.get(`[id="${id}"]`).clear().type("EDR Marketer");
|
||||||
});
|
});
|
||||||
cy.drawSignature();
|
cy.drawSignature();
|
||||||
|
// Staff counter-sign gates on a stamp too, same as the customer's modal.
|
||||||
|
cy.uploadCompanyStamp();
|
||||||
// Scoped to the modal — the toolbar behind it has its own "Approve & sign".
|
// Scoped to the modal — the toolbar behind it has its own "Approve & sign".
|
||||||
cy.get(".mantine-Modal-content")
|
cy.get(".mantine-Modal-content")
|
||||||
.contains("button", /^Confirm signature$|^Approve & sign$/)
|
.contains("button", /^Confirm signature$|^Approve & sign$/)
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user