mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 19:30:57 +00:00
add cron jobs and aslo
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { Readable } from 'stream';
|
||||
@@ -22,6 +23,8 @@ import { ContractSignerRole } from './entities/booking-contract-signature.entity
|
||||
|
||||
@Injectable()
|
||||
export class BookingContractService {
|
||||
private readonly logger = new Logger(BookingContractService.name);
|
||||
|
||||
constructor(
|
||||
private readonly bookingsRepository: BookingsRepository,
|
||||
private readonly filesService: FilesService,
|
||||
@@ -92,7 +95,16 @@ export class BookingContractService {
|
||||
|
||||
const templateKey = this.templateResolver.resolve(booking);
|
||||
const summary = this.buildContractSummary(booking);
|
||||
await this.upsertContractPdf(bookingId, booking.reference, templateKey);
|
||||
|
||||
// PDF rendering (Puppeteer/Chromium) is best-effort and must NOT block the contract
|
||||
// from becoming ready — the document is (re)rendered lazily on view/download.
|
||||
try {
|
||||
await this.upsertContractPdf(bookingId, booking.reference, templateKey);
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`Contract PDF deferred for ${booking.reference}: ${err}. It will render on view/download once Chromium is available.`,
|
||||
);
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const updated = await this.bookingsRepository.update(bookingId, {
|
||||
@@ -191,11 +203,17 @@ export class BookingContractService {
|
||||
}
|
||||
|
||||
const updated = await this.bookingsRepository.update(bookingId, updates as never);
|
||||
await this.upsertContractPdf(
|
||||
bookingId,
|
||||
booking.reference,
|
||||
booking.contractTemplateKey ?? this.templateResolver.resolve(booking),
|
||||
);
|
||||
try {
|
||||
await this.upsertContractPdf(
|
||||
bookingId,
|
||||
booking.reference,
|
||||
booking.contractTemplateKey ?? this.templateResolver.resolve(booking),
|
||||
);
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`Signed-contract PDF deferred for ${booking.reference}: ${err}. It will render on view/download.`,
|
||||
);
|
||||
}
|
||||
return updated!;
|
||||
}
|
||||
|
||||
|
||||
@@ -659,6 +659,7 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
originStationId?: string;
|
||||
destinationStationId?: string;
|
||||
schedulingStatus?: string;
|
||||
trainScheduleId?: string;
|
||||
}): Promise<Booking[]> {
|
||||
const qb = this.repository
|
||||
.createQueryBuilder('booking')
|
||||
@@ -676,6 +677,14 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
.where('booking.status = :paidStatus', { paidStatus: 'PAID' })
|
||||
.andWhere('scheduleBooking.id IS NULL');
|
||||
|
||||
// Mirror the automatic batch pool: a schedule only ever considers bookings that
|
||||
// targeted THAT schedule (same as findBatchPool's train_schedule_id filter).
|
||||
if (options.trainScheduleId) {
|
||||
qb.andWhere('booking.train_schedule_id = :trainScheduleId', {
|
||||
trainScheduleId: options.trainScheduleId,
|
||||
});
|
||||
}
|
||||
|
||||
if (options.freightType) {
|
||||
qb.andWhere('booking.freightType = :freightType', { freightType: options.freightType });
|
||||
}
|
||||
@@ -726,6 +735,19 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
.getMany();
|
||||
}
|
||||
|
||||
/** Every booking that targeted a schedule (any status) — for the batch monitoring board. */
|
||||
findAllBySchedule(scheduleId: string): Promise<Booking[]> {
|
||||
return this.repository
|
||||
.createQueryBuilder('booking')
|
||||
.leftJoinAndSelect('booking.company', 'company')
|
||||
.leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
|
||||
.where('booking.train_schedule_id = :scheduleId', { scheduleId })
|
||||
.orderBy('booking.is_government', 'DESC')
|
||||
.addOrderBy('booking.priority_score', 'DESC')
|
||||
.addOrderBy('booking.created_at', 'ASC')
|
||||
.getMany();
|
||||
}
|
||||
|
||||
/** Bookings currently reserved (AWAITING_PAYMENT) against a schedule. */
|
||||
findReservedForSchedule(scheduleId: string): Promise<Booking[]> {
|
||||
return this.repository
|
||||
|
||||
@@ -13,3 +13,10 @@ export const PAYMENT_WINDOW_MS = 60 * 60 * 1000; // 1 hour
|
||||
|
||||
/** Fallback wagons-per-booking when a booking has no computed `wagonsRequired`. */
|
||||
export const DEFAULT_WAGONS_PER_BOOKING = 1;
|
||||
|
||||
/**
|
||||
* Fallback per-wagon length (m) for the batch length budget when global rules don't yet
|
||||
* define maxTrainLength / maxWagons to derive it from. Used only to estimate train length
|
||||
* against the locomotive's max train length.
|
||||
*/
|
||||
export const DEFAULT_WAGON_LENGTH_METERS = 14;
|
||||
|
||||
@@ -11,25 +11,86 @@ import { DataSource } from 'typeorm';
|
||||
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
import { BookingsRepository } from '../bookings/bookings.repository';
|
||||
import { Locomotive } from '../locomotives/entities/locomotive.entity';
|
||||
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
|
||||
import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity';
|
||||
import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository';
|
||||
import { TrainScheduleBookingsRepository } from '../train-schedules/train-schedule-bookings.repository';
|
||||
import { TrainSchedulingGlobalRules } from './entities/train-scheduling-global-rules.entity';
|
||||
import { BookingNotifierService } from './booking-notifier.service';
|
||||
import {
|
||||
BATCH_CRON,
|
||||
BATCH_TIMEZONE,
|
||||
DEFAULT_WAGON_LENGTH_METERS,
|
||||
DEFAULT_WAGONS_PER_BOOKING,
|
||||
PAYMENT_WINDOW_MS,
|
||||
} from './booking-batch.constants';
|
||||
|
||||
/** A train's remaining capacity along the three physical limits the batch enforces. */
|
||||
interface Capacity {
|
||||
wagons: number;
|
||||
weightTons: number;
|
||||
lengthMeters: number;
|
||||
}
|
||||
|
||||
export type BatchBoardBookingState =
|
||||
| 'ALLOCATED'
|
||||
| 'AWAITING_PAYMENT'
|
||||
| 'WAITING'
|
||||
| 'PENDING_CONTRACT'
|
||||
| 'EXPIRED';
|
||||
|
||||
export interface BatchBoardBooking {
|
||||
id: string;
|
||||
reference: string;
|
||||
company: string;
|
||||
isGovernment: boolean;
|
||||
wagons: number;
|
||||
weightTons: number;
|
||||
paymentDeadline: string | null;
|
||||
state: BatchBoardBookingState;
|
||||
}
|
||||
|
||||
export interface BatchBoardSchedule {
|
||||
scheduleId: string;
|
||||
trainNumber: string | null;
|
||||
routeName: string | null;
|
||||
origin: string | null;
|
||||
destination: string | null;
|
||||
scheduleDate: string | null;
|
||||
status: string;
|
||||
bookingWindowStatus: string;
|
||||
locomotive: {
|
||||
code: string;
|
||||
name: string | null;
|
||||
maxPullWeightTons: number;
|
||||
maxTrainLengthMeters: number;
|
||||
} | null;
|
||||
capacity: {
|
||||
maxWagons: number;
|
||||
usedWagons: number;
|
||||
remainingWagons: number;
|
||||
usedWeightTons: number;
|
||||
maxWeightTons: number | null;
|
||||
};
|
||||
counts: {
|
||||
allocated: number;
|
||||
awaitingPayment: number;
|
||||
waiting: number;
|
||||
pendingContract: number;
|
||||
expired: number;
|
||||
};
|
||||
bookings: BatchBoardBooking[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Demand-batching engine: every 3h (EAT) it ranks each OPEN schedule's ready pool
|
||||
* by priority, greedily fills the train to capacity (skipping oversized bookings),
|
||||
* by priority, greedily fills the train to capacity (skipping bookings that don't fit),
|
||||
* reserves a 1h pay window for commercial customers (government allocated unpaid,
|
||||
* preempting lower-priority commercial if needed), then settles each batch 1h later —
|
||||
* allocating those who paid and expiring those who didn't, topping up from the waiting list.
|
||||
* Capacity here is modelled by wagon count (`schedule.maxWagons`); locomotive weight/length
|
||||
* is still enforced by the existing assignment path when staff pin wagons.
|
||||
* Capacity is bounded on three axes at once: wagon count (`schedule.maxWagons`), the
|
||||
* locomotive's max pull weight, and its max train length (also capped by global rules).
|
||||
*/
|
||||
@Injectable()
|
||||
export class BookingBatchService implements OnModuleInit {
|
||||
@@ -73,19 +134,122 @@ 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).
|
||||
*/
|
||||
async getBatchBoard(): Promise<BatchBoardSchedule[]> {
|
||||
const schedules = await this.trainSchedulesRepository.findAll({
|
||||
relations: {
|
||||
trainSet: { locomotive: true },
|
||||
originStation: true,
|
||||
destinationStation: true,
|
||||
route: true,
|
||||
},
|
||||
order: { scheduledDepartureDate: 'ASC' },
|
||||
});
|
||||
|
||||
const rules = await this.loadGlobalRules();
|
||||
const perWagonLength = this.perWagonLength(rules);
|
||||
const linkRepo = this.dataSource.getRepository(TrainScheduleBooking);
|
||||
|
||||
const board: BatchBoardSchedule[] = [];
|
||||
for (const s of schedules) {
|
||||
if (s.status === 'ARRIVED' || s.status === 'CANCELLED') 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);
|
||||
|
||||
const items: BatchBoardBooking[] = bookings.map((b) => {
|
||||
const need = this.needFor(b, perWagonLength);
|
||||
return {
|
||||
id: b.id,
|
||||
reference: b.reference ?? b.id.slice(0, 8),
|
||||
company: b.isGovernment
|
||||
? (b.governmentInstitution ?? 'Government')
|
||||
: (b.company?.name ?? '—'),
|
||||
isGovernment: Boolean(b.isGovernment),
|
||||
wagons: need.wagons,
|
||||
weightTons: need.weightTons,
|
||||
paymentDeadline: b.paymentDeadline ? b.paymentDeadline.toISOString() : null,
|
||||
state: this.boardState(b, linkedIds.has(b.id)),
|
||||
};
|
||||
});
|
||||
|
||||
const usedWagons = items
|
||||
.filter((i) => i.state === 'ALLOCATED' || i.state === 'AWAITING_PAYMENT')
|
||||
.reduce((sum, i) => sum + i.wagons, 0);
|
||||
const usedWeight = items
|
||||
.filter((i) => i.state === 'ALLOCATED' || i.state === 'AWAITING_PAYMENT')
|
||||
.reduce((sum, i) => sum + i.weightTons, 0);
|
||||
const loco = s.trainSet?.locomotive ?? null;
|
||||
|
||||
board.push({
|
||||
scheduleId: s.id,
|
||||
trainNumber: s.trainNumber ?? null,
|
||||
routeName: s.route?.name ?? null,
|
||||
origin: s.originStation?.label ?? s.originStation?.code ?? null,
|
||||
destination: s.destinationStation?.label ?? s.destinationStation?.code ?? null,
|
||||
scheduleDate: s.scheduledDepartureDate ? s.scheduledDepartureDate.toISOString() : null,
|
||||
status: s.status,
|
||||
bookingWindowStatus: s.bookingWindowStatus,
|
||||
locomotive: loco
|
||||
? {
|
||||
code: loco.code,
|
||||
name: loco.name ?? null,
|
||||
maxPullWeightTons: Number(loco.maxPullWeightTons),
|
||||
maxTrainLengthMeters: Number(loco.maxTrainLengthMeters),
|
||||
}
|
||||
: null,
|
||||
capacity: {
|
||||
maxWagons: s.maxWagons ?? 0,
|
||||
usedWagons,
|
||||
remainingWagons: Math.max(0, (s.maxWagons ?? 0) - usedWagons),
|
||||
usedWeightTons: Math.round(usedWeight * 100) / 100,
|
||||
maxWeightTons: loco ? Number(loco.maxPullWeightTons) : null,
|
||||
},
|
||||
counts: {
|
||||
allocated: items.filter((i) => i.state === 'ALLOCATED').length,
|
||||
awaitingPayment: items.filter((i) => i.state === 'AWAITING_PAYMENT').length,
|
||||
waiting: items.filter((i) => i.state === 'WAITING').length,
|
||||
pendingContract: items.filter((i) => i.state === 'PENDING_CONTRACT').length,
|
||||
expired: items.filter((i) => i.state === 'EXPIRED').length,
|
||||
},
|
||||
bookings: items,
|
||||
});
|
||||
}
|
||||
return board;
|
||||
}
|
||||
|
||||
private boardState(booking: Booking, linked: boolean): BatchBoardBookingState {
|
||||
if (linked) return 'ALLOCATED';
|
||||
if (booking.status === 'AWAITING_PAYMENT') return 'AWAITING_PAYMENT';
|
||||
if (booking.status === 'EXPIRED') return 'EXPIRED';
|
||||
if (booking.status === 'PAID') return 'WAITING';
|
||||
return 'PENDING_CONTRACT';
|
||||
}
|
||||
|
||||
// ---- core fill ------------------------------------------------------------
|
||||
|
||||
/** Fill one schedule from its priority-ordered pool until full. */
|
||||
async fillSchedule(scheduleId: string): Promise<void> {
|
||||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||||
if (!schedule || schedule.bookingWindowStatus !== 'OPEN') return;
|
||||
if (!schedule.trainSetId || !schedule.trainSet?.locomotive) {
|
||||
const locomotive = schedule.trainSet?.locomotive;
|
||||
if (!schedule.trainSetId || !locomotive) {
|
||||
this.logger.warn(`Schedule ${scheduleId} has no locomotive/train set — skipped.`);
|
||||
return;
|
||||
}
|
||||
|
||||
let remaining = await this.remainingWagons(schedule);
|
||||
if (remaining <= 0) {
|
||||
const rules = await this.loadGlobalRules();
|
||||
const perWagonLength = this.perWagonLength(rules);
|
||||
const limits = this.capacityLimits(schedule, locomotive, rules);
|
||||
let budget = await this.remainingCapacity(schedule, limits, perWagonLength);
|
||||
if (budget.wagons <= 0) {
|
||||
await this.setWindow(scheduleId, 'FULL');
|
||||
return;
|
||||
}
|
||||
@@ -94,14 +258,14 @@ export class BookingBatchService implements OnModuleInit {
|
||||
let armed = false;
|
||||
|
||||
for (const booking of pool) {
|
||||
const need = this.wagonsFor(booking);
|
||||
const need = this.needFor(booking, perWagonLength);
|
||||
|
||||
if (need > remaining) {
|
||||
if (!this.fits(need, budget)) {
|
||||
if (booking.isGovernment) {
|
||||
remaining = await this.preemptForGovernment(scheduleId, need, remaining);
|
||||
if (need > remaining) continue; // still doesn't fit even after preempt
|
||||
budget = await this.preemptForGovernment(scheduleId, need, budget, perWagonLength);
|
||||
if (!this.fits(need, budget)) continue; // still doesn't fit even after preempt
|
||||
} else {
|
||||
continue; // skip oversized commercial, try the next
|
||||
continue; // skip a booking that exceeds weight/length/wagons, try the next
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,11 +275,11 @@ export class BookingBatchService implements OnModuleInit {
|
||||
await this.reserve(booking);
|
||||
armed = true;
|
||||
}
|
||||
remaining -= need;
|
||||
if (remaining <= 0) break;
|
||||
budget = this.subtract(budget, need);
|
||||
if (budget.wagons <= 0) break; // no wagon slots left — nothing more can board
|
||||
}
|
||||
|
||||
if (remaining <= 0) await this.setWindow(scheduleId, 'FULL');
|
||||
if (budget.wagons <= 0) await this.setWindow(scheduleId, 'FULL');
|
||||
if (armed) this.armSettle(scheduleId);
|
||||
}
|
||||
|
||||
@@ -280,9 +444,10 @@ export class BookingBatchService implements OnModuleInit {
|
||||
*/
|
||||
private async preemptForGovernment(
|
||||
scheduleId: string,
|
||||
need: number,
|
||||
remaining: number,
|
||||
): Promise<number> {
|
||||
need: Capacity,
|
||||
budget: Capacity,
|
||||
perWagonLength: number,
|
||||
): Promise<Capacity> {
|
||||
const reservedCommercial = (
|
||||
await this.bookingsRepository.findReservedForSchedule(scheduleId)
|
||||
).filter((b) => !b.isGovernment);
|
||||
@@ -294,8 +459,9 @@ export class BookingBatchService implements OnModuleInit {
|
||||
(a, b) => (a.priorityScore ?? 0) - (b.priorityScore ?? 0),
|
||||
);
|
||||
|
||||
let freed = budget;
|
||||
for (const victim of candidates) {
|
||||
if (need <= remaining) break;
|
||||
if (this.fits(need, freed)) break;
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
await this.trainScheduleBookingsRepository.deleteByScheduleAndBooking(
|
||||
scheduleId,
|
||||
@@ -309,9 +475,9 @@ export class BookingBatchService implements OnModuleInit {
|
||||
} as never);
|
||||
});
|
||||
this.notifier.displaced(victim);
|
||||
remaining += this.wagonsFor(victim);
|
||||
freed = this.add(freed, this.needFor(victim, perWagonLength));
|
||||
}
|
||||
return remaining;
|
||||
return freed;
|
||||
}
|
||||
|
||||
// ---- capacity helpers -----------------------------------------------------
|
||||
@@ -327,6 +493,86 @@ export class BookingBatchService implements OnModuleInit {
|
||||
return Math.max(DEFAULT_WAGONS_PER_BOOKING, fromContainers || DEFAULT_WAGONS_PER_BOOKING);
|
||||
}
|
||||
|
||||
/** What one booking consumes along all three capacity axes. */
|
||||
private needFor(booking: Booking, perWagonLength: number): Capacity {
|
||||
const wagons = this.wagonsFor(booking);
|
||||
return {
|
||||
wagons,
|
||||
weightTons: Number(booking.cargoTotalWeightVgm ?? 0),
|
||||
lengthMeters: wagons * perWagonLength,
|
||||
};
|
||||
}
|
||||
|
||||
private fits(need: Capacity, budget: Capacity): boolean {
|
||||
return (
|
||||
need.wagons <= budget.wagons &&
|
||||
need.weightTons <= budget.weightTons &&
|
||||
need.lengthMeters <= budget.lengthMeters
|
||||
);
|
||||
}
|
||||
|
||||
private subtract(budget: Capacity, need: Capacity): Capacity {
|
||||
return {
|
||||
wagons: budget.wagons - need.wagons,
|
||||
weightTons: budget.weightTons - need.weightTons,
|
||||
lengthMeters: budget.lengthMeters - need.lengthMeters,
|
||||
};
|
||||
}
|
||||
|
||||
private add(budget: Capacity, freed: Capacity): Capacity {
|
||||
return {
|
||||
wagons: budget.wagons + freed.wagons,
|
||||
weightTons: budget.weightTons + freed.weightTons,
|
||||
lengthMeters: budget.lengthMeters + freed.lengthMeters,
|
||||
};
|
||||
}
|
||||
|
||||
/** The train's hard caps: wagon count, locomotive pull weight, locomotive/global length. */
|
||||
private capacityLimits(
|
||||
schedule: TrainSchedule,
|
||||
locomotive: Locomotive,
|
||||
rules: TrainSchedulingGlobalRules | null,
|
||||
): Capacity {
|
||||
const locoWeight = Number(locomotive.maxPullWeightTons) || Infinity;
|
||||
const locoLength = Number(locomotive.maxTrainLengthMeters) || Infinity;
|
||||
const ruleWeight = rules?.maxTrainWeightTons ? Number(rules.maxTrainWeightTons) : Infinity;
|
||||
const ruleLength = rules?.maxTrainLengthMeters ? Number(rules.maxTrainLengthMeters) : Infinity;
|
||||
return {
|
||||
wagons: schedule.maxWagons ?? 0,
|
||||
weightTons: Math.min(locoWeight, ruleWeight),
|
||||
lengthMeters: Math.min(locoLength, ruleLength),
|
||||
};
|
||||
}
|
||||
|
||||
/** Per-wagon length, derived from global rules (maxLength / maxWagons) or a fallback. */
|
||||
private perWagonLength(rules: TrainSchedulingGlobalRules | null): number {
|
||||
const len = rules ? Number(rules.maxTrainLengthMeters) : 0;
|
||||
const wagons = rules ? Number(rules.maxWagonsPerTrain) : 0;
|
||||
if (len > 0 && wagons > 0) return len / wagons;
|
||||
return DEFAULT_WAGON_LENGTH_METERS;
|
||||
}
|
||||
|
||||
private async loadGlobalRules(): Promise<TrainSchedulingGlobalRules | null> {
|
||||
return this.dataSource.getRepository(TrainSchedulingGlobalRules).findOne({ where: {} });
|
||||
}
|
||||
|
||||
/** Remaining capacity = hard caps minus what allocated + reserved bookings already use. */
|
||||
private async remainingCapacity(
|
||||
schedule: TrainSchedule,
|
||||
limits: Capacity,
|
||||
perWagonLength: number,
|
||||
): Promise<Capacity> {
|
||||
const allocated = (schedule.scheduleBookings ?? [])
|
||||
.map((sb) => sb.booking)
|
||||
.filter((b): b is Booking => Boolean(b));
|
||||
const reserved = await this.bookingsRepository.findReservedForSchedule(schedule.id);
|
||||
const used = [...allocated, ...reserved].reduce<Capacity>(
|
||||
(acc, b) => this.add(acc, this.needFor(b, perWagonLength)),
|
||||
{ wagons: 0, weightTons: 0, lengthMeters: 0 },
|
||||
);
|
||||
return this.subtract(limits, used);
|
||||
}
|
||||
|
||||
/** maxWagons minus wagons already taken by allocated + reserved bookings. */
|
||||
private async remainingWagons(schedule: TrainSchedule): Promise<number> {
|
||||
const allocated = (schedule.scheduleBookings ?? [])
|
||||
|
||||
@@ -17,6 +17,14 @@ export class GetEligibleBookingsDto {
|
||||
@IsUUID()
|
||||
destinationStationId?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
format: 'uuid',
|
||||
description: 'Scope to bookings that targeted this specific schedule (batch parity).',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
trainScheduleId?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
schedulingStatus?: string;
|
||||
|
||||
@@ -12,6 +12,11 @@ export class GetEligibleBulkBookingsDto {
|
||||
@IsUUID()
|
||||
destinationStationId?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
trainScheduleId?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'HOLDING' })
|
||||
@IsOptional()
|
||||
schedulingStatus?: string;
|
||||
|
||||
@@ -12,6 +12,11 @@ export class GetEligibleContainerBookingsDto {
|
||||
@IsUUID()
|
||||
destinationStationId?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
trainScheduleId?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'HOLDING' })
|
||||
@IsOptional()
|
||||
schedulingStatus?: string;
|
||||
|
||||
@@ -57,6 +57,13 @@ export class TrainSchedulingController {
|
||||
return this.trainSchedulingService.getEligibleBookings(query);
|
||||
}
|
||||
|
||||
@Get('batch-board')
|
||||
@TrainSchedulingView()
|
||||
@ApiOperation({ summary: 'Batch monitoring board: schedules with bookings grouped by state' })
|
||||
getBatchBoard() {
|
||||
return this.bookingBatchService.getBatchBoard();
|
||||
}
|
||||
|
||||
@Get('bookable-schedules')
|
||||
@TrainSchedulingView()
|
||||
@ApiOperation({ summary: 'OPEN same-route schedules a new booking can target' })
|
||||
|
||||
@@ -113,6 +113,7 @@ export class TrainSchedulingService {
|
||||
originStationId: query.originStationId,
|
||||
destinationStationId: query.destinationStationId,
|
||||
schedulingStatus: query.schedulingStatus,
|
||||
trainScheduleId: query.trainScheduleId,
|
||||
});
|
||||
return { count: bookings.length, items: bookings.map((b) => this.mapEligibleBooking(b)) };
|
||||
}
|
||||
@@ -272,6 +273,20 @@ export class TrainSchedulingService {
|
||||
throw new BadRequestException('Schedule has no train set');
|
||||
}
|
||||
|
||||
// Batch parity: a schedule may only allocate bookings that targeted it. This mirrors
|
||||
// the automatic fill, which only pulls bookings whose train_schedule_id is this schedule.
|
||||
if (dto.bookingIds.length) {
|
||||
const targeted = await this.bookingsRepository.findByIdsForScheduling(dto.bookingIds);
|
||||
const stray = targeted.filter((b) => b.trainScheduleId !== scheduleId);
|
||||
if (stray.length) {
|
||||
throw new BadRequestException(
|
||||
`These bookings are not assigned to this schedule: ${stray
|
||||
.map((b) => b.reference ?? b.id)
|
||||
.join(', ')}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const previewDto = {
|
||||
bookingIds: dto.bookingIds,
|
||||
scheduleDate: schedule.scheduledDepartureDate.toISOString(),
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
Boxes,
|
||||
FileText,
|
||||
LayoutDashboard,
|
||||
LayoutGrid,
|
||||
Network,
|
||||
Paperclip,
|
||||
Settings,
|
||||
@@ -37,6 +38,7 @@ import RuleEngineLegacyRedirect from "./pages/ruleEngine/RuleEngineLegacyRedirec
|
||||
import RuleEngineResourcePage from "./pages/ruleEngine/RuleEngineResourcePage";
|
||||
import TrainsPage from "./pages/trains/TrainsPage";
|
||||
import TrainScheduleV2ListPage from "./pages/trainScheduling/TrainScheduleV2ListPage";
|
||||
import BatchBoardPage from "./pages/trainScheduling/BatchBoardPage";
|
||||
import TrainScheduleV2DetailPage from "./pages/trainScheduling/TrainScheduleV2DetailPage";
|
||||
import TrainScheduleTrackPage from "./pages/trainScheduling/TrainScheduleTrackPage";
|
||||
import TrainSchedulingGlobalRulesPage from "./pages/trainScheduling/TrainSchedulingGlobalRulesPage";
|
||||
@@ -76,6 +78,11 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
href: "/dashboard/operations/train-scheduling-v2",
|
||||
icon: <Train />,
|
||||
},
|
||||
{
|
||||
label: "Batch Board",
|
||||
href: "/dashboard/operations/batch-board",
|
||||
icon: <LayoutGrid />,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -257,6 +264,7 @@ const App = () => {
|
||||
element={<BookingContractPage />}
|
||||
/>
|
||||
<Route path="operations/train-scheduling" element={<TrainsPage />} />
|
||||
<Route path="operations/batch-board" element={<BatchBoardPage />} />
|
||||
<Route
|
||||
path="operations/train-scheduling-v2"
|
||||
element={<TrainScheduleV2ListPage />}
|
||||
|
||||
@@ -48,6 +48,7 @@ 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,
|
||||
},
|
||||
|
||||
FLEET: {
|
||||
|
||||
@@ -69,6 +69,11 @@ export const URL_CONSTANTS = {
|
||||
BOOKINGS: (id: string | number) => `/customers/${id}/bookings`,
|
||||
},
|
||||
|
||||
COMPANIES: {
|
||||
BASE: "/companies",
|
||||
BY_ID: (id: string | number) => `/companies/${id}`,
|
||||
},
|
||||
|
||||
CUSTOMERS_API: {
|
||||
BASE: "/api/customers",
|
||||
BY_ID: (id: string) => `/api/customers/${id}`,
|
||||
@@ -133,6 +138,7 @@ export const URL_CONSTANTS = {
|
||||
TRAIN_SCHEDULING: {
|
||||
ELIGIBLE_BOOKINGS: "/train-scheduling/eligible-bookings",
|
||||
BOOKABLE_SCHEDULES: "/train-scheduling/bookable-schedules",
|
||||
BATCH_BOARD: "/train-scheduling/batch-board",
|
||||
RUN_BATCH: (id: string) => `/train-scheduling/schedules/${id}/run-batch`,
|
||||
BOOKING_WINDOW: (id: string) => `/train-scheduling/schedules/${id}/booking-window`,
|
||||
MARK_BOOKING_PAID: (bookingId: string) =>
|
||||
|
||||
@@ -18,6 +18,13 @@ export const useScheduleList = (freightType?: FreightType) =>
|
||||
queryFn: () => trainSchedulingService.listSchedules(freightType),
|
||||
});
|
||||
|
||||
export const useBatchBoard = () =>
|
||||
useQuery({
|
||||
queryKey: QUERY_KEYS.TRAIN_SCHEDULING.batchBoard(),
|
||||
queryFn: () => trainSchedulingService.getBatchBoard(),
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
|
||||
export const useScheduleDetail = (id: string | undefined, freightType?: FreightType) =>
|
||||
useQuery({
|
||||
queryKey: QUERY_KEYS.TRAIN_SCHEDULING.scheduleById(id ?? ""),
|
||||
|
||||
@@ -49,11 +49,10 @@ import { api } from "@/auth/http";
|
||||
import { unwrap } from "@/utils/endpoint";
|
||||
import { URL_CONSTANTS } from "@/constants/URLS";
|
||||
|
||||
interface CustomerOption {
|
||||
interface CompanyOption {
|
||||
id: string;
|
||||
companyName?: string | null;
|
||||
firstName?: string | null;
|
||||
lastName?: string | null;
|
||||
name?: string | null;
|
||||
tin?: string | null;
|
||||
email?: string | null;
|
||||
}
|
||||
|
||||
@@ -192,21 +191,17 @@ export default function NewBookingPage() {
|
||||
queryFn: () => bookingsService.getReferenceData() as Promise<ReferenceData>,
|
||||
});
|
||||
|
||||
const { data: customers, isLoading: customersLoading } = useQuery({
|
||||
queryKey: ["customers", "list"],
|
||||
const { data: companies, isLoading: companiesLoading } = useQuery({
|
||||
queryKey: ["companies", "list"],
|
||||
queryFn: async () => {
|
||||
const res = await api.get(URL_CONSTANTS.CUSTOMERS.BASE);
|
||||
return unwrap(res.data) as CustomerOption[];
|
||||
const res = await api.get(URL_CONSTANTS.COMPANIES.BASE);
|
||||
return unwrap(res.data) as CompanyOption[];
|
||||
},
|
||||
});
|
||||
|
||||
const customerOptions = (customers ?? []).map((c) => ({
|
||||
const companyOptions = (companies ?? []).map((c) => ({
|
||||
value: c.id,
|
||||
label:
|
||||
c.companyName ||
|
||||
[c.firstName, c.lastName].filter(Boolean).join(" ") ||
|
||||
c.email ||
|
||||
c.id,
|
||||
label: c.name || c.email || c.tin || c.id,
|
||||
}));
|
||||
|
||||
const { data: bookableSchedules, isLoading: schedulesLoading } = useBookableSchedules(
|
||||
@@ -219,6 +214,14 @@ export default function NewBookingPage() {
|
||||
s.scheduleDate,
|
||||
).toLocaleString()} · ${s.remainingWagons}/${s.maxWagons} wagons free`,
|
||||
}));
|
||||
const selectedSchedule = (bookableSchedules ?? []).find((s) => s.id === trainScheduleId);
|
||||
|
||||
// When a schedule is chosen its date IS the departure; otherwise fall back to the manual field.
|
||||
const effectiveDepartureIso = selectedSchedule
|
||||
? new Date(selectedSchedule.scheduleDate).toISOString()
|
||||
: scheduledDate
|
||||
? new Date(scheduledDate).toISOString()
|
||||
: "";
|
||||
|
||||
const yards = (refData?.yard ?? []).map((y) => ({ value: y.id, label: y.name ?? y.code }));
|
||||
const services = (refData?.service ?? []).map((s) => ({ value: s.id, label: s.name ?? s.code }));
|
||||
@@ -268,7 +271,7 @@ export default function NewBookingPage() {
|
||||
!sameYard &&
|
||||
Boolean(trainScheduleId) &&
|
||||
Boolean(serviceTypeId) &&
|
||||
Boolean(scheduledDate) &&
|
||||
(Boolean(selectedSchedule) || Boolean(scheduledDate)) &&
|
||||
(isGovernment ? governmentInstitution.trim().length >= 2 : Boolean(companyId)) &&
|
||||
(freightType === "BULK"
|
||||
? Boolean(cargoTypeId) && bulkWeight > 0
|
||||
@@ -291,7 +294,7 @@ export default function NewBookingPage() {
|
||||
tradeDirection,
|
||||
paymentCurrency,
|
||||
isHazardous,
|
||||
scheduledDate: scheduledDate ? new Date(scheduledDate).toISOString() : new Date().toISOString(),
|
||||
scheduledDate: effectiveDepartureIso || new Date().toISOString(),
|
||||
originYardId,
|
||||
destinationYardId,
|
||||
trainScheduleId: trainScheduleId || undefined,
|
||||
@@ -399,14 +402,14 @@ export default function NewBookingPage() {
|
||||
) : (
|
||||
<Select
|
||||
label="Customer"
|
||||
placeholder="Select customer"
|
||||
data={customerOptions}
|
||||
placeholder="Select company"
|
||||
data={companyOptions}
|
||||
value={companyId}
|
||||
onChange={setCompanyId}
|
||||
searchable
|
||||
required
|
||||
disabled={customersLoading}
|
||||
nothingFoundMessage="No customers found"
|
||||
disabled={companiesLoading}
|
||||
nothingFoundMessage="No companies found"
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -499,12 +502,21 @@ export default function NewBookingPage() {
|
||||
|
||||
<FormSection icon={CalendarClock} title="Schedule & payment" accent="grape">
|
||||
<Group grow align="flex-start">
|
||||
<TextInput
|
||||
label="Preferred departure"
|
||||
type="datetime-local"
|
||||
value={scheduledDate}
|
||||
onChange={(e) => setScheduledDate(e.target.value)}
|
||||
/>
|
||||
{selectedSchedule ? (
|
||||
<TextInput
|
||||
label="Departure"
|
||||
value={new Date(selectedSchedule.scheduleDate).toLocaleString()}
|
||||
readOnly
|
||||
description="Taken from the selected train schedule"
|
||||
/>
|
||||
) : (
|
||||
<TextInput
|
||||
label="Preferred departure"
|
||||
type="datetime-local"
|
||||
value={scheduledDate}
|
||||
onChange={(e) => setScheduledDate(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
<Select
|
||||
label="Payment currency"
|
||||
data={[
|
||||
@@ -753,7 +765,7 @@ export default function NewBookingPage() {
|
||||
/>
|
||||
<SummaryRow
|
||||
label="Departure"
|
||||
value={scheduledDate ? new Date(scheduledDate).toLocaleString() : "Not set"}
|
||||
value={effectiveDepartureIso ? new Date(effectiveDepartureIso).toLocaleString() : "Not set"}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Container,
|
||||
Group,
|
||||
Loader,
|
||||
Paper,
|
||||
Progress,
|
||||
ScrollArea,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
ArrowRight,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
Hourglass,
|
||||
LayoutGrid,
|
||||
RefreshCw,
|
||||
Train,
|
||||
Weight,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
|
||||
import Breadcrumbs from "@/components/ui/Breadcrumbs";
|
||||
import { useBatchBoard } from "@/hooks/trainScheduling/useTrainScheduling";
|
||||
import type {
|
||||
BatchBoardBooking,
|
||||
BatchBoardBookingState,
|
||||
BatchBoardSchedule,
|
||||
} from "@/types/trainScheduling";
|
||||
|
||||
const STATE_META: Record<
|
||||
BatchBoardBookingState,
|
||||
{ label: string; color: string; icon: typeof CheckCircle2 }
|
||||
> = {
|
||||
ALLOCATED: { label: "Allocated", color: "green", icon: CheckCircle2 },
|
||||
AWAITING_PAYMENT: { label: "Awaiting payment", color: "orange", icon: Clock },
|
||||
WAITING: { label: "Paid · waiting", color: "blue", icon: Hourglass },
|
||||
PENDING_CONTRACT: { label: "Pending contract", color: "gray", icon: Hourglass },
|
||||
EXPIRED: { label: "Expired", color: "red", icon: XCircle },
|
||||
};
|
||||
|
||||
const fmtTons = (n: number) =>
|
||||
`${n.toLocaleString(undefined, { maximumFractionDigits: 1 })} t`;
|
||||
|
||||
function StateBadge({ state }: { state: BatchBoardBookingState }) {
|
||||
const meta = STATE_META[state];
|
||||
const Icon = meta.icon;
|
||||
return (
|
||||
<Badge variant="light" color={meta.color} radius="sm" leftSection={<Icon size={11} />}>
|
||||
{meta.label}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
function BookingRow({ booking }: { booking: BatchBoardBooking }) {
|
||||
return (
|
||||
<Group justify="space-between" wrap="nowrap" gap="sm" py={6} px="xs">
|
||||
<Group gap="xs" wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<Text size="sm" fw={600} truncate>
|
||||
{booking.reference}
|
||||
</Text>
|
||||
{booking.isGovernment ? (
|
||||
<Badge size="xs" variant="light" color="grape" radius="sm">
|
||||
Gov
|
||||
</Badge>
|
||||
) : null}
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{booking.company}
|
||||
</Text>
|
||||
</Group>
|
||||
<Group gap="sm" wrap="nowrap" style={{ flexShrink: 0 }}>
|
||||
<Text size="xs" c="dimmed">
|
||||
{booking.wagons}w · {fmtTons(booking.weightTons)}
|
||||
</Text>
|
||||
<StateBadge state={booking.state} />
|
||||
</Group>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
function ScheduleCard({ schedule }: { schedule: BatchBoardSchedule }) {
|
||||
const navigate = useNavigate();
|
||||
const { capacity, counts, locomotive } = schedule;
|
||||
const wagonPct =
|
||||
capacity.maxWagons > 0 ? (capacity.usedWagons / capacity.maxWagons) * 100 : 0;
|
||||
const weightPct =
|
||||
capacity.maxWeightTons && capacity.maxWeightTons > 0
|
||||
? (capacity.usedWeightTons / capacity.maxWeightTons) * 100
|
||||
: 0;
|
||||
|
||||
const windowColor =
|
||||
schedule.bookingWindowStatus === "OPEN"
|
||||
? "green"
|
||||
: schedule.bookingWindowStatus === "FULL"
|
||||
? "orange"
|
||||
: "gray";
|
||||
|
||||
return (
|
||||
<Paper radius="lg" withBorder style={{ borderColor: "var(--mantine-color-gray-2)", overflow: "hidden" }}>
|
||||
<Box style={{ height: 3, background: "linear-gradient(90deg, var(--mantine-color-green-5), var(--mantine-color-teal-7))" }} />
|
||||
<Stack gap="sm" p="md">
|
||||
<Group justify="space-between" align="flex-start" wrap="nowrap">
|
||||
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<ThemeIcon size={38} radius="md" variant="light" color="green">
|
||||
<Train size={19} />
|
||||
</ThemeIcon>
|
||||
<Box style={{ minWidth: 0 }}>
|
||||
<Text fw={700} size="sm" truncate>
|
||||
{schedule.trainNumber ?? schedule.routeName ?? "Schedule"}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{schedule.origin ?? "—"} → {schedule.destination ?? "—"}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{schedule.scheduleDate
|
||||
? new Date(schedule.scheduleDate).toLocaleString()
|
||||
: "No date"}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
<Stack gap={4} align="flex-end">
|
||||
<Badge variant="light" color={windowColor} radius="sm">
|
||||
{schedule.bookingWindowStatus}
|
||||
</Badge>
|
||||
<Badge variant="outline" color="gray" radius="sm">
|
||||
{schedule.status}
|
||||
</Badge>
|
||||
</Stack>
|
||||
</Group>
|
||||
|
||||
{locomotive ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
Loco {locomotive.code} · max {fmtTons(locomotive.maxPullWeightTons)} ·{" "}
|
||||
{locomotive.maxTrainLengthMeters} m
|
||||
</Text>
|
||||
) : (
|
||||
<Badge variant="light" color="red" radius="sm">
|
||||
No locomotive — won't allocate
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{/* Capacity meters */}
|
||||
<Box>
|
||||
<Group justify="space-between" mb={2}>
|
||||
<Text size="xs" c="dimmed">
|
||||
Wagons
|
||||
</Text>
|
||||
<Text size="xs" fw={600}>
|
||||
{capacity.usedWagons}/{capacity.maxWagons}
|
||||
</Text>
|
||||
</Group>
|
||||
<Progress value={wagonPct} color={wagonPct >= 100 ? "orange" : "green"} radius="xl" size="sm" />
|
||||
</Box>
|
||||
{capacity.maxWeightTons ? (
|
||||
<Box>
|
||||
<Group justify="space-between" mb={2}>
|
||||
<Group gap={4}>
|
||||
<Weight size={12} />
|
||||
<Text size="xs" c="dimmed">
|
||||
Weight
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="xs" fw={600}>
|
||||
{fmtTons(capacity.usedWeightTons)}/{fmtTons(capacity.maxWeightTons)}
|
||||
</Text>
|
||||
</Group>
|
||||
<Progress value={weightPct} color={weightPct >= 100 ? "red" : "teal"} radius="xl" size="sm" />
|
||||
</Box>
|
||||
) : null}
|
||||
|
||||
{/* Count chips */}
|
||||
<Group gap={6}>
|
||||
<Tooltip label="Allocated to the train">
|
||||
<Badge variant="light" color="green" radius="sm">
|
||||
{counts.allocated} allocated
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
<Tooltip label="Notified — 1h to pay">
|
||||
<Badge variant="light" color="orange" radius="sm">
|
||||
{counts.awaitingPayment} to pay
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
<Tooltip label="Paid, waiting for a slot">
|
||||
<Badge variant="light" color="blue" radius="sm">
|
||||
{counts.waiting} waiting
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
{counts.expired ? (
|
||||
<Badge variant="light" color="red" radius="sm">
|
||||
{counts.expired} expired
|
||||
</Badge>
|
||||
) : null}
|
||||
</Group>
|
||||
|
||||
{/* Bookings */}
|
||||
{schedule.bookings.length ? (
|
||||
<ScrollArea.Autosize mah={220}>
|
||||
<Stack gap={2}>
|
||||
{schedule.bookings.map((b) => (
|
||||
<BookingRow key={b.id} booking={b} />
|
||||
))}
|
||||
</Stack>
|
||||
</ScrollArea.Autosize>
|
||||
) : (
|
||||
<Text size="xs" c="dimmed" ta="center" py="sm">
|
||||
No bookings targeting this schedule yet.
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="light"
|
||||
color="green"
|
||||
radius="md"
|
||||
size="compact-sm"
|
||||
rightSection={<ArrowRight size={15} />}
|
||||
onClick={() =>
|
||||
navigate(`/dashboard/operations/train-scheduling-v2/${schedule.scheduleId}`)
|
||||
}
|
||||
>
|
||||
Open schedule
|
||||
</Button>
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
export default function BatchBoardPage() {
|
||||
const { data, isLoading, isFetching, refetch } = useBatchBoard();
|
||||
const schedules = data ?? [];
|
||||
|
||||
return (
|
||||
<Container size="xl" py="lg">
|
||||
<Breadcrumbs
|
||||
items={[{ label: "Operations" }, { label: "Batch board" }]}
|
||||
/>
|
||||
|
||||
<Paper
|
||||
radius="xl"
|
||||
p="xl"
|
||||
mt="md"
|
||||
style={{
|
||||
background: "#ffffff",
|
||||
border: "1px solid var(--mantine-color-gray-2)",
|
||||
boxShadow: "0 1px 3px rgba(15,23,42,0.04)",
|
||||
}}
|
||||
>
|
||||
<Group justify="space-between" align="flex-start" wrap="wrap">
|
||||
<Group gap="md" align="center" wrap="nowrap">
|
||||
<ThemeIcon size={54} radius="lg" variant="light" color="green">
|
||||
<LayoutGrid size={26} />
|
||||
</ThemeIcon>
|
||||
<Stack gap={2}>
|
||||
<Text size="xs" fw={700} c="green.7" tt="uppercase" style={{ letterSpacing: 1 }}>
|
||||
Allocation · Live
|
||||
</Text>
|
||||
<Title order={2} fw={700} style={{ color: "#0f172a" }}>
|
||||
Batch board
|
||||
</Title>
|
||||
<Text size="sm" c="dimmed" maw={560}>
|
||||
Every active schedule with its bookings grouped by state — allocated, awaiting
|
||||
payment, paid-waiting and expired. Filling is automatic; this is the live view.
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
<Button
|
||||
variant="default"
|
||||
radius="lg"
|
||||
leftSection={<RefreshCw size={16} />}
|
||||
loading={isFetching}
|
||||
onClick={() => void refetch()}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
</Group>
|
||||
</Paper>
|
||||
|
||||
{isLoading ? (
|
||||
<Group justify="center" py="xl">
|
||||
<Loader color="green" />
|
||||
</Group>
|
||||
) : schedules.length === 0 ? (
|
||||
<Paper radius="lg" withBorder p="xl" mt="lg" bg="gray.0">
|
||||
<Text ta="center" c="dimmed">
|
||||
No active schedules to show.
|
||||
</Text>
|
||||
</Paper>
|
||||
) : (
|
||||
<SimpleGrid cols={{ base: 1, md: 2, xl: 3 }} spacing="lg" mt="lg">
|
||||
{schedules.map((s) => (
|
||||
<ScheduleCard key={s.scheduleId} schedule={s} />
|
||||
))}
|
||||
</SimpleGrid>
|
||||
)}
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
@@ -102,9 +102,11 @@ export default function TrainScheduleV2DetailPage() {
|
||||
? {
|
||||
originStationId: schedule.originStation?.id,
|
||||
destinationStationId: schedule.destinationStation?.id,
|
||||
// Only this schedule's own bookings are eligible — same rule as the auto batch.
|
||||
trainScheduleId: scheduleId,
|
||||
}
|
||||
: undefined,
|
||||
[schedule],
|
||||
[schedule, scheduleId],
|
||||
);
|
||||
|
||||
const eligibleFreightType =
|
||||
|
||||
@@ -2,6 +2,7 @@ import { api as client } from '../auth/http';
|
||||
import { unwrap } from '@/utils/endpoint';
|
||||
import { URL_CONSTANTS } from '@/constants/URLS';
|
||||
import type {
|
||||
BatchBoardSchedule,
|
||||
BookableSchedule,
|
||||
AssignBookingsPayload,
|
||||
CreateTrainSchedulePayload,
|
||||
@@ -78,6 +79,13 @@ export const trainSchedulingService = {
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
getBatchBoard: async (): Promise<BatchBoardSchedule[]> => {
|
||||
const response = await client.get<BatchBoardSchedule[]>(
|
||||
URL_CONSTANTS.TRAIN_SCHEDULING.BATCH_BOARD,
|
||||
);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
getBookableSchedules: async (
|
||||
originYardId?: string,
|
||||
destinationYardId?: string,
|
||||
|
||||
@@ -178,6 +178,56 @@ export interface BookableSchedule {
|
||||
locomotive: { id: string; code: string; name?: string | null } | null;
|
||||
}
|
||||
|
||||
export type BatchBoardBookingState =
|
||||
| "ALLOCATED"
|
||||
| "AWAITING_PAYMENT"
|
||||
| "WAITING"
|
||||
| "PENDING_CONTRACT"
|
||||
| "EXPIRED";
|
||||
|
||||
export interface BatchBoardBooking {
|
||||
id: string;
|
||||
reference: string;
|
||||
company: string;
|
||||
isGovernment: boolean;
|
||||
wagons: number;
|
||||
weightTons: number;
|
||||
paymentDeadline: string | null;
|
||||
state: BatchBoardBookingState;
|
||||
}
|
||||
|
||||
export interface BatchBoardSchedule {
|
||||
scheduleId: string;
|
||||
trainNumber: string | null;
|
||||
routeName: string | null;
|
||||
origin: string | null;
|
||||
destination: string | null;
|
||||
scheduleDate: string | null;
|
||||
status: string;
|
||||
bookingWindowStatus: string;
|
||||
locomotive: {
|
||||
code: string;
|
||||
name: string | null;
|
||||
maxPullWeightTons: number;
|
||||
maxTrainLengthMeters: number;
|
||||
} | null;
|
||||
capacity: {
|
||||
maxWagons: number;
|
||||
usedWagons: number;
|
||||
remainingWagons: number;
|
||||
usedWeightTons: number;
|
||||
maxWeightTons: number | null;
|
||||
};
|
||||
counts: {
|
||||
allocated: number;
|
||||
awaitingPayment: number;
|
||||
waiting: number;
|
||||
pendingContract: number;
|
||||
expired: number;
|
||||
};
|
||||
bookings: BatchBoardBooking[];
|
||||
}
|
||||
|
||||
export interface TrainScheduleWagonAllocation {
|
||||
id: string;
|
||||
bookingId: string;
|
||||
@@ -314,6 +364,8 @@ export interface TrainScheduleFilters {
|
||||
originStationId?: string;
|
||||
destinationStationId?: string;
|
||||
schedulingStatus?: SchedulingStatus;
|
||||
/** Scope eligible bookings to a single schedule (batch parity). */
|
||||
trainScheduleId?: string;
|
||||
}
|
||||
|
||||
export interface TrainSchedulePreviewPayload {
|
||||
|
||||
Reference in New Issue
Block a user