mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
Merge pull request #564 from Tria-plc/freight_feature/usermanagement
enhance gate pass and freight payment handling in train scheduling
This commit is contained in:
@@ -546,6 +546,11 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
if (!statuses.length) return [];
|
||||
return this.repository.find({
|
||||
where: { status: In(statuses) },
|
||||
relations: {
|
||||
company: true,
|
||||
originYard: true,
|
||||
destinationYard: true,
|
||||
},
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import type { DataSource } from 'typeorm';
|
||||
|
||||
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
||||
import type { ClearanceMilestone } from './entities/clearance-milestone.entity';
|
||||
|
||||
type Status = 'PENDING' | 'COMPLETED' | 'SKIPPED';
|
||||
|
||||
/**
|
||||
* Risk assignment is gated on the T1 being closed (catalog order
|
||||
* T1_CLOSED → RISK_ASSIGNED): customs cannot rate cargo still under transit.
|
||||
*/
|
||||
function makeService(t1Status: Status | 'MISSING') {
|
||||
const rows = new Map<string, ClearanceMilestone>();
|
||||
if (t1Status !== 'MISSING') {
|
||||
rows.set('T1_CLOSED', { milestoneCode: 'T1_CLOSED', status: t1Status } as ClearanceMilestone);
|
||||
}
|
||||
const risk = { milestoneCode: 'RISK_ASSIGNED', status: 'PENDING' } as ClearanceMilestone;
|
||||
rows.set('RISK_ASSIGNED', risk);
|
||||
|
||||
const repo = {
|
||||
findOne: jest.fn(({ where }: { where: { milestoneCode: string } }) =>
|
||||
Promise.resolve(rows.get(where.milestoneCode) ?? null),
|
||||
),
|
||||
save: jest.fn((m: ClearanceMilestone) => Promise.resolve(m)),
|
||||
};
|
||||
const dataSource = { getRepository: () => repo } as unknown as DataSource;
|
||||
return { service: new ClearanceMilestoneService(dataSource), repo, risk };
|
||||
}
|
||||
|
||||
describe('ClearanceMilestoneService.assignRisk', () => {
|
||||
it('rejects the assignment while the T1 is still open', async () => {
|
||||
const { service, repo } = makeService('PENDING');
|
||||
|
||||
await expect(service.assignRisk('b-1', 'GREEN')).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
expect(repo.save).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects the assignment when the booking has no T1_CLOSED milestone', async () => {
|
||||
const { service, repo } = makeService('MISSING');
|
||||
|
||||
await expect(service.assignRisk('b-1', 'GREEN')).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
expect(repo.save).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('assigns the risk level once the T1 is closed', async () => {
|
||||
const { service, risk } = makeService('COMPLETED');
|
||||
|
||||
const saved = await service.assignRisk('b-1', 'RED', 'user-1');
|
||||
|
||||
expect(saved.status).toBe('COMPLETED');
|
||||
expect(saved.metadata?.riskLevel).toBe('RED');
|
||||
expect(risk.triggeredByUserId).toBe('user-1');
|
||||
});
|
||||
|
||||
it('assigns the risk level when the T1 step was skipped', async () => {
|
||||
const { service } = makeService('SKIPPED');
|
||||
|
||||
const saved = await service.assignRisk('b-1', 'YELLOW');
|
||||
|
||||
expect(saved.status).toBe('COMPLETED');
|
||||
expect(saved.metadata?.riskLevel).toBe('YELLOW');
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
import {
|
||||
@@ -181,6 +181,10 @@ export class ClearanceMilestoneService {
|
||||
* Assign a customs risk level (GREEN/YELLOW/RED) and complete the RISK_ASSIGNED
|
||||
* milestone on a booking (GL Import US-04 / §11.3 #19). Stores the level in the
|
||||
* milestone metadata so the timeline shows it.
|
||||
*
|
||||
* Customs cannot risk-rate cargo still moving under transit: the T1 must be
|
||||
* closed (accepted by GL Ethiopia after the train arrives) first, which is the
|
||||
* catalog order T1_CLOSED → RISK_ASSIGNED.
|
||||
*/
|
||||
async assignRisk(
|
||||
bookingId: string,
|
||||
@@ -188,9 +192,22 @@ export class ClearanceMilestoneService {
|
||||
userId?: string,
|
||||
note?: string,
|
||||
): Promise<ClearanceMilestone> {
|
||||
await this.assertT1Closed(bookingId);
|
||||
return this.completeWithMetadata(bookingId, 'RISK_ASSIGNED', { riskLevel }, userId, note);
|
||||
}
|
||||
|
||||
/** Guard: the booking's T1 must be closed before customs risk can be assigned. */
|
||||
private async assertT1Closed(bookingId: string): Promise<void> {
|
||||
const t1 = await this.repo.findOne({
|
||||
where: { bookingId, milestoneCode: 'T1_CLOSED' },
|
||||
});
|
||||
if (t1?.status !== 'COMPLETED' && t1?.status !== 'SKIPPED') {
|
||||
throw new BadRequestException(
|
||||
'The T1 must be closed before a customs risk level can be assigned.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Advise duty & tax (amount + declaration serial) and complete the
|
||||
* DUTY_TAXES_ADVISED milestone (§11.3 #6). The customer then uploads the
|
||||
|
||||
@@ -122,6 +122,15 @@ export class ContractBookingService {
|
||||
const generalCustoms =
|
||||
contract.contractKind === 'GENERAL' && Boolean(contract.customsClearingEnabled);
|
||||
|
||||
// GENERAL without customs (Path A) ALSO clears per booking: the customer
|
||||
// uploads his own clearance proof on each booking and Operations reviews it
|
||||
// (legacy AWAITING_DOCUMENTS → DOCUMENTS_UNDER_REVIEW → CLEARANCE_READY →
|
||||
// requestOperation machine). DOMESTIC has no border, so no gate.
|
||||
const generalSelfClear =
|
||||
contract.contractKind === 'GENERAL' &&
|
||||
!contract.customsClearingEnabled &&
|
||||
contract.tradeDirection !== 'DOMESTIC';
|
||||
|
||||
// Intercity (DOMESTIC) bookings ride on a passing import/export train:
|
||||
// there is no window and no date — staff accept them onto a train at
|
||||
// finalize time, so both the window gate and scheduledDate are skipped.
|
||||
@@ -140,9 +149,10 @@ export class ContractBookingService {
|
||||
// Booking-window gate (config-driven): an operations booking may only be
|
||||
// created while the route's booking window is open — import: the day's window
|
||||
// (windowOpenHour EAT, importWindowLeadDays before departure, windowDurationHours);
|
||||
// export: within exportBookingLeadHours of departure. Customs Path B bookings
|
||||
// enter clearance first and are scheduled later, so they are not gated here.
|
||||
if (!generalCustoms && !isIntercity) {
|
||||
// export: within exportBookingLeadHours of departure. Bookings that enter the
|
||||
// clearance gate first (Path B customs AND Path A per-booking self-clearance)
|
||||
// are scheduled later, so they are not gated here.
|
||||
if (!generalCustoms && !generalSelfClear && !isIntercity) {
|
||||
await this.trainSchedulingService.assertBookingWindowOpen({
|
||||
originYardId: route?.originYardId ?? null,
|
||||
destinationYardId: route?.destinationYardId ?? null,
|
||||
@@ -174,7 +184,10 @@ export class ContractBookingService {
|
||||
companyProfileId: contract.companyProfileId ?? null,
|
||||
isGovernment: contract.isGovernment,
|
||||
governmentInstitution: contract.governmentInstitution ?? null,
|
||||
status: generalCustoms ? 'AWAITING_DOCUMENTS' : 'OPERATION_REQUEST_PENDING',
|
||||
status:
|
||||
generalCustoms || generalSelfClear
|
||||
? 'AWAITING_DOCUMENTS'
|
||||
: 'OPERATION_REQUEST_PENDING',
|
||||
bookingType: 'ONE_TIME',
|
||||
contractId: contract.id,
|
||||
contractRouteId: route?.id ?? null,
|
||||
@@ -259,9 +272,10 @@ export class ContractBookingService {
|
||||
const withContainers = await this.bookingsRepository.findByIdWithFiles(
|
||||
booking.id,
|
||||
);
|
||||
const intendedStatus = generalCustoms
|
||||
? 'AWAITING_DOCUMENTS'
|
||||
: 'OPERATION_REQUEST_PENDING';
|
||||
const intendedStatus =
|
||||
generalCustoms || generalSelfClear
|
||||
? 'AWAITING_DOCUMENTS'
|
||||
: 'OPERATION_REQUEST_PENDING';
|
||||
if (
|
||||
withContainers &&
|
||||
freightType === 'CONTAINER' &&
|
||||
|
||||
@@ -632,16 +632,15 @@ export class ContractTransitionService {
|
||||
contract.customsClearingEnabled ?? false,
|
||||
);
|
||||
|
||||
// GENERAL + customs (Path B) runs clearance PER BOOKING, not at the contract
|
||||
// level: there is no contract clearance cycle. The contract just becomes
|
||||
// active; the customer then files shipment requests and GL books + clears
|
||||
// each one. ONE_TIME customs and Path A self-clearance keep the contract
|
||||
// cycle below.
|
||||
const isGeneralCustoms =
|
||||
contract.contractKind === 'GENERAL' &&
|
||||
Boolean(contract.customsClearingEnabled);
|
||||
// GENERAL contracts run clearance PER BOOKING, not at the contract level —
|
||||
// both paths. Customs (Path B): the customer files shipment requests, GL
|
||||
// books each one and the booking carries its own clearance. Self-clearance
|
||||
// (Path A): the customer books, then uploads the clearance docs on that
|
||||
// booking for Operations to review. Only ONE_TIME contracts keep the
|
||||
// contract-level cycle below.
|
||||
const isGeneral = contract.contractKind === 'GENERAL';
|
||||
|
||||
if (clearanceCode && !isGeneralCustoms) {
|
||||
if (clearanceCode && !isGeneral) {
|
||||
// Open a clearance cycle, seed the pre-booking milestones, and route the
|
||||
// customer to upload. Path A is ops-reviewed; Path B is GL-reviewed — the
|
||||
// distinction is enforced at the review/finalize endpoints, not here.
|
||||
@@ -652,8 +651,8 @@ export class ContractTransitionService {
|
||||
updates.clearanceStatus = 'AWAITING_DOCUMENTS';
|
||||
updates.clearanceCycleNumber = cycleNumber;
|
||||
} else {
|
||||
// No contract-level clearance gate — DOMESTIC, or GENERAL+customs (which
|
||||
// clears per booking). Ready for shipment requests / direct booking.
|
||||
// No contract-level clearance gate — DOMESTIC, or any GENERAL contract
|
||||
// (which clears per booking). Ready for shipment requests / direct booking.
|
||||
updates.status =
|
||||
contract.contractKind === 'GENERAL' ? 'CONTRACT_ACTIVE' : 'FULLY_EXECUTED';
|
||||
updates.clearanceStatus = 'NOT_APPLICABLE';
|
||||
|
||||
@@ -630,4 +630,104 @@ describe('BookingBatchService — PAID reconcile', () => {
|
||||
expect(check({ ...importGeneral, contractKind: null } as Booking, false)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('settleDueReservations — expire then promote the waiting list', () => {
|
||||
const originYardId = 'yard-origin';
|
||||
const destinationYardId = 'yard-dest';
|
||||
const trainId = 'train-a';
|
||||
// 14m / 70t default wagon → two wagon slots on this locomotive.
|
||||
const smallLoco = { maxPullWeightTons: 200, maxTrainLengthMeters: 28 };
|
||||
|
||||
const booking = (id: string, priority: number, overrides = {}): Booking =>
|
||||
({
|
||||
id,
|
||||
reference: id,
|
||||
isGovernment: false,
|
||||
priorityScore: priority,
|
||||
status: 'FULLY_EXECUTED',
|
||||
wagonsRequired: 1,
|
||||
cargoTotalWeightVgm: 10,
|
||||
freightType: 'CONTAINER',
|
||||
bookingContainers: [],
|
||||
originYardId,
|
||||
destinationYardId,
|
||||
trainScheduleId: trainId,
|
||||
...overrides,
|
||||
}) as unknown as Booking;
|
||||
|
||||
beforeEach(() => {
|
||||
const scheduleRow = {
|
||||
id: trainId,
|
||||
maxWagons: 2,
|
||||
bookingWindowStatus: 'CLOSED',
|
||||
windowPhase: 'PAYMENT',
|
||||
direction: 'IMPORT',
|
||||
trainSetId: `set-${trainId}`,
|
||||
trainSet: { locomotive: smallLoco },
|
||||
scheduleBookings: [],
|
||||
scheduledDepartureDate: new Date('2026-06-20T06:00:00.000Z'),
|
||||
originStationId: originYardId,
|
||||
destinationStationId: destinationYardId,
|
||||
};
|
||||
trainSchedulesRepository.findAll.mockResolvedValue([{ ...scheduleRow }]);
|
||||
trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue(scheduleRow);
|
||||
trainSchedulesRepository.findById.mockResolvedValue({
|
||||
id: trainId,
|
||||
bookingWindowStatus: 'CLOSED',
|
||||
windowPhase: 'PAYMENT',
|
||||
scheduledDepartureDate: scheduleRow.scheduledDepartureDate,
|
||||
originStationId: originYardId,
|
||||
destinationStationId: destinationYardId,
|
||||
});
|
||||
});
|
||||
|
||||
it('promotes a waiting booking into the wagons an expired reservation frees', async () => {
|
||||
// One reservation whose pay window lapsed, and one booking on the waiting list.
|
||||
const lapsed = booking('lapsed', 50, {
|
||||
status: 'SELECTED_FOR_BATCH',
|
||||
paymentDeadline: new Date(Date.now() - 60_000),
|
||||
});
|
||||
const waiting = booking('waiting', 10, { trainScheduleId: null });
|
||||
|
||||
bookingsRepository.findReservedForSchedule
|
||||
.mockResolvedValueOnce([lapsed]) // settleReserved sees the lapsed one
|
||||
.mockResolvedValue([]); // afterwards nothing is reserved
|
||||
// The day pool the top-up draws from: only the waiting booking is eligible.
|
||||
bookingsRepository.findBatchPoolByCorridorDay
|
||||
.mockResolvedValueOnce([waiting])
|
||||
.mockResolvedValue([]);
|
||||
|
||||
await service.settleDueReservations(trainId);
|
||||
|
||||
// The lapsed reservation expired...
|
||||
expect(notifier.expired).toHaveBeenCalledTimes(1);
|
||||
expect((notifier.expired.mock.calls[0][0] as Booking).id).toBe('lapsed');
|
||||
// ...and the waiting booking was promoted in the SAME settle, not next cycle.
|
||||
expect(notifier.payNow).toHaveBeenCalledTimes(1);
|
||||
expect((notifier.payNow.mock.calls[0][0] as Booking).id).toBe('waiting');
|
||||
});
|
||||
|
||||
it('serialises concurrent settles so the same reservation is not settled twice', async () => {
|
||||
const lapsed = booking('lapsed', 50, {
|
||||
status: 'SELECTED_FOR_BATCH',
|
||||
paymentDeadline: new Date(Date.now() - 60_000),
|
||||
});
|
||||
// Both callers read the reservation; the lock must stop the second from
|
||||
// acting on rows the first already expired. (The PAYMENT transition and the
|
||||
// tick's overdue backstop do exactly this, in the same second.)
|
||||
let reads = 0;
|
||||
bookingsRepository.findReservedForSchedule.mockImplementation(() => {
|
||||
reads += 1;
|
||||
return Promise.resolve(reads === 1 ? [lapsed] : []);
|
||||
});
|
||||
bookingsRepository.findBatchPoolByCorridorDay.mockResolvedValue([]);
|
||||
|
||||
await Promise.all([
|
||||
service.settleDueReservations(trainId),
|
||||
service.settleDueReservations(trainId),
|
||||
]);
|
||||
|
||||
expect(notifier.expired).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -219,6 +219,14 @@ export interface BatchBoardSchedule {
|
||||
export class BookingBatchService implements OnModuleInit {
|
||||
private readonly logger = new Logger(BookingBatchService.name);
|
||||
|
||||
/**
|
||||
* Serialises settle/top-up per schedule. The PAYMENT phase transition and the
|
||||
* tick's overdue backstop both call settleDueReservations for the same schedule
|
||||
* in the same second; without this they interleave and the top-up runs against a
|
||||
* schedule whose phase has already been concluded.
|
||||
*/
|
||||
private readonly scheduleLocks = new Map<string, Promise<void>>();
|
||||
|
||||
constructor(
|
||||
@InjectDataSource() private readonly dataSource: DataSource,
|
||||
private readonly bookingsRepository: BookingsRepository,
|
||||
@@ -1602,21 +1610,92 @@ export class BookingBatchService implements OnModuleInit {
|
||||
return anySettled;
|
||||
}
|
||||
|
||||
/** Durable settle: allocate paid / expire overdue reservations, then top up. */
|
||||
/**
|
||||
* Durable settle: allocate paid / expire overdue reservations, then top up the
|
||||
* freed capacity from the waiting list.
|
||||
*
|
||||
* Serialised per schedule. Two callers race here every time a payment phase
|
||||
* ends: `advanceImport`'s PAYMENT branch and the tick's `settleOverdueReservations`
|
||||
* backstop. Both read the same reserved rows in the same second, so without the
|
||||
* lock the second caller re-settles rows the first is mid-way through expiring,
|
||||
* and `concludeCycle` observes capacity that is neither pre- nor post-expiry.
|
||||
*/
|
||||
async settleDueReservations(scheduleId: string): Promise<void> {
|
||||
const anySettled = await this.settleReserved(scheduleId, false);
|
||||
// A settle that allocated/expired anything frees or fills capacity → re-run the
|
||||
// fill so the next waiting-list bookings get a fresh pay window (top-up).
|
||||
if (anySettled) {
|
||||
await this.withScheduleLock(scheduleId, () =>
|
||||
this.settleAndTopUp(scheduleId, false),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Settle, then keep promoting the waiting list until the train can take no more.
|
||||
* Returns whether anything settled.
|
||||
*
|
||||
* One top-up pass is not enough: expiring an N-wagon booking can free room for
|
||||
* several smaller ones, and reserving those can in turn leave room for the next
|
||||
* size down. Loop until a pass reserves nothing, so the batch ends with the train
|
||||
* as full as the pool allows — rather than leaving a booking stranded until the
|
||||
* next window cycle.
|
||||
*
|
||||
* Each round that opens a fresh pay window pushes `paymentPhaseEndsAt` out, so
|
||||
* `concludeCycle` cannot fire before the promoted customers' deadlines.
|
||||
*/
|
||||
private async settleAndTopUp(
|
||||
scheduleId: string,
|
||||
expireUnpaidUnknownDeadline: boolean,
|
||||
): Promise<boolean> {
|
||||
const anySettled = await this.settleReserved(
|
||||
scheduleId,
|
||||
expireUnpaidUnknownDeadline,
|
||||
);
|
||||
if (!anySettled) return false;
|
||||
|
||||
this.logger.log(
|
||||
`[BATCH] settle changed state on ${scheduleId} — running top-up fill for the waiting list`,
|
||||
);
|
||||
|
||||
// Bounded: every round either reserves at least one unit (shrinking the pool)
|
||||
// or breaks. The cap is a backstop against a pathological reserve/expire cycle.
|
||||
let promoted = 0;
|
||||
for (let round = 0; round < 10; round += 1) {
|
||||
const reservedThisRound = await this.topUpFill(scheduleId);
|
||||
if (reservedThisRound <= 0) break;
|
||||
promoted += reservedThisRound;
|
||||
await this.extendPaymentPhaseForTopUp(scheduleId);
|
||||
}
|
||||
|
||||
if (promoted > 0) {
|
||||
this.logger.log(
|
||||
`[BATCH] settle changed state on ${scheduleId} — running top-up fill for the waiting list`,
|
||||
`[BATCH] top-up promoted ${promoted} waiting booking(s) onto ${scheduleId} ` +
|
||||
`— payment phase extended for them`,
|
||||
);
|
||||
const topUpReserved = await this.topUpFill(scheduleId);
|
||||
// A top-up opened a fresh pay window for waiting bookings — push the
|
||||
// schedule's PAYMENT phase out so the window tick's concludeCycle doesn't
|
||||
// fire before those customers' new deadlines and expire them prematurely.
|
||||
if (topUpReserved > 0) {
|
||||
await this.extendPaymentPhaseForTopUp(scheduleId);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run `fn` with exclusive access to `scheduleId`. Concurrent callers await the
|
||||
* in-flight run rather than interleaving with it. Single-process only — a second
|
||||
* API replica would need a row lock on the schedule instead.
|
||||
*/
|
||||
private async withScheduleLock<T>(
|
||||
scheduleId: string,
|
||||
fn: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
const inFlight = this.scheduleLocks.get(scheduleId) ?? Promise.resolve();
|
||||
// Chain onto the previous holder; swallow its rejection so one failure does
|
||||
// not poison every later caller's lock.
|
||||
const run = inFlight.catch(() => undefined).then(fn);
|
||||
const gate = run.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
this.scheduleLocks.set(scheduleId, gate);
|
||||
try {
|
||||
return await run;
|
||||
} finally {
|
||||
// Last one out clears the slot so the map does not grow without bound.
|
||||
if (this.scheduleLocks.get(scheduleId) === gate) {
|
||||
this.scheduleLocks.delete(scheduleId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1626,11 +1705,9 @@ export class BookingBatchService implements OnModuleInit {
|
||||
/** Allocate paid reservations, expire the rest, then top up. */
|
||||
async settleBatch(scheduleId: string): Promise<void> {
|
||||
this.removeTimeout(scheduleId);
|
||||
await this.settleReserved(scheduleId, true);
|
||||
const topUpReserved = await this.topUpFill(scheduleId);
|
||||
if (topUpReserved > 0) {
|
||||
await this.extendPaymentPhaseForTopUp(scheduleId);
|
||||
}
|
||||
await this.withScheduleLock(scheduleId, () =>
|
||||
this.settleAndTopUp(scheduleId, true),
|
||||
);
|
||||
void this.triggerWagonAllocation(scheduleId);
|
||||
}
|
||||
|
||||
|
||||
@@ -304,6 +304,24 @@ export class BookingWindowService implements OnModuleInit {
|
||||
`(allocate paid / expire unpaid) then concluding the cycle`,
|
||||
);
|
||||
await this.bookingBatchService.settleDueReservations(schedule.id);
|
||||
|
||||
// The settle expires unpaid reservations and promotes the waiting list into
|
||||
// the wagons they free. Those promoted customers get a fresh pay window, and
|
||||
// `extendPaymentPhaseForTopUp` pushes `paymentPhaseEndsAt` past `now` to
|
||||
// cover it. Concluding here on the STALE in-memory timestamp would end the
|
||||
// cycle the top-up just extended and expire them before they could pay — so
|
||||
// re-read, and stay in PAYMENT if the deadline moved.
|
||||
const settled = await this.trainSchedulesRepository.findById(schedule.id);
|
||||
if (settled?.paymentPhaseEndsAt && now < settled.paymentPhaseEndsAt) {
|
||||
schedule.paymentPhaseEndsAt = settled.paymentPhaseEndsAt;
|
||||
this.logger.log(
|
||||
`[WINDOW] ${schedule.id} PAYMENT extended to ` +
|
||||
`${settled.paymentPhaseEndsAt.toISOString()} — waiting-list bookings were ` +
|
||||
`promoted into the freed wagons; not concluding this cycle yet`,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
await this.concludeCycle(schedule, cfg, now);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { TrainSchedulingService } from './train-scheduling.service';
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
import { ClearanceMilestone } from '../contracts/entities/clearance-milestone.entity';
|
||||
|
||||
type Row = Pick<ClearanceMilestone, 'bookingId' | 'milestoneCode' | 'status'> & {
|
||||
metadata?: Record<string, unknown> | null;
|
||||
triggeredAt?: Date | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* The gate pass is secured once per train schedule, but each booking only earns
|
||||
* its GATEPASS_GRANTED milestone after settling freight payment. An unpaid
|
||||
* booking must not ride a paid neighbour's grant — the train proceeds, that
|
||||
* booking stays pending.
|
||||
*/
|
||||
function makeService(bookings: Array<Partial<Booking>>, rows: Row[]) {
|
||||
const milestoneRepo = {
|
||||
find: jest.fn().mockResolvedValue(rows),
|
||||
save: jest.fn((row: Row) => Promise.resolve(row)),
|
||||
};
|
||||
const bookingRepo = { find: jest.fn().mockResolvedValue(bookings) };
|
||||
const dataSource = {
|
||||
getRepository: (entity: unknown) =>
|
||||
entity === Booking ? bookingRepo : milestoneRepo,
|
||||
};
|
||||
|
||||
const service = Object.create(
|
||||
TrainSchedulingService.prototype,
|
||||
) as TrainSchedulingService;
|
||||
Object.assign(service, {
|
||||
dataSource,
|
||||
logger: { warn: jest.fn(), log: jest.fn() },
|
||||
});
|
||||
return { service, milestoneRepo };
|
||||
}
|
||||
|
||||
/** Reach the private bridge write under test. */
|
||||
function grant(service: TrainSchedulingService, at: Date): Promise<void> {
|
||||
return (
|
||||
service as unknown as {
|
||||
completeGatepassMilestoneForSchedule(id: string, at: Date): Promise<void>;
|
||||
}
|
||||
).completeGatepassMilestoneForSchedule('sched-1', at);
|
||||
}
|
||||
|
||||
const securedAt = new Date('2026-07-09T08:00:00.000Z');
|
||||
|
||||
describe('gate pass is withheld from bookings that have not paid freight', () => {
|
||||
it('grants the paid booking and leaves the unpaid one pending', async () => {
|
||||
const rows: Row[] = [
|
||||
{ bookingId: 'paid', milestoneCode: 'FREIGHT_PAYMENT_SETTLED', status: 'COMPLETED' },
|
||||
{ bookingId: 'paid', milestoneCode: 'GATEPASS_GRANTED', status: 'PENDING' },
|
||||
{ bookingId: 'unpaid', milestoneCode: 'FREIGHT_PAYMENT_SETTLED', status: 'PENDING' },
|
||||
{ bookingId: 'unpaid', milestoneCode: 'GATEPASS_GRANTED', status: 'PENDING' },
|
||||
];
|
||||
const { service, milestoneRepo } = makeService(
|
||||
[
|
||||
{ id: 'paid', status: 'CONFIRMED', paymentStatus: 'PENDING' },
|
||||
{ id: 'unpaid', status: 'CONFIRMED', paymentStatus: 'PENDING' },
|
||||
],
|
||||
rows,
|
||||
);
|
||||
|
||||
await grant(service, securedAt);
|
||||
|
||||
const saved = milestoneRepo.save.mock.calls.map(([r]: [Row]) => r);
|
||||
expect(saved).toHaveLength(1);
|
||||
expect(saved[0]!.bookingId).toBe('paid');
|
||||
expect(saved[0]!.status).toBe('COMPLETED');
|
||||
expect(saved[0]!.triggeredAt).toBe(securedAt);
|
||||
|
||||
const unpaid = rows.find(
|
||||
(r) => r.bookingId === 'unpaid' && r.milestoneCode === 'GATEPASS_GRANTED',
|
||||
);
|
||||
expect(unpaid!.status).toBe('PENDING');
|
||||
});
|
||||
|
||||
it('treats a booking paid outside the milestone path as paid', async () => {
|
||||
// Some payment paths settle the invoice without writing the milestone; the
|
||||
// clearance views self-heal it on read, so the gate pass must not lag.
|
||||
const rows: Row[] = [
|
||||
{ bookingId: 'b-1', milestoneCode: 'FREIGHT_PAYMENT_SETTLED', status: 'PENDING' },
|
||||
{ bookingId: 'b-1', milestoneCode: 'GATEPASS_GRANTED', status: 'PENDING' },
|
||||
];
|
||||
const { service, milestoneRepo } = makeService(
|
||||
[{ id: 'b-1', status: 'CONFIRMED', paymentStatus: 'PAID' }],
|
||||
rows,
|
||||
);
|
||||
|
||||
await grant(service, securedAt);
|
||||
|
||||
expect(milestoneRepo.save).toHaveBeenCalledTimes(1);
|
||||
expect(milestoneRepo.save.mock.calls[0]![0].bookingId).toBe('b-1');
|
||||
});
|
||||
|
||||
it('leaves an already-granted milestone untouched', async () => {
|
||||
const rows: Row[] = [
|
||||
{ bookingId: 'b-1', milestoneCode: 'FREIGHT_PAYMENT_SETTLED', status: 'COMPLETED' },
|
||||
{ bookingId: 'b-1', milestoneCode: 'GATEPASS_GRANTED', status: 'COMPLETED' },
|
||||
];
|
||||
const { service, milestoneRepo } = makeService(
|
||||
[{ id: 'b-1', status: 'PAID', paymentStatus: 'PAID' }],
|
||||
rows,
|
||||
);
|
||||
|
||||
await grant(service, securedAt);
|
||||
|
||||
expect(milestoneRepo.save).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does nothing when the schedule carries no customs bookings', async () => {
|
||||
const { service, milestoneRepo } = makeService([], []);
|
||||
|
||||
await grant(service, securedAt);
|
||||
|
||||
expect(milestoneRepo.save).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1655,6 +1655,13 @@ export class TrainSchedulingService {
|
||||
* clearance views still reading that milestone (older deployed builds) see
|
||||
* the gate pass as done. Drop once every clearance-api deployment reads
|
||||
* ImportDjiboutiOperation.gatepassGrantedAt directly.
|
||||
*
|
||||
* A booking only earns its gate pass once the customer has settled the freight
|
||||
* charges (FREIGHT_PAYMENT_SETTLED). The gate pass itself is secured per train
|
||||
* schedule, so an unpaid booking must not ride a paid neighbour's grant: it
|
||||
* keeps GATEPASS_GRANTED pending — and therefore cannot upload T1 — while the
|
||||
* train and its paid bookings proceed. Re-securing the gate pass after payment
|
||||
* settles picks the booking up; so does any later call to this bridge.
|
||||
*/
|
||||
private async completeGatepassMilestoneForSchedule(
|
||||
scheduleId: string,
|
||||
@@ -1666,20 +1673,49 @@ export class TrainSchedulingService {
|
||||
if (bookings.length === 0) return;
|
||||
|
||||
const milestoneRepo = this.dataSource.getRepository(ClearanceMilestone);
|
||||
const bookingIds = bookings.map((b) => b.id);
|
||||
const rows = await milestoneRepo.find({
|
||||
where: {
|
||||
bookingId: In(bookings.map((b) => b.id)),
|
||||
milestoneCode: 'GATEPASS_GRANTED',
|
||||
bookingId: In(bookingIds),
|
||||
milestoneCode: In(['GATEPASS_GRANTED', 'FREIGHT_PAYMENT_SETTLED']),
|
||||
},
|
||||
});
|
||||
|
||||
const paidBookingIds = new Set(
|
||||
rows
|
||||
.filter(
|
||||
(r) => r.milestoneCode === 'FREIGHT_PAYMENT_SETTLED' && r.status === 'COMPLETED',
|
||||
)
|
||||
.map((r) => r.bookingId),
|
||||
);
|
||||
// A booking whose payment settled through a path that never wrote the
|
||||
// milestone still counts as paid — the clearance views self-heal the row on
|
||||
// read, and the gate pass must not lag behind that.
|
||||
for (const booking of bookings) {
|
||||
if (booking.paymentStatus === 'PAID' || booking.status === 'PAID') {
|
||||
paidBookingIds.add(booking.id);
|
||||
}
|
||||
}
|
||||
|
||||
const skipped: string[] = [];
|
||||
for (const row of rows) {
|
||||
if (row.milestoneCode !== 'GATEPASS_GRANTED') continue;
|
||||
if (row.status === 'COMPLETED') continue;
|
||||
if (!row.bookingId || !paidBookingIds.has(row.bookingId)) {
|
||||
skipped.push(row.bookingId ?? '(unknown)');
|
||||
continue;
|
||||
}
|
||||
row.status = 'COMPLETED';
|
||||
row.triggeredAt = securedAt;
|
||||
row.metadata = { ...(row.metadata ?? {}), gatepassAt: securedAt.toISOString() };
|
||||
await milestoneRepo.save(row);
|
||||
}
|
||||
|
||||
if (skipped.length > 0) {
|
||||
this.logger.warn(
|
||||
`Gate pass secured for schedule ${scheduleId}, but ${skipped.length} booking(s) have not settled freight payment and stay pending: ${skipped.join(', ')}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async markImportReadyForLoading(scheduleId: string, dto: ImportDjiboutiActionDto = {}) {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Injectable, Logger } from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
import { Booking } from '../modules/bookings/entities/booking.entity';
|
||||
import { CompanyProfile } from '../modules/companies/entities/company-profile.entity';
|
||||
import { ServiceType } from '../modules/rule-engine/entities/service-type.entity';
|
||||
import { Yard } from '../modules/rule-engine/entities/yard.entity';
|
||||
import { Warehouse } from '../modules/warehouses/entities/warehouse.entity';
|
||||
@@ -91,12 +92,26 @@ export class Batch5TestDataSeeder {
|
||||
return;
|
||||
}
|
||||
|
||||
// bookings.company_id AND bookings.company_profile_id are both NOT NULL, so a
|
||||
// seed booking needs an owning company profile. Resolve the profile and take
|
||||
// its company from it, so the two columns can never disagree. Without this the
|
||||
// seeder aborted on its first insert.
|
||||
const companyProfile = await this.dataSource
|
||||
.getRepository(CompanyProfile)
|
||||
.findOne({ where: {} });
|
||||
if (!companyProfile) {
|
||||
this.logger.warn('No company profile found; skipping Batch 5 seed');
|
||||
return;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
|
||||
for (const seed of SEEDS) {
|
||||
const booking = await bookingRepo.save(
|
||||
bookingRepo.create({
|
||||
reference: seed.ref,
|
||||
companyId: companyProfile.companyId,
|
||||
companyProfileId: companyProfile.id,
|
||||
originYardId: originYard.id,
|
||||
destinationYardId: destYard.id,
|
||||
serviceTypeId: serviceType.id,
|
||||
|
||||
@@ -662,9 +662,13 @@ export const ROLE_PERMISSION_PRESETS = {
|
||||
FREIGHT_PERMS.fleet.view,
|
||||
FREIGHT_PERMS.fleet.manage,
|
||||
// Path A (no customs): Operations reviews the customer's self-clearance docs
|
||||
// on the contract before the customer may create a shipment booking.
|
||||
// — on the contract for ONE_TIME contracts, and PER BOOKING for GENERAL
|
||||
// contracts (booking-level document review → finalize → CLEARANCE_READY).
|
||||
FREIGHT_PERMS.contracts.view,
|
||||
FREIGHT_PERMS.contracts.opsClearanceReview,
|
||||
FREIGHT_PERMS.bookings.clearanceView,
|
||||
FREIGHT_PERMS.bookings.reviewDocuments,
|
||||
FREIGHT_PERMS.bookings.finalizeClearance,
|
||||
...allRuleEngineViewKeys(),
|
||||
],
|
||||
director: [
|
||||
|
||||
@@ -3,6 +3,7 @@ import { DataSource } from 'typeorm';
|
||||
|
||||
import { Booking } from '../modules/bookings/entities/booking.entity';
|
||||
import { CargoType } from '../modules/rule-engine/entities/cargo-type.entity';
|
||||
import { CompanyProfile } from '../modules/companies/entities/company-profile.entity';
|
||||
import { Locomotive } from '../modules/locomotives/entities/locomotive.entity';
|
||||
import { ServiceType } from '../modules/rule-engine/entities/service-type.entity';
|
||||
import { Yard } from '../modules/rule-engine/entities/yard.entity';
|
||||
@@ -60,10 +61,16 @@ export class WarehouseDemoSeeder {
|
||||
(await serviceTypeRepo.findOne({ where: { code: 'RAIL_CONTAINER' } })) ??
|
||||
(await serviceTypeRepo.findOne({ where: { isActive: true } }));
|
||||
const cargoType = await cargoTypeRepo.findOne({ where: { isActive: true } });
|
||||
// bookings.company_id AND company_profile_id are both NOT NULL — a demo booking
|
||||
// still needs an owner. Take the company from the profile so they always agree.
|
||||
const companyProfile = await this.dataSource
|
||||
.getRepository(CompanyProfile)
|
||||
.findOne({ where: {} });
|
||||
|
||||
if (!djibYard || !ethYard || !serviceType) {
|
||||
if (!djibYard || !ethYard || !serviceType || !companyProfile) {
|
||||
this.logger.warn(
|
||||
`Missing yards/service type (djib=${djibYard?.code}, eth=${ethYard?.code}, svc=${serviceType?.code}); skipping`,
|
||||
`Missing yards/service type/company profile (djib=${djibYard?.code}, eth=${ethYard?.code}, ` +
|
||||
`svc=${serviceType?.code}, companyProfile=${companyProfile?.id ?? 'none'}); skipping`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -89,7 +96,7 @@ export class WarehouseDemoSeeder {
|
||||
): Promise<Booking> =>
|
||||
bookingRepo.save(
|
||||
bookingRepo.create({
|
||||
...this.demoBookingDefaults(),
|
||||
...this.demoBookingDefaults(companyProfile),
|
||||
reference,
|
||||
originYardId: direction === 'EXPORT' ? ethYard.id : djibYard.id,
|
||||
destinationYardId: direction === 'EXPORT' ? djibYard.id : ethYard.id,
|
||||
@@ -182,7 +189,15 @@ export class WarehouseDemoSeeder {
|
||||
}
|
||||
|
||||
// 6) Import Arrive Queue — an ARRIVED import train with IN_TRANSIT bookings, no inventory yet.
|
||||
await this.seedArrivedImportTrain(djibYard, ethYard, serviceType, cargoType, ago(60), ago(360));
|
||||
await this.seedArrivedImportTrain(
|
||||
djibYard,
|
||||
ethYard,
|
||||
serviceType,
|
||||
cargoType,
|
||||
companyProfile,
|
||||
ago(60),
|
||||
ago(360),
|
||||
);
|
||||
created += 1;
|
||||
|
||||
this.logger.log(`✅ Warehouse demo seeded: ${created} buckets populated across every queue`);
|
||||
@@ -199,6 +214,7 @@ export class WarehouseDemoSeeder {
|
||||
ethYard: Yard,
|
||||
serviceType: ServiceType,
|
||||
cargoType: CargoType | null,
|
||||
owner: CompanyProfile,
|
||||
arrival: Date,
|
||||
departure: Date,
|
||||
): Promise<void> {
|
||||
@@ -238,7 +254,7 @@ export class WarehouseDemoSeeder {
|
||||
for (let i = 1; i <= 3; i++) {
|
||||
const b = await bookingRepo.save(
|
||||
bookingRepo.create({
|
||||
...this.demoBookingDefaults(),
|
||||
...this.demoBookingDefaults(owner),
|
||||
reference: `WH-DEMO-ARR-${i}`,
|
||||
originYardId: djibYard.id,
|
||||
destinationYardId: ethYard.id,
|
||||
@@ -258,8 +274,10 @@ export class WarehouseDemoSeeder {
|
||||
}
|
||||
}
|
||||
|
||||
private demoBookingDefaults(): Partial<Booking> {
|
||||
private demoBookingDefaults(owner: CompanyProfile): Partial<Booking> {
|
||||
return {
|
||||
companyId: owner.companyId,
|
||||
companyProfileId: owner.id,
|
||||
scheduledDate: new Date(),
|
||||
contractType: 'SPOT',
|
||||
equipmentReturn: 'TERMINAL',
|
||||
|
||||
@@ -53,11 +53,11 @@ import ContractClearanceListPage from "./pages/contracts/ContractClearanceListPa
|
||||
import ContractClearanceDetailPage from "./pages/contracts/ContractClearanceDetailPage";
|
||||
import GlDjiboutiClearanceListPage from "./pages/contracts/GlDjiboutiClearanceListPage";
|
||||
import GlClearanceDetailPage from "./pages/contracts/GlClearanceDetailPage";
|
||||
// Hidden for now — Shipment Requests pages disabled (imports kept commented).
|
||||
// import ShipmentRequestsPage from "./pages/contracts/ShipmentRequestsPage";
|
||||
// import ShipmentRequestDetailPage from "./pages/contracts/ShipmentRequestDetailPage";
|
||||
import ShipmentRequestsPage from "./pages/contracts/ShipmentRequestsPage";
|
||||
import ShipmentRequestDetailPage from "./pages/contracts/ShipmentRequestDetailPage";
|
||||
import GlCreateBookingForm from "./components/contracts/GlCreateBookingForm";
|
||||
import DocumentClearanceDetailPage from "./pages/bookings/DocumentClearanceDetailPage";
|
||||
import DocumentClearanceListPage from "./pages/bookings/DocumentClearanceListPage";
|
||||
import CustomerDetailPage from "./pages/customers/CustomerDetailPage";
|
||||
import CustomersPage from "./pages/customers/CustomersPage";
|
||||
import InvoiceDetailPage from "./pages/invoices/InvoiceDetailPage";
|
||||
@@ -187,13 +187,18 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
FREIGHT_PERMS.contracts.clearanceEtActions,
|
||||
],
|
||||
},
|
||||
// Hidden for now — Shipment Requests nav item disabled.
|
||||
// {
|
||||
// label: "Shipment Requests",
|
||||
// href: "/dashboard/shipment-requests",
|
||||
// icon: <Send />,
|
||||
// permission: FREIGHT_PERMS.contracts.createBooking,
|
||||
// },
|
||||
{
|
||||
label: "Shipment Requests",
|
||||
href: "/dashboard/shipment-requests",
|
||||
icon: <Send />,
|
||||
permission: FREIGHT_PERMS.contracts.createBooking,
|
||||
},
|
||||
{
|
||||
label: "Self-Clearance Review",
|
||||
href: "/dashboard/contracts/ops-clearance",
|
||||
icon: <ShieldCheck />,
|
||||
permission: FREIGHT_PERMS.contracts.opsClearanceReview,
|
||||
},
|
||||
{
|
||||
label: "GL Djibouti Clearance",
|
||||
href: "/dashboard/gl-djibouti/clearance",
|
||||
@@ -752,7 +757,6 @@ const App = () => {
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
{/* Hidden for now — Shipment Requests pages disabled.
|
||||
<Route
|
||||
path="shipment-requests"
|
||||
element={
|
||||
@@ -773,7 +777,6 @@ const App = () => {
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
*/}
|
||||
{/* GL (Path B) contract clearance review hub */}
|
||||
<Route
|
||||
path="contracts/clearance"
|
||||
@@ -829,10 +832,17 @@ const App = () => {
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
{/* Path A ops queue out of scope for now → fold into the GL hub. */}
|
||||
{/* Path A — Operations reviews per-booking self-clearance documents
|
||||
(GENERAL contracts without customs). */}
|
||||
<Route
|
||||
path="contracts/ops-clearance"
|
||||
element={<Navigate to="/dashboard/contracts/clearance" replace />}
|
||||
element={
|
||||
<RequirePermission
|
||||
permission={FREIGHT_PERMS.contracts.opsClearanceReview}
|
||||
>
|
||||
<DocumentClearanceListPage opsMode />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="contracts/:id/create-booking"
|
||||
|
||||
@@ -99,6 +99,7 @@ function computeImportActiveStep(
|
||||
bookingCreated: boolean,
|
||||
bookingMilestones: MilestoneRow[],
|
||||
t1Uploaded: boolean,
|
||||
freightPaid: boolean,
|
||||
): number {
|
||||
if (!isMilestoneDone(clearance.milestones, "DOCUMENTS_APPROVED")) return 0;
|
||||
if (!isMilestoneDone(clearance.milestones, "DECLARED")) return 1;
|
||||
@@ -119,9 +120,12 @@ function computeImportActiveStep(
|
||||
if (!clearance.preClearanceFinalized) return 5;
|
||||
if (!isMilestoneDone(clearance.milestones, "DO_COLLECTED")) return 6;
|
||||
if (!bookingCreated) return 7;
|
||||
if (!clearance.gatepassGranted) return 8;
|
||||
if (!t1Uploaded && !clearance.t1?.closed) return 9;
|
||||
if (!clearance.t1?.closed) return 10;
|
||||
// The customer pays the train/freight charges on the booking. Until that
|
||||
// settles the gate pass is not granted for this booking, so the flow stops here.
|
||||
if (!freightPaid) return 8;
|
||||
if (!clearance.gatepassGranted) return 9;
|
||||
if (!t1Uploaded && !clearance.t1?.closed) return 10;
|
||||
if (!clearance.t1?.closed) return 11;
|
||||
// Risk is "assigned" when the booking milestone says so OR the clearance view
|
||||
// already carries a riskLevel. The ET page derives its bookingMilestones from a
|
||||
// separately-fetched booking id that can lag or mismatch the booking carrying
|
||||
@@ -129,15 +133,15 @@ function computeImportActiveStep(
|
||||
const riskAssigned =
|
||||
Boolean(clearance.riskLevel) ||
|
||||
isBookingMilestoneDone(bookingMilestones, "RISK_ASSIGNED");
|
||||
if (!riskAssigned) return 11;
|
||||
if (!riskAssigned) return 12;
|
||||
// Additional duty round is optional — resolved once skipped or paid.
|
||||
const secondDutyResolved =
|
||||
clearance.secondDuty?.skipped ||
|
||||
clearance.secondDuty?.paid ||
|
||||
isBookingMilestoneDone(bookingMilestones, "SECOND_DUTY_PAID");
|
||||
if (!secondDutyResolved) return 12;
|
||||
if (!clearance.importReleaseGranted) return 13;
|
||||
return 14;
|
||||
if (!secondDutyResolved) return 13;
|
||||
if (!clearance.importReleaseGranted) return 14;
|
||||
return 15;
|
||||
}
|
||||
|
||||
function t1FilesFromWorkflow(
|
||||
@@ -243,6 +247,13 @@ export function PhasedClearanceActionPanel({
|
||||
const riskAssigned =
|
||||
Boolean(clearance.riskLevel) ||
|
||||
isBookingMilestoneDone(bookingMilestones, "RISK_ASSIGNED");
|
||||
// Freight (train + service) charges settled on the booking. The gate pass is
|
||||
// only granted to a booking that has paid, so a granted gate pass is server
|
||||
// proof of payment — it keeps the stepper moving on a page whose
|
||||
// bookingMilestones have not loaded yet or point at a different booking.
|
||||
const freightPaid =
|
||||
isBookingMilestoneDone(bookingMilestones, "FREIGHT_PAYMENT_SETTLED") ||
|
||||
Boolean(clearance.gatepassGranted);
|
||||
const activeStep = useMemo(
|
||||
() =>
|
||||
isImport
|
||||
@@ -251,9 +262,17 @@ export function PhasedClearanceActionPanel({
|
||||
effectiveBookingCreated,
|
||||
bookingMilestones,
|
||||
t1Uploaded,
|
||||
freightPaid,
|
||||
)
|
||||
: 0,
|
||||
[clearance, isImport, effectiveBookingCreated, bookingMilestones, t1Uploaded],
|
||||
[
|
||||
clearance,
|
||||
isImport,
|
||||
effectiveBookingCreated,
|
||||
bookingMilestones,
|
||||
t1Uploaded,
|
||||
freightPaid,
|
||||
],
|
||||
);
|
||||
|
||||
if (isImport) {
|
||||
@@ -554,14 +573,26 @@ export function PhasedClearanceActionPanel({
|
||||
)}
|
||||
</Stepper.Step>
|
||||
|
||||
<Stepper.Step
|
||||
label="Freight payment"
|
||||
description="Customer pays the train and service charges"
|
||||
icon={freightPaid ? <CheckCircle2 size={14} /> : <Receipt size={14} />}
|
||||
>
|
||||
<StepStatus
|
||||
done={freightPaid}
|
||||
pendingLabel="Waiting for the customer to pay the train and service charges. The gate pass is not granted until this settles."
|
||||
doneLabel="Train and service charges settled."
|
||||
/>
|
||||
</Stepper.Step>
|
||||
|
||||
<Stepper.Step
|
||||
label="Gate pass"
|
||||
description="Secured on the train schedule after wagon allocation"
|
||||
description="Secured on the train schedule after payment and wagon allocation"
|
||||
icon={
|
||||
clearance.gatepassGranted ? <CheckCircle2 size={14} /> : <Truck size={14} />
|
||||
}
|
||||
>
|
||||
<ImportGatepassStep clearance={clearance} />
|
||||
<ImportGatepassStep clearance={clearance} freightPaid={freightPaid} />
|
||||
</Stepper.Step>
|
||||
|
||||
<Stepper.Step
|
||||
@@ -927,8 +958,16 @@ function ImportT1CloseStep({
|
||||
/**
|
||||
* Gate pass status, read-only. Secured on the train schedule's "Save as
|
||||
* Secured" action (train-scheduling-v2) — clearance no longer grants it directly.
|
||||
* The train may be secured while this booking still owes freight charges; the
|
||||
* booking only picks the gate pass up once its payment settles.
|
||||
*/
|
||||
function ImportGatepassStep({ clearance }: { clearance: ClearanceViewLike }) {
|
||||
function ImportGatepassStep({
|
||||
clearance,
|
||||
freightPaid,
|
||||
}: {
|
||||
clearance: ClearanceViewLike;
|
||||
freightPaid: boolean;
|
||||
}) {
|
||||
const scheduleId = clearance.train?.scheduleId ?? null;
|
||||
|
||||
if (clearance.gatepassGranted) {
|
||||
@@ -943,6 +982,16 @@ function ImportGatepassStep({ clearance }: { clearance: ClearanceViewLike }) {
|
||||
);
|
||||
}
|
||||
|
||||
if (!freightPaid) {
|
||||
return (
|
||||
<StepStatus
|
||||
done={false}
|
||||
pendingLabel="Blocked — the customer must pay the train and service charges before the gate pass is granted for this shipment."
|
||||
doneLabel=""
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const wagonAllocated = Boolean(clearance.train?.wagonAllocated);
|
||||
|
||||
return (
|
||||
@@ -1015,6 +1064,18 @@ function RiskStep({
|
||||
);
|
||||
}
|
||||
|
||||
// Customs cannot rate cargo still under transit — the server rejects the
|
||||
// assignment until the T1 is closed, so do not offer the control yet.
|
||||
if (!clearance.t1?.closed) {
|
||||
return (
|
||||
<StepStatus
|
||||
done={false}
|
||||
pendingLabel="Available once the T1 is closed."
|
||||
doneLabel=""
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!canAct || !bookingId) {
|
||||
return (
|
||||
<StepStatus
|
||||
|
||||
@@ -128,7 +128,16 @@ function DirectionIcon({ direction }: { direction: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
export default function DocumentClearanceListPage() {
|
||||
export default function DocumentClearanceListPage({
|
||||
opsMode = false,
|
||||
}: {
|
||||
/**
|
||||
* true → Operations self-clearance queue: NON-customs bookings whose
|
||||
* per-booking clearance docs the operations team reviews (GENERAL Path A).
|
||||
* false → legacy GL queue: customs bookings only.
|
||||
*/
|
||||
opsMode?: boolean;
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
const [pageTab, setPageTab] = useState<PageTab>("queue");
|
||||
const [activeTab, setActiveTab] = useState<ClearanceTabKey>("all");
|
||||
@@ -139,7 +148,7 @@ export default function DocumentClearanceListPage() {
|
||||
const isHistory = pageTab === "history";
|
||||
|
||||
const { data, isLoading, isError, isFetching, refetch } = useQuery({
|
||||
queryKey: ["clearance", "list", isHistory],
|
||||
queryKey: ["clearance", "list", isHistory, opsMode],
|
||||
queryFn: () =>
|
||||
bookingsService.list({
|
||||
status: isHistory ? CLEARANCE_HISTORY_STATUS : CLEARANCE_REVIEW_STATUS,
|
||||
@@ -148,8 +157,10 @@ export default function DocumentClearanceListPage() {
|
||||
});
|
||||
|
||||
const allRows = useMemo(() => {
|
||||
// GL clearance queue: customs bookings only
|
||||
const rows = (data?.items ?? []).map(toClearanceRow).filter((r) => r.hasCustoms);
|
||||
// opsMode: self-clearance (non-customs) bookings; else customs bookings only.
|
||||
const rows = (data?.items ?? [])
|
||||
.map(toClearanceRow)
|
||||
.filter((r) => (opsMode ? !r.hasCustoms : r.hasCustoms));
|
||||
|
||||
if (isHistory) {
|
||||
return [...rows].sort((a, b) => {
|
||||
@@ -159,7 +170,7 @@ export default function DocumentClearanceListPage() {
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
}, [data?.items, isHistory]);
|
||||
}, [data?.items, isHistory, opsMode]);
|
||||
|
||||
const tabCounts = useMemo(
|
||||
() => ({
|
||||
@@ -310,8 +321,12 @@ export default function DocumentClearanceListPage() {
|
||||
<PageContainer>
|
||||
<Stack gap="lg">
|
||||
<PageHeader
|
||||
title="Document Clearance"
|
||||
subtitle="Review customer documents, raise queries, and finalize clearance for each booking."
|
||||
title={opsMode ? "Self-Clearance Review" : "Document Clearance"}
|
||||
subtitle={
|
||||
opsMode
|
||||
? "Review the customer's own clearance documents per shipment booking, raise queries, and finalize."
|
||||
: "Review customer documents, raise queries, and finalize clearance for each booking."
|
||||
}
|
||||
meta={statusBadge}
|
||||
action={
|
||||
<ActionIcon
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
PackagePlus,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Send,
|
||||
ShieldCheck,
|
||||
ShipWheel,
|
||||
Table as TableIcon,
|
||||
@@ -49,11 +50,13 @@ import {
|
||||
useContractClearanceQueue,
|
||||
useEtClearanceQueue,
|
||||
} from "@/hooks/contracts/useContracts";
|
||||
import { useBookingEtClearanceQueue } from "@/hooks/bookings/useBookings";
|
||||
import type { BookingDetail } from "@/types/booking";
|
||||
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||
import { GlUpcomingWindowsSection } from "@/components/contracts/GlUpcomingWindowsSection";
|
||||
|
||||
type ViewMode = "table" | "cards";
|
||||
type QueueTab = "all" | "et";
|
||||
type QueueTab = "all" | "et" | "shipments";
|
||||
|
||||
interface ClearanceRow {
|
||||
id: string;
|
||||
@@ -194,6 +197,10 @@ export default function ContractClearanceListPage() {
|
||||
const { user } = useAuth();
|
||||
const canReview = hasPermission(user, FREIGHT_PERMS.contracts.clearanceReview);
|
||||
const canEt = hasPermission(user, FREIGHT_PERMS.contracts.clearanceEtActions);
|
||||
const canCreateBooking = hasPermission(
|
||||
user,
|
||||
FREIGHT_PERMS.contracts.createBooking,
|
||||
);
|
||||
|
||||
const defaultQueue: QueueTab = canReview ? "all" : "et";
|
||||
const [queueTab, setQueueTab] = useState<QueueTab>(defaultQueue);
|
||||
@@ -202,16 +209,29 @@ export default function ContractClearanceListPage() {
|
||||
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
||||
|
||||
const { data: allData, isLoading: allLoading, isError: allError, isFetching: allFetching, refetch: refetchAll } =
|
||||
useContractClearanceQueue(queueTab === "all");
|
||||
useContractClearanceQueue(queueTab === "all" || queueTab === "shipments");
|
||||
const { data: etData, isLoading: etLoading, isError: etError, isFetching: etFetching, refetch: refetchEt } =
|
||||
useEtClearanceQueue(queueTab === "et");
|
||||
const {
|
||||
data: bookingQueue,
|
||||
isLoading: bookingsLoading,
|
||||
isError: bookingsError,
|
||||
isFetching: bookingsFetching,
|
||||
refetch: refetchBookings,
|
||||
} = useBookingEtClearanceQueue(queueTab === "shipments");
|
||||
|
||||
const data = queueTab === "et" ? etData : allData;
|
||||
const isLoading = queueTab === "et" ? etLoading : allLoading;
|
||||
const isError = queueTab === "et" ? etError : allError;
|
||||
const isFetching = queueTab === "et" ? etFetching : allFetching;
|
||||
const isFetching =
|
||||
queueTab === "et"
|
||||
? etFetching
|
||||
: queueTab === "shipments"
|
||||
? bookingsFetching
|
||||
: allFetching;
|
||||
const refetch = () => {
|
||||
if (queueTab === "et") void refetchEt();
|
||||
else if (queueTab === "shipments") void refetchBookings();
|
||||
else void refetchAll();
|
||||
};
|
||||
|
||||
@@ -239,9 +259,43 @@ export default function ContractClearanceListPage() {
|
||||
),
|
||||
});
|
||||
}
|
||||
if (canReview || canEt) {
|
||||
opts.push({
|
||||
value: "shipments",
|
||||
label: (
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<PackageCheck size={15} />
|
||||
<Box visibleFrom="sm">Shipments</Box>
|
||||
</Group>
|
||||
),
|
||||
});
|
||||
}
|
||||
return opts;
|
||||
}, [canReview, canEt]);
|
||||
|
||||
// GENERAL-contract shipment bookings in per-booking clearance (ET queue).
|
||||
const bookingRows = useMemo(() => {
|
||||
const rows = (bookingQueue ?? []).map((b: BookingDetail) => ({
|
||||
id: b.id,
|
||||
reference: b.reference,
|
||||
customerLabel: b.company?.name ?? b.governmentInstitution ?? "—",
|
||||
originLabel: b.originYard?.name ?? "—",
|
||||
destinationLabel: b.destinationYard?.name ?? "—",
|
||||
tradeDirection: b.tradeDirection ?? "—",
|
||||
freightType: b.freightType ?? "—",
|
||||
status: b.status,
|
||||
}));
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return rows;
|
||||
return rows.filter(
|
||||
(r) =>
|
||||
r.reference.toLowerCase().includes(q) ||
|
||||
r.customerLabel.toLowerCase().includes(q) ||
|
||||
r.originLabel.toLowerCase().includes(q) ||
|
||||
r.destinationLabel.toLowerCase().includes(q),
|
||||
);
|
||||
}, [bookingQueue, query]);
|
||||
|
||||
const allRows = useMemo(
|
||||
() => (data?.items ?? []).map(toClearanceRow),
|
||||
[data?.items],
|
||||
@@ -269,7 +323,7 @@ export default function ContractClearanceListPage() {
|
||||
);
|
||||
}, [allRows, query]);
|
||||
|
||||
const total = rows.length;
|
||||
const total = queueTab === "shipments" ? bookingRows.length : rows.length;
|
||||
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
|
||||
|
||||
const pagedRows = useMemo(() => {
|
||||
@@ -410,16 +464,29 @@ export default function ContractClearanceListPage() {
|
||||
</Badge>
|
||||
}
|
||||
action={
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
size="lg"
|
||||
radius="md"
|
||||
onClick={() => refetch()}
|
||||
loading={isFetching}
|
||||
aria-label="Refresh"
|
||||
>
|
||||
<RefreshCw size={16} />
|
||||
</ActionIcon>
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
{canCreateBooking ? (
|
||||
<Button
|
||||
variant="filled"
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
leftSection={<Send size={15} />}
|
||||
onClick={() => navigate("/dashboard/shipment-requests")}
|
||||
>
|
||||
Shipment requests
|
||||
</Button>
|
||||
) : null}
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
size="lg"
|
||||
radius="md"
|
||||
onClick={() => refetch()}
|
||||
loading={isFetching}
|
||||
aria-label="Refresh"
|
||||
>
|
||||
<RefreshCw size={16} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -528,7 +595,14 @@ export default function ContractClearanceListPage() {
|
||||
</Group>
|
||||
</Box>
|
||||
|
||||
{view === "table" ? (
|
||||
{queueTab === "shipments" ? (
|
||||
<ShipmentBookingsTable
|
||||
rows={bookingRows}
|
||||
loading={bookingsLoading}
|
||||
error={bookingsError}
|
||||
onOpen={(id) => navigate(`/dashboard/clearance/${id}`)}
|
||||
/>
|
||||
) : view === "table" ? (
|
||||
<Box style={{ overflowX: "auto" }} px="xs" pb="xs">
|
||||
<DataTable<ClearanceRow, unknown>
|
||||
columns={columns}
|
||||
@@ -567,6 +641,143 @@ export default function ContractClearanceListPage() {
|
||||
);
|
||||
}
|
||||
|
||||
interface ShipmentBookingRow {
|
||||
id: string;
|
||||
reference: string;
|
||||
customerLabel: string;
|
||||
originLabel: string;
|
||||
destinationLabel: string;
|
||||
tradeDirection: string;
|
||||
freightType: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
const prettyStatus = (s: string) =>
|
||||
s
|
||||
.toLowerCase()
|
||||
.replace(/_/g, " ")
|
||||
.replace(/^\w/, (c) => c.toUpperCase());
|
||||
|
||||
const shipmentStatusColor = (s: string) => {
|
||||
if (s === "AWAITING_DOCUMENTS") return "yellow";
|
||||
if (s === "DOCUMENTS_UNDER_REVIEW") return "blue";
|
||||
if (s === "CLEARANCE_READY") return "edr-green";
|
||||
return "gray";
|
||||
};
|
||||
|
||||
/** GENERAL-contract shipment bookings currently in per-booking clearance. */
|
||||
function ShipmentBookingsTable({
|
||||
rows,
|
||||
loading,
|
||||
error,
|
||||
onOpen,
|
||||
}: {
|
||||
rows: ShipmentBookingRow[];
|
||||
loading: boolean;
|
||||
error: boolean;
|
||||
onOpen: (id: string) => void;
|
||||
}) {
|
||||
const columns = useMemo<ColumnDef<ShipmentBookingRow>[]>(
|
||||
() => [
|
||||
{
|
||||
id: "booking",
|
||||
header: () => <span className={bookingTable.headerCell}>Booking</span>,
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center gap-3 py-1.5">
|
||||
<div className={bookingTable.rowIcon}>
|
||||
<PackageCheck className="size-4" strokeWidth={1.75} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-medium text-foreground">
|
||||
{row.original.reference}
|
||||
</p>
|
||||
<p className="mt-0.5 flex items-center gap-1 truncate text-xs text-muted-foreground">
|
||||
<User className="size-3 shrink-0 opacity-70" />
|
||||
{row.original.customerLabel}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "route",
|
||||
header: () => <span className={bookingTable.headerCell}>Route</span>,
|
||||
cell: ({ row }) => (
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Text size="sm" className="truncate">
|
||||
{row.original.originLabel}
|
||||
</Text>
|
||||
<ArrowRight size={13} className="shrink-0 text-muted-foreground" />
|
||||
<Text size="sm" className="truncate">
|
||||
{row.original.destinationLabel}
|
||||
</Text>
|
||||
</Group>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "kind",
|
||||
header: () => <span className={bookingTable.headerCell}>Type</span>,
|
||||
cell: ({ row }) => (
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Badge variant="light" color="gray" radius="sm">
|
||||
{prettyStatus(row.original.tradeDirection)}
|
||||
</Badge>
|
||||
<Badge variant="outline" color="gray" radius="sm">
|
||||
{prettyStatus(row.original.freightType)}
|
||||
</Badge>
|
||||
</Group>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
header: () => <span className={bookingTable.headerCell}>Status</span>,
|
||||
cell: ({ row }) => (
|
||||
<Badge
|
||||
variant="light"
|
||||
color={shipmentStatusColor(row.original.status)}
|
||||
radius="sm"
|
||||
>
|
||||
{prettyStatus(row.original.status)}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "chevron",
|
||||
header: "",
|
||||
cell: () => (
|
||||
<Group justify="flex-end" pr="xs">
|
||||
<ChevronRight size={16} className="text-muted-foreground" />
|
||||
</Group>
|
||||
),
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
if (!loading && !error && rows.length === 0) {
|
||||
return (
|
||||
<Stack align="center" gap={8} py={48}>
|
||||
<ThemeIcon variant="light" color="gray" radius="xl" size={48}>
|
||||
<Inbox size={22} />
|
||||
</ThemeIcon>
|
||||
<Text c="dimmed">No shipment bookings in clearance.</Text>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box style={{ overflowX: "auto" }} px="xs" pb="xs">
|
||||
<DataTable<ShipmentBookingRow, unknown>
|
||||
columns={columns}
|
||||
data={rows}
|
||||
status={loading ? "loading" : error ? "error" : "success"}
|
||||
onRowClick={(row) => onOpen(row.id)}
|
||||
containerClassName="border-0 shadow-none bg-transparent"
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function ClearanceCardGrid({
|
||||
rows,
|
||||
loading,
|
||||
|
||||
@@ -1,62 +1,99 @@
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Badge, Card, Group, Loader, Stack, Text } from "@mantine/core";
|
||||
import { ChevronRight, Ship } from "lucide-react";
|
||||
import { ChevronRight, PackageCheck, Ship } from "lucide-react";
|
||||
|
||||
import { PageContainer } from "@/components/page/PageContainer";
|
||||
import { PageHeader } from "@/components/page/PageHeader";
|
||||
import { useDjClearanceQueue } from "@/hooks/contracts/useContracts";
|
||||
import { useBookingDjClearanceQueue } from "@/hooks/bookings/useBookings";
|
||||
|
||||
export default function GlDjiboutiClearanceListPage() {
|
||||
const navigate = useNavigate();
|
||||
const { data: contractQueue, isLoading: contractsLoading } = useDjClearanceQueue();
|
||||
const { data: bookingQueue, isLoading: bookingsLoading } =
|
||||
useBookingDjClearanceQueue();
|
||||
|
||||
const contractItems = contractQueue?.items ?? [];
|
||||
const bookingItems = bookingQueue ?? [];
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
title="GL Djibouti — Clearance"
|
||||
subtitle="Customs contracts handed off to Djibouti GL."
|
||||
subtitle="Customs contracts and shipment bookings handed off to Djibouti GL."
|
||||
/>
|
||||
{contractsLoading ? (
|
||||
{contractsLoading || bookingsLoading ? (
|
||||
<Group justify="center" py={60}>
|
||||
<Loader color="edr-green" />
|
||||
</Group>
|
||||
) : (
|
||||
<Stack gap="sm">
|
||||
{contractItems.length === 0 ? (
|
||||
{contractItems.length === 0 && bookingItems.length === 0 ? (
|
||||
<Text c="dimmed" ta="center" py="xl">
|
||||
No Djibouti customs contracts yet.
|
||||
No Djibouti customs work yet.
|
||||
</Text>
|
||||
) : (
|
||||
contractItems.map((c) => (
|
||||
<Card
|
||||
key={c.id}
|
||||
withBorder
|
||||
radius="md"
|
||||
padding="md"
|
||||
style={{ cursor: "pointer" }}
|
||||
onClick={() => navigate(`/dashboard/gl-djibouti/clearance/${c.id}`)}
|
||||
>
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Group gap="sm">
|
||||
<Ship size={18} className="text-[color:var(--freight-brand)]" />
|
||||
<div>
|
||||
<Text fw={700}>{c.reference}</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{c.tradeDirection} · {c.status}
|
||||
</Text>
|
||||
</div>
|
||||
<>
|
||||
{contractItems.map((c) => (
|
||||
<Card
|
||||
key={c.id}
|
||||
withBorder
|
||||
radius="md"
|
||||
padding="md"
|
||||
style={{ cursor: "pointer" }}
|
||||
onClick={() => navigate(`/dashboard/gl-djibouti/clearance/${c.id}`)}
|
||||
>
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Group gap="sm">
|
||||
<Ship size={18} className="text-[color:var(--freight-brand)]" />
|
||||
<div>
|
||||
<Text fw={700}>{c.reference}</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{c.tradeDirection} · {c.status}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<Group gap="xs">
|
||||
<Badge variant="light" color="edr-green">
|
||||
Contract
|
||||
</Badge>
|
||||
<ChevronRight size={18} className="text-muted-foreground" />
|
||||
</Group>
|
||||
</Group>
|
||||
<Group gap="xs">
|
||||
<Badge variant="light" color="edr-green">
|
||||
Contract
|
||||
</Badge>
|
||||
<ChevronRight size={18} className="text-muted-foreground" />
|
||||
</Card>
|
||||
))}
|
||||
{bookingItems.map((b) => (
|
||||
<Card
|
||||
key={b.id}
|
||||
withBorder
|
||||
radius="md"
|
||||
padding="md"
|
||||
style={{ cursor: "pointer" }}
|
||||
onClick={() => navigate(`/dashboard/clearance/${b.id}`)}
|
||||
>
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Group gap="sm">
|
||||
<PackageCheck
|
||||
size={18}
|
||||
className="text-[color:var(--freight-brand)]"
|
||||
/>
|
||||
<div>
|
||||
<Text fw={700}>{b.reference}</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{b.tradeDirection} · {b.status}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<Group gap="xs">
|
||||
<Badge variant="light" color="blue">
|
||||
Shipment
|
||||
</Badge>
|
||||
<ChevronRight size={18} className="text-muted-foreground" />
|
||||
</Group>
|
||||
</Group>
|
||||
</Group>
|
||||
</Card>
|
||||
))
|
||||
</Card>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
@@ -241,8 +241,12 @@ export default function ContractDetailPage() {
|
||||
});
|
||||
// Intercity contracts are never window-gated: the shipment rides a passing
|
||||
// import/export train that staff assign later, so booking is always open.
|
||||
// GENERAL contracts are also not gated at creation — the booking enters the
|
||||
// per-booking clearance gate first and picks its shipment day at proceed time.
|
||||
const bookingWindowOpen =
|
||||
contract?.tradeDirection === "DOMESTIC" || hasOpenWindow(bookingWindows);
|
||||
contract?.tradeDirection === "DOMESTIC" ||
|
||||
contract?.contractKind === "GENERAL" ||
|
||||
hasOpenWindow(bookingWindows);
|
||||
|
||||
// Draw-down capacity per cargo line (GENERAL contracts only). The backend
|
||||
// excludes CANCELLED/REJECTED/EXPIRED bookings, so a shipment that never ships
|
||||
|
||||
@@ -139,8 +139,14 @@ export default function NewShipmentPage() {
|
||||
// open, show the same closed-state notice as the contract page instead of the
|
||||
// form. Still allowed the moment any window isOpenNow. Intercity contracts
|
||||
// are never window-gated — the shipment rides a passing train that staff
|
||||
// pick at finalize time, so booking is always open.
|
||||
if (contract.tradeDirection !== "DOMESTIC" && !hasOpenWindow(bookingWindows)) {
|
||||
// pick at finalize time, so booking is always open. GENERAL contracts are not
|
||||
// gated at creation either: the booking enters per-booking clearance first
|
||||
// and picks its shipment day at proceed time.
|
||||
if (
|
||||
contract.tradeDirection !== "DOMESTIC" &&
|
||||
contract.contractKind !== "GENERAL" &&
|
||||
!hasOpenWindow(bookingWindows)
|
||||
) {
|
||||
return (
|
||||
<Box style={{ padding: "28px 0 0" }}>
|
||||
<Group
|
||||
|
||||
Reference in New Issue
Block a user