Enhance overview and train scheduling features

- Updated OverviewContractsTabPanel to include a new donut chart for freight type distribution.
- Modified OverviewOperationsTabPanel to improve data visualization with additional charts and refactored data handling.
- Introduced CreateScheduleWindowFields component for configuring booking windows in train scheduling.
- Added new API endpoints for allocation candidates and booking allocation in trainScheduling.service.
- Enhanced BookingRequestsPage to support allocation of paid bookings with a modal for selecting alternative dates.
- Updated QUERY_KEYS and URLS constants to accommodate new operations and features.
- Improved type definitions for overview and train scheduling to support new functionalities.
This commit is contained in:
Marshal
2026-08-03 21:06:59 +00:00
parent 488c2465be
commit e68bdb7a1a
30 changed files with 1580 additions and 82 deletions

View File

@@ -0,0 +1,28 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Marks a train schedule whose booking-window rule was configured by staff at
* creation rather than inherited from the live global rules.
*
* Without this flag `restampPendingWindows` — which re-derives EVERY still
* PRE_WINDOW schedule from the current global config after a global-rules edit —
* would silently overwrite those hand-picked settings, which is precisely what
* the per-schedule configuration exists to prevent.
*
* Defaults false, so every existing schedule keeps following the global rules.
*/
export class AddScheduleWindowRuleCustom3200000000000 implements MigrationInterface {
name = 'AddScheduleWindowRuleCustom3200000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "freight"."train_schedules" ADD COLUMN IF NOT EXISTS "window_rule_custom" boolean NOT NULL DEFAULT false`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "freight"."train_schedules" DROP COLUMN IF EXISTS "window_rule_custom"`,
);
}
}

View File

@@ -21,6 +21,8 @@ export class OverviewOperationsKpisDto {
@ApiProperty() wagonsAvailable!: number;
@ApiProperty() containersInTransit!: number;
@ApiProperty() cargoesLoaded!: number;
@ApiProperty() schedulesUpcoming!: number;
@ApiProperty() dispatchedToday!: number;
}
export class OverviewCustomerKpisDto {

View File

@@ -104,10 +104,40 @@ export class OverviewBillingTabDto {
generatedAt!: string;
}
export class OverviewDirectionTrendPointDto {
@ApiProperty({ example: '2026-08-01' }) date!: string;
@ApiProperty() importCount!: number;
@ApiProperty() exportCount!: number;
@ApiProperty() domesticCount!: number;
}
export class OverviewTonnagePointDto {
@ApiProperty() label!: string;
@ApiProperty() tons!: number;
}
export class OverviewOperationsTabDto {
@ApiProperty({ type: OverviewOperationsKpisDto })
kpis!: OverviewOperationsKpisDto;
@ApiProperty({ type: [OverviewDirectionTrendPointDto] })
departureTrend!: OverviewDirectionTrendPointDto[];
@ApiProperty({ type: [OverviewStatusCountDto] })
scheduleStatusBreakdown!: OverviewStatusCountDto[];
@ApiProperty({ type: [OverviewLabelCountDto] })
wagonsByType!: OverviewLabelCountDto[];
@ApiProperty({ type: [OverviewLabelCountDto] })
wagonsByYard!: OverviewLabelCountDto[];
@ApiProperty({ type: [OverviewLabelCountDto] })
containersBySize!: OverviewLabelCountDto[];
@ApiProperty({ type: [OverviewTonnagePointDto] })
cargoTonnageByType!: OverviewTonnagePointDto[];
@ApiProperty({ type: [OverviewStatusCountDto] })
trainStatusBreakdown!: OverviewStatusCountDto[];

View File

@@ -99,8 +99,10 @@ export class OverviewController {
@BookingView()
@ApiOperation({ summary: 'Operations tab metrics and charts' })
@ApiOkResponse({ type: OverviewOperationsTabDto })
getOperationsTab(): Promise<OverviewOperationsTabDto> {
return this.overviewService.getOperationsTab();
getOperationsTab(
@Query() query: OverviewQueryDto,
): Promise<OverviewOperationsTabDto> {
return this.overviewService.getOperationsTab(query.range ?? '30d');
}
@Get('customers')

View File

@@ -9,6 +9,7 @@ import { Container } from "../container-management/entities/container.entity";
import { Company } from "../companies/entities/company.entity";
import { Contract } from "../contracts/entities/contract.entity";
import { PaymentEntity } from "../payment/entities/payment.entity";
import { TrainSchedule } from "../train-schedules/entities/train-schedule.entity";
import { Train } from "../trains/entities/train.entity";
import { Wagon } from "../wagons/entities/wagon.entity";
import { UserTradeAccessModule } from "../user-trade-access/user-trade-access.module";
@@ -23,6 +24,7 @@ import { OverviewService } from "./overview.service";
PaymentEntity,
Company,
Train,
TrainSchedule,
Wagon,
Container,
Cargo,

View File

@@ -11,7 +11,12 @@ import { Cargo } from "../cargoes/entities/cargoes.entity";
import { Container } from "../container-management/entities/container.entity";
import { Contract } from "../contracts/entities/contract.entity";
import { PaymentEntity } from "../payment/entities/payment.entity";
import { CargoType } from "../rule-engine/entities/cargo-type.entity";
import { ContainerType } from "../rule-engine/entities/container-type.entity";
import { Yard } from "../rule-engine/entities/yard.entity";
import { TrainSchedule } from "../train-schedules/entities/train-schedule.entity";
import { Train } from "../trains/entities/train.entity";
import { WagonType } from "../wagon-types/entities/wagon-type.entity";
import { Wagon } from "../wagons/entities/wagon.entity";
import {
OVERVIEW_CLOSED_STATUSES,
@@ -83,6 +88,8 @@ export class OverviewRepository {
private readonly companyRepository: Repository<Company>,
@InjectRepository(Train)
private readonly trainRepository: Repository<Train>,
@InjectRepository(TrainSchedule)
private readonly trainScheduleRepository: Repository<TrainSchedule>,
@InjectRepository(Wagon)
private readonly wagonRepository: Repository<Wagon>,
@InjectRepository(Container)
@@ -146,9 +153,17 @@ export class OverviewRepository {
wagonsAvailable: number;
containersInTransit: number;
cargoesLoaded: number;
schedulesUpcoming: number;
dispatchedToday: number;
}> {
const [trainsActive, wagonsAvailable, containersInTransit, cargoesLoaded] =
await Promise.all([
const [
trainsActive,
wagonsAvailable,
containersInTransit,
cargoesLoaded,
schedulesUpcoming,
dispatchedToday,
] = await Promise.all([
this.trainRepository
.createQueryBuilder("train")
.where("train.deleted_at IS NULL")
@@ -178,6 +193,22 @@ export class OverviewRepository {
statuses: ["LOADED", "IN_TRANSIT"],
})
.getCount(),
this.trainScheduleRepository
.createQueryBuilder("schedule")
.where("schedule.deleted_at IS NULL")
.andWhere("schedule.status = :status", {
status: Freight.TrainScheduleStatus.Scheduled,
})
.andWhere("schedule.scheduled_departure_date >= CURRENT_DATE")
.getCount(),
this.trainScheduleRepository
.createQueryBuilder("schedule")
.where("schedule.deleted_at IS NULL")
.andWhere("schedule.status = :status", {
status: Freight.TrainScheduleStatus.Dispatched,
})
.andWhere("schedule.scheduled_departure_date::date = CURRENT_DATE")
.getCount(),
]);
return {
@@ -185,6 +216,8 @@ export class OverviewRepository {
wagonsAvailable,
containersInTransit,
cargoesLoaded,
schedulesUpcoming,
dispatchedToday,
};
}
@@ -533,6 +566,141 @@ export class OverviewRepository {
return this.statusBreakdown(this.cargoRepository, "cargo");
}
async getScheduleStatusBreakdown(): Promise<
{ status: string; count: number }[]
> {
return this.statusBreakdown(this.trainScheduleRepository, "schedule");
}
/** Scheduled departures per day over the range, split by trade direction. */
async getDepartureTrend(days: number): Promise<
{
date: string;
importCount: number;
exportCount: number;
domesticCount: number;
}[]
> {
const rows = await this.trainScheduleRepository
.createQueryBuilder("schedule")
.select(
`to_char(schedule.scheduled_departure_date::date, 'YYYY-MM-DD')`,
"date",
)
.addSelect(
`COUNT(*) FILTER (WHERE schedule.direction = 'IMPORT')::int`,
"importCount",
)
.addSelect(
`COUNT(*) FILTER (WHERE schedule.direction = 'EXPORT')::int`,
"exportCount",
)
.addSelect(
`COUNT(*) FILTER (WHERE schedule.direction NOT IN ('IMPORT', 'EXPORT') OR schedule.direction IS NULL)::int`,
"domesticCount",
)
.where("schedule.deleted_at IS NULL")
.andWhere("schedule.status != :draft", {
draft: Freight.TrainScheduleStatus.Draft,
})
.andWhere(
`schedule.scheduled_departure_date >= CURRENT_DATE - :days::int + 1`,
{ days },
)
.andWhere(
`schedule.scheduled_departure_date < CURRENT_DATE + :ahead::int`,
{ ahead: 8 },
)
.groupBy("schedule.scheduled_departure_date::date")
.orderBy("schedule.scheduled_departure_date::date", "ASC")
.getRawMany<{
date: string;
importCount: string;
exportCount: string;
domesticCount: string;
}>();
return rows.map((row) => ({
date: row.date,
importCount: Number(row.importCount),
exportCount: Number(row.exportCount),
domesticCount: Number(row.domesticCount),
}));
}
async getWagonsByType(): Promise<{ label: string; count: number }[]> {
const rows = await this.wagonRepository
.createQueryBuilder("wagon")
.leftJoin(WagonType, "wagon_type", "wagon_type.id = wagon.wagon_type_id")
.select(`COALESCE(wagon_type.name, 'Unknown')`, "label")
.addSelect("COUNT(*)::int", "count")
.where("wagon.deleted_at IS NULL")
.groupBy("wagon_type.name")
.orderBy("count", "DESC")
.getRawMany<{ label: string; count: string }>();
return rows.map((row) => ({ label: row.label, count: Number(row.count) }));
}
async getWagonsByYard(limit: number): Promise<
{ label: string; count: number }[]
> {
const rows = await this.wagonRepository
.createQueryBuilder("wagon")
.innerJoin(Yard, "yard", "yard.id = wagon.current_yard_id")
.select("yard.label", "label")
.addSelect("COUNT(*)::int", "count")
.where("wagon.deleted_at IS NULL")
.groupBy("yard.label")
.orderBy("count", "DESC")
.limit(limit)
.getRawMany<{ label: string; count: string }>();
return rows.map((row) => ({ label: row.label, count: Number(row.count) }));
}
async getContainersBySize(): Promise<{ label: string; count: number }[]> {
const rows = await this.containerRepository
.createQueryBuilder("container")
.leftJoin(
ContainerType,
"container_type",
"container_type.id = container.container_type_id",
)
.select(
`COALESCE(container_type.size_ft::text || ' ft', container_type.code, 'Unknown')`,
"label",
)
.addSelect("COUNT(*)::int", "count")
.where("container.deleted_at IS NULL")
.groupBy("container_type.size_ft")
.addGroupBy("container_type.code")
.orderBy("count", "DESC")
.getRawMany<{ label: string; count: string }>();
return rows.map((row) => ({ label: row.label, count: Number(row.count) }));
}
/** Total cargo weight (tons) grouped by cargo type, heaviest first. */
async getCargoTonnageByType(limit: number): Promise<
{ label: string; tons: number }[]
> {
const rows = await this.cargoRepository
.createQueryBuilder("cargo")
.leftJoin(CargoType, "cargo_type", "cargo_type.id = cargo.cargo_type_id")
.select(`COALESCE(cargo_type.cargo_type_name, 'Other')`, "label")
.addSelect(`ROUND(COALESCE(SUM(cargo.weight), 0) / 1000, 1)`, "tons")
.where("cargo.deleted_at IS NULL")
.groupBy("cargo_type.cargo_type_name")
.orderBy("tons", "DESC")
.limit(limit)
.getRawMany<{ label: string; tons: string }>();
return rows
.map((row) => ({ label: row.label, tons: Number(row.tons) }))
.filter((row) => row.tons > 0);
}
private async statusBreakdown(
repository: Repository<ObjectLiteral>,
alias: string,

View File

@@ -202,15 +202,31 @@ export class OverviewService {
};
}
async getOperationsTab(): Promise<OverviewOperationsTabDto> {
async getOperationsTab(
range: OverviewRangeQuery = '30d',
): Promise<OverviewOperationsTabDto> {
const days = OVERVIEW_RANGE_DAYS[range];
const [
kpis,
departureTrend,
scheduleStatusBreakdown,
wagonsByType,
wagonsByYard,
containersBySize,
cargoTonnageByType,
trainStatusBreakdown,
wagonStatusBreakdown,
containerStatusBreakdown,
cargoStatusBreakdown,
] = await Promise.all([
this.overviewRepository.getOperationsKpis(),
this.overviewRepository.getDepartureTrend(days),
this.overviewRepository.getScheduleStatusBreakdown(),
this.overviewRepository.getWagonsByType(),
this.overviewRepository.getWagonsByYard(8),
this.overviewRepository.getContainersBySize(),
this.overviewRepository.getCargoTonnageByType(8),
this.overviewRepository.getTrainStatusBreakdown(),
this.overviewRepository.getWagonStatusBreakdown(),
this.overviewRepository.getContainerStatusBreakdown(),
@@ -219,6 +235,12 @@ export class OverviewService {
return {
kpis,
departureTrend,
scheduleStatusBreakdown,
wagonsByType,
wagonsByYard,
containersBySize,
cargoTonnageByType,
trainStatusBreakdown,
wagonStatusBreakdown,
containerStatusBreakdown,

View File

@@ -161,6 +161,15 @@ export class TrainSchedule extends BaseEntity {
@Column({ name: 'rule_payment_window_minutes', type: 'int', nullable: true })
rulePaymentWindowMinutes?: number | null;
/**
* Staff configured this schedule's booking window by hand (at creation or via
* the per-schedule override) instead of inheriting the live global rules.
* `restampPendingWindows` skips these, so a later global-rules edit cannot
* silently overwrite the hand-picked settings.
*/
@Column({ name: 'window_rule_custom', type: 'boolean', default: false })
windowRuleCustom!: boolean;
@Column({ name: 'rule_import_window_lead_days', type: 'int', nullable: true })
ruleImportWindowLeadDays?: number | null;

View File

@@ -151,6 +151,14 @@ export interface ExportTrainOption {
}>;
}
/** A train a paid-unallocated booking can board (route + capacity verified). */
export interface AllocationCandidate {
id: string;
reference: string | null;
direction: string | null;
scheduledDepartureDate: Date;
}
/** A day-level pool key: all trains on this route departing on this EAT day. */
interface RouteDayGroup {
originYardId: string;
@@ -3040,6 +3048,104 @@ export class BookingBatchService implements OnModuleInit {
this.notifyBoardChanged(newScheduleId, "booking_moved");
}
/**
* Trains a paid-but-unallocated booking can board right now: OPEN window,
* future departure, route covers the booking's leg, and remaining corridor
* capacity fits it. Split by the booking's own scheduled day so the UI can
* offer one-click same-day allocation vs an explicit "another date" choice.
*/
async allocationCandidates(bookingId: string): Promise<{
sameDay: AllocationCandidate[];
otherDays: AllocationCandidate[];
}> {
const booking = await this.dataSource.getRepository(Booking).findOne({
where: { id: bookingId },
relations: {
bookingContainers: { containerType: true },
// wagonTypes drives the break-bulk items-per-wagon fit — size the
// booking exactly as the intercity accept check does.
cargoType: { wagonTypes: true },
},
});
if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`);
const schedules = await this.trainSchedulesRepository.findAll({
where: [
{ status: TrainScheduleStatusEnum.Draft },
{ status: TrainScheduleStatusEnum.Scheduled },
],
});
const today = eatDay(new Date());
const bookingDay = booking.scheduledDate ? eatDay(booking.scheduledDate) : null;
const sameDay: AllocationCandidate[] = [];
const otherDays: AllocationCandidate[] = [];
for (const s of schedules) {
if (!s.scheduledDepartureDate || eatDay(s.scheduledDepartureDate) < today) continue;
if (s.bookingWindowStatus !== "OPEN") continue;
if (s.id === booking.trainScheduleId) continue;
const stops = await this.stopsForSchedule(s);
const fromIdx = stops.indexOf(booking.originYardId);
const toIdx = stops.indexOf(booking.destinationYardId);
if (fromIdx < 0 || toIdx < 0 || fromIdx >= toIdx) continue;
// ponytail: full capacity build per candidate is heavy; the set is small
// (future OPEN trains on the booking's route) — precompute if it grows.
const cap = await this.intercityCapacity(s.id);
if (!cap) continue;
const leg = cap.budget.legForYards(booking.originYardId, booking.destinationYardId);
if (!cap.budget.fits(cap.needFor(booking), leg)) continue;
const candidate: AllocationCandidate = {
id: s.id,
reference: s.reference ?? s.trainNumber ?? null,
direction: s.direction ?? null,
scheduledDepartureDate: s.scheduledDepartureDate,
};
(eatDay(s.scheduledDepartureDate) === bookingDay ? sameDay : otherDays).push(candidate);
}
const byDate = (a: AllocationCandidate, b: AllocationCandidate) =>
new Date(a.scheduledDepartureDate).getTime() - new Date(b.scheduledDepartureDate).getTime();
sameDay.sort(byDate);
otherDays.sort(byDate);
return { sameDay, otherDays };
}
/**
* Place a PAID booking that lost (or never got) its train: re-point via
* moveToSchedule (window/route validation + day sync), then allocate it
* immediately — payment already landed, so no new pay window opens. The
* customer gets an in-app notice when the new train departs on a different
* day than their original choice.
*/
async allocatePaid(bookingId: string, scheduleId: string): Promise<void> {
const before = await this.dataSource
.getRepository(Booking)
.findOne({ where: { id: bookingId } });
if (!before) throw new NotFoundException(`Booking ${bookingId} not found`);
if (before.paymentStatus !== "PAID" && before.status !== "PAID") {
throw new BadRequestException(
"Booking is not paid — use the regular scheduling flow",
);
}
const previousDay = before.scheduledDate ? eatDay(before.scheduledDate) : null;
await this.moveToSchedule(bookingId, scheduleId);
const fresh = await this.dataSource.getRepository(Booking).findOne({
where: { id: bookingId },
relations: { bookingContainers: { containerType: true }, cargoType: true },
});
if (!fresh) return;
if (!(await this.holdIfWagonShort(scheduleId, fresh))) {
await this.allocate(scheduleId, fresh, "paid");
}
const schedule = await this.dataSource
.getRepository(TrainSchedule)
.findOne({ where: { id: scheduleId } });
if (
previousDay &&
schedule?.scheduledDepartureDate &&
eatDay(schedule.scheduledDepartureDate) !== previousDay
) {
this.notifier.allocatedOtherDay(fresh, schedule.scheduledDepartureDate);
}
}
/**
* One reminder per hold, shortly before its pay deadline (the window tick
* calls this every pass; `payment_reminder_sent_at` dedups). Skips paid
@@ -3526,6 +3632,16 @@ export class BookingBatchService implements OnModuleInit {
}
return;
}
// Paid but detached from any train (staff removed it from an allocation,
// or a sweep caught it unpinned): money was taken, so it must board — it
// stays paid-unallocated for staff to place via the allocate action.
if (paid) {
this.logger.log(
`[BATCH] expire skipped for ${booking.reference} — payment landed ` +
`but no train attached; left paid-unallocated for manual placement`,
);
return;
}
// Reconcile-before-expire (only when a pay window was actually open):
// no webhook arrived, so ask the gateway DIRECTLY whether the money
// landed. A late capture found there is registered as SUCCEEDED and
@@ -3854,12 +3970,26 @@ export class BookingBatchService implements OnModuleInit {
// booking can use — don't kill it for nothing.
const overlaps = victimLeg.fromEdge < leg.toEdge && leg.fromEdge < victimLeg.toEdge;
if (!overlaps) continue;
const victimPaid =
victim.paymentStatus === "PAID" || victim.status === "PAID";
await this.dataSource.transaction(async (manager) => {
await this.trainScheduleBookingsRepository.deleteByScheduleAndBooking(
scheduleId,
victim.id,
manager,
);
if (victimPaid) {
// Paid bookings are never expired — money was taken, so it boards.
// Detach it so it surfaces in the paid-unallocated queue for staff
// to re-place; the settled invoice stays untouched.
await manager.getRepository(Booking).update(victim.id, {
trainScheduleId: null,
schedulingStatus: "ELIGIBLE",
paymentDeadline: null,
selectedForBatchAt: null,
} as never);
return;
}
await manager.getRepository(Booking).update(victim.id, {
status: "EXPIRED",
schedulingStatus: "ELIGIBLE",

View File

@@ -275,6 +275,19 @@ export class BookingNotifierService {
this.inApp(b, 'Booking rescheduled', msg);
}
/**
* Staff placed a paid booking onto a train departing on a DIFFERENT day than
* the customer's original choice. In-app only — staff drove the change and
* the allocation itself already notifies through the secured path.
*/
allocatedOtherDay(b: Booking, newDeparture: Date): void {
const when = newDeparture.toLocaleDateString('en-GB', { timeZone: BATCH_TIMEZONE });
const msg =
`Booking ${b.reference ?? b.id} has been allocated to a train on a different date. ` +
`New departure date: ${when}.`;
this.inApp(b, 'Booking allocated to another date', msg);
}
/**
* Booking was removed from its train during a staff reschedule (not a government
* pre-empt). It returns to eligible — the customer must rebook or reschedule.

View File

@@ -9,9 +9,107 @@ import {
IsNumber,
IsOptional,
IsUUID,
Max,
Min,
ValidateNested,
} from 'class-validator';
/**
* Per-schedule booking-window rule chosen AT CREATION, instead of inheriting the
* live global rules. Mirrors {@link UpdateScheduleWindowRuleDto}, plus the
* booking-close offset (which the post-creation override deliberately never
* touches). Every field is optional — an omitted field falls back to the global
* value, so staff can override just the one knob they care about.
*/
export class CreateScheduleWindowRuleDto {
@ApiPropertyOptional({ example: 8, description: 'Local EAT hour the booking desk opens each day' })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(0)
@Max(23)
windowOpenHour?: number;
@ApiPropertyOptional({
example: 17,
description:
'Local EAT hour the booking desk shuts each day. Equal to windowOpenHour = 24-hour desk',
})
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(0)
@Max(23)
windowCloseHour?: number;
@ApiPropertyOptional({ example: 3, description: 'How long each booking cycle stays open, in hours' })
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(0.0166)
@Max(12)
windowDurationHours?: number;
@ApiPropertyOptional({ example: 30, description: 'Max staff document-review minutes after the window closes' })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(0)
docReviewMinutes?: number;
@ApiPropertyOptional({ example: 60, description: 'Customer payment window minutes' })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
paymentWindowMinutes?: number;
@ApiPropertyOptional({
example: 3,
description: 'Days before departure the IMPORT/DOMESTIC booking window starts',
})
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(0)
importWindowLeadDays?: number;
@ApiPropertyOptional({
example: 24,
description: 'Hours before departure the single FCFS EXPORT window opens',
})
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
exportBookingLeadHours?: number;
@ApiPropertyOptional({
example: 180,
nullable: true,
description:
'Minutes before departure the booking window closes; 0/null = close at departure. ' +
'Only the offset matching the schedule direction is used (import offset for ' +
'IMPORT/DOMESTIC, export offset for EXPORT).',
})
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(0)
importCloseOffsetMinutes?: number | null;
@ApiPropertyOptional({
example: 1440,
nullable: true,
description: 'Minutes before departure an EXPORT booking window closes; 0/null = at departure',
})
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(0)
exportCloseOffsetMinutes?: number | null;
}
export class CreateContainerTrainScheduleDto {
@ApiProperty({ format: 'uuid' })
@IsUUID()
@@ -73,4 +171,19 @@ export class CreateContainerTrainScheduleDto {
@IsOptional()
@IsBoolean()
reverseWagonOrder?: boolean;
@ApiPropertyOptional({
type: CreateScheduleWindowRuleDto,
description:
'Configure the booking window for THIS schedule instead of inheriting the live ' +
'global rules. Omit to use the global rules (the default). The values sent are ' +
'frozen onto the schedule as its rule snapshot, exactly as a post-creation ' +
'override would. Rejected for an IMPORT/DOMESTIC train that joins an existing ' +
'route+day group — those siblings share one window timeline, so edit the group ' +
"window instead of giving one member its own.",
})
@IsOptional()
@ValidateNested()
@Type(() => CreateScheduleWindowRuleDto)
windowRule?: CreateScheduleWindowRuleDto;
}

View File

@@ -842,6 +842,32 @@ export class TrainSchedulingController {
return { ok: true };
}
@Get("bookings/:bookingId/allocation-candidates")
@TrainSchedulingView()
@ApiOperation({
summary:
"Trains a paid-unallocated booking fits, split same-day vs other days",
})
getAllocationCandidates(
@Param("bookingId", ParseUUIDPipe) bookingId: string,
) {
return this.bookingBatchService.allocationCandidates(bookingId);
}
@Post("bookings/:bookingId/allocate")
@TrainSchedulingUpdate()
@ApiOperation({
summary:
"Staff: place a paid booking onto a fitting train (notifies customer on date change)",
})
async allocatePaidBooking(
@Param("bookingId", ParseUUIDPipe) bookingId: string,
@Body("trainScheduleId", ParseUUIDPipe) trainScheduleId: string,
) {
await this.bookingBatchService.allocatePaid(bookingId, trainScheduleId);
return { ok: true };
}
@Get("schedules/:id/checkpoints")
@TrainSchedulingView()
@ApiOperation({

View File

@@ -805,6 +805,51 @@ describe('TrainSchedulingService', () => {
).rejects.toBeInstanceOf(BadRequestException);
});
describe('restampPendingWindows (hand-configured windows are exempt)', () => {
const future = new Date(Date.now() + 30 * 24 * 3600_000);
const update = jest.fn();
beforeEach(() => {
update.mockClear();
// Global rules read + the TrainSchedule repo the restamp writes through.
dataSource.getRepository.mockImplementation((entity: unknown) => {
const name = (entity as { name?: string })?.name;
if (name === 'TrainSchedulingGlobalRules') {
return { find: jest.fn().mockResolvedValue([]) };
}
return { update };
});
});
it('re-stamps a schedule that follows the global rules', async () => {
trainSchedulesRepository.findAll.mockResolvedValue([
{
id: 'sched-global',
direction: 'IMPORT',
scheduledDepartureDate: future,
windowRuleCustom: false,
},
]);
await expect(service.restampPendingWindows()).resolves.toBe(1);
expect(update).toHaveBeenCalledWith('sched-global', expect.anything());
});
it('leaves a hand-configured schedule alone', async () => {
trainSchedulesRepository.findAll.mockResolvedValue([
{
id: 'sched-custom',
direction: 'IMPORT',
scheduledDepartureDate: future,
windowRuleCustom: true,
},
]);
// Staff picked these times deliberately — a global-rules edit must not
// overwrite them, or the per-schedule configuration would be pointless.
await expect(service.restampPendingWindows()).resolves.toBe(0);
expect(update).not.toHaveBeenCalled();
});
});
describe('getUnassignedBookings', () => {
const scheduleId = 'sched-unassigned-1';
const trainSetId = 'train-set-unassigned';

View File

@@ -181,6 +181,13 @@ import {
const SCHEDULABLE_BOOKING_STATUSES = ['PAID'] as const;
/** Drops the keys a partial override left undefined, so `...` merges keep the base value. */
function pickDefined<T extends object>(source: T): Partial<T> {
return Object.fromEntries(
Object.entries(source).filter(([, v]) => v !== undefined),
) as Partial<T>;
}
/**
* The booking-window rule fields frozen onto a train schedule at creation (and
* refreshed by restampPendingWindows for not-yet-open schedules). The board draws
@@ -906,6 +913,9 @@ export class TrainSchedulingService {
windowClosesAt: cap(times.windowClosesAt, t.departure),
...ruleFields,
rulePaymentWindowMinutes,
// Deliberately overridden — exempt from the global re-stamp, which would
// otherwise revert this schedule the next time global rules are saved.
windowRuleCustom: true,
});
}
this.logger.log(
@@ -1197,6 +1207,9 @@ export class TrainSchedulingService {
let restamped = 0;
for (const s of schedules) {
if (!s.scheduledDepartureDate || s.scheduledDepartureDate <= now) continue;
// Hand-configured windows are not "pending the global rule" — staff picked
// these times deliberately, so a global-rules edit must leave them alone.
if (s.windowRuleCustom) continue;
const times =
s.direction === 'EXPORT'
? computeExportWindowTimes(s.scheduledDepartureDate, cfg)
@@ -1460,29 +1473,8 @@ export class TrainSchedulingService {
// it on schedule. DOMESTIC runs the same one-booking-day cycle as IMPORT
// (opens at 08:00 EAT `importWindowLeadDays` before departure); EXPORT opens
// 24h before departure (FCFS). No schedule is ever always-open now.
const windowCfg = await this.getWindowConfig();
const globalCfg = await this.getWindowConfig();
// Staff cannot schedule inside the lead window — there must be room for a
// booking window before departure. IMPORT/DOMESTIC lead is in whole EAT
// days (lead 3, today 11th → first allowed departure is the 14th); EXPORT
// lead is in hours (24h = 1 day ahead).
const earliest = earliestSchedulableDeparture(direction, windowCfg, new Date());
if (departure.getTime() < earliest.getTime()) {
const detail =
direction === 'EXPORT'
? `at least ${windowCfg.exportBookingLeadHours} hour(s) ahead`
: `at least ${windowCfg.importWindowLeadDays} day(s) ahead`;
throw new BadRequestException(
`Departure ${departure.toISOString()} is inside the booking lead window; ` +
`${direction === 'EXPORT' ? 'export' : 'import'} trains must be scheduled ${detail} ` +
`(earliest ${earliest.toISOString()})`,
);
}
// Freeze the rule this schedule is born with. A later global-rules edit
// only re-derives NOT-YET-OPEN schedules (see restampPendingWindows); an
// already-open schedule keeps this snapshot, and the batch board draws its
// windows from it rather than the live config.
const ruleSnapshot = windowRuleSnapshot(windowCfg);
// Route+day grouping (IMPORT/DOMESTIC only): if a schedule already exists
// on this origin + destination + EAT departure day, this new train JOINS
// its group and adopts the group's shared window timeline (open/close +
@@ -1506,6 +1498,77 @@ export class TrainSchedulingService {
route.destinationYardId,
departure,
);
// Per-schedule window rule chosen at creation. Refused for a train that
// JOINS an existing route+day group: the group shares ONE window timeline,
// so a joining train adopts the anchor's times verbatim and its own
// settings would be silently discarded. Staff edit the group's window
// instead (Booking window settings, which fans out to every sibling).
if (dto.windowRule && groupAnchor) {
throw new BadRequestException(
'This train joins an existing booking group (same route and departure day), ' +
'which shares one booking window across all its trains. Create it with the ' +
'group settings, then use Booking window settings to change the window for ' +
'the whole group.',
);
}
// The rule this schedule is born under: staff overrides on top of the live
// global config, so an omitted field still follows the global value.
const windowCfg: BookingWindowConfig = dto.windowRule
? {
...globalCfg,
...pickDefined({
windowOpenHour: dto.windowRule.windowOpenHour,
windowCloseHour: dto.windowRule.windowCloseHour,
windowDurationHours: dto.windowRule.windowDurationHours,
docReviewMinutes: dto.windowRule.docReviewMinutes,
importWindowLeadDays: dto.windowRule.importWindowLeadDays,
exportBookingLeadHours: dto.windowRule.exportBookingLeadHours,
}),
// One pay-window override drives both directions (only the one
// matching this schedule's direction is ever read).
...(dto.windowRule.paymentWindowMinutes !== undefined
? {
paymentWindowMinutes: dto.windowRule.paymentWindowMinutes,
exportPaymentWindowMinutes: dto.windowRule.paymentWindowMinutes,
}
: {}),
// Close offsets are nullable-by-intent: null/0 means "close at
// departure", which must override a non-null global, so these are
// merged on presence rather than on definedness.
...(dto.windowRule.importCloseOffsetMinutes !== undefined
? { importCloseOffsetMinutes: dto.windowRule.importCloseOffsetMinutes ?? null }
: {}),
...(dto.windowRule.exportCloseOffsetMinutes !== undefined
? { exportCloseOffsetMinutes: dto.windowRule.exportCloseOffsetMinutes ?? null }
: {}),
}
: globalCfg;
// Staff cannot schedule inside the lead window — there must be room for a
// booking window before departure. IMPORT/DOMESTIC lead is in whole EAT
// days (lead 3, today 11th → first allowed departure is the 14th); EXPORT
// lead is in hours (24h = 1 day ahead). Checked against the schedule's OWN
// lead, so a custom lead is honoured rather than rejected by the global one.
const earliest = earliestSchedulableDeparture(direction, windowCfg, new Date());
if (departure.getTime() < earliest.getTime()) {
const detail =
direction === 'EXPORT'
? `at least ${windowCfg.exportBookingLeadHours} hour(s) ahead`
: `at least ${windowCfg.importWindowLeadDays} day(s) ahead`;
throw new BadRequestException(
`Departure ${departure.toISOString()} is inside the booking lead window; ` +
`${direction === 'EXPORT' ? 'export' : 'import'} trains must be scheduled ${detail} ` +
`(earliest ${earliest.toISOString()})`,
);
}
// Freeze the rule this schedule is born with. A later global-rules edit
// only re-derives NOT-YET-OPEN schedules (see restampPendingWindows); an
// already-open schedule keeps this snapshot, and the batch board draws its
// windows from it rather than the live config.
const ruleSnapshot = windowRuleSnapshot(windowCfg);
const computedTimes =
direction === 'EXPORT'
? { ...ruleSnapshot, ...computeExportWindowTimes(departure, windowCfg) }
@@ -1514,12 +1577,30 @@ export class TrainSchedulingService {
...ruleSnapshot,
...computeImportWindowTimes(departure, windowCfg, new Date()),
};
if (
computedTimes.windowOpensAt.getTime() >= computedTimes.windowClosesAt.getTime()
) {
throw new BadRequestException(
'These booking-window settings leave no window before departure — with the ' +
'desk hours and close offset applied, the window would only open once the ' +
'train has left.',
);
}
const windowFields = {
bookingWindowStatus: 'CLOSED',
windowPhase: 'PRE_WINDOW',
...(groupAnchor
? this.groupWindowFieldsFrom(groupAnchor, departure)
: computedTimes),
// `windowRuleSnapshot` never stamps the pay window (NULL = follow the
// live global value for the direction), so an explicit staff override is
// persisted here — the same field the post-creation override writes.
...(dto.windowRule?.paymentWindowMinutes !== undefined
? { rulePaymentWindowMinutes: dto.windowRule.paymentWindowMinutes }
: {}),
// Hand-configured windows opt OUT of the global re-stamp, or the next
// global-rules edit would overwrite exactly what staff chose here.
windowRuleCustom: dto.windowRule != null,
};
// A built train's own consist is the schedule's capacity: full when all
// its wagons are allocated. Trains built without wagons yet fall back to

View File

@@ -0,0 +1,82 @@
import {
Bar,
BarChart,
CartesianGrid,
Legend,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import { Paper, Stack, Text } from "@mantine/core";
export interface StackedBarSeries {
/** Key into each data row holding this series' value. */
key: string;
label: string;
color: string;
}
interface OverviewStackedBarChartProps<T extends object> {
title: string;
data: T[];
/** Fixed order + fixed color per series — colors follow the entity, not the rank. */
series: StackedBarSeries[];
xKey?: string;
emptyMessage?: string;
formatXLabel?: (value: string) => string;
}
export function OverviewStackedBarChart<T extends object>({
title,
data,
series,
xKey = "date",
emptyMessage = "No data available",
formatXLabel,
}: OverviewStackedBarChartProps<T>) {
const hasData = data.some((row) =>
series.some((s) => Number((row as Record<string, unknown>)[s.key]) > 0),
);
return (
<Paper p="md" radius="lg" withBorder h="100%" style={{ minHeight: 260 }}>
<Stack gap="sm" h="100%">
<Text fw={600}>{title}</Text>
{!hasData ? (
<Text size="sm" c="dimmed" ta="center" py="xl">
{emptyMessage}
</Text>
) : (
<ResponsiveContainer width="100%" height={230}>
<BarChart data={data} margin={{ top: 8, right: 8, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" vertical={false} />
<XAxis
dataKey={xKey}
tickFormatter={formatXLabel}
tick={{ fontSize: 11 }}
stroke="#94a3b8"
/>
<YAxis allowDecimals={false} tick={{ fontSize: 12 }} stroke="#94a3b8" />
<Tooltip labelFormatter={formatXLabel && ((v) => formatXLabel(String(v)))} />
<Legend iconType="circle" iconSize={9} />
{series.map((s, index) => (
<Bar
key={s.key}
dataKey={s.key}
name={s.label}
stackId="stack"
fill={s.color}
stroke="#ffffff"
strokeWidth={1}
barSize={18}
radius={index === series.length - 1 ? [4, 4, 0, 0] : undefined}
/>
))}
</BarChart>
</ResponsiveContainer>
)}
</Stack>
</Paper>
);
}

View File

@@ -36,7 +36,7 @@ export function OverviewTabContent({ tab, range }: OverviewTabContentProps) {
const bookings = useOverviewBookingsTab(range, tab === "bookings");
const contracts = useOverviewContractsTab(range, tab === "contracts");
const billing = useOverviewBillingTab(range, tab === "billing");
const operations = useOverviewOperationsTab(tab === "operations");
const operations = useOverviewOperationsTab(range, tab === "operations");
const customers = useOverviewCustomersTab(range, tab === "customers");
const staff = useOverviewStaffTab(range, tab === "staff");

View File

@@ -71,7 +71,7 @@ export function OverviewBookingsTabPanel({ data }: OverviewBookingsTabPanelProps
</Grid>
<Grid gap="md">
<Grid.Col span={{ base: 12, md: 6 }}>
<Grid.Col span={{ base: 12, md: 4 }}>
<OverviewDonutChart
title="By status"
data={data.bookingsByStatus.map((item) => ({
@@ -81,10 +81,20 @@ export function OverviewBookingsTabPanel({ data }: OverviewBookingsTabPanelProps
emptyMessage="No bookings yet"
/>
</Grid.Col>
<Grid.Col span={{ base: 12, md: 6 }}>
<OverviewDonutChart
<Grid.Col span={{ base: 12, md: 4 }}>
<OverviewHorizontalBarChart
title="By freight type"
data={data.bookingsByFreightType.map((item) => ({
label: item.label,
value: item.count,
}))}
valueLabel="Bookings"
/>
</Grid.Col>
<Grid.Col span={{ base: 12, md: 4 }}>
<OverviewDonutChart
title="By payment currency"
data={data.bookingsByCurrency.map((item) => ({
name: item.label,
value: item.count,
}))}
@@ -92,14 +102,6 @@ export function OverviewBookingsTabPanel({ data }: OverviewBookingsTabPanelProps
</Grid.Col>
</Grid>
<OverviewHorizontalBarChart
title="By payment currency"
data={data.bookingsByCurrency.map((item) => ({
label: item.label,
value: item.count,
}))}
/>
<OverviewRecentBookingsTable bookings={data.recentBookings} />
</Stack>
);

View File

@@ -94,7 +94,7 @@ export function OverviewContractsTabPanel({
</Grid>
<Grid gap="md">
<Grid.Col span={{ base: 12, md: 6 }}>
<Grid.Col span={{ base: 12, md: 4 }}>
<OverviewDonutChart
title="By status"
data={data.contractsByStatus.map((item) => ({
@@ -104,7 +104,7 @@ export function OverviewContractsTabPanel({
emptyMessage="No contracts yet"
/>
</Grid.Col>
<Grid.Col span={{ base: 12, md: 6 }}>
<Grid.Col span={{ base: 12, md: 4 }}>
<OverviewDonutChart
title="By kind"
data={data.contractsByKind.map((item) => ({
@@ -113,16 +113,17 @@ export function OverviewContractsTabPanel({
}))}
/>
</Grid.Col>
<Grid.Col span={{ base: 12, md: 4 }}>
<OverviewDonutChart
title="By freight type"
data={data.contractsByFreightType.map((item) => ({
name: item.label === "CONTAINER" ? "Container" : "Bulk",
value: item.count,
}))}
/>
</Grid.Col>
</Grid>
<OverviewHorizontalBarChart
title="By freight type"
data={data.contractsByFreightType.map((item) => ({
label: item.label === "CONTAINER" ? "Container" : "Bulk",
value: item.count,
}))}
/>
<OverviewRecentContractsTable contracts={data.recentContracts} />
</Stack>
);

View File

@@ -1,13 +1,25 @@
import { Box, Container as ContainerIcon, Train, Truck } from "lucide-react";
import {
Box,
CalendarClock,
Container as ContainerIcon,
Send,
Train,
Truck,
} from "lucide-react";
import { Grid, Stack } from "@mantine/core";
import type { IOverviewOperationsTab } from "@/types/overview";
import { OverviewDonutChart } from "../OverviewDonutChart";
import { OverviewHorizontalBarChart } from "../OverviewHorizontalBarChart";
import { OverviewKpiStrip } from "../OverviewKpiStrip";
import { OverviewStackedBarChart } from "../OverviewStackedBarChart";
interface OverviewOperationsTabPanelProps {
data: IOverviewOperationsTab;
}
/** Fixed direction colors (CVD-validated pair + violet): color follows the entity. */
const DIRECTION_SERIES = [
{ key: "exportCount", label: "Export", color: "#D98A0B" },
{ key: "importCount", label: "Import", color: "#0369a1" },
{ key: "domesticCount", label: "Domestic", color: "#7c3aed" },
];
function formatStatusLabel(status: string) {
return status
@@ -16,6 +28,22 @@ function formatStatusLabel(status: string) {
.replace(/\b\w/g, (char) => char.toUpperCase());
}
function formatDateLabel(date: string) {
const parsed = new Date(`${date}T00:00:00`);
return parsed.toLocaleDateString(undefined, { month: "short", day: "numeric" });
}
function toDonutData(items: { status: string; count: number }[]) {
return items.map((item) => ({
name: formatStatusLabel(item.status),
value: item.count,
}));
}
interface OverviewOperationsTabPanelProps {
data: IOverviewOperationsTab;
}
export function OverviewOperationsTabPanel({ data }: OverviewOperationsTabPanelProps) {
return (
<Stack gap="lg">
@@ -27,6 +55,19 @@ export function OverviewOperationsTabPanel({ data }: OverviewOperationsTabPanelP
icon: Train,
accent: "emerald",
},
{
label: "Upcoming departures",
value: data.kpis.schedulesUpcoming,
icon: CalendarClock,
accent: "sky",
hint: "Scheduled, not yet departed",
},
{
label: "Dispatched today",
value: data.kpis.dispatchedToday,
icon: Send,
accent: "amber",
},
{
label: "Wagons available",
value: data.kpis.wagonsAvailable,
@@ -45,41 +86,95 @@ export function OverviewOperationsTabPanel({ data }: OverviewOperationsTabPanelP
]}
/>
<Grid gap="md">
<Grid.Col span={{ base: 12, lg: 8 }}>
<OverviewStackedBarChart
title="Train departures by direction"
data={data.departureTrend}
series={DIRECTION_SERIES}
formatXLabel={formatDateLabel}
emptyMessage="No scheduled departures in this period"
/>
</Grid.Col>
<Grid.Col span={{ base: 12, lg: 4 }}>
<OverviewDonutChart
title="Schedule status"
data={toDonutData(data.scheduleStatusBreakdown)}
emptyMessage="No train schedules yet"
/>
</Grid.Col>
</Grid>
<Grid gap="md">
<Grid.Col span={{ base: 12, md: 6 }}>
<OverviewHorizontalBarChart
title="Wagon fleet by type"
data={data.wagonsByType.map((item) => ({
label: item.label,
value: item.count,
}))}
valueLabel="Wagons"
/>
</Grid.Col>
<Grid.Col span={{ base: 12, md: 6 }}>
<OverviewHorizontalBarChart
title="Wagons by yard"
data={data.wagonsByYard.map((item) => ({
label: item.label,
value: item.count,
}))}
valueLabel="Wagons"
emptyMessage="No wagons assigned to yards"
/>
</Grid.Col>
</Grid>
<Grid gap="md">
<Grid.Col span={{ base: 12, md: 6 }}>
<OverviewHorizontalBarChart
title="Cargo tonnage by type"
data={data.cargoTonnageByType.map((item) => ({
label: item.label,
value: item.tons,
}))}
valueLabel="Tons"
emptyMessage="No cargo recorded"
/>
</Grid.Col>
<Grid.Col span={{ base: 12, md: 6 }}>
<OverviewDonutChart
title="Containers by size"
data={data.containersBySize.map((item) => ({
name: item.label,
value: item.count,
}))}
/>
</Grid.Col>
</Grid>
<Grid gap="md">
<Grid.Col span={{ base: 12, md: 6 }}>
<OverviewDonutChart
title="Train status"
data={data.trainStatusBreakdown.map((item) => ({
name: formatStatusLabel(item.status),
value: item.count,
}))}
data={toDonutData(data.trainStatusBreakdown)}
/>
</Grid.Col>
<Grid.Col span={{ base: 12, md: 6 }}>
<OverviewDonutChart
title="Wagon status"
data={data.wagonStatusBreakdown.map((item) => ({
name: formatStatusLabel(item.status),
value: item.count,
}))}
data={toDonutData(data.wagonStatusBreakdown)}
/>
</Grid.Col>
<Grid.Col span={{ base: 12, md: 6 }}>
<OverviewDonutChart
title="Container status"
data={data.containerStatusBreakdown.map((item) => ({
name: formatStatusLabel(item.status),
value: item.count,
}))}
data={toDonutData(data.containerStatusBreakdown)}
/>
</Grid.Col>
<Grid.Col span={{ base: 12, md: 6 }}>
<OverviewDonutChart
title="Cargo status"
data={data.cargoStatusBreakdown.map((item) => ({
name: formatStatusLabel(item.status),
value: item.count,
}))}
data={toDonutData(data.cargoStatusBreakdown)}
/>
</Grid.Col>
</Grid>

View File

@@ -0,0 +1,366 @@
import { useEffect, useMemo, useState } from "react";
import {
Alert,
Badge,
Box,
Divider,
Group,
Loader,
NumberInput,
Select,
Stack,
Switch,
Text,
} from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
import { Info, Moon, Sun } from "lucide-react";
import DurationField from "@/components/trainScheduling/DurationField";
import { trainSchedulingService } from "@/services/trainScheduling.service";
import type { CreateScheduleWindowRulePayload } from "@/types/trainScheduling";
/** Fallbacks matching the API's global-rules defaults (used if the fetch fails). */
const DEFAULTS = {
windowOpenHour: 8,
windowCloseHour: 17,
windowDurationHours: 3,
docReviewMinutes: 30,
paymentWindowMinutes: 60,
importWindowLeadDays: 3,
exportBookingLeadHours: 24,
};
/** 12-hour label for an EAT hour 023, e.g. 8 → "8:00 AM", 17 → "5:00 PM". */
function hourLabel(hour: number): string {
const period = hour < 12 ? "AM" : "PM";
const h12 = hour % 12 === 0 ? 12 : hour % 12;
return `${h12}:00 ${period}`;
}
const HOUR_OPTIONS = Array.from({ length: 24 }, (_, h) => ({
value: String(h),
label: `${hourLabel(h)} · ${String(h).padStart(2, "0")}:00`,
}));
export interface WindowFormState {
windowOpenHour: number;
windowCloseHour: number;
windowDurationHours: number | "";
docReviewMinutes: number | "";
paymentWindowMinutes: number | "";
importWindowLeadDays: number | "";
exportBookingLeadHours: number | "";
/** Blank = close exactly at departure. */
closeOffsetMinutes: number | "";
}
/**
* Builds the create payload from form state, or returns an error message when a
* required field was left blank. The close offset is direction-scoped: only the
* offset matching this schedule's direction is sent, since the other is never read.
*/
export function buildWindowRulePayload(
form: WindowFormState,
isExport: boolean,
): { payload: CreateScheduleWindowRulePayload } | { error: string } {
const duration = Number(form.windowDurationHours);
const doc = Number(form.docReviewMinutes);
const pay = Number(form.paymentWindowMinutes);
const lead = Number(form.importWindowLeadDays);
const exportLead = Number(form.exportBookingLeadHours);
const leadInvalid = isExport
? form.exportBookingLeadHours === "" || !Number.isFinite(exportLead) || exportLead < 1
: form.importWindowLeadDays === "" || !Number.isFinite(lead);
if (
form.windowDurationHours === "" ||
form.docReviewMinutes === "" ||
form.paymentWindowMinutes === "" ||
!Number.isFinite(duration) ||
!Number.isFinite(doc) ||
!Number.isFinite(pay) ||
leadInvalid
) {
return { error: "Fill every booking-window field, or turn the toggle off" };
}
// Blank offset = close at departure. Sent as null (not omitted) so it wins
// over a non-null global offset.
const offset = form.closeOffsetMinutes === "" ? null : Number(form.closeOffsetMinutes);
return {
payload: {
windowOpenHour: form.windowOpenHour,
windowCloseHour: form.windowCloseHour,
windowDurationHours: duration,
docReviewMinutes: doc,
paymentWindowMinutes: pay,
...(isExport
? { exportBookingLeadHours: exportLead, exportCloseOffsetMinutes: offset }
: { importWindowLeadDays: lead, importCloseOffsetMinutes: offset }),
},
};
}
export interface CreateScheduleWindowFieldsProps {
/** Direction of the selected route — picks lead/offset semantics. */
isExport: boolean;
form: WindowFormState | null;
onChange: (next: WindowFormState) => void;
}
/**
* Booking-window settings for a schedule being created. Prefills from the live
* global rules (so the fields show what the schedule WOULD inherit), then lets
* staff tune them for this one train. Mirrors BookingWindowSettingsModal, plus
* the booking-close offset.
*/
export default function CreateScheduleWindowFields({
isExport,
form,
onChange,
}: CreateScheduleWindowFieldsProps) {
const rulesQuery = useQuery({
queryKey: ["train-scheduling", "global-rules"],
queryFn: () => trainSchedulingService.getGlobalRules(),
staleTime: 5 * 60_000,
});
// Seed once from the global rules, so the toggle opens on the values this
// schedule would otherwise inherit rather than on hardcoded guesses.
const [seeded, setSeeded] = useState(false);
useEffect(() => {
if (seeded || form != null) return;
const r = rulesQuery.data;
if (!r && rulesQuery.isLoading) return;
const num = (v: unknown, fallback: number) => {
const n = v == null || v === "" ? NaN : Number(v);
return Number.isFinite(n) ? n : fallback;
};
const offset = isExport
? (r as { exportCloseOffsetMinutes?: number | null } | undefined)
?.exportCloseOffsetMinutes
: (r as { importCloseOffsetMinutes?: number | null } | undefined)
?.importCloseOffsetMinutes;
onChange({
windowOpenHour: num(r?.windowOpenHour, DEFAULTS.windowOpenHour),
windowCloseHour: num(r?.windowCloseHour, DEFAULTS.windowCloseHour),
windowDurationHours: num(r?.windowDurationHours, DEFAULTS.windowDurationHours),
docReviewMinutes: num(r?.docReviewMinutes, DEFAULTS.docReviewMinutes),
paymentWindowMinutes: num(
isExport
? (r as { exportPaymentWindowMinutes?: number } | undefined)
?.exportPaymentWindowMinutes
: r?.paymentWindowMinutes,
DEFAULTS.paymentWindowMinutes,
),
importWindowLeadDays: num(r?.importWindowLeadDays, DEFAULTS.importWindowLeadDays),
exportBookingLeadHours: num(
r?.exportBookingLeadHours,
DEFAULTS.exportBookingLeadHours,
),
closeOffsetMinutes: offset == null || offset === 0 ? "" : Number(offset),
});
setSeeded(true);
}, [seeded, form, rulesQuery.data, rulesQuery.isLoading, isExport, onChange]);
const set = (patch: Partial<WindowFormState>) => {
if (form) onChange({ ...form, ...patch });
};
const is24h = form != null && form.windowOpenHour === form.windowCloseHour;
// Close < open is a valid OVERNIGHT desk (e.g. 08:00 → 07:00 next morning).
const isOvernight = form != null && form.windowCloseHour < form.windowOpenHour;
const reopenSummary = useMemo(() => {
if (!form) return "";
const total = (Number(form.docReviewMinutes) || 0) + (Number(form.paymentWindowMinutes) || 0);
const h = Math.floor(total / 60);
const m = total % 60;
const parts = [h ? `${h}h` : "", m ? `${m}m` : ""].filter(Boolean);
return parts.length ? parts.join(" ") : "0m";
}, [form]);
if (!form) {
return (
<Group justify="center" py="md">
<Loader size="sm" />
</Group>
);
}
return (
<Stack gap="lg">
{isExport ? (
<Alert variant="light" color="blue" icon={<Info size={16} />}>
Export schedules use a single first-come-first-served window: it opens the
export lead time before departure shifted to the next desk opening if that
lands outside desk hours and stays open until it closes. Cycle timing below
doesn&apos;t apply.
</Alert>
) : (
<Alert variant="light" color="orange" icon={<Info size={16} />}>
These settings apply to this train only, and can be set only for the FIRST
train on a route and departure day. Later trains that day join its booking
group and share the same window.
</Alert>
)}
{/* ── Daily desk hours ─────────────────────────────────────────── */}
<Box>
<Group justify="space-between" align="center" mb={6}>
<Text size="sm" fw={600}>
Daily desk hours (EAT)
</Text>
{is24h ? (
<Badge variant="light" color="grape" leftSection={<Moon size={12} />}>
24-hour desk
</Badge>
) : (
<Badge variant="light" color="edr-green" leftSection={<Sun size={12} />}>
{hourLabel(form.windowOpenHour)} {hourLabel(form.windowCloseHour)}
</Badge>
)}
</Group>
<Group grow align="flex-start">
<Select
label="Opens"
data={HOUR_OPTIONS}
value={String(form.windowOpenHour)}
onChange={(v) => v != null && set({ windowOpenHour: Number(v) })}
allowDeselect={false}
comboboxProps={{ withinPortal: true }}
/>
<Select
label="Closes"
data={HOUR_OPTIONS}
value={String(form.windowCloseHour)}
onChange={(v) => v != null && set({ windowCloseHour: Number(v) })}
allowDeselect={false}
comboboxProps={{ withinPortal: true }}
/>
</Group>
{isOvernight && !is24h ? (
<Text size="xs" c="dimmed" mt={4}>
Overnight desk opens {form.windowOpenHour}:00 and runs past midnight,
closing {form.windowCloseHour}:00 the next morning.
</Text>
) : null}
<Switch
mt="sm"
size="sm"
color="grape"
label="Run 24 hours a day (never pause overnight)"
checked={is24h}
onChange={(e) =>
set({
// On → close == open (24h desk). Off → restore a normal ~9h day.
windowCloseHour: e.currentTarget.checked
? form.windowOpenHour
: Math.min(23, form.windowOpenHour + 9),
})
}
/>
</Box>
<Divider />
{/* ── Cycle timing ─────────────────────────────────────────────── */}
<Box>
<Text size="sm" fw={600} mb={6}>
Cycle timing
</Text>
<Stack gap="sm">
<DurationField
label="Window duration"
description="How long each booking cycle stays open before it closes for review"
value={form.windowDurationHours}
nativeUnit="hours"
onChange={(v) => set({ windowDurationHours: v })}
min={0.0166}
disabled={isExport}
/>
<Group grow align="flex-start">
<DurationField
label="Document review"
description="Staff time to accept documents after the window closes"
value={form.docReviewMinutes}
nativeUnit="minutes"
onChange={(v) => set({ docReviewMinutes: v })}
min={0}
disabled={isExport}
/>
<DurationField
label="Payment window"
description="Time a selected customer has to pay"
value={form.paymentWindowMinutes}
nativeUnit="minutes"
onChange={(v) => set({ paymentWindowMinutes: v })}
min={1}
/>
</Group>
{!isExport ? (
<Text size="xs" c="dimmed">
Reopen gap after each cycle = document review + payment ={" "}
<b>{reopenSummary}</b>.
</Text>
) : null}
</Stack>
</Box>
<Divider />
{/* ── Lead time ────────────────────────────────────────────────── */}
{isExport ? (
<NumberInput
label="Export booking lead (hours)"
description="How many hours before departure the export booking window opens"
value={form.exportBookingLeadHours}
onChange={(v) => set({ exportBookingLeadHours: v === "" ? "" : Number(v) })}
min={1}
clampBehavior="none"
allowNegative={false}
allowDecimal={false}
/>
) : (
<NumberInput
label="Window lead (days)"
description="How many days before departure the booking window starts"
value={form.importWindowLeadDays}
onChange={(v) => set({ importWindowLeadDays: v === "" ? "" : Number(v) })}
min={0}
clampBehavior="none"
allowNegative={false}
allowDecimal={false}
/>
)}
<Divider />
{/* ── Booking close offset ─────────────────────────────────────── */}
<Box>
<Text size="sm" fw={600} mb={2}>
Booking close offset
</Text>
<Text size="xs" c="dimmed" mb={8}>
How long before departure this schedule stops accepting bookings. e.g. a
3-hour import offset closes a 17:00 departure&apos;s window at 14:00; a 1-day
export offset closes a Jul-10 16:00 departure at Jul-9 16:00. Leave blank to
close exactly at departure.
</Text>
<DurationField
label={isExport ? "Export close offset" : "Import close offset"}
description={
isExport
? "This export booking window closes this long before departure (blank = at departure)"
: "This import booking window closes this long before departure (blank = at departure)"
}
value={form.closeOffsetMinutes}
nativeUnit="minutes"
onChange={(v) => set({ closeOffsetMinutes: v })}
min={0}
/>
</Box>
</Stack>
);
}

View File

@@ -184,7 +184,8 @@ export const QUERY_KEYS = {
["overview", "contracts", range ?? "30d"] as const,
billingTab: (range?: string) =>
["overview", "billing", range ?? "30d"] as const,
operationsTab: () => ["overview", "operations"] as const,
operationsTab: (range?: string) =>
["overview", "operations", range ?? "30d"] as const,
customersTab: (range?: string) =>
["overview", "customers", range ?? "30d"] as const,
staffTab: (range?: string) =>

View File

@@ -343,6 +343,10 @@ export const URL_CONSTANTS = {
`/train-scheduling/bookings/${bookingId}/expire`,
MOVE_BOOKING_SCHEDULE: (bookingId: string) =>
`/train-scheduling/bookings/${bookingId}/move-schedule`,
ALLOCATION_CANDIDATES: (bookingId: string) =>
`/train-scheduling/bookings/${bookingId}/allocation-candidates`,
ALLOCATE_BOOKING: (bookingId: string) =>
`/train-scheduling/bookings/${bookingId}/allocate`,
GLOBAL_RULES: "/train-scheduling/global-rules",
BOOKING_WINDOWS: "/train-scheduling/booking-windows",
PREVIEW: "/train-scheduling/preview",

View File

@@ -35,10 +35,10 @@ export function useOverviewBillingTab(range: OverviewRange, enabled: boolean) {
});
}
export function useOverviewOperationsTab(enabled: boolean) {
export function useOverviewOperationsTab(range: OverviewRange, enabled: boolean) {
return useQuery({
queryKey: QUERY_KEYS.OVERVIEW.operationsTab(),
queryFn: () => overviewService.getOperationsTab(),
queryKey: QUERY_KEYS.OVERVIEW.operationsTab(range),
queryFn: () => overviewService.getOperationsTab(range),
enabled,
});
}

View File

@@ -4,7 +4,9 @@ import {
Box,
Button,
Card,
Checkbox,
Group,
Modal,
MultiSelect,
Select,
Stack,
@@ -50,7 +52,10 @@ import {
import { ContractReferenceLink } from "@/components/bookings/ContractReferenceLink";
import { api } from "@/services/api";
import type { BookingListFilter } from "@/services/bookings.service";
import { trainSchedulingService } from "@/services/trainScheduling.service";
import type { AllocationCandidate } from "@/types/trainScheduling";
import type { BookingListRow } from "@/types/booking";
import { useToast } from "@/hooks/use-toast";
import {
Badge,
DataTable,
@@ -155,6 +160,15 @@ export default function BookingRequestsPage() {
const [scheduledTo, setScheduledTo] = useState<Date | null>(null);
const [allocateOpen, setAllocateOpen] = useState(false);
const [allocateIds, setAllocateIds] = useState<string[]>([]);
// Paid bookings with no train attached (staff removed them or a sweep
// detached them) — the queue the per-row Allocate action works through.
const [paidUnallocated, setPaidUnallocated] = useState(false);
const [allocatingId, setAllocatingId] = useState<string | null>(null);
const [otherDayModal, setOtherDayModal] = useState<{
booking: BookingListRow;
candidates: AllocationCandidate[];
} | null>(null);
const { toast } = useToast();
const suppressRowClickRef = useRef(false);
const suppressRowClick = useCallback(() => {
suppressRowClickRef.current = true;
@@ -187,6 +201,10 @@ export default function BookingRequestsPage() {
...(directionFilter ? { tradeDirection: directionFilter } : {}),
...(freightTypeFilter ? { freightType: freightTypeFilter } : {}),
...(paymentStatusFilter ? { paymentStatus: paymentStatusFilter } : {}),
// Wins over the payment-status select — the queue is by definition PAID.
...(paidUnallocated
? { paymentStatus: "PAID", assignedToSchedule: "false" as const }
: {}),
...(ownershipFilter
? { isGovernment: ownershipFilter as "true" | "false" }
: {}),
@@ -208,6 +226,7 @@ export default function BookingRequestsPage() {
directionFilter,
freightTypeFilter,
paymentStatusFilter,
paidUnallocated,
ownershipFilter,
originYardFilter,
destinationYardFilter,
@@ -251,6 +270,7 @@ export default function BookingRequestsPage() {
(directionFilter ? 1 : 0) +
(freightTypeFilter ? 1 : 0) +
(paymentStatusFilter ? 1 : 0) +
(paidUnallocated ? 1 : 0) +
(ownershipFilter ? 1 : 0) +
(originYardFilter ? 1 : 0) +
(destinationYardFilter ? 1 : 0) +
@@ -263,6 +283,7 @@ export default function BookingRequestsPage() {
setDirectionFilter(null);
setFreightTypeFilter(null);
setPaymentStatusFilter(null);
setPaidUnallocated(false);
setOwnershipFilter(null);
setOriginYardFilter(null);
setDestinationYardFilter(null);
@@ -301,6 +322,67 @@ export default function BookingRequestsPage() {
[navigate],
);
// One click: same-day fit → allocate straight away. No same-day fit but a
// train on another date fits → let staff pick it (customer is notified of
// the date change by the API). Nothing fits → say so.
const handleAllocatePaid = useCallback(
async (row: BookingListRow) => {
setAllocatingId(row.id);
try {
const candidates =
await trainSchedulingService.getAllocationCandidates(row.id);
if (candidates.sameDay.length > 0) {
const target = candidates.sameDay[0];
await trainSchedulingService.allocatePaidBooking(row.id, target.id);
toast({
title: `Allocated ${row.reference}`,
description: `Placed on ${target.reference ?? "train"} departing ${formatDate(target.scheduledDepartureDate)}.`,
});
void refetch();
} else if (candidates.otherDays.length > 0) {
setOtherDayModal({ booking: row, candidates: candidates.otherDays });
} else {
toast({
title: "No fitting train",
description:
"No open schedule covers this booking's route with enough capacity.",
variant: "destructive",
});
}
} catch {
toast({ title: "Allocation failed", variant: "destructive" });
} finally {
setAllocatingId(null);
}
},
[refetch, toast],
);
const handleAllocateOtherDay = useCallback(
async (candidate: AllocationCandidate) => {
if (!otherDayModal) return;
const { booking } = otherDayModal;
setAllocatingId(booking.id);
try {
await trainSchedulingService.allocatePaidBooking(
booking.id,
candidate.id,
);
toast({
title: `Allocated ${booking.reference}`,
description: `Placed on ${candidate.reference ?? "train"} departing ${formatDate(candidate.scheduledDepartureDate)}. Customer notified of the date change.`,
});
setOtherDayModal(null);
void refetch();
} catch {
toast({ title: "Allocation failed", variant: "destructive" });
} finally {
setAllocatingId(null);
}
},
[otherDayModal, refetch, toast],
);
const columns: ColumnDef<BookingListRow>[] = [
{
id: "booking",
@@ -435,13 +517,33 @@ export default function BookingRequestsPage() {
{
id: "actions",
size: 140,
cell: ({ row }) => (
<BookingActionsMenu
row={row.original}
variant="table"
onSuppressRowClick={suppressRowClick}
/>
),
cell: ({ row }) => {
const b = row.original;
const needsAllocation = b.paymentStatus === "PAID" && !b.trainScheduleId;
return (
<Group gap="xs" wrap="nowrap">
{needsAllocation ? (
<Button
size="compact-xs"
color="edr-green"
loading={allocatingId === b.id}
onClick={(e) => {
e.stopPropagation();
suppressRowClick();
void handleAllocatePaid(b);
}}
>
Allocate
</Button>
) : null}
<BookingActionsMenu
row={b}
variant="table"
onSuppressRowClick={suppressRowClick}
/>
</Group>
);
},
},
];
@@ -640,6 +742,16 @@ export default function BookingRequestsPage() {
radius="lg"
style={{ minWidth: 180 }}
/>
<Checkbox
label="Paid, not allocated"
checked={paidUnallocated}
onChange={(e) => {
setPaidUnallocated(e.currentTarget.checked);
resetPage();
}}
radius="sm"
style={{ alignSelf: "center" }}
/>
<Select
placeholder="Gov / Private"
data={OWNERSHIP_OPTIONS}
@@ -751,6 +863,42 @@ export default function BookingRequestsPage() {
</Card>
</Stack>
<Modal
opened={otherDayModal !== null}
onClose={() => setOtherDayModal(null)}
title="Allocate to another date"
centered
>
<Stack gap="sm">
<Text size="sm" c="dimmed">
No train on {otherDayModal ? formatDate(otherDayModal.booking.scheduledDate) : "the booking's day"}{" "}
fits booking {otherDayModal?.booking.reference}. These trains on
other dates do the customer will be notified of the date change.
</Text>
{otherDayModal?.candidates.map((c) => (
<Group key={c.id} justify="space-between" wrap="nowrap">
<div>
<Text size="sm" fw={500}>
{c.reference ?? "Train"}
</Text>
<Text size="xs" c="dimmed">
Departs {formatDate(c.scheduledDepartureDate)}
{c.direction ? ` · ${c.direction}` : ""}
</Text>
</div>
<Button
size="compact-sm"
color="edr-green"
loading={allocatingId === otherDayModal.booking.id}
onClick={() => void handleAllocateOtherDay(c)}
>
Allocate
</Button>
</Group>
))}
</Stack>
</Modal>
{allocateBooking ? (
<AllocateBookingWizard
booking={allocateBooking}

View File

@@ -6,12 +6,14 @@ import {
Button,
Card,
Checkbox,
Divider,
Group,
Menu,
Modal,
Select,
SimpleGrid,
Stack,
Switch,
Text,
TextInput,
ThemeIcon,
@@ -46,6 +48,10 @@ import {
directionRowStyle,
} from "@/components/trainBuilder/trainStatus";
import BookingWindowSettingsModal from "@/components/trainScheduling/BookingWindowSettingsModal";
import CreateScheduleWindowFields, {
buildWindowRulePayload,
type WindowFormState,
} from "@/components/trainScheduling/CreateScheduleWindowFields";
import EditScheduleDateModal from "@/components/trainScheduling/EditScheduleDateModal";
import { showScheduleWarnings } from "@/components/trainScheduling/locomotiveOptions";
import {
@@ -59,6 +65,7 @@ import { useToast } from "@/hooks/use-toast";
import { useAuth } from "@/auth/useAuth";
import { canCreateSchedule, canUpdateSchedule } from "@/lib/permissions";
import type {
CreateScheduleWindowRulePayload,
FreightType,
TrainScheduleListFilters,
TrainScheduleListItem,
@@ -133,6 +140,10 @@ export default function TrainScheduleV2ListPage() {
const [scheduleDate, setScheduleDate] = useState("");
const [trainId, setTrainId] = useState("");
const [reverseWagonOrder, setReverseWagonOrder] = useState(false);
// Booking window for the schedule being created: off = inherit the live global
// rules (the default), on = the values in `windowForm` are frozen onto it.
const [configureWindow, setConfigureWindow] = useState(false);
const [windowForm, setWindowForm] = useState<WindowFormState | null>(null);
// Recomputed each time the create modal opens so a long-lived tab can't keep
// offering a stale "now" as the earliest selectable departure.
const minScheduleDate = useMemo(
@@ -535,6 +546,25 @@ export default function TrainScheduleV2ListPage() {
});
return;
}
// Only build the window override when the toggle is on — off means "inherit
// the global rules", which the API expresses as an absent windowRule.
let windowRule: CreateScheduleWindowRulePayload | undefined;
if (configureWindow) {
if (!windowForm) {
toast({ title: "Booking window settings are still loading", variant: "destructive" });
return;
}
const built = buildWindowRulePayload(
windowForm,
selectedRoute?.direction === "EXPORT",
);
if ("error" in built) {
toast({ title: built.error, variant: "destructive" });
return;
}
windowRule = built.payload;
}
try {
const created = await create.mutateAsync({
payload: {
@@ -542,11 +572,14 @@ export default function TrainScheduleV2ListPage() {
scheduleDate: new Date(scheduleDate).toISOString(),
trainId,
reverseWagonOrder,
...(windowRule ? { windowRule } : {}),
},
});
toast({ title: "Train schedule created" });
showScheduleWarnings(created.warnings);
setReverseWagonOrder(false);
setConfigureWindow(false);
setWindowForm(null);
setCreateOpen(false);
navigate(`/dashboard/operations/train-scheduling-v2/${created.id}`);
} catch (err) {
@@ -843,6 +876,22 @@ export default function TrainScheduleV2ListPage() {
checked={reverseWagonOrder}
onChange={(e) => setReverseWagonOrder(e.currentTarget.checked)}
/>
<Divider />
<Switch
label="Configure booking window for this schedule"
description="Off, this train follows the global booking rules. On, the settings below are frozen onto it and a later global-rules change won't move them."
checked={configureWindow}
onChange={(e) => setConfigureWindow(e.currentTarget.checked)}
/>
{configureWindow ? (
<CreateScheduleWindowFields
isExport={selectedRoute?.direction === "EXPORT"}
form={windowForm}
onChange={setWindowForm}
/>
) : null}
<Group justify="flex-end">
<Button variant="default" onClick={() => setCreateOpen(false)}>
Cancel

View File

@@ -43,8 +43,12 @@ export const overviewService = {
return unwrap(response);
},
getOperationsTab: async (): Promise<IOverviewOperationsTab> => {
const response = await client.get<IOverviewOperationsTab>(O.OPERATIONS);
getOperationsTab: async (
range?: OverviewRange,
): Promise<IOverviewOperationsTab> => {
const response = await client.get<IOverviewOperationsTab>(O.OPERATIONS, {
params: range ? { range } : undefined,
});
return unwrap(response);
},

View File

@@ -3,6 +3,7 @@ import { api as client } from "../auth/http";
import { unwrap } from "@/utils/endpoint";
import { URL_CONSTANTS } from "@/constants/URLS";
import type {
AllocationCandidates,
BatchBoardFilters,
BatchBoardListResponse,
BatchBoardScheduleDetail,
@@ -319,6 +320,25 @@ export const trainSchedulingService = {
);
},
getAllocationCandidates: async (
bookingId: string,
): Promise<AllocationCandidates> => {
const response = await client.get<AllocationCandidates>(
URL_CONSTANTS.TRAIN_SCHEDULING.ALLOCATION_CANDIDATES(bookingId),
);
return unwrap(response.data);
},
allocatePaidBooking: async (
bookingId: string,
trainScheduleId: string,
): Promise<void> => {
await client.post(
URL_CONSTANTS.TRAIN_SCHEDULING.ALLOCATE_BOOKING(bookingId),
{ trainScheduleId },
);
},
getScheduleById: async (
id: string,
freightType?: FreightType,

View File

@@ -8,6 +8,8 @@ export type {
IOverviewBillingKpis,
IOverviewStaffKpis,
IOverviewTrendPoint,
IOverviewDirectionTrendPoint,
IOverviewTonnagePoint,
IOverviewStatusCount,
IOverviewPipelineCount,
IOverviewPaymentTrendPoint,

View File

@@ -103,6 +103,21 @@ export interface BookingWagonShortage {
wagonsShort: number;
}
/** A train a paid-unallocated booking can board (route + capacity verified). */
export interface AllocationCandidate {
id: string;
reference: string | null;
direction: string | null;
scheduledDepartureDate: string;
}
export interface AllocationCandidates {
/** Trains departing on the booking's own scheduled day. */
sameDay: AllocationCandidate[];
/** Fitting trains on other days — allocating to one notifies the customer. */
otherDays: AllocationCandidate[];
}
export interface DeferredBookingRow {
id: string;
reference: string;
@@ -891,6 +906,23 @@ export interface CreateTrainSchedulePayload {
maxWagonsPerTrain?: number;
/** Reverse the wagon order on this train: physically-last wagon becomes position 1. */
reverseWagonOrder?: boolean;
/**
* Configure the booking window for THIS schedule instead of inheriting the
* live global rules. Omit to follow the global rules (the default).
*/
windowRule?: CreateScheduleWindowRulePayload;
}
/**
* Booking-window rule chosen at creation. Mirrors the per-schedule override plus
* the booking-close offset; omitted fields fall back to the global rule.
*/
export interface CreateScheduleWindowRulePayload
extends UpdateScheduleWindowRulePayload {
/** Minutes before departure an IMPORT/DOMESTIC window closes; null = at departure. */
importCloseOffsetMinutes?: number | null;
/** Minutes before departure an EXPORT window closes; null = at departure. */
exportCloseOffsetMinutes?: number | null;
}
export interface AssignBookingsPayload {

View File

@@ -13,6 +13,8 @@ export interface IOverviewOperationsKpis {
wagonsAvailable: number;
containersInTransit: number;
cargoesLoaded: number;
schedulesUpcoming: number;
dispatchedToday: number;
}
export interface IOverviewCustomerKpis {
@@ -151,8 +153,27 @@ export interface IOverviewBillingTab {
generatedAt: string;
}
/** Scheduled train departures per day, split by trade direction. */
export interface IOverviewDirectionTrendPoint {
date: string;
importCount: number;
exportCount: number;
domesticCount: number;
}
export interface IOverviewTonnagePoint {
label: string;
tons: number;
}
export interface IOverviewOperationsTab {
kpis: IOverviewOperationsKpis;
departureTrend: IOverviewDirectionTrendPoint[];
scheduleStatusBreakdown: IOverviewStatusCount[];
wagonsByType: IOverviewLabelCount[];
wagonsByYard: IOverviewLabelCount[];
containersBySize: IOverviewLabelCount[];
cargoTonnageByType: IOverviewTonnagePoint[];
trainStatusBreakdown: IOverviewStatusCount[];
wagonStatusBreakdown: IOverviewStatusCount[];
containerStatusBreakdown: IOverviewStatusCount[];