mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
fix issues
This commit is contained in:
@@ -44,6 +44,11 @@ export interface BookingListFilterOptions {
|
||||
customsClearingEnabled?: boolean;
|
||||
createdFrom?: string;
|
||||
createdTo?: string;
|
||||
scheduledFrom?: string;
|
||||
scheduledTo?: string;
|
||||
originYardId?: string;
|
||||
destinationYardId?: string;
|
||||
isGovernment?: 'true' | 'false';
|
||||
consolidationPaired?: string;
|
||||
}
|
||||
|
||||
@@ -814,6 +819,32 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
createdTo: options.createdTo,
|
||||
});
|
||||
}
|
||||
if (options.scheduledFrom) {
|
||||
qb.andWhere('booking.scheduled_date >= :scheduledFrom', {
|
||||
scheduledFrom: options.scheduledFrom,
|
||||
});
|
||||
}
|
||||
if (options.scheduledTo) {
|
||||
// Inclusive end-of-day: callers pass a date; include the whole day.
|
||||
qb.andWhere('booking.scheduled_date <= :scheduledTo', {
|
||||
scheduledTo: options.scheduledTo,
|
||||
});
|
||||
}
|
||||
if (options.originYardId) {
|
||||
qb.andWhere('booking.origin_yard_id = :originYardId', {
|
||||
originYardId: options.originYardId,
|
||||
});
|
||||
}
|
||||
if (options.destinationYardId) {
|
||||
qb.andWhere('booking.destination_yard_id = :destinationYardId', {
|
||||
destinationYardId: options.destinationYardId,
|
||||
});
|
||||
}
|
||||
if (options.isGovernment === 'true') {
|
||||
qb.andWhere('booking.is_government = TRUE');
|
||||
} else if (options.isGovernment === 'false') {
|
||||
qb.andWhere('booking.is_government = FALSE');
|
||||
}
|
||||
if (options.tradeDirection) {
|
||||
qb.andWhere('booking.trade_direction = :tradeDirection', {
|
||||
tradeDirection: options.tradeDirection,
|
||||
|
||||
@@ -1158,6 +1158,11 @@ export class BookingsService {
|
||||
paymentStatus: filter.paymentStatus,
|
||||
createdFrom: filter.createdFrom,
|
||||
createdTo: filter.createdTo,
|
||||
scheduledFrom: filter.scheduledFrom,
|
||||
scheduledTo: filter.scheduledTo,
|
||||
originYardId: filter.originYardId,
|
||||
destinationYardId: filter.destinationYardId,
|
||||
isGovernment: filter.isGovernment,
|
||||
consolidationPaired: filter.consolidationPaired,
|
||||
sortBy: filter.sortBy,
|
||||
sortOrder: filter.sortOrder,
|
||||
@@ -1371,11 +1376,17 @@ export class BookingsService {
|
||||
serviceTypeId: filter.serviceTypeId,
|
||||
cargoTypeId: filter.cargoTypeId,
|
||||
freightType: filter.freightType,
|
||||
bookingType: filter.bookingType,
|
||||
tradeDirection: filter.tradeDirection,
|
||||
paymentCurrency: filter.paymentCurrency,
|
||||
paymentStatus: filter.paymentStatus,
|
||||
createdFrom: filter.createdFrom,
|
||||
createdTo: filter.createdTo,
|
||||
scheduledFrom: filter.scheduledFrom,
|
||||
scheduledTo: filter.scheduledTo,
|
||||
originYardId: filter.originYardId,
|
||||
destinationYardId: filter.destinationYardId,
|
||||
isGovernment: filter.isGovernment,
|
||||
consolidationPaired: filter.consolidationPaired,
|
||||
};
|
||||
|
||||
|
||||
@@ -81,6 +81,31 @@ export class FilterBookingDto {
|
||||
@IsDateString()
|
||||
createdTo?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Filter bookings scheduled on/after this date (ISO)' })
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
scheduledFrom?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Filter bookings scheduled on/before this date (ISO)' })
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
scheduledTo?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid', description: 'Filter by origin yard' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
originYardId?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid', description: 'Filter by destination yard' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
destinationYardId?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: ['true', 'false'], description: 'Filter government vs private bookings' })
|
||||
@IsOptional()
|
||||
@IsIn(['true', 'false'])
|
||||
isGovernment?: 'true' | 'false';
|
||||
|
||||
@ApiPropertyOptional({ enum: TRADE_DIRECTIONS })
|
||||
@IsOptional()
|
||||
@IsIn([...TRADE_DIRECTIONS])
|
||||
|
||||
@@ -11,7 +11,15 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { InjectDataSource } from '@nestjs/typeorm';
|
||||
import { SchedulerRegistry } from '@nestjs/schedule';
|
||||
import { DataSource, In } from 'typeorm';
|
||||
import {
|
||||
Between,
|
||||
DataSource,
|
||||
FindOptionsWhere,
|
||||
ILike,
|
||||
In,
|
||||
LessThanOrEqual,
|
||||
MoreThanOrEqual,
|
||||
} from 'typeorm';
|
||||
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
import { BookingsRepository } from '../bookings/bookings.repository';
|
||||
@@ -27,6 +35,10 @@ import { TrainSchedulingGlobalRules } from './entities/train-scheduling-global-r
|
||||
import { BookingNotifierService } from './booking-notifier.service';
|
||||
import { TrainSchedulingService } from './train-scheduling.service';
|
||||
import { eatDay, groupBookingsIntoBoardWindows } from './batch-window.util';
|
||||
import {
|
||||
BATCH_BOARD_STATUSES,
|
||||
BatchBoardQueryDto,
|
||||
} from './dto/batch-board-query.dto';
|
||||
import { Freight, TrainScheduleStatus as TrainScheduleStatusEnum } from "@edr/types";
|
||||
import { BillingService } from "../billing/billing.service";
|
||||
|
||||
@@ -158,6 +170,8 @@ export interface BatchWindowGroup {
|
||||
|
||||
export interface BatchBoardScheduleDetail {
|
||||
scheduleId: string;
|
||||
/** Human-facing schedule reference (S-YYYY-NNNNN). */
|
||||
scheduleReference: string | null;
|
||||
trainNumber: string | null;
|
||||
routeName: string | null;
|
||||
origin: string | null;
|
||||
@@ -182,11 +196,14 @@ export interface BatchBoardScheduleDetail {
|
||||
|
||||
export interface BatchBoardSchedule {
|
||||
scheduleId: string;
|
||||
/** Human-facing schedule reference (S-YYYY-NNNNN). */
|
||||
scheduleReference: string | null;
|
||||
trainNumber: string | null;
|
||||
routeName: string | null;
|
||||
origin: string | null;
|
||||
destination: string | null;
|
||||
scheduleDate: string | null;
|
||||
createdAt: string | null;
|
||||
status: string;
|
||||
bookingWindowStatus: string;
|
||||
direction: string | null;
|
||||
@@ -225,6 +242,16 @@ export interface BatchBoardSchedule {
|
||||
bookings: BatchBoardBooking[];
|
||||
}
|
||||
|
||||
/** Paginated batch-board list. `items` (not `data`) — the API response wrapper
|
||||
* already uses `data`, and the frontend's unwrap() strips one `data` level. */
|
||||
export interface BatchBoardListResponse {
|
||||
items: BatchBoardSchedule[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Demand-batching engine: every 3h (EAT) it ranks each OPEN schedule's ready pool
|
||||
* by priority, greedily fills the train to capacity (skipping bookings that don't fit),
|
||||
@@ -454,7 +481,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(
|
||||
booking.trainScheduleId,
|
||||
);
|
||||
if (schedule && (await this.remainingWagons(schedule)) <= 0) {
|
||||
if (schedule && (await this.isTrainFull(schedule))) {
|
||||
await this.setWindow(booking.trainScheduleId, "FULL");
|
||||
}
|
||||
|
||||
@@ -612,7 +639,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
this.armSettle(scheduleId);
|
||||
const schedule =
|
||||
await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||||
if (schedule && (await this.remainingWagons(schedule)) <= 0) {
|
||||
if (schedule && (await this.isTrainFull(schedule))) {
|
||||
await this.setWindow(scheduleId, 'FULL');
|
||||
}
|
||||
}
|
||||
@@ -653,12 +680,70 @@ export class BookingBatchService implements OnModuleInit {
|
||||
// ---- monitoring board -----------------------------------------------------
|
||||
|
||||
/**
|
||||
* Read model for the batch monitoring page: every still-relevant schedule (not arrived/
|
||||
* cancelled) with its locomotive, capacity usage and its bookings grouped by lifecycle
|
||||
* state (allocated / awaiting payment / paid-waiting / pending contract / expired).
|
||||
* Read model for the batch monitoring page: every import schedule — including
|
||||
* dispatched, arrived and cancelled history — with its locomotive, capacity
|
||||
* usage and its bookings grouped by lifecycle state (allocated / awaiting
|
||||
* payment / paid-waiting / pending contract / expired). Paginated and
|
||||
* filterable; per-schedule booking summaries are only computed for the
|
||||
* requested page.
|
||||
*/
|
||||
async getBatchBoard(): Promise<BatchBoardSchedule[]> {
|
||||
const schedules = await this.trainSchedulesRepository.findAll({
|
||||
async getBatchBoard(
|
||||
query: BatchBoardQueryDto = {},
|
||||
): Promise<BatchBoardListResponse> {
|
||||
const page = query.page ?? 1;
|
||||
const pageSize = query.pageSize ?? 12;
|
||||
|
||||
// Status filter: any subset of the lifecycle. Omitted = all statuses, so
|
||||
// arrived / cancelled / dispatched schedules stay visible as history.
|
||||
const allowedStatuses = new Set<string>(BATCH_BOARD_STATUSES);
|
||||
const statuses = (query.statuses ?? "")
|
||||
.split(",")
|
||||
.map((v) => v.trim().toUpperCase())
|
||||
.filter((v) => allowedStatuses.has(v));
|
||||
|
||||
const dateRange = (from?: string, to?: string) => {
|
||||
const f = from ? new Date(from) : null;
|
||||
const t = to ? new Date(to) : null;
|
||||
if (f && t) return Between(f, t);
|
||||
if (f) return MoreThanOrEqual(f);
|
||||
if (t) return LessThanOrEqual(t);
|
||||
return undefined;
|
||||
};
|
||||
|
||||
// Batch board is IMPORT-only: export is FCFS with no batch/priority calc,
|
||||
// and domestic/legacy schedules run the legacy fill, not the window batch.
|
||||
const base: FindOptionsWhere<TrainSchedule> = { direction: "IMPORT" };
|
||||
if (statuses.length) base.status = In(statuses) as never;
|
||||
if (query.bookingWindowStatus) {
|
||||
base.bookingWindowStatus = query.bookingWindowStatus;
|
||||
}
|
||||
const departure = dateRange(query.departureFrom, query.departureTo);
|
||||
if (departure) base.scheduledDepartureDate = departure as never;
|
||||
const created = dateRange(query.createdFrom, query.createdTo);
|
||||
if (created) base.createdAt = created as never;
|
||||
|
||||
// Search fans out across every human-recognizable label. Each OR variant
|
||||
// repeats the base filters so the search never widens them.
|
||||
const term = query.search?.trim();
|
||||
let where: FindOptionsWhere<TrainSchedule> | FindOptionsWhere<TrainSchedule>[] =
|
||||
base;
|
||||
if (term) {
|
||||
const like = ILike(`%${term}%`);
|
||||
where = [
|
||||
{ ...base, trainNumber: like as never },
|
||||
{ ...base, originStation: { label: like } },
|
||||
{ ...base, destinationStation: { label: like } },
|
||||
{ ...base, route: { originYard: { label: like } } },
|
||||
{ ...base, route: { destinationYard: { label: like } } },
|
||||
{ ...base, trainSet: { locomotive: { code: like } } },
|
||||
] as FindOptionsWhere<TrainSchedule>[];
|
||||
}
|
||||
|
||||
const sortBy = query.sortBy ?? "createdAt";
|
||||
const sortOrder = query.sortOrder ?? "DESC";
|
||||
|
||||
const [schedules, total] = await this.trainSchedulesRepository.findAndCount({
|
||||
where,
|
||||
relations: {
|
||||
trainSet: { locomotive: true },
|
||||
originStation: true,
|
||||
@@ -666,7 +751,9 @@ export class BookingBatchService implements OnModuleInit {
|
||||
// Yards supply the route's display name for `routeName` below.
|
||||
route: { originYard: true, destinationYard: true },
|
||||
},
|
||||
order: { scheduledDepartureDate: "ASC" },
|
||||
order: { [sortBy]: sortOrder } as never,
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
});
|
||||
|
||||
const wagonDims = await this.loadWagonDims();
|
||||
@@ -675,11 +762,6 @@ export class BookingBatchService implements OnModuleInit {
|
||||
|
||||
const board: BatchBoardSchedule[] = [];
|
||||
for (const s of schedules) {
|
||||
if (s.status === "ARRIVED" || s.status === "CANCELLED") continue;
|
||||
// Batch board is IMPORT-only: export is FCFS with no batch/priority calc,
|
||||
// and domestic/legacy schedules run the legacy fill, not the window batch.
|
||||
if (s.direction !== "IMPORT") continue;
|
||||
|
||||
const links = await linkRepo.find({ where: { trainScheduleId: s.id } });
|
||||
const linkedIds = new Set(links.map((l) => l.bookingId));
|
||||
const bookings = await this.bookingsRepository.findAllBySchedule(s.id);
|
||||
@@ -707,7 +789,14 @@ export class BookingBatchService implements OnModuleInit {
|
||||
|
||||
board.push(this.buildScheduleSummary(s, items, rules));
|
||||
}
|
||||
return board;
|
||||
|
||||
return {
|
||||
items: board,
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
totalPages: Math.max(1, Math.ceil(total / pageSize)),
|
||||
};
|
||||
}
|
||||
|
||||
/** Schedule-level batch board with EAT 3h windows grouped by fullyExecutedAt. */
|
||||
@@ -718,9 +807,8 @@ export class BookingBatchService implements OnModuleInit {
|
||||
await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||||
if (!s)
|
||||
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
|
||||
if (s.status === "ARRIVED" || s.status === "CANCELLED") {
|
||||
throw new BadRequestException("Schedule is no longer active");
|
||||
}
|
||||
// Arrived / cancelled schedules stay viewable — the board is also the
|
||||
// historical record of what each train carried.
|
||||
// Batch board is IMPORT-only (export is FCFS, no batch/priority calc).
|
||||
if (s.direction !== "IMPORT") {
|
||||
throw new BadRequestException(
|
||||
@@ -895,6 +983,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
|
||||
return {
|
||||
scheduleId: s.id,
|
||||
scheduleReference: s.reference ?? null,
|
||||
trainNumber: s.trainNumber ?? null,
|
||||
routeName: s.route ? formatRouteLabel(s.route) : null,
|
||||
origin: s.originStation?.label ?? s.originStation?.code ?? null,
|
||||
@@ -1016,6 +1105,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
|
||||
return {
|
||||
scheduleId: s.id,
|
||||
scheduleReference: s.reference ?? null,
|
||||
trainNumber: s.trainNumber ?? null,
|
||||
routeName: s.route ? formatRouteLabel(s.route) : null,
|
||||
origin: s.originStation?.label ?? s.originStation?.code ?? null,
|
||||
@@ -1024,6 +1114,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
scheduleDate: s.scheduledDepartureDate
|
||||
? s.scheduledDepartureDate.toISOString()
|
||||
: null,
|
||||
createdAt: s.createdAt ? s.createdAt.toISOString() : null,
|
||||
status: s.status,
|
||||
bookingWindowStatus: s.bookingWindowStatus,
|
||||
direction: s.direction ?? null,
|
||||
@@ -1117,7 +1208,8 @@ export class BookingBatchService implements OnModuleInit {
|
||||
const limits = await this.capacityLimits(locomotive, rules);
|
||||
await this.syncScheduleMaxWagons(schedule, locomotive, rules);
|
||||
const budget = await this.remainingBudget(schedule, limits, wagonDims);
|
||||
if (budget.maxRemaining().wagons <= 0) {
|
||||
const minPerWagon = this.minPerWagonNeed(wagonDims);
|
||||
if (budget.isExhausted(minPerWagon)) {
|
||||
await this.setWindow(scheduleId, "FULL");
|
||||
return 0;
|
||||
}
|
||||
@@ -1213,7 +1305,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
this.logger.log(
|
||||
`[fillSchedule ${scheduleId}] reserved ${reservedThisPass}/${units.length} unit(s) this pass`,
|
||||
);
|
||||
if (budget.maxRemaining().wagons <= 0) await this.setWindow(scheduleId, "FULL");
|
||||
if (budget.isExhausted(minPerWagon)) await this.setWindow(scheduleId, "FULL");
|
||||
if (armed) this.armSettle(scheduleId);
|
||||
void this.triggerWagonAllocation(scheduleId);
|
||||
return commercialReserved;
|
||||
@@ -1461,8 +1553,9 @@ export class BookingBatchService implements OnModuleInit {
|
||||
`[fillRouteDay ${originYardId}->${destinationYardId} ${day}] reserved ${reservedThisPass}/${units.length} unit(s) this pass`,
|
||||
);
|
||||
|
||||
const minPerWagon = this.minPerWagonNeed(wagonDims);
|
||||
for (const t of trains) {
|
||||
if (t.budget.maxRemaining().wagons <= 0) await this.setWindow(t.id, "FULL");
|
||||
if (t.budget.isExhausted(minPerWagon)) await this.setWindow(t.id, "FULL");
|
||||
if (t.armed) this.armSettle(t.id);
|
||||
void this.triggerWagonAllocation(t.id);
|
||||
}
|
||||
@@ -1783,7 +1876,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(
|
||||
booking.trainScheduleId,
|
||||
);
|
||||
if (schedule && (await this.remainingWagons(schedule)) <= 0) {
|
||||
if (schedule && (await this.isTrainFull(schedule))) {
|
||||
await this.setWindow(booking.trainScheduleId, "FULL");
|
||||
}
|
||||
void this.triggerWagonAllocation(booking.trainScheduleId!);
|
||||
@@ -2616,7 +2709,9 @@ export class BookingBatchService implements OnModuleInit {
|
||||
|
||||
/**
|
||||
* Wagon slots still boardable somewhere on the corridor (most-open edge).
|
||||
* ≤ 0 means no leg can take another booking — the train-wide FULL signal.
|
||||
* ≤ 0 means no leg can take another booking. Slot axis ONLY — the train-wide
|
||||
* FULL signal is {@link isTrainFull}, which also closes weight/length-bound
|
||||
* trains that still show free slots.
|
||||
*/
|
||||
private async remainingWagons(schedule: TrainSchedule): Promise<number> {
|
||||
const wagonDims = await this.loadWagonDims();
|
||||
@@ -2681,12 +2776,52 @@ export class BookingBatchService implements OnModuleInit {
|
||||
);
|
||||
}
|
||||
|
||||
/** No wagon slots left for allocated + reserved bookings. */
|
||||
/**
|
||||
* FULL on ANY capacity axis: out of wagon slots, or out of pull weight /
|
||||
* train length for even one more loaded wagon. The old slot-only check let
|
||||
* a weight-bound train (PW2: weight binds at 37 wagons = 3522.4T of
|
||||
* 3500+90T, slots bind at 44) cycle its booking window forever instead of
|
||||
* finalizing — 7 phantom slots kept it "not full" while nothing could board.
|
||||
*/
|
||||
async isScheduleFull(scheduleId: string): Promise<boolean> {
|
||||
const schedule =
|
||||
await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||||
if (!schedule) return false;
|
||||
return (await this.remainingWagons(schedule)) <= 0;
|
||||
return this.isTrainFull(schedule);
|
||||
}
|
||||
|
||||
/** See {@link isScheduleFull} — same check for callers that already hold the full graph. */
|
||||
private async isTrainFull(schedule: TrainSchedule): Promise<boolean> {
|
||||
if ((await this.remainingWagons(schedule)) <= 0) return true;
|
||||
const locomotive = schedule.trainSet?.locomotive;
|
||||
if (!locomotive) return false; // no weight/length limits to bind against
|
||||
const rules = await this.loadGlobalRules();
|
||||
const wagonDims = await this.loadWagonDims();
|
||||
const limits = await this.capacityLimits(locomotive, rules);
|
||||
const budget = await this.remainingBudget(schedule, limits, wagonDims);
|
||||
return budget.isExhausted(this.minPerWagonNeed(wagonDims));
|
||||
}
|
||||
|
||||
/**
|
||||
* Smallest gross weight / shortest length one more wagon could add: the
|
||||
* lightest wagon type at its rated payload. Feeds CorridorBudget.isExhausted,
|
||||
* so FULL is only declared when not even this wagon fits anywhere.
|
||||
*/
|
||||
private minPerWagonNeed(wagonDims: WagonDims): {
|
||||
grossWeightTons: number;
|
||||
lengthMeters: number;
|
||||
} {
|
||||
const all = [
|
||||
wagonDims.container,
|
||||
wagonDims.bulk,
|
||||
...wagonDims.byWagonTypeId.values(),
|
||||
];
|
||||
return {
|
||||
grossWeightTons: Math.min(
|
||||
...all.map((d) => d.tareWeightTons + d.capacityTons),
|
||||
),
|
||||
lengthMeters: Math.min(...all.map((d) => d.lengthMeters)),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2707,7 +2842,10 @@ export class BookingBatchService implements OnModuleInit {
|
||||
const schedule =
|
||||
await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||||
if (!schedule || schedule.bookingWindowStatus !== "FULL") return;
|
||||
if ((await this.remainingWagons(schedule)) <= 0) return;
|
||||
// Symmetric with isScheduleFull: a weight/length-bound FULL is not stale
|
||||
// just because slots remain — clearing it here would reopen a train
|
||||
// nothing can board.
|
||||
if (await this.isTrainFull(schedule)) return;
|
||||
|
||||
const customerWindowOpen =
|
||||
schedule.windowPhase == null || schedule.windowPhase === "OPEN";
|
||||
|
||||
@@ -28,6 +28,9 @@ import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity';
|
||||
* unload at its destination yard (IN_TRANSIT → ARRIVED for import/export,
|
||||
* → COMPLETED for intercity), possibly long before the train's final arrival.
|
||||
* Both are gated on the train's latest recorded checkpoint being at that yard.
|
||||
* Unload also fires automatically: recording a checkpoint at a yard auto-
|
||||
* unloads every booking destined there (autoUnloadAtYard), so the manual
|
||||
* unload endpoint remains only a fallback.
|
||||
*
|
||||
* Unloading also settles the physical wagons: each wagon that alights with the
|
||||
* booking is released at that yard and the move is written to the
|
||||
@@ -198,6 +201,47 @@ export class BookingJourneyService {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-unload on checkpoint: every IN_TRANSIT booking on this schedule whose
|
||||
* destination is the yard the train just reached alights automatically, so
|
||||
* the customer's booking flips to ARRIVED (COMPLETED for intercity) the
|
||||
* moment the train is recorded at their yard — no separate operator unload.
|
||||
* Runs through the same per-booking unload path (wagon settle + ledger +
|
||||
* milestones); one booking's failure is logged and never blocks the
|
||||
* checkpoint or the other bookings. Returns the unloaded booking ids.
|
||||
*/
|
||||
async autoUnloadAtYard(
|
||||
scheduleId: string,
|
||||
yardId: string,
|
||||
userId?: string | null,
|
||||
): Promise<string[]> {
|
||||
const bookings = await this.dataSource
|
||||
.getRepository(Booking)
|
||||
.createQueryBuilder('booking')
|
||||
.innerJoin(
|
||||
'freight.train_schedule_bookings',
|
||||
'tsb',
|
||||
'tsb.booking_id = booking.id AND tsb.train_schedule_id = :scheduleId AND tsb.deleted_at IS NULL',
|
||||
{ scheduleId },
|
||||
)
|
||||
.where('booking.destination_yard_id = :yardId', { yardId })
|
||||
.andWhere(`booking.status = 'IN_TRANSIT'`)
|
||||
.getMany();
|
||||
|
||||
const unloaded: string[] = [];
|
||||
for (const booking of bookings) {
|
||||
try {
|
||||
await this.unloadBooking(scheduleId, booking.id, userId);
|
||||
unloaded.push(booking.id);
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`Auto-unload failed for booking ${booking.id} at yard ${yardId}: ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return unloaded;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk fallback at the train's FINAL arrival: any booking destined for the
|
||||
* final yard that operators didn't unload individually gets its per-booking
|
||||
|
||||
@@ -72,4 +72,49 @@ describe('corridor-capacity.util — overage tolerance', () => {
|
||||
strict.subtract(need(3500, 10, 170), strict.fullLeg());
|
||||
expect(strict.fits(need(1), strict.fullLeg())).toBe(false);
|
||||
});
|
||||
|
||||
describe('isExhausted — train-wide FULL across all axes', () => {
|
||||
// Lightest wagon at rated payload: PW2 25.2T tare + 70T = 95.2T gross.
|
||||
const perWagon = {
|
||||
grossWeightTons: pw2.tareWeightTons + pw2.capacityTons,
|
||||
lengthMeters: pw2.lengthMeters,
|
||||
};
|
||||
|
||||
it('reports FULL when weight binds first, with wagon slots still free', () => {
|
||||
// 37 loaded PW2 wagons = 3522.4T of 3500+90T. 7 length-derived slots
|
||||
// remain, but wagon 38 would need 95.2T against 67.6T of room — the
|
||||
// schedule must finalize and its window must disappear.
|
||||
const budget = new CorridorBudget(stops, base, tolerance);
|
||||
budget.subtract(need(3522.4, 37, 631.442), budget.fullLeg());
|
||||
expect(budget.maxRemaining().wagons).toBeGreaterThan(0); // slot check alone says "not full"
|
||||
expect(budget.isExhausted(perWagon)).toBe(true);
|
||||
});
|
||||
|
||||
it('is not FULL while one more loaded wagon still fits within base + tolerance', () => {
|
||||
const budget = budgetAt(3300); // 200T base room + 90T tolerance ≥ 95.2T
|
||||
expect(budget.isExhausted(perWagon)).toBe(false);
|
||||
});
|
||||
|
||||
it('reports FULL when wagon slots run out regardless of weight room', () => {
|
||||
const budget = new CorridorBudget(stops, base, tolerance);
|
||||
budget.subtract(need(1000, 44, 700), budget.fullLeg());
|
||||
expect(budget.isExhausted(perWagon)).toBe(true);
|
||||
});
|
||||
|
||||
it('reports FULL when length room cannot take one more wagon', () => {
|
||||
const budget = new CorridorBudget(stops, base, tolerance);
|
||||
budget.subtract(need(1000, 30, 750), budget.fullLeg()); // 10m left < 17.066m
|
||||
expect(budget.isExhausted(perWagon)).toBe(true);
|
||||
});
|
||||
|
||||
it('only counts an edge as open when EVERY axis has room on that same edge', () => {
|
||||
// Three stops → two edges. Edge 0 has weight but no slots; edge 1 has
|
||||
// slots but no weight. Neither can board a wagon, so the train is FULL
|
||||
// even though the per-axis maxima both look open.
|
||||
const budget = new CorridorBudget(['a', 'b', 'c'], base, tolerance);
|
||||
budget.subtract(need(0, 44, 0), { fromEdge: 0, toEdge: 1 });
|
||||
budget.subtract(need(3522.4, 0, 0), { fromEdge: 1, toEdge: 2 });
|
||||
expect(budget.isExhausted(perWagon)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -168,6 +168,25 @@ export class CorridorBudget {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Train-wide FULL across ALL capacity axes: true when no edge can board even
|
||||
* one more loaded wagon. `perWagon` is the smallest gross weight and length
|
||||
* a future wagon could add (lightest wagon type at rated payload); weight and
|
||||
* length may dip into the overage tolerance, mirroring {@link fits}. Checked
|
||||
* per edge — an edge with slots free but no pull weight is just as closed as
|
||||
* one with no slots. A slot-only check misses weight-bound trains: PW2 at
|
||||
* 37 × 95.2T = 3522.4T of 3500+90T has 7 length-derived slots free but no
|
||||
* weight room for wagon 38, and its window must read FULL.
|
||||
*/
|
||||
isExhausted(perWagon: { grossWeightTons: number; lengthMeters: number }): boolean {
|
||||
return this.edges.every(
|
||||
(e) =>
|
||||
e.wagons <= 0 ||
|
||||
e.weightTons + this.tolerance.weightTons < perWagon.grossWeightTons ||
|
||||
e.lengthMeters + this.tolerance.lengthMeters < perWagon.lengthMeters,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The most open edge — when even this has no wagon slots left, nothing can
|
||||
* board anywhere and the schedule's window is genuinely FULL. (A train can be
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
IsIn,
|
||||
IsInt,
|
||||
IsISO8601,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Max,
|
||||
MaxLength,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
|
||||
export const BATCH_BOARD_STATUSES = [
|
||||
'DRAFT',
|
||||
'SCHEDULED',
|
||||
'DISPATCHED',
|
||||
'ARRIVED',
|
||||
'CANCELLED',
|
||||
] as const;
|
||||
|
||||
export const BATCH_BOARD_SORT_FIELDS = [
|
||||
'createdAt',
|
||||
'scheduledDepartureDate',
|
||||
'trainNumber',
|
||||
'status',
|
||||
] as const;
|
||||
export type BatchBoardSortField = (typeof BATCH_BOARD_SORT_FIELDS)[number];
|
||||
|
||||
/** Filters for the batch monitoring board list (import schedules, all statuses). */
|
||||
export class BatchBoardQueryDto {
|
||||
@ApiPropertyOptional({ default: 1, minimum: 1 })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
page?: number;
|
||||
|
||||
@ApiPropertyOptional({ default: 12, minimum: 1, maximum: 100 })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(100)
|
||||
pageSize?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'Comma-separated schedule statuses (DRAFT,SCHEDULED,DISPATCHED,ARRIVED,CANCELLED). Omit for all.',
|
||||
example: 'DISPATCHED,ARRIVED',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
statuses?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: ['OPEN', 'FULL', 'CLOSED'] })
|
||||
@IsOptional()
|
||||
@IsIn(['OPEN', 'FULL', 'CLOSED'])
|
||||
bookingWindowStatus?: 'OPEN' | 'FULL' | 'CLOSED';
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'Case-insensitive match on train number, route yards, stations, or locomotive code.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(120)
|
||||
search?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Departure date lower bound (ISO 8601).' })
|
||||
@IsOptional()
|
||||
@IsISO8601()
|
||||
departureFrom?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Departure date upper bound (ISO 8601).' })
|
||||
@IsOptional()
|
||||
@IsISO8601()
|
||||
departureTo?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Created-at lower bound (ISO 8601).' })
|
||||
@IsOptional()
|
||||
@IsISO8601()
|
||||
createdFrom?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Created-at upper bound (ISO 8601).' })
|
||||
@IsOptional()
|
||||
@IsISO8601()
|
||||
createdTo?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: BATCH_BOARD_SORT_FIELDS, default: 'createdAt' })
|
||||
@IsOptional()
|
||||
@IsIn(BATCH_BOARD_SORT_FIELDS as unknown as string[])
|
||||
sortBy?: BatchBoardSortField;
|
||||
|
||||
@ApiPropertyOptional({ enum: ['ASC', 'DESC'], default: 'DESC' })
|
||||
@IsOptional()
|
||||
@IsIn(['ASC', 'DESC'])
|
||||
sortOrder?: 'ASC' | 'DESC';
|
||||
}
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
UploadImportDjiboutiDocumentDto,
|
||||
} from "./dto/import-djibouti-operation.dto";
|
||||
import { AvailableLocomotivesQueryDto } from "./dto/available-locomotives-query.dto";
|
||||
import { BatchBoardQueryDto } from "./dto/batch-board-query.dto";
|
||||
import { BookableSchedulesQueryDto } from "./dto/bookable-schedules-query.dto";
|
||||
import { AvailableDaysQueryDto } from "./dto/available-days-query.dto";
|
||||
import { AvailableDaysForCargoQueryDto } from "./dto/available-days-for-cargo-query.dto";
|
||||
@@ -124,10 +125,11 @@ export class TrainSchedulingController {
|
||||
@Get("batch-board")
|
||||
@TrainSchedulingView()
|
||||
@ApiOperation({
|
||||
summary: "Batch monitoring board: schedules with bookings grouped by state",
|
||||
summary:
|
||||
"Batch monitoring board: paginated import schedules (all statuses) with bookings grouped by state",
|
||||
})
|
||||
getBatchBoard() {
|
||||
return this.bookingBatchService.getBatchBoard();
|
||||
getBatchBoard(@Query() query: BatchBoardQueryDto) {
|
||||
return this.bookingBatchService.getBatchBoard(query);
|
||||
}
|
||||
|
||||
@Get("batch-board/:scheduleId")
|
||||
|
||||
@@ -2514,6 +2514,12 @@ export class TrainSchedulingService {
|
||||
|
||||
if (dto.sequenceNo === finalSeq) {
|
||||
await this.arriveSchedule(scheduleId);
|
||||
} else {
|
||||
// Mid-corridor auto-unload: bookings destined for this yard alight the
|
||||
// moment the train is recorded here — the yard operator no longer has to
|
||||
// unload each one by hand. The final station is covered by
|
||||
// arriveSchedule's bulk fallback above.
|
||||
await this.bookingJourneyService.autoUnloadAtYard(scheduleId, station.yardId);
|
||||
}
|
||||
|
||||
return this.getScheduleCheckpoints(scheduleId);
|
||||
@@ -4518,6 +4524,11 @@ export class TrainSchedulingService {
|
||||
capacityTons: roundTons(Number(wagon.capacityTons)),
|
||||
lengthMeters: roundTons(Number(wagon.lengthMeters)),
|
||||
assignedWeightTons: roundTons(Number(wagon.assignedWeightTons)),
|
||||
// Empty-wagon weight — the pull limit hauls tare + cargo, so the
|
||||
// frontend needs it to show the gross train weight.
|
||||
tareWeightTons: wagon.wagonType
|
||||
? roundTons(Number(wagon.wagonType.tareWeightTons))
|
||||
: null,
|
||||
status: wagon.status,
|
||||
physicalWagonId: wagon.physicalWagonId ?? null,
|
||||
physicalWagonNumber: wagon.physicalWagon?.wagonNumber ?? null,
|
||||
|
||||
@@ -16,6 +16,7 @@ type DiagramWagonInput = {
|
||||
sequenceNo: number;
|
||||
capacityTons: number;
|
||||
assignedWeightTons: number;
|
||||
tareWeightTons?: number | null;
|
||||
slotLoadType?: string | null;
|
||||
wagonType?: { code?: string | null } | null;
|
||||
wagonTypeCode?: string | null;
|
||||
@@ -33,6 +34,7 @@ type NormalizedWagon = {
|
||||
sequenceNo: number;
|
||||
capacityTons: number;
|
||||
assignedWeightTons: number;
|
||||
tareWeightTons: number;
|
||||
wagonTypeCode: string | null;
|
||||
physicalWagonNumber: string | null;
|
||||
isEmpty: boolean;
|
||||
@@ -69,6 +71,7 @@ function normalizeWagon(w: DiagramWagonInput, freightType?: string | null): Norm
|
||||
sequenceNo: w.sequenceNo,
|
||||
capacityTons: Number(w.capacityTons) || 0,
|
||||
assignedWeightTons: Number(w.assignedWeightTons) || 0,
|
||||
tareWeightTons: Number(w.tareWeightTons) || 0,
|
||||
wagonTypeCode: w.wagonType?.code ?? w.wagonTypeCode ?? null,
|
||||
physicalWagonNumber: w.physicalWagonNumber ?? null,
|
||||
isEmpty: allocations.length === 0,
|
||||
@@ -278,7 +281,9 @@ function WagonCar({ wagon }: { wagon: NormalizedWagon }) {
|
||||
wagon.bookingRefs.length ? wagon.bookingRefs.join(", ") : ""
|
||||
}${
|
||||
wagon.containerNumbers.length ? `\nContainers: ${wagon.containerNumbers.join(", ")}` : ""
|
||||
}${wagon.cargoDescription ? `\n${wagon.cargoDescription}` : ""}\nLoad: ${wagon.assignedWeightTons}/${wagon.capacityTons}T (${utilization}%)`;
|
||||
}${wagon.cargoDescription ? `\n${wagon.cargoDescription}` : ""}\nLoad: ${wagon.assignedWeightTons}/${wagon.capacityTons}T (${utilization}%)${
|
||||
wagon.tareWeightTons ? `\nTare: ${wagon.tareWeightTons}T` : ""
|
||||
}`;
|
||||
|
||||
// container blocks: one per container number (cap visual at 2 = TEU per wagon)
|
||||
const blocks = wagon.containerNumbers.slice(0, 2);
|
||||
@@ -523,15 +528,22 @@ export function TrainCompositionDiagram({
|
||||
const assigned = normalized.filter((w) => !w.isEmpty).length;
|
||||
const totalWeight = normalized.reduce((s, w) => s + w.assignedWeightTons, 0);
|
||||
const totalCapacity = normalized.reduce((s, w) => s + w.capacityTons, 0);
|
||||
// Every coupled wagon's tare is hauled — empty ones included — so the
|
||||
// locomotive pull limit is measured against gross (tare + cargo), the same
|
||||
// ceiling the allocation engine spends from.
|
||||
const totalTare = normalized.reduce((s, w) => s + w.tareWeightTons, 0);
|
||||
const grossWeight = totalWeight + totalTare;
|
||||
return {
|
||||
total: normalized.length,
|
||||
assigned,
|
||||
empty: normalized.length - assigned,
|
||||
totalWeight: Math.round(totalWeight * 100) / 100,
|
||||
totalTare: Math.round(totalTare * 100) / 100,
|
||||
grossWeight: Math.round(grossWeight * 100) / 100,
|
||||
totalCapacity,
|
||||
pullUtil:
|
||||
locomotive?.maxPullWeightTons && locomotive.maxPullWeightTons > 0
|
||||
? Math.min(100, Math.round((totalWeight / locomotive.maxPullWeightTons) * 100))
|
||||
? Math.min(100, Math.round((grossWeight / locomotive.maxPullWeightTons) * 100))
|
||||
: null,
|
||||
};
|
||||
}, [normalized, locomotive]);
|
||||
@@ -598,12 +610,30 @@ export function TrainCompositionDiagram({
|
||||
}}
|
||||
>
|
||||
<Text size="xs" fw={700} c="dark.4">
|
||||
{stats.totalWeight}T
|
||||
{stats.totalWeight}T cargo
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
of {stats.totalCapacity}T capacity
|
||||
</Text>
|
||||
</Group>
|
||||
{stats.totalTare > 0 ? (
|
||||
<Group
|
||||
gap={6}
|
||||
style={{
|
||||
padding: "4px 12px",
|
||||
borderRadius: 999,
|
||||
background: "var(--mantine-color-gray-0)",
|
||||
border: "1px solid var(--mantine-color-gray-2)",
|
||||
}}
|
||||
>
|
||||
<Text size="xs" fw={700} c="dark.4">
|
||||
{stats.grossWeight}T gross
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
incl. {stats.totalTare}T tare
|
||||
</Text>
|
||||
</Group>
|
||||
) : null}
|
||||
<Group gap="xs">
|
||||
<LegendDot color="cyan" label="Container" />
|
||||
<LegendDot color="orange" label="Bulk" />
|
||||
@@ -626,7 +656,10 @@ export function TrainCompositionDiagram({
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Gauge size={14} color={freightBrand.primary} />
|
||||
<Text size="xs" fw={700} c="edr-green.8">
|
||||
Locomotive load · {stats.totalWeight}T of {locomotive?.maxPullWeightTons}T
|
||||
Locomotive load ·{" "}
|
||||
{stats.totalTare > 0
|
||||
? `${stats.grossWeight}T of ${locomotive?.maxPullWeightTons}T (${stats.totalWeight}T cargo + ${stats.totalTare}T tare)`
|
||||
: `${stats.totalWeight}T of ${locomotive?.maxPullWeightTons}T`}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="sm" fw={800} c={stats.pullUtil > 95 ? "red.7" : "edr-green.7"}>
|
||||
|
||||
@@ -103,7 +103,8 @@ export const QUERY_KEYS = {
|
||||
schedules: () => ["train-scheduling", "schedules"] as const,
|
||||
scheduleById: (id: string) => ["train-scheduling", "schedule", id] as const,
|
||||
track: (id: string) => ["train-scheduling", "track", id] as const,
|
||||
batchBoard: () => ["train-scheduling", "batch-board"] as const,
|
||||
batchBoard: (filters?: unknown) =>
|
||||
["train-scheduling", "batch-board", "list", filters ?? {}] as const,
|
||||
batchBoardDetail: (scheduleId: string) =>
|
||||
["train-scheduling", "batch-board", scheduleId] as const,
|
||||
unassignedBookings: (id: string) =>
|
||||
|
||||
@@ -80,7 +80,7 @@ export function toContractListRow(contract: Freight.IContract): ContractListRow
|
||||
approvalSteps: contract.approvalSteps,
|
||||
customerLabel: contract.isGovernment
|
||||
? (contract.governmentInstitution ?? "Government")
|
||||
: (contract.companyId ?? "—"),
|
||||
: (contract.company?.name ?? "—"),
|
||||
status: contract.status,
|
||||
contractKind: contract.contractKind,
|
||||
tradeDirection: contract.tradeDirection,
|
||||
|
||||
@@ -28,6 +28,10 @@ export const queryClient = new QueryClient({
|
||||
queries: {
|
||||
retry: 1,
|
||||
staleTime: 30_000,
|
||||
// Data freshness is driven by mutation invalidation (MutationCache above),
|
||||
// socket pushes, and explicit polling — not by tab focus. Focus refetch
|
||||
// just re-fires every mounted query each time the window is refocused.
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -4,18 +4,21 @@ import {
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
MultiSelect,
|
||||
Select,
|
||||
Stack,
|
||||
Tabs,
|
||||
Text,
|
||||
TextInput,
|
||||
} from "@mantine/core";
|
||||
import { DateInput } from "@mantine/dates";
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowRight,
|
||||
Calendar,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
FilterX,
|
||||
LayoutList,
|
||||
Package,
|
||||
Plus,
|
||||
@@ -26,6 +29,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { BookingActionsMenu } from "@/components/bookings/BookingActionsMenu";
|
||||
import { BookingApprovalProgressCell } from "@/components/bookings/BookingApprovalProgressCell";
|
||||
@@ -43,6 +47,7 @@ import {
|
||||
useBookingList,
|
||||
useBookingListSummary,
|
||||
} from "@/hooks/bookings/useBookings";
|
||||
import { api } from "@/services/api";
|
||||
import type { BookingListFilter } from "@/services/bookings.service";
|
||||
import type { BookingListRow } from "@/types/booking";
|
||||
import {
|
||||
@@ -77,6 +82,33 @@ const FREIGHT_TYPE_OPTIONS = [
|
||||
{ value: "BULK", label: "Bulk" },
|
||||
];
|
||||
|
||||
const PAYMENT_STATUS_OPTIONS = [
|
||||
{ value: "PENDING", label: "Payment pending" },
|
||||
{ value: "PNR_GENERATED", label: "PNR generated" },
|
||||
{ value: "VERIFICATION_IN_PROGRESS", label: "Verification in progress" },
|
||||
{ value: "PAID", label: "Paid" },
|
||||
{ value: "FAILED", label: "Payment failed" },
|
||||
];
|
||||
|
||||
const OWNERSHIP_OPTIONS = [
|
||||
{ value: "true", label: "Government" },
|
||||
{ value: "false", label: "Private" },
|
||||
];
|
||||
|
||||
/** Local start-of-day → ISO, for inclusive "from" date filters. */
|
||||
function startOfDayIso(d: Date): string {
|
||||
const x = new Date(d);
|
||||
x.setHours(0, 0, 0, 0);
|
||||
return x.toISOString();
|
||||
}
|
||||
|
||||
/** Local end-of-day → ISO, for inclusive "to" date filters. */
|
||||
function endOfDayIso(d: Date): string {
|
||||
const x = new Date(d);
|
||||
x.setHours(23, 59, 59, 999);
|
||||
return x.toISOString();
|
||||
}
|
||||
|
||||
function formatDate(value: string | null | undefined): string {
|
||||
if (!value) return "—";
|
||||
const d = new Date(value);
|
||||
@@ -95,10 +127,18 @@ export default function BookingRequestsPage() {
|
||||
const [query, setQuery] = useState("");
|
||||
// Booking-kind tabs (one-time vs general contract) replace the old status tabs.
|
||||
const [kindTab, setKindTab] = useState<BookingKindTab>("ONE_TIME");
|
||||
// Per-tab filter selects (each nullable = "all").
|
||||
const [statusFilter, setStatusFilter] = useState<string | null>(null);
|
||||
// Per-tab filter controls (empty/null = "all").
|
||||
const [statusFilter, setStatusFilter] = useState<string[]>([]);
|
||||
const [directionFilter, setDirectionFilter] = useState<string | null>(null);
|
||||
const [freightTypeFilter, setFreightTypeFilter] = useState<string | null>(null);
|
||||
const [paymentStatusFilter, setPaymentStatusFilter] = useState<string | null>(null);
|
||||
const [ownershipFilter, setOwnershipFilter] = useState<string | null>(null);
|
||||
const [originYardFilter, setOriginYardFilter] = useState<string | null>(null);
|
||||
const [destinationYardFilter, setDestinationYardFilter] = useState<string | null>(null);
|
||||
const [createdFrom, setCreatedFrom] = useState<Date | null>(null);
|
||||
const [createdTo, setCreatedTo] = useState<Date | null>(null);
|
||||
const [scheduledFrom, setScheduledFrom] = useState<Date | null>(null);
|
||||
const [scheduledTo, setScheduledTo] = useState<Date | null>(null);
|
||||
const [allocateOpen, setAllocateOpen] = useState(false);
|
||||
const [allocateIds, setAllocateIds] = useState<string[]>([]);
|
||||
const suppressRowClickRef = useRef(false);
|
||||
@@ -118,9 +158,21 @@ export default function BookingRequestsPage() {
|
||||
// React Query cache key per kind tab.
|
||||
tab: kindTab,
|
||||
bookingType: kindTab,
|
||||
...(statusFilter ? { statuses: statusFilter } : {}),
|
||||
...(statusFilter.length ? { statuses: statusFilter.join(",") } : {}),
|
||||
...(directionFilter ? { tradeDirection: directionFilter } : {}),
|
||||
...(freightTypeFilter ? { freightType: freightTypeFilter } : {}),
|
||||
...(paymentStatusFilter ? { paymentStatus: paymentStatusFilter } : {}),
|
||||
...(ownershipFilter
|
||||
? { isGovernment: ownershipFilter as "true" | "false" }
|
||||
: {}),
|
||||
...(originYardFilter ? { originYardId: originYardFilter } : {}),
|
||||
...(destinationYardFilter
|
||||
? { destinationYardId: destinationYardFilter }
|
||||
: {}),
|
||||
...(createdFrom ? { createdFrom: startOfDayIso(createdFrom) } : {}),
|
||||
...(createdTo ? { createdTo: endOfDayIso(createdTo) } : {}),
|
||||
...(scheduledFrom ? { scheduledFrom: startOfDayIso(scheduledFrom) } : {}),
|
||||
...(scheduledTo ? { scheduledTo: endOfDayIso(scheduledTo) } : {}),
|
||||
};
|
||||
}, [
|
||||
pagination.pageIndex,
|
||||
@@ -129,6 +181,14 @@ export default function BookingRequestsPage() {
|
||||
statusFilter,
|
||||
directionFilter,
|
||||
freightTypeFilter,
|
||||
paymentStatusFilter,
|
||||
ownershipFilter,
|
||||
originYardFilter,
|
||||
destinationYardFilter,
|
||||
createdFrom,
|
||||
createdTo,
|
||||
scheduledFrom,
|
||||
scheduledTo,
|
||||
]);
|
||||
|
||||
const { data, isLoading, isError, refetch, isFetching } = useBookingList(filter);
|
||||
@@ -142,6 +202,49 @@ export default function BookingRequestsPage() {
|
||||
refetch: refetchSummary,
|
||||
} = useBookingListSummary(filter);
|
||||
|
||||
// Yard options for the origin/destination filters (shared routes reference list).
|
||||
const { data: yardRefs } = useQuery(
|
||||
api.routes.yards.queryOptions({ staleTime: 5 * 60_000 }),
|
||||
);
|
||||
const yardOptions = useMemo(
|
||||
() =>
|
||||
(yardRefs ?? []).map((y) => ({
|
||||
value: y.id,
|
||||
label: y.label ?? y.code,
|
||||
})),
|
||||
[yardRefs],
|
||||
);
|
||||
|
||||
const resetPage = useCallback(() => {
|
||||
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
|
||||
}, [setPagination, pagination.pageSize]);
|
||||
|
||||
const activeFilterCount =
|
||||
(statusFilter.length ? 1 : 0) +
|
||||
(directionFilter ? 1 : 0) +
|
||||
(freightTypeFilter ? 1 : 0) +
|
||||
(paymentStatusFilter ? 1 : 0) +
|
||||
(ownershipFilter ? 1 : 0) +
|
||||
(originYardFilter ? 1 : 0) +
|
||||
(destinationYardFilter ? 1 : 0) +
|
||||
(createdFrom || createdTo ? 1 : 0) +
|
||||
(scheduledFrom || scheduledTo ? 1 : 0);
|
||||
|
||||
const clearFilters = useCallback(() => {
|
||||
setStatusFilter([]);
|
||||
setDirectionFilter(null);
|
||||
setFreightTypeFilter(null);
|
||||
setPaymentStatusFilter(null);
|
||||
setOwnershipFilter(null);
|
||||
setOriginYardFilter(null);
|
||||
setDestinationYardFilter(null);
|
||||
setCreatedFrom(null);
|
||||
setCreatedTo(null);
|
||||
setScheduledFrom(null);
|
||||
setScheduledTo(null);
|
||||
resetPage();
|
||||
}, [resetPage]);
|
||||
|
||||
const rows = useMemo(() => {
|
||||
const items = (data?.items ?? []).map(toBookingListRow);
|
||||
const q = query.trim().toLowerCase();
|
||||
@@ -415,18 +518,44 @@ export default function BookingRequestsPage() {
|
||||
</Text>
|
||||
</Group>
|
||||
<Group gap="sm" wrap="wrap">
|
||||
<Select
|
||||
placeholder="All statuses"
|
||||
<MultiSelect
|
||||
placeholder={statusFilter.length ? undefined : "All statuses"}
|
||||
data={STATUS_OPTIONS}
|
||||
value={statusFilter}
|
||||
onChange={(v) => {
|
||||
setStatusFilter(v);
|
||||
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
|
||||
resetPage();
|
||||
}}
|
||||
clearable
|
||||
searchable
|
||||
radius="lg"
|
||||
style={{ minWidth: 200 }}
|
||||
style={{ minWidth: 220 }}
|
||||
/>
|
||||
<Select
|
||||
placeholder="All origins"
|
||||
data={yardOptions}
|
||||
value={originYardFilter}
|
||||
onChange={(v) => {
|
||||
setOriginYardFilter(v);
|
||||
resetPage();
|
||||
}}
|
||||
clearable
|
||||
searchable
|
||||
radius="lg"
|
||||
style={{ minWidth: 180 }}
|
||||
/>
|
||||
<Select
|
||||
placeholder="All destinations"
|
||||
data={yardOptions}
|
||||
value={destinationYardFilter}
|
||||
onChange={(v) => {
|
||||
setDestinationYardFilter(v);
|
||||
resetPage();
|
||||
}}
|
||||
clearable
|
||||
searchable
|
||||
radius="lg"
|
||||
style={{ minWidth: 180 }}
|
||||
/>
|
||||
<Select
|
||||
placeholder="All directions"
|
||||
@@ -434,11 +563,11 @@ export default function BookingRequestsPage() {
|
||||
value={directionFilter}
|
||||
onChange={(v) => {
|
||||
setDirectionFilter(v);
|
||||
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
|
||||
resetPage();
|
||||
}}
|
||||
clearable
|
||||
radius="lg"
|
||||
style={{ minWidth: 170 }}
|
||||
style={{ minWidth: 150 }}
|
||||
/>
|
||||
<Select
|
||||
placeholder="All freight types"
|
||||
@@ -446,13 +575,98 @@ export default function BookingRequestsPage() {
|
||||
value={freightTypeFilter}
|
||||
onChange={(v) => {
|
||||
setFreightTypeFilter(v);
|
||||
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
|
||||
resetPage();
|
||||
}}
|
||||
clearable
|
||||
radius="lg"
|
||||
style={{ minWidth: 170 }}
|
||||
style={{ minWidth: 150 }}
|
||||
/>
|
||||
</Group>
|
||||
<Group gap="sm" wrap="wrap">
|
||||
<Select
|
||||
placeholder="All payment statuses"
|
||||
data={PAYMENT_STATUS_OPTIONS}
|
||||
value={paymentStatusFilter}
|
||||
onChange={(v) => {
|
||||
setPaymentStatusFilter(v);
|
||||
resetPage();
|
||||
}}
|
||||
clearable
|
||||
radius="lg"
|
||||
style={{ minWidth: 180 }}
|
||||
/>
|
||||
<Select
|
||||
placeholder="Gov / Private"
|
||||
data={OWNERSHIP_OPTIONS}
|
||||
value={ownershipFilter}
|
||||
onChange={(v) => {
|
||||
setOwnershipFilter(v);
|
||||
resetPage();
|
||||
}}
|
||||
clearable
|
||||
radius="lg"
|
||||
style={{ minWidth: 140 }}
|
||||
/>
|
||||
<DateInput
|
||||
placeholder="Created from"
|
||||
value={createdFrom}
|
||||
onChange={(v) => {
|
||||
setCreatedFrom(v ? new Date(v) : null);
|
||||
resetPage();
|
||||
}}
|
||||
maxDate={createdTo ?? undefined}
|
||||
clearable
|
||||
radius="lg"
|
||||
style={{ minWidth: 140 }}
|
||||
/>
|
||||
<DateInput
|
||||
placeholder="Created to"
|
||||
value={createdTo}
|
||||
onChange={(v) => {
|
||||
setCreatedTo(v ? new Date(v) : null);
|
||||
resetPage();
|
||||
}}
|
||||
minDate={createdFrom ?? undefined}
|
||||
clearable
|
||||
radius="lg"
|
||||
style={{ minWidth: 140 }}
|
||||
/>
|
||||
<DateInput
|
||||
placeholder="Scheduled from"
|
||||
value={scheduledFrom}
|
||||
onChange={(v) => {
|
||||
setScheduledFrom(v ? new Date(v) : null);
|
||||
resetPage();
|
||||
}}
|
||||
maxDate={scheduledTo ?? undefined}
|
||||
clearable
|
||||
radius="lg"
|
||||
style={{ minWidth: 150 }}
|
||||
/>
|
||||
<DateInput
|
||||
placeholder="Scheduled to"
|
||||
value={scheduledTo}
|
||||
onChange={(v) => {
|
||||
setScheduledTo(v ? new Date(v) : null);
|
||||
resetPage();
|
||||
}}
|
||||
minDate={scheduledFrom ?? undefined}
|
||||
clearable
|
||||
radius="lg"
|
||||
style={{ minWidth: 150 }}
|
||||
/>
|
||||
{activeFilterCount > 0 ? (
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
radius="lg"
|
||||
leftSection={<FilterX size={16} />}
|
||||
onClick={clearFilters}
|
||||
>
|
||||
Clear filters ({activeFilterCount})
|
||||
</Button>
|
||||
) : null}
|
||||
</Group>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
|
||||
@@ -302,7 +302,7 @@ export default function ContractRequestDetailPage() {
|
||||
|
||||
const customerLabel = contract.isGovernment
|
||||
? (contract.governmentInstitution ?? "Government")
|
||||
: (contract.companyId ?? "—");
|
||||
: (contract.company?.name ?? "—");
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
|
||||
@@ -133,21 +133,21 @@ export default function ContractRequestsPage() {
|
||||
const columns: ColumnDef<ContractListRow>[] = [
|
||||
{
|
||||
id: "contract",
|
||||
header: () => <span className={bookingTable.headerCell}>Contract</span>,
|
||||
header: () => <span className={bookingTable.headerCell}>Customer</span>,
|
||||
cell: ({ row }) => {
|
||||
const c = row.original;
|
||||
return (
|
||||
<div className="flex items-center gap-3 py-1.5">
|
||||
<div className={bookingTable.rowIcon}>
|
||||
<FileText className="size-4" strokeWidth={1.75} />
|
||||
<User className="size-4" strokeWidth={1.75} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-medium text-foreground">
|
||||
{c.reference}
|
||||
{c.customerLabel}
|
||||
</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" />
|
||||
{c.customerLabel}
|
||||
<FileText className="size-3 shrink-0 opacity-70" />
|
||||
{c.reference}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
ActionIcon,
|
||||
Alert,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
@@ -16,10 +17,15 @@ import {
|
||||
Stack,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { DateInput } from "@mantine/dates";
|
||||
import { useDebouncedValue } from "@mantine/hooks";
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowDownWideNarrow,
|
||||
ArrowRight,
|
||||
ArrowUpNarrowWide,
|
||||
CalendarClock,
|
||||
CalendarDays,
|
||||
Eye,
|
||||
@@ -47,9 +53,31 @@ import {
|
||||
import { RouteCorridor } from "@/components/trainScheduling/scheduleVisuals";
|
||||
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
|
||||
import { FREIGHT_BRAND, FREIGHT_BRAND_DARK } from "@/theme/freight-brand";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
||||
import { api } from "@/services/api";
|
||||
import type { BatchBoardSchedule } from "@/types/trainScheduling";
|
||||
import type {
|
||||
BatchBoardFilters,
|
||||
BatchBoardSchedule,
|
||||
BatchBoardSortField,
|
||||
TrainScheduleStatus,
|
||||
} from "@/types/trainScheduling";
|
||||
|
||||
const STATUS_BADGE: Record<string, { color: string; label: string }> = {
|
||||
DRAFT: { color: "gray", label: "Draft" },
|
||||
SCHEDULED: { color: "blue", label: "Scheduled" },
|
||||
DISPATCHED: { color: "orange", label: "Dispatched" },
|
||||
ARRIVED: { color: "green", label: "Arrived" },
|
||||
CANCELLED: { color: "red", label: "Cancelled" },
|
||||
};
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
const meta = STATUS_BADGE[status] ?? { color: "gray", label: status };
|
||||
return (
|
||||
<Badge variant="light" color={meta.color} radius="sm">
|
||||
{meta.label}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
const fmtTons = (n: number) =>
|
||||
`${n.toLocaleString(undefined, { maximumFractionDigits: 1 })} t`;
|
||||
@@ -237,7 +265,8 @@ function ScheduleCard({ schedule }: { schedule: BatchBoardSchedule }) {
|
||||
c="dimmed"
|
||||
style={{ letterSpacing: 0.8 }}
|
||||
>
|
||||
Freight schedule · {schedule.status}
|
||||
{schedule.scheduleReference ?? "Freight schedule"} ·{" "}
|
||||
{schedule.status}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
@@ -395,15 +424,73 @@ function CardSkeleton() {
|
||||
|
||||
export default function BatchBoardPage() {
|
||||
const navigate = useNavigate();
|
||||
const { data, isLoading, isError, isFetching, refetch } = useQuery(
|
||||
api.trainScheduling.batchBoard.queryOptions({ refetchInterval: 30_000 }),
|
||||
);
|
||||
const { viewMode, setViewMode } = useFleetViewMode("batch-board");
|
||||
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
||||
const { pagination, setPagination } = usePagination({ pageSize: 12 });
|
||||
const [search, setSearch] = useState("");
|
||||
const [debouncedSearch] = useDebouncedValue(search, 300);
|
||||
const [statusFilter, setStatusFilter] = useState("ALL");
|
||||
const [windowFilter, setWindowFilter] = useState("ALL");
|
||||
const [sortBy, setSortBy] = useState<BatchBoardSortField>("createdAt");
|
||||
const [sortOrder, setSortOrder] = useState<"ASC" | "DESC">("DESC");
|
||||
const [departureFrom, setDepartureFrom] = useState<Date | null>(null);
|
||||
const [departureTo, setDepartureTo] = useState<Date | null>(null);
|
||||
|
||||
const schedules = data ?? [];
|
||||
// Every knob maps straight onto the server-side batch-board query — the API
|
||||
// filters, searches, sorts and paginates; this page just renders the page.
|
||||
const filters = useMemo((): BatchBoardFilters => {
|
||||
const endOfDay = (d: Date) =>
|
||||
new Date(d.getFullYear(), d.getMonth(), d.getDate(), 23, 59, 59, 999);
|
||||
return {
|
||||
page: pagination.pageIndex + 1,
|
||||
pageSize: pagination.pageSize,
|
||||
search: debouncedSearch.trim() || undefined,
|
||||
statuses:
|
||||
statusFilter === "ALL"
|
||||
? undefined
|
||||
: [statusFilter as TrainScheduleStatus],
|
||||
bookingWindowStatus:
|
||||
windowFilter === "ALL"
|
||||
? undefined
|
||||
: (windowFilter as "OPEN" | "FULL" | "CLOSED"),
|
||||
departureFrom: departureFrom ? departureFrom.toISOString() : undefined,
|
||||
departureTo: departureTo ? endOfDay(departureTo).toISOString() : undefined,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
};
|
||||
}, [
|
||||
pagination,
|
||||
debouncedSearch,
|
||||
statusFilter,
|
||||
windowFilter,
|
||||
departureFrom,
|
||||
departureTo,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
]);
|
||||
|
||||
// Any filter change restarts from the first page.
|
||||
useEffect(() => {
|
||||
setPagination((p) => ({ ...p, pageIndex: 0 }));
|
||||
}, [
|
||||
debouncedSearch,
|
||||
statusFilter,
|
||||
windowFilter,
|
||||
departureFrom,
|
||||
departureTo,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
setPagination,
|
||||
]);
|
||||
|
||||
const { data, isLoading, isError, isFetching, refetch } = useQuery({
|
||||
...api.trainScheduling.batchBoard.queryOptions({ input: { filters } }),
|
||||
refetchInterval: 30_000,
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
|
||||
const schedules = data?.items ?? [];
|
||||
const total = data?.total ?? 0;
|
||||
const pageCount = data?.totalPages ?? 1;
|
||||
|
||||
const summary = useMemo(() => {
|
||||
const openWindows = schedules.filter((s) => s.bookingWindowStatus === "OPEN").length;
|
||||
@@ -412,33 +499,6 @@ export default function BatchBoardPage() {
|
||||
return { openWindows, totalBookings, totalWagons };
|
||||
}, [schedules]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const query = search.trim().toLowerCase();
|
||||
return schedules.filter((s) => {
|
||||
if (windowFilter !== "ALL" && s.bookingWindowStatus !== windowFilter) return false;
|
||||
if (!query) return true;
|
||||
const haystack = [
|
||||
s.trainNumber,
|
||||
s.routeName,
|
||||
s.origin,
|
||||
s.destination,
|
||||
s.locomotive?.code,
|
||||
s.status,
|
||||
s.bookingWindowStatus,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
.toLowerCase();
|
||||
return haystack.includes(query);
|
||||
});
|
||||
}, [schedules, search, windowFilter]);
|
||||
|
||||
const pageCount = Math.max(1, Math.ceil(filtered.length / pagination.pageSize));
|
||||
const paged = useMemo(() => {
|
||||
const start = pagination.pageIndex * pagination.pageSize;
|
||||
return filtered.slice(start, start + pagination.pageSize);
|
||||
}, [filtered, pagination]);
|
||||
|
||||
const columns = useMemo((): ColumnDef<BatchBoardSchedule>[] => {
|
||||
const headerClassName = ruleEngineTable.headerCell;
|
||||
const cellClassName = ruleEngineTable.bodyCell;
|
||||
@@ -456,6 +516,11 @@ export default function BatchBoardPage() {
|
||||
<Text size="sm" fw={700} lh={1.2} truncate>
|
||||
{row.original.trainNumber ?? row.original.routeName ?? "Schedule"}
|
||||
</Text>
|
||||
{row.original.scheduleReference ? (
|
||||
<Text size="10px" fw={600} c="dimmed" lh={1.2}>
|
||||
{row.original.scheduleReference}
|
||||
</Text>
|
||||
) : null}
|
||||
<Box maw={220}>
|
||||
<RouteCorridor
|
||||
origin={row.original.origin}
|
||||
@@ -502,6 +567,30 @@ export default function BatchBoardPage() {
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
header: "Status",
|
||||
meta: { headerClassName, cellClassName },
|
||||
cell: ({ row }) => <StatusBadge status={row.original.status} />,
|
||||
},
|
||||
{
|
||||
id: "created",
|
||||
header: "Created",
|
||||
meta: { headerClassName, cellClassName },
|
||||
cell: ({ row }) => {
|
||||
const { day, time } = splitDate(row.original.createdAt);
|
||||
return (
|
||||
<Stack gap={0}>
|
||||
<Text size="sm" fw={600} lh={1.2}>
|
||||
{day}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" lh={1.2}>
|
||||
{time || "—"}
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "window",
|
||||
header: "Window",
|
||||
@@ -623,7 +712,7 @@ export default function BatchBoardPage() {
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
title="Batch Board"
|
||||
subtitle="Active schedules and their booking windows."
|
||||
subtitle="All import schedules — live and historical — with their booking windows."
|
||||
action={
|
||||
<Button variant="default" loading={isFetching} onClick={() => void refetch()}>
|
||||
Refresh
|
||||
@@ -635,21 +724,21 @@ export default function BatchBoardPage() {
|
||||
loading={isLoading}
|
||||
items={[
|
||||
{
|
||||
label: "Active schedules",
|
||||
value: schedules.length,
|
||||
hint: "on the board right now",
|
||||
label: "Schedules",
|
||||
value: total,
|
||||
hint: "matching the current filters",
|
||||
icon: Train,
|
||||
},
|
||||
{
|
||||
label: "Open windows",
|
||||
value: summary.openWindows,
|
||||
hint: "accepting bookings",
|
||||
hint: "accepting bookings (this page)",
|
||||
icon: CalendarDays,
|
||||
},
|
||||
{
|
||||
label: "Bookings in play",
|
||||
value: summary.totalBookings,
|
||||
hint: `${summary.totalWagons} wagons allocated`,
|
||||
hint: `${summary.totalWagons} wagons allocated (this page)`,
|
||||
icon: Package,
|
||||
},
|
||||
]}
|
||||
@@ -661,24 +750,96 @@ export default function BatchBoardPage() {
|
||||
<FleetToolbar
|
||||
search={search}
|
||||
onSearchChange={setSearch}
|
||||
searchPlaceholder="Search schedules…"
|
||||
searchPlaceholder="Search train, route, station, loco…"
|
||||
viewMode={viewMode}
|
||||
onViewModeChange={setViewMode}
|
||||
filters={
|
||||
<Select
|
||||
size="sm"
|
||||
radius="lg"
|
||||
value={windowFilter}
|
||||
onChange={(v) => v && setWindowFilter(v)}
|
||||
data={[
|
||||
{ value: "ALL", label: "All windows" },
|
||||
{ value: "OPEN", label: "Open" },
|
||||
{ value: "FULL", label: "Full" },
|
||||
{ value: "CLOSED", label: "Closed" },
|
||||
]}
|
||||
w={150}
|
||||
styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }}
|
||||
/>
|
||||
<Group gap="xs" wrap="wrap">
|
||||
<Select
|
||||
size="sm"
|
||||
radius="lg"
|
||||
value={statusFilter}
|
||||
onChange={(v) => v && setStatusFilter(v)}
|
||||
data={[
|
||||
{ value: "ALL", label: "All statuses" },
|
||||
{ value: "DRAFT", label: "Draft" },
|
||||
{ value: "SCHEDULED", label: "Scheduled" },
|
||||
{ value: "DISPATCHED", label: "Dispatched" },
|
||||
{ value: "ARRIVED", label: "Arrived" },
|
||||
{ value: "CANCELLED", label: "Cancelled" },
|
||||
]}
|
||||
w={150}
|
||||
styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }}
|
||||
/>
|
||||
<Select
|
||||
size="sm"
|
||||
radius="lg"
|
||||
value={windowFilter}
|
||||
onChange={(v) => v && setWindowFilter(v)}
|
||||
data={[
|
||||
{ value: "ALL", label: "All windows" },
|
||||
{ value: "OPEN", label: "Open" },
|
||||
{ value: "FULL", label: "Full" },
|
||||
{ value: "CLOSED", label: "Closed" },
|
||||
]}
|
||||
w={140}
|
||||
styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }}
|
||||
/>
|
||||
<DateInput
|
||||
size="sm"
|
||||
radius="lg"
|
||||
placeholder="Departs from"
|
||||
value={departureFrom}
|
||||
onChange={(v) => setDepartureFrom(v ? new Date(v) : null)}
|
||||
clearable
|
||||
w={140}
|
||||
styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }}
|
||||
/>
|
||||
<DateInput
|
||||
size="sm"
|
||||
radius="lg"
|
||||
placeholder="Departs to"
|
||||
value={departureTo}
|
||||
onChange={(v) => setDepartureTo(v ? new Date(v) : null)}
|
||||
clearable
|
||||
w={140}
|
||||
styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }}
|
||||
/>
|
||||
<Select
|
||||
size="sm"
|
||||
radius="lg"
|
||||
value={sortBy}
|
||||
onChange={(v) => v && setSortBy(v as BatchBoardSortField)}
|
||||
data={[
|
||||
{ value: "createdAt", label: "Sort: Created" },
|
||||
{ value: "scheduledDepartureDate", label: "Sort: Departure" },
|
||||
{ value: "trainNumber", label: "Sort: Train no." },
|
||||
{ value: "status", label: "Sort: Status" },
|
||||
]}
|
||||
w={160}
|
||||
styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }}
|
||||
/>
|
||||
<Tooltip
|
||||
label={sortOrder === "DESC" ? "Newest / Z–A first" : "Oldest / A–Z first"}
|
||||
withArrow
|
||||
>
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
size={36}
|
||||
radius="lg"
|
||||
aria-label="Toggle sort direction"
|
||||
onClick={() =>
|
||||
setSortOrder((o) => (o === "DESC" ? "ASC" : "DESC"))
|
||||
}
|
||||
>
|
||||
{sortOrder === "DESC" ? (
|
||||
<ArrowDownWideNarrow size={16} />
|
||||
) : (
|
||||
<ArrowUpNarrowWide size={16} />
|
||||
)}
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
@@ -686,7 +847,7 @@ export default function BatchBoardPage() {
|
||||
{viewMode === "table" ? (
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={paged}
|
||||
data={schedules}
|
||||
status={tableStatus}
|
||||
onRowClick={(schedule) =>
|
||||
navigate(`/dashboard/operations/batch-board/${schedule.scheduleId}`)
|
||||
@@ -699,12 +860,12 @@ export default function BatchBoardPage() {
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
emptyMessage="No active schedules"
|
||||
emptyMessage="No schedules match the current filters"
|
||||
pagination={{
|
||||
pageIndex: pagination.pageIndex,
|
||||
pageSize: pagination.pageSize,
|
||||
pageCount,
|
||||
totalCount: filtered.length,
|
||||
totalCount: total,
|
||||
}}
|
||||
tableOptions={{
|
||||
manualPagination: true,
|
||||
@@ -727,7 +888,7 @@ export default function BatchBoardPage() {
|
||||
<CardSkeleton />
|
||||
<CardSkeleton />
|
||||
</SimpleGrid>
|
||||
) : filtered.length === 0 ? (
|
||||
) : schedules.length === 0 ? (
|
||||
<Paper radius="lg" p={48} m="md" bg="gray.0">
|
||||
<Stack align="center" gap="sm">
|
||||
<Box
|
||||
@@ -745,20 +906,52 @@ export default function BatchBoardPage() {
|
||||
<Inbox size={28} color="var(--mantine-color-gray-5)" />
|
||||
</Box>
|
||||
<Text fw={700} c="gray.7">
|
||||
No active schedules
|
||||
No schedules match the current filters
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed" ta="center" maw={380}>
|
||||
Schedules with an open booking window appear here. Create or activate a
|
||||
schedule to get started.
|
||||
Every import schedule — live and historical — appears here. Loosen the
|
||||
filters or clear the search to see more.
|
||||
</Text>
|
||||
</Stack>
|
||||
</Paper>
|
||||
) : (
|
||||
<SimpleGrid cols={{ base: 1, md: 2, lg: 3, xl: 4 }} spacing="lg" p="md">
|
||||
{filtered.map((s) => (
|
||||
<ScheduleCard key={s.scheduleId} schedule={s} />
|
||||
))}
|
||||
</SimpleGrid>
|
||||
<>
|
||||
<SimpleGrid cols={{ base: 1, md: 2, lg: 3, xl: 4 }} spacing="lg" p="md">
|
||||
{schedules.map((s) => (
|
||||
<ScheduleCard key={s.scheduleId} schedule={s} />
|
||||
))}
|
||||
</SimpleGrid>
|
||||
<Group justify="space-between" px="md" pb="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
{total} schedule{total === 1 ? "" : "s"}
|
||||
</Text>
|
||||
<Group gap="xs">
|
||||
<Button
|
||||
variant="default"
|
||||
size="xs"
|
||||
disabled={pagination.pageIndex === 0}
|
||||
onClick={() =>
|
||||
setPagination((p) => ({ ...p, pageIndex: p.pageIndex - 1 }))
|
||||
}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
<Text size="sm" c="dimmed">
|
||||
Page {pagination.pageIndex + 1} of {pageCount}
|
||||
</Text>
|
||||
<Button
|
||||
variant="default"
|
||||
size="xs"
|
||||
disabled={pagination.pageIndex + 1 >= pageCount}
|
||||
onClick={() =>
|
||||
setPagination((p) => ({ ...p, pageIndex: p.pageIndex + 1 }))
|
||||
}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
ClipboardCheck,
|
||||
Clock,
|
||||
FileSignature,
|
||||
Hash,
|
||||
Hourglass,
|
||||
Layers,
|
||||
Package,
|
||||
@@ -621,6 +622,9 @@ export default function BatchScheduleDetailPage() {
|
||||
api.trainScheduling.scheduleDetail.queryOptions({
|
||||
input: { id: scheduleId ?? "", freightType: "CONTAINER" },
|
||||
enabled: Boolean(scheduleId),
|
||||
// Composition data only changes through mutations, which invalidate the
|
||||
// whole train-scheduling root — no need to refetch on remounts in between.
|
||||
staleTime: 5 * 60_000,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -744,7 +748,13 @@ export default function BatchScheduleDetailPage() {
|
||||
items={[
|
||||
{ label: "Operations" },
|
||||
{ label: "Batch board", href: "/dashboard/operations/batch-board" },
|
||||
{ label: data.trainNumber ?? data.routeName ?? "Schedule" },
|
||||
{
|
||||
label:
|
||||
data.scheduleReference ??
|
||||
data.trainNumber ??
|
||||
data.routeName ??
|
||||
"Schedule",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -791,6 +801,11 @@ export default function BatchScheduleDetailPage() {
|
||||
<Title order={2} fw={800}>
|
||||
{data.trainNumber ?? data.routeName ?? "Schedule"}
|
||||
</Title>
|
||||
{data.scheduleReference ? (
|
||||
<HeroChip icon={<Hash size={12} />}>
|
||||
{data.scheduleReference}
|
||||
</HeroChip>
|
||||
) : null}
|
||||
<WindowStatusPill status={data.bookingWindowStatus} />
|
||||
{data.windowPhase ? (
|
||||
<WindowPhasePill
|
||||
|
||||
@@ -42,7 +42,8 @@ import {
|
||||
} from "@/types/rule-engine";
|
||||
import type {
|
||||
AssignBookingsPayload,
|
||||
BatchBoardSchedule,
|
||||
BatchBoardFilters,
|
||||
BatchBoardListResponse,
|
||||
BatchBoardScheduleDetail,
|
||||
BookableSchedule,
|
||||
BookingWindow,
|
||||
@@ -221,11 +222,14 @@ export const api = {
|
||||
() => QUERY_KEYS.TRAIN_SCHEDULING.schedules(),
|
||||
),
|
||||
|
||||
batchBoard: endpoint<void, BatchBoardSchedule[]>(
|
||||
batchBoard: endpoint<
|
||||
{ filters?: BatchBoardFilters },
|
||||
BatchBoardListResponse
|
||||
>(
|
||||
"train-scheduling",
|
||||
"batch-board",
|
||||
() => trainSchedulingService.getBatchBoard(),
|
||||
() => QUERY_KEYS.TRAIN_SCHEDULING.batchBoard(),
|
||||
({ filters }) => trainSchedulingService.getBatchBoard(filters),
|
||||
({ filters }) => QUERY_KEYS.TRAIN_SCHEDULING.batchBoard(filters),
|
||||
),
|
||||
|
||||
allBookingWindows: endpoint<void, StaffBookingWindow[]>(
|
||||
|
||||
@@ -21,6 +21,19 @@ export interface BookingListFilter {
|
||||
bookingType?: string;
|
||||
tradeDirection?: string;
|
||||
paymentCurrency?: string;
|
||||
paymentStatus?: string;
|
||||
/** ISO date-time — bookings created on/after. */
|
||||
createdFrom?: string;
|
||||
/** ISO date-time — bookings created on/before (pass end-of-day for inclusive). */
|
||||
createdTo?: string;
|
||||
/** ISO date-time — bookings scheduled on/after. */
|
||||
scheduledFrom?: string;
|
||||
/** ISO date-time — bookings scheduled on/before (pass end-of-day for inclusive). */
|
||||
scheduledTo?: string;
|
||||
originYardId?: string;
|
||||
destinationYardId?: string;
|
||||
/** "true" = government bookings only, "false" = private only. */
|
||||
isGovernment?: "true" | "false";
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
sortBy?: string;
|
||||
@@ -130,6 +143,14 @@ export const bookingsService = {
|
||||
if (filter.bookingType) params.bookingType = filter.bookingType;
|
||||
if (filter.tradeDirection) params.tradeDirection = filter.tradeDirection;
|
||||
if (filter.paymentCurrency) params.paymentCurrency = filter.paymentCurrency;
|
||||
if (filter.paymentStatus) params.paymentStatus = filter.paymentStatus;
|
||||
if (filter.createdFrom) params.createdFrom = filter.createdFrom;
|
||||
if (filter.createdTo) params.createdTo = filter.createdTo;
|
||||
if (filter.scheduledFrom) params.scheduledFrom = filter.scheduledFrom;
|
||||
if (filter.scheduledTo) params.scheduledTo = filter.scheduledTo;
|
||||
if (filter.originYardId) params.originYardId = filter.originYardId;
|
||||
if (filter.destinationYardId) params.destinationYardId = filter.destinationYardId;
|
||||
if (filter.isGovernment) params.isGovernment = filter.isGovernment;
|
||||
}
|
||||
const response = await client.get<BookingListSummary>(B.LIST_SUMMARY, {
|
||||
params,
|
||||
@@ -154,6 +175,14 @@ export const bookingsService = {
|
||||
if (filter.bookingType) params.bookingType = filter.bookingType;
|
||||
if (filter.tradeDirection) params.tradeDirection = filter.tradeDirection;
|
||||
if (filter.paymentCurrency) params.paymentCurrency = filter.paymentCurrency;
|
||||
if (filter.paymentStatus) params.paymentStatus = filter.paymentStatus;
|
||||
if (filter.createdFrom) params.createdFrom = filter.createdFrom;
|
||||
if (filter.createdTo) params.createdTo = filter.createdTo;
|
||||
if (filter.scheduledFrom) params.scheduledFrom = filter.scheduledFrom;
|
||||
if (filter.scheduledTo) params.scheduledTo = filter.scheduledTo;
|
||||
if (filter.originYardId) params.originYardId = filter.originYardId;
|
||||
if (filter.destinationYardId) params.destinationYardId = filter.destinationYardId;
|
||||
if (filter.isGovernment) params.isGovernment = filter.isGovernment;
|
||||
}
|
||||
const response = await client.get<PaginatedBookings>(B.BASE, {
|
||||
params,
|
||||
|
||||
@@ -3,7 +3,8 @@ import { api as client } from "../auth/http";
|
||||
import { unwrap } from "@/utils/endpoint";
|
||||
import { URL_CONSTANTS } from "@/constants/URLS";
|
||||
import type {
|
||||
BatchBoardSchedule,
|
||||
BatchBoardFilters,
|
||||
BatchBoardListResponse,
|
||||
BatchBoardScheduleDetail,
|
||||
BookableSchedule,
|
||||
BookingWindow,
|
||||
@@ -100,9 +101,25 @@ export const trainSchedulingService = {
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
getBatchBoard: async (): Promise<BatchBoardSchedule[]> => {
|
||||
const response = await client.get<BatchBoardSchedule[]>(
|
||||
getBatchBoard: async (
|
||||
filters: BatchBoardFilters = {},
|
||||
): Promise<BatchBoardListResponse> => {
|
||||
const params: Record<string, string | number> = {};
|
||||
if (filters.page) params.page = filters.page;
|
||||
if (filters.pageSize) params.pageSize = filters.pageSize;
|
||||
if (filters.statuses?.length) params.statuses = filters.statuses.join(",");
|
||||
if (filters.bookingWindowStatus)
|
||||
params.bookingWindowStatus = filters.bookingWindowStatus;
|
||||
if (filters.search?.trim()) params.search = filters.search.trim();
|
||||
if (filters.departureFrom) params.departureFrom = filters.departureFrom;
|
||||
if (filters.departureTo) params.departureTo = filters.departureTo;
|
||||
if (filters.createdFrom) params.createdFrom = filters.createdFrom;
|
||||
if (filters.createdTo) params.createdTo = filters.createdTo;
|
||||
if (filters.sortBy) params.sortBy = filters.sortBy;
|
||||
if (filters.sortOrder) params.sortOrder = filters.sortOrder;
|
||||
const response = await client.get<BatchBoardListResponse>(
|
||||
URL_CONSTANTS.TRAIN_SCHEDULING.BATCH_BOARD,
|
||||
{ params },
|
||||
);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
@@ -257,11 +257,14 @@ export interface StaffBookingWindow {
|
||||
|
||||
export interface BatchBoardSchedule {
|
||||
scheduleId: string;
|
||||
/** Human-facing schedule reference (S-YYYY-NNNNN). */
|
||||
scheduleReference: string | null;
|
||||
trainNumber: string | null;
|
||||
routeName: string | null;
|
||||
origin: string | null;
|
||||
destination: string | null;
|
||||
scheduleDate: string | null;
|
||||
createdAt: string | null;
|
||||
status: string;
|
||||
bookingWindowStatus: string;
|
||||
direction: string | null;
|
||||
@@ -300,6 +303,37 @@ export interface BatchBoardSchedule {
|
||||
bookings: BatchBoardBooking[];
|
||||
}
|
||||
|
||||
export type BatchBoardSortField =
|
||||
| "createdAt"
|
||||
| "scheduledDepartureDate"
|
||||
| "trainNumber"
|
||||
| "status";
|
||||
|
||||
/** Server-side filters for the paginated batch board list. */
|
||||
export interface BatchBoardFilters {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
/** Subset of schedule statuses; omit for all (incl. arrived/cancelled). */
|
||||
statuses?: TrainScheduleStatus[];
|
||||
bookingWindowStatus?: "OPEN" | "FULL" | "CLOSED";
|
||||
/** Matches train number, route yards, stations, locomotive code. */
|
||||
search?: string;
|
||||
departureFrom?: string;
|
||||
departureTo?: string;
|
||||
createdFrom?: string;
|
||||
createdTo?: string;
|
||||
sortBy?: BatchBoardSortField;
|
||||
sortOrder?: "ASC" | "DESC";
|
||||
}
|
||||
|
||||
export interface BatchBoardListResponse {
|
||||
items: BatchBoardSchedule[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
export type BookingAllocationStatus =
|
||||
| "NOT_ATTEMPTED"
|
||||
| "ASSIGNED"
|
||||
@@ -337,6 +371,8 @@ export interface BatchWindowGroup {
|
||||
|
||||
export interface BatchBoardScheduleDetail {
|
||||
scheduleId: string;
|
||||
/** Human-facing schedule reference (S-YYYY-NNNNN). */
|
||||
scheduleReference: string | null;
|
||||
trainNumber: string | null;
|
||||
routeName: string | null;
|
||||
origin: string | null;
|
||||
@@ -506,6 +542,8 @@ export interface TrainScheduleDetail {
|
||||
capacityTons: number;
|
||||
lengthMeters: number;
|
||||
assignedWeightTons: number;
|
||||
/** Empty-wagon weight from the wagon type — gross = tare + cargo. */
|
||||
tareWeightTons?: number | null;
|
||||
status?: string;
|
||||
physicalWagonId?: string | null;
|
||||
physicalWagonNumber?: string | null;
|
||||
|
||||
@@ -83,7 +83,7 @@ export function ContractClearanceAction({
|
||||
centered
|
||||
overlayProps={{ blur: 2, backgroundOpacity: 0.55 }}
|
||||
>
|
||||
<ContractClearancePanel contractId={contractId} bare />
|
||||
<ContractClearancePanel contractId={contractId} bare onSubmitted={close} />
|
||||
</Modal>
|
||||
</ModalSafeWrapper>
|
||||
);
|
||||
|
||||
@@ -142,7 +142,7 @@ function ProgressTracker({
|
||||
const activeRing = tone === "ink" ? "#D9E0E7" : "#BFE8D4";
|
||||
|
||||
return (
|
||||
/* Scrollable on mobile so 5 stages never overflow */
|
||||
/* Scrollable on mobile so the stages never overflow */
|
||||
<Box
|
||||
className="overflow-x-auto pt-2"
|
||||
style={
|
||||
@@ -152,7 +152,7 @@ function ProgressTracker({
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<div className="flex items-start" style={{ minWidth: 520 }}>
|
||||
<div className="flex items-start" style={{ minWidth: 640 }}>
|
||||
{PROGRESS_STAGES.map((stage, idx) => {
|
||||
const state =
|
||||
idx < current ? "done" : idx === current ? "active" : "idle";
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
FileText,
|
||||
MapPin,
|
||||
PackageCheck,
|
||||
PackageOpen,
|
||||
ShieldCheck,
|
||||
Ship,
|
||||
Train,
|
||||
@@ -54,12 +55,22 @@ export const PROGRESS_STAGES = [
|
||||
statuses: ["EXPIRED", "IN_TRANSIT"],
|
||||
},
|
||||
{
|
||||
// ARRIVED: cargo unloaded at the booking's own destination yard (segment
|
||||
// corridor journeys). Legacy bookings stay IN_TRANSIT until delivery, so
|
||||
// this stage also lights up from the assigned train's own status
|
||||
// (trainScheduleStatus === "ARRIVED") — see resolveStage.
|
||||
// The train reached the booking's destination yard — cargo may still be
|
||||
// on board. No booking status of its own: it lights up from the assigned
|
||||
// train's status (trainScheduleStatus === "ARRIVED") while the booking is
|
||||
// still IN_TRANSIT — see resolveStage. Bookings unloaded mid-corridor (or
|
||||
// auto-unloaded on a checkpoint) jump straight past it to Unloading.
|
||||
label: "Arrival",
|
||||
icon: MapPin,
|
||||
statuses: [],
|
||||
},
|
||||
{
|
||||
// ARRIVED: cargo unloaded off the train at the booking's own destination
|
||||
// yard. Unloading is automatic — the API alights the booking the moment a
|
||||
// checkpoint is recorded at its destination yard (or, as a fallback, when
|
||||
// the train's final arrival is logged).
|
||||
label: "Unloading",
|
||||
icon: PackageOpen,
|
||||
statuses: ["ARRIVED"],
|
||||
},
|
||||
{
|
||||
@@ -69,17 +80,17 @@ export const PROGRESS_STAGES = [
|
||||
},
|
||||
];
|
||||
|
||||
/** Stage index of the Arrival step (train ARRIVED, cargo not yet delivered). */
|
||||
/** Stage index of the Arrival step (train ARRIVED, cargo not yet unloaded). */
|
||||
export const ARRIVAL_STAGE = PROGRESS_STAGES.findIndex(
|
||||
(s) => s.label === "Arrival",
|
||||
);
|
||||
|
||||
/**
|
||||
* Stage for a booking, factoring in the assigned train's operational status:
|
||||
* a booking with per-booking journey data reaches ARRIVED when it is unloaded
|
||||
* at its own destination yard; a legacy booking is stuck at IN_TRANSIT between
|
||||
* dispatch and delivery, so once its train has ARRIVED the tracker advances to
|
||||
* the Arrival stage.
|
||||
* a booking with per-booking journey data reaches ARRIVED (the Unloading
|
||||
* stage) when it is unloaded at its own destination yard; a legacy booking is
|
||||
* stuck at IN_TRANSIT between dispatch and delivery, so once its train has
|
||||
* ARRIVED the tracker advances to the Arrival stage.
|
||||
*/
|
||||
export function resolveStage(booking: {
|
||||
status: string;
|
||||
@@ -181,10 +192,10 @@ export const STATUS_MAP: Record<
|
||||
stage: 6,
|
||||
},
|
||||
ARRIVED: {
|
||||
title: "Arrived at destination",
|
||||
title: "Unloaded at destination",
|
||||
description:
|
||||
"Your cargo has been unloaded at its destination yard and is being prepared for release.",
|
||||
stage: 7,
|
||||
stage: 8,
|
||||
},
|
||||
OPERATION_REQUEST_PENDING: {
|
||||
title: "Operation request under review",
|
||||
@@ -251,7 +262,7 @@ export const STATUS_MAP: Record<
|
||||
title: "Contract closed",
|
||||
description:
|
||||
"This general contract is closed — its reserved quantity has been used or its window has elapsed.",
|
||||
stage: 8,
|
||||
stage: 9,
|
||||
},
|
||||
PRICE_CHANGED_PENDING_CONFIRM: {
|
||||
title: "Price changed — confirm to proceed",
|
||||
@@ -277,12 +288,12 @@ export const STATUS_MAP: Record<
|
||||
COMPLETED: {
|
||||
title: "Service complete",
|
||||
description: "Cargo delivered and service successfully terminated.",
|
||||
stage: 8,
|
||||
stage: 9,
|
||||
},
|
||||
DELIVERED: {
|
||||
title: "Service complete",
|
||||
description: "Cargo delivered and service successfully terminated.",
|
||||
stage: 8,
|
||||
stage: 9,
|
||||
},
|
||||
REJECTED: {
|
||||
title: "Booking rejected",
|
||||
|
||||
@@ -42,7 +42,7 @@ function BookingActionModalBody({
|
||||
const flow = useClearanceFlow(booking);
|
||||
const reference = booking.reference;
|
||||
|
||||
const handleSubmit = () => flow.submitDocuments();
|
||||
const handleSubmit = () => flow.submitDocuments({ onSuccess: onClose });
|
||||
const handleProceed = () => flow.proceedToOperation({ onSuccess: onClose });
|
||||
|
||||
return (
|
||||
|
||||
@@ -39,6 +39,8 @@ export interface ContractClearancePanelProps {
|
||||
tradeDirection?: string;
|
||||
/** Show the loading state without the surrounding Paper (e.g. inside a modal). */
|
||||
bare?: boolean;
|
||||
/** Called after a successful document submission (e.g. to close the host modal). */
|
||||
onSubmitted?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -52,6 +54,7 @@ export function ContractClearancePanel({
|
||||
contractId,
|
||||
tradeDirection = "IMPORT",
|
||||
bare,
|
||||
onSubmitted,
|
||||
}: ContractClearancePanelProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const [pending, setPending] = useState<Record<string, File>>({});
|
||||
@@ -77,6 +80,13 @@ export function ContractClearancePanel({
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: api.contracts.get.queryKey({ id: contractId }),
|
||||
});
|
||||
// Submitting moves the contract to CLEARANCE_UNDER_REVIEW — refresh every
|
||||
// contracts-list query (prefix key) so the /contracts table row and its
|
||||
// action button update without a page reload.
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: api.contracts.list.queryKey(),
|
||||
});
|
||||
onSubmitted?.();
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -519,6 +519,8 @@ export interface IContract extends BaseEntity {
|
||||
|
||||
companyId?: string | null;
|
||||
companyProfileId?: string | null;
|
||||
/** Loaded company relation (list + detail responses include it). */
|
||||
company?: BookingRequestCompany | null;
|
||||
isGovernment: boolean;
|
||||
governmentInstitution?: string | null;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user