Add contract booking windows feature

This commit is contained in:
Marshal
2026-07-03 15:01:16 +00:00
parent c977deb460
commit 7eae1a920e
18 changed files with 637 additions and 80 deletions

View File

@@ -1052,16 +1052,11 @@ export class BookingTransitionService {
// only reserve once both partners are FULLY_EXECUTED (handled inside).
const fresh = await this.bookingsService.findById(booking.id);
await this.bookingBatchService.acceptExportBooking(fresh);
} else if (booking.tradeDirection === "IMPORT") {
// Import bookings wait for their booking-day window cycle — the batch runs
// after staff document review, never at accept time.
} else if (booking.scheduledDate) {
this.bookingBatchService.enqueueRouteDayProcessing(
booking.originYardId,
booking.destinationYardId,
eatDay(new Date(booking.scheduledDate)),
);
}
// IMPORT and DOMESTIC bookings wait for their booking-day window cycle — the
// batch runs after the window closes + staff document review, never at accept
// time. (Legacy pre-migration schedules with no window phase are still served
// by the periodic legacy fill.)
return this.bookingsService.findById(booking.id);
}

View File

@@ -7,6 +7,7 @@ import { TrainScheduleStatus as TrainScheduleStatusEnum } from '@edr/types';
import { Booking } from '../bookings/entities/booking.entity';
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository';
import { NotificationsService } from '../notifications/notifications.service';
import { BookingBatchService } from './booking-batch.service';
import { TrainSchedulingService } from './train-scheduling.service';
import { BATCH_TIMEZONE } from './booking-batch.constants';
@@ -19,12 +20,13 @@ import { type BookingWindowConfig } from './booking-window.config';
* schedule row, so every transition is derived purely from the clock — a restart
* resumes mid-phase with no loss (onModuleInit runs one tick immediately).
*
* Import phases: PRE_WINDOW → OPEN (customers book) → DOC_REVIEW (staff accept
* documents) → PAYMENT (batch reserves in priority order, customers pay) →
* reopen same day | CLOSED_FOR_DAY | DONE (full → auto-finalized).
* Import & domestic phases: PRE_WINDOW → OPEN (customers book) → DOC_REVIEW
* (staff accept documents) → PAYMENT (batch reserves in priority order, customers
* pay) → reopen same day | CLOSED_FOR_DAY | DONE (full → auto-finalized).
* Export phases: PRE_WINDOW → OPEN → DONE (no batch, no priority).
* Legacy/DOMESTIC schedules have windowPhase NULL and are served by the legacy
* fill (runBatchFill), which this tick invokes every 5th minute.
* Only PRE-MIGRATION rows have windowPhase NULL; those are served by the legacy
* fill (runBatchFill), which this tick invokes every 5th minute. New schedules of
* every direction get a window phase.
*/
@Injectable()
export class BookingWindowService implements OnModuleInit {
@@ -37,6 +39,7 @@ export class BookingWindowService implements OnModuleInit {
private readonly trainSchedulesRepository: TrainSchedulesRepository,
private readonly bookingBatchService: BookingBatchService,
private readonly trainSchedulingService: TrainSchedulingService,
private readonly notifications: NotificationsService,
) {}
async onModuleInit(): Promise<void> {
@@ -156,6 +159,7 @@ export class BookingWindowService implements OnModuleInit {
await this.bookingBatchService.setWindow(schedule.id, 'OPEN');
schedule.bookingWindowStatus = 'OPEN';
}
await this.notifyWindowOpened(schedule);
this.logger.log(`Export booking window opened for schedule ${schedule.id}`);
return true;
}
@@ -193,6 +197,8 @@ export class BookingWindowService implements OnModuleInit {
await this.bookingBatchService.setWindow(schedule.id, 'OPEN');
schedule.bookingWindowStatus = 'OPEN';
}
// Only announce the first opening of the day; reopen cycles don't re-notify.
if (schedule.bookingCycleNo === 1) await this.notifyWindowOpened(schedule);
this.logger.log(
`Import booking window opened for schedule ${schedule.id} (cycle ${schedule.bookingCycleNo})`,
);
@@ -325,6 +331,67 @@ export class BookingWindowService implements OnModuleInit {
}
}
/**
* SMS + email every active-contract customer on this schedule's route when its
* booking window opens, so they can book from the portal home before it closes.
* Fire-and-forget; a failed notification never blocks the window transition.
*/
private async notifyWindowOpened(schedule: TrainSchedule): Promise<void> {
try {
const rows: Array<{ phone: string | null; email: string | null }> =
await this.dataSource.query(
`SELECT DISTINCT
COALESCE(co.contact_person_phone, co.phone) AS phone,
COALESCE(co.email, co.general_manager_email) AS email
FROM freight.contract_routes cr
JOIN freight.contracts c
ON c.id = cr.contract_id
AND c.status IN ('CONTRACT_ACTIVE', 'FULLY_EXECUTED')
AND c.deleted_at IS NULL
JOIN freight.companies co ON co.id = c.company_id
WHERE cr.origin_yard_id = $1
AND cr.destination_yard_id = $2
AND cr.deleted_at IS NULL`,
[schedule.originStationId, schedule.destinationStationId],
);
if (!rows.length) return;
const closes = schedule.windowClosesAt
? schedule.windowClosesAt.toLocaleString('en-GB', { timeZone: BATCH_TIMEZONE })
: 'later today';
const depart = schedule.scheduledDepartureDate.toLocaleDateString('en-GB', {
timeZone: BATCH_TIMEZONE,
});
const msg =
`Booking is now open for the train departing ${depart}. ` +
`Book your shipment from the portal home page before ${closes} EAT.`;
const seenPhone = new Set<string>();
const seenEmail = new Set<string>();
for (const r of rows) {
if (r.phone && !seenPhone.has(r.phone)) {
seenPhone.add(r.phone);
await this.notifications
.directSend('sms', r.phone, msg)
.catch((e) => this.logger.warn(`Window-open SMS failed: ${(e as Error).message}`));
}
if (r.email && !seenEmail.has(r.email)) {
seenEmail.add(r.email);
await this.notifications
.directSend('email', r.email, msg)
.catch((e) => this.logger.warn(`Window-open email failed: ${(e as Error).message}`));
}
}
this.logger.log(
`Notified ${seenPhone.size} phone / ${seenEmail.size} email contacts of open window for schedule ${schedule.id}`,
);
} catch (err) {
this.logger.warn(
`notifyWindowOpened failed for ${schedule.id}: ${(err as Error).message}`,
);
}
}
private async setPhase(
schedule: TrainSchedule,
patch: Partial<

View File

@@ -70,6 +70,17 @@ export class TrainSchedulingController {
return this.trainSchedulingService.getBookingWindowsForCompany(companyId);
}
@Get("contracts/:contractId/booking-windows")
@ApiOperation({
summary:
"Upcoming/open booking windows on a contract's routes — gates the booking form for customer + Ethiopian GL",
})
getContractBookingWindows(
@Param("contractId", ParseUUIDPipe) contractId: string,
) {
return this.trainSchedulingService.getBookingWindowsForContract(contractId);
}
@Get("global-rules")
@TrainSchedulingView()
@ApiOperation({ summary: "Get global train scheduling rules (singleton)" })

View File

@@ -9,6 +9,7 @@ import {
BadRequestException,
ConflictException,
Injectable,
Logger,
NotFoundException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
@@ -168,8 +169,27 @@ const DEFAULT_TRAIN_LIMITS: Required<TrainLimitConfig> = {
max20ftPairWeightDiffTons: 10,
};
/** Raw row shape for the booking-window queries (company- and contract-scoped). */
interface BookingWindowRow {
schedule_id: string;
contract_id: string | null;
direction: string | null;
window_phase: string | null;
window_opens_at: Date | null;
window_closes_at: Date | null;
booking_window_status: string;
booking_cycle_no: number;
scheduled_departure_date: Date;
origin_label: string | null;
origin_code: string | null;
destination_label: string | null;
destination_code: string | null;
}
@Injectable()
export class TrainSchedulingService {
private readonly logger = new Logger(TrainSchedulingService.name);
constructor(
@InjectDataSource()
private readonly dataSource: DataSource,
@@ -248,7 +268,68 @@ export class TrainSchedulingService {
if (dto.docReviewMinutes != null) row.docReviewMinutes = dto.docReviewMinutes;
if (dto.paymentWindowMinutes != null) row.paymentWindowMinutes = dto.paymentWindowMinutes;
if (dto.reopenDelayMinutes != null) row.reopenDelayMinutes = dto.reopenDelayMinutes;
return this.dataSource.getRepository(TrainSchedulingGlobalRules).save(row);
// Fields that change the STAMPED open/close times of a schedule. docReview/
// payment/reopen are read live by the cron each tick, so they need no
// re-stamp; only the four below feed computeImport/ExportWindowTimes.
const windowTimingChanged =
dto.importWindowLeadDays != null ||
dto.windowOpenHour != null ||
dto.windowDurationHours != null ||
dto.exportBookingLeadHours != null;
const saved = await this.dataSource
.getRepository(TrainSchedulingGlobalRules)
.save(row);
// The cron reads config fresh every tick, so derived timings (doc review,
// payment, reopen) take effect on the next tick with no restart. But each
// schedule's initial open/close times were FROZEN at creation — re-stamp the
// ones whose window has not opened yet so a config edit applies to them too.
if (windowTimingChanged) {
await this.restampPendingWindows();
}
return saved;
}
/**
* Re-derive windowOpensAt/windowClosesAt for schedules whose booking window has
* not opened yet (windowPhase === 'PRE_WINDOW', still Draft/Scheduled, departure
* in the future) using the CURRENT global-rules config. Schedules already OPEN or
* past their window are left untouched — customers may have booked against the
* times they were shown, so those stay frozen. Returns the count re-stamped.
*/
async restampPendingWindows(): Promise<number> {
const cfg = await this.getWindowConfig();
const now = new Date();
const schedules = await this.trainSchedulesRepository.findAll({
where: [
{ status: TrainScheduleStatusEnum.Draft, windowPhase: 'PRE_WINDOW' },
{ status: TrainScheduleStatusEnum.Scheduled, windowPhase: 'PRE_WINDOW' },
],
});
const repo = this.dataSource.getRepository(TrainSchedule);
let restamped = 0;
for (const s of schedules) {
if (!s.scheduledDepartureDate || s.scheduledDepartureDate <= now) continue;
const times =
s.direction === 'EXPORT'
? computeExportWindowTimes(s.scheduledDepartureDate, cfg)
: computeImportWindowTimes(s.scheduledDepartureDate, cfg, now);
await repo.update(s.id, {
windowOpensAt: times.windowOpensAt,
windowClosesAt: times.windowClosesAt,
});
restamped += 1;
}
if (restamped > 0) {
this.logger.log(
`Re-stamped booking windows for ${restamped} pending schedule(s) after a global-rules change`,
);
}
return restamped;
}
/**
@@ -383,24 +464,24 @@ export class TrainSchedulingService {
// Effective capacity is capped by the weakest locomotive in the set.
const limitLoco = minLocomotiveLimits(lockedLocomotives) ?? undefined;
const departure = new Date(dto.scheduleDate);
// IMPORT/EXPORT trains start with a CLOSED customer window; the window engine
// opens it on schedule (import: booking day at 08:00 EAT; export: 24h lead).
// DOMESTIC keeps the legacy always-OPEN behavior (windowPhase stays NULL).
// Every schedule starts with a CLOSED customer window; the window engine opens
// 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 windowFields =
direction === 'IMPORT'
direction === 'EXPORT'
? {
bookingWindowStatus: 'CLOSED',
windowPhase: 'PRE_WINDOW',
...computeImportWindowTimes(departure, windowCfg, new Date()),
...computeExportWindowTimes(departure, windowCfg),
}
: direction === 'EXPORT'
? {
bookingWindowStatus: 'CLOSED',
windowPhase: 'PRE_WINDOW',
...computeExportWindowTimes(departure, windowCfg),
}
: {};
: {
// IMPORT and DOMESTIC share the import booking-day window cycle.
bookingWindowStatus: 'CLOSED',
windowPhase: 'PRE_WINDOW',
...computeImportWindowTimes(departure, windowCfg, new Date()),
};
const schedule = manager.getRepository(TrainSchedule).create({
trainSetId: trainSet.id,
routeId: route.id,
@@ -2921,21 +3002,9 @@ export class TrainSchedulingService {
* always open and need no announcement.
*/
async getBookingWindowsForCompany(companyId: string) {
const rows: Array<{
schedule_id: string;
direction: string | null;
window_phase: string | null;
window_opens_at: Date | null;
window_closes_at: Date | null;
booking_window_status: string;
booking_cycle_no: number;
scheduled_departure_date: Date;
origin_label: string | null;
origin_code: string | null;
destination_label: string | null;
destination_code: string | null;
}> = await this.dataSource.query(
const rows: Array<BookingWindowRow> = await this.dataSource.query(
`SELECT DISTINCT ts.id AS schedule_id,
cr.contract_id AS contract_id,
ts.direction,
ts.window_phase,
ts.window_opens_at,
@@ -2965,8 +3034,50 @@ export class TrainSchedulingService {
ORDER BY ts.window_opens_at ASC NULLS LAST`,
[companyId],
);
return rows.map((r) => ({
return rows.map((r) => this.mapBookingWindowRow(r));
}
/**
* Upcoming/open booking windows on a single contract's routes. Used to gate the
* booking form for the customer AND Ethiopian GL (who books on the customer's
* behalf): no window row with isOpenNow=true → booking entry is hidden.
*/
async getBookingWindowsForContract(contractId: string) {
const rows: Array<BookingWindowRow> = await this.dataSource.query(
`SELECT DISTINCT ts.id AS schedule_id,
cr.contract_id AS contract_id,
ts.direction,
ts.window_phase,
ts.window_opens_at,
ts.window_closes_at,
ts.booking_window_status,
ts.booking_cycle_no,
ts.scheduled_departure_date,
oy.label AS origin_label, oy.code AS origin_code,
dy.label AS destination_label, dy.code AS destination_code
FROM freight.train_schedules ts
JOIN freight.contract_routes cr
ON cr.origin_yard_id = ts.origin_station_id
AND cr.destination_yard_id = ts.destination_station_id
AND cr.contract_id = $1
AND cr.deleted_at IS NULL
LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id
LEFT JOIN freight.yards dy ON dy.id = ts.destination_station_id
WHERE ts.deleted_at IS NULL
AND ts.status IN ('DRAFT', 'SCHEDULED')
AND ts.window_phase IS NOT NULL
AND ts.window_phase NOT IN ('DONE', 'CLOSED_FOR_DAY')
AND ts.scheduled_departure_date >= now()
ORDER BY ts.window_opens_at ASC NULLS LAST`,
[contractId],
);
return rows.map((r) => this.mapBookingWindowRow(r));
}
private mapBookingWindowRow(r: BookingWindowRow) {
return {
scheduleId: r.schedule_id,
contractId: r.contract_id,
direction: r.direction,
windowPhase: r.window_phase,
isOpenNow: r.window_phase === 'OPEN' && r.booking_window_status === 'OPEN',
@@ -2977,7 +3088,7 @@ export class TrainSchedulingService {
departureDate: r.scheduled_departure_date,
origin: r.origin_label ?? r.origin_code ?? null,
destination: r.destination_label ?? r.destination_code ?? null,
}));
};
}
/** OPEN schedules a new booking may target (with rough remaining capacity).

View File

@@ -58,6 +58,25 @@ import {
StepLabel,
} from "./gl-booking-form/form-ui";
/** All booking-window times are communicated in East Africa Time. */
const EAT_TZ = "Africa/Addis_Ababa";
function fmtWindowOpensAt(iso: string): string {
const date = new Date(iso).toLocaleDateString("en-GB", {
weekday: "short",
day: "numeric",
month: "short",
timeZone: EAT_TZ,
});
const time = new Date(iso).toLocaleTimeString("en-GB", {
hour: "2-digit",
minute: "2-digit",
hour12: false,
timeZone: EAT_TZ,
});
return `${date} · ${time}`;
}
interface UnitDraft {
containerNumber: string;
sealNumber: string;
@@ -106,6 +125,33 @@ export default function GlCreateBookingForm() {
enabled: Boolean(requestId),
});
// Same window-gating the customer sees: GL may only create a booking while a
// booking window is OPEN for one of the contract's routes.
const contractId = contract?.id ?? id;
const { data: bookingWindows, isLoading: windowsLoading } = useQuery({
...api.trainScheduling.contractBookingWindows.queryOptions({
input: { contractId: contractId ?? "" },
}),
enabled: Boolean(contractId),
});
const windowOpen = useMemo(
() => (bookingWindows ?? []).some((w) => w.isOpenNow),
[bookingWindows],
);
// Soonest future window across all routes, used for the "next window" notice.
const nextWindow = useMemo(() => {
const now = Date.now();
return (bookingWindows ?? [])
.filter((w) => w.windowOpensAt && new Date(w.windowOpensAt).getTime() > now)
.sort(
(a, b) =>
new Date(a.windowOpensAt!).getTime() -
new Date(b.windowOpensAt!).getTime(),
)[0];
}, [bookingWindows]);
const [scheduledDate, setScheduledDate] = useState("");
const [contractRouteId, setContractRouteId] = useState<string | null>(null);
const [notes, setNotes] = useState("");
@@ -314,12 +360,13 @@ export default function GlCreateBookingForm() {
);
const canSubmit =
windowOpen &&
Boolean(scheduledDate) &&
(!needsRouteSelect || Boolean(contractRouteId)) &&
(isContainer ? containerLines.some((l) => l.units.length > 0) : bulkLines.length > 0);
const handleSubmit = () => {
if (!scheduledDate || !contract) return;
if (!scheduledDate || !contract || !windowOpen) return;
const payload: Freight.CreateBookingUnderContractDto = {
scheduledDate,
@@ -451,6 +498,33 @@ export default function GlCreateBookingForm() {
</Alert>
) : null}
{!windowsLoading && !windowOpen ? (
<Alert
color="orange"
variant="light"
radius="md"
icon={<AlertCircle size={16} />}
title="Booking window is closed"
mb="lg"
>
GL can create a booking only while a window is open.{" "}
{nextWindow?.windowOpensAt ? (
<>
Next window: <b>{fmtWindowOpensAt(nextWindow.windowOpensAt)} EAT</b>{" "}
for{" "}
<b>
{nextWindow.origin ?? "Origin"} {nextWindow.destination ?? "Destination"}
</b>
.
</>
) : (
<>No upcoming booking window scheduled.</>
)}
</Alert>
) : null}
{windowsLoading || windowOpen ? (
<>
<Stack gap="lg" maw={896} mx="auto">
<StepCard>
<StepHeader
@@ -846,6 +920,8 @@ export default function GlCreateBookingForm() {
</Stack>
) : null}
</Modal>
</>
) : null}
</PageContainer>
);
}

View File

@@ -286,6 +286,8 @@ export const URL_CONSTANTS = {
`/train-scheduling/schedules/${id}/assign-unassigned-booking`,
BOOKING_WINDOW: (id: string) =>
`/train-scheduling/schedules/${id}/booking-window`,
CONTRACT_BOOKING_WINDOWS: (contractId: string) =>
`/train-scheduling/contracts/${contractId}/booking-windows`,
MARK_BOOKING_PAID: (bookingId: string) =>
`/train-scheduling/bookings/${bookingId}/mark-paid`,
EXPIRE_BOOKING: (bookingId: string) =>

View File

@@ -135,9 +135,11 @@ export default function CustomerDetailPage() {
}),
);
const bookings = bookingsQuery.data ?? [];
const documents = documentsQuery.data ?? [];
const payments = paymentsQuery.data ?? [];
const bookings = Array.isArray(bookingsQuery.data) ? bookingsQuery.data : [];
const documents = Array.isArray(documentsQuery.data)
? documentsQuery.data
: [];
const payments = Array.isArray(paymentsQuery.data) ? paymentsQuery.data : [];
const invoices = invoicesQuery.data?.items ?? [];
const invoiceTotal = invoicesQuery.data?.total ?? 0;
const invoicePageCount = Math.max(

View File

@@ -29,22 +29,40 @@ export default function TrainSchedulingGlobalRulesPage() {
}, [toast]);
const handleSave = async () => {
// Every field must hold a real number — an empty box (cleared but not
// refilled) must not silently save as 0. Collect the numeric payload and
// reject if any value is blank or NaN.
const fields: (keyof TrainSchedulingGlobalRules)[] = [
"maxTrainLengthMeters",
"maxTrainWeightTons",
"maxWagonsPerTrain",
"max20ftContainerWeightTons",
"max20ftPairWeightDiffTons",
"importWindowLeadDays",
"exportBookingLeadHours",
"windowOpenHour",
"windowDurationHours",
"docReviewMinutes",
"paymentWindowMinutes",
"reopenDelayMinutes",
];
const payload: Partial<Record<keyof TrainSchedulingGlobalRules, number>> = {};
for (const key of fields) {
const raw = form[key];
const num = raw === "" || raw == null ? NaN : Number(raw);
if (!Number.isFinite(num)) {
toast({
title: "All fields are required — fill every value before saving.",
variant: "destructive",
});
return;
}
payload[key] = num;
}
setSaving(true);
try {
const updated = await trainSchedulingService.updateGlobalRules({
maxTrainLengthMeters: Number(form.maxTrainLengthMeters),
maxTrainWeightTons: Number(form.maxTrainWeightTons),
maxWagonsPerTrain: Number(form.maxWagonsPerTrain),
max20ftContainerWeightTons: Number(form.max20ftContainerWeightTons),
max20ftPairWeightDiffTons: Number(form.max20ftPairWeightDiffTons),
importWindowLeadDays: Number(form.importWindowLeadDays),
exportBookingLeadHours: Number(form.exportBookingLeadHours),
windowOpenHour: Number(form.windowOpenHour),
windowDurationHours: Number(form.windowDurationHours),
docReviewMinutes: Number(form.docReviewMinutes),
paymentWindowMinutes: Number(form.paymentWindowMinutes),
reopenDelayMinutes: Number(form.reopenDelayMinutes),
});
const updated = await trainSchedulingService.updateGlobalRules(payload);
setForm(updated);
toast({ title: "Train scheduling rules saved" });
} catch {
@@ -71,6 +89,7 @@ export default function TrainSchedulingGlobalRulesPage() {
setForm((current) => ({ ...current, maxTrainLengthMeters: value }))
}
min={1}
clampBehavior="strict"
disabled={loading}
/>
<NumberInput
@@ -81,6 +100,7 @@ export default function TrainSchedulingGlobalRulesPage() {
setForm((current) => ({ ...current, maxTrainWeightTons: value }))
}
min={1}
clampBehavior="strict"
disabled={loading}
/>
<NumberInput
@@ -90,6 +110,7 @@ export default function TrainSchedulingGlobalRulesPage() {
setForm((current) => ({ ...current, maxWagonsPerTrain: value }))
}
min={1}
clampBehavior="strict"
disabled={loading}
/>
<NumberInput
@@ -103,6 +124,7 @@ export default function TrainSchedulingGlobalRulesPage() {
}))
}
min={0.001}
clampBehavior="strict"
disabled={loading}
/>
<NumberInput
@@ -116,6 +138,7 @@ export default function TrainSchedulingGlobalRulesPage() {
}))
}
min={0}
clampBehavior="strict"
disabled={loading}
/>
</Stack>
@@ -135,6 +158,7 @@ export default function TrainSchedulingGlobalRulesPage() {
setForm((current) => ({ ...current, importWindowLeadDays: value }))
}
min={0}
clampBehavior="strict"
disabled={loading}
/>
<NumberInput
@@ -145,6 +169,7 @@ export default function TrainSchedulingGlobalRulesPage() {
setForm((current) => ({ ...current, exportBookingLeadHours: value }))
}
min={1}
clampBehavior="strict"
disabled={loading}
/>
<NumberInput
@@ -156,6 +181,7 @@ export default function TrainSchedulingGlobalRulesPage() {
}
min={0}
max={23}
clampBehavior="strict"
disabled={loading}
/>
<NumberInput
@@ -167,6 +193,7 @@ export default function TrainSchedulingGlobalRulesPage() {
min={0.25}
max={12}
step={0.25}
clampBehavior="strict"
disabled={loading}
/>
<NumberInput
@@ -177,6 +204,7 @@ export default function TrainSchedulingGlobalRulesPage() {
setForm((current) => ({ ...current, docReviewMinutes: value }))
}
min={0}
clampBehavior="strict"
disabled={loading}
/>
<NumberInput
@@ -187,6 +215,7 @@ export default function TrainSchedulingGlobalRulesPage() {
setForm((current) => ({ ...current, paymentWindowMinutes: value }))
}
min={1}
clampBehavior="strict"
disabled={loading}
/>
<NumberInput
@@ -197,6 +226,7 @@ export default function TrainSchedulingGlobalRulesPage() {
setForm((current) => ({ ...current, reopenDelayMinutes: value }))
}
min={1}
clampBehavior="strict"
disabled={loading}
/>
<Group justify="flex-end">

View File

@@ -44,6 +44,7 @@ import type {
BatchBoardSchedule,
BatchBoardScheduleDetail,
BookableSchedule,
BookingWindow,
CompositionRemovalEntry,
CreateTrainSchedulePayload,
EligibleContainerBookingsResponse,
@@ -281,6 +282,18 @@ export const api = {
],
),
contractBookingWindows: endpoint<{ contractId: string }, BookingWindow[]>(
"train-scheduling",
"contract-booking-windows",
({ contractId }) =>
trainSchedulingService.getContractBookingWindows(contractId),
({ contractId }) => [
...QUERY_KEYS.TRAIN_SCHEDULING.ROOT,
"contract-booking-windows",
contractId,
],
),
availableDays: endpoint<
{ originYardId?: string | null; destinationYardId?: string | null },
string[]

View File

@@ -6,6 +6,7 @@ import type {
BatchBoardSchedule,
BatchBoardScheduleDetail,
BookableSchedule,
BookingWindow,
AssignBookingsPayload,
CompositionRemovalEntry,
UnassignedBookingsResponse,
@@ -106,6 +107,19 @@ export const trainSchedulingService = {
return unwrap(response.data);
},
/**
* Booking windows for every route/schedule of a contract. A window with
* `isOpenNow === true` means GL may create a booking right now for that route.
*/
getContractBookingWindows: async (
contractId: string,
): Promise<BookingWindow[]> => {
const response = await client.get<BookingWindow[]>(
URL_CONSTANTS.TRAIN_SCHEDULING.CONTRACT_BOOKING_WINDOWS(contractId),
);
return unwrap(response.data);
},
getBookableSchedules: async (
originYardId?: string,
destinationYardId?: string,

View File

@@ -325,6 +325,25 @@ export interface BatchBoardScheduleDetail {
allocationViolations: string[];
}
/**
* A booking window for one of a contract's routes/schedules. `isOpenNow === true`
* means a booking may be created right now for that route. Times are ISO strings;
* render them in EAT (Africa/Addis_Ababa).
*/
export interface BookingWindow {
scheduleId: string;
direction: string | null;
windowPhase: BookingWindowPhase | null;
isOpenNow: boolean;
windowOpensAt: string | null;
windowClosesAt: string | null;
bookingWindowStatus: string;
bookingCycleNo: number;
departureDate: string;
origin: string | null;
destination: string | null;
}
export interface WagonAllocationAttemptResult {
assignedBookingIds: string[];
deferred: Array<{ id: string; reference: string; reason: string }>;

View File

@@ -148,6 +148,8 @@ export const URL_CONSTANTS = {
AVAILABLE_DAYS: "/api/train-scheduling/available-days",
AVAILABLE_DAYS_FOR_CARGO: "/api/train-scheduling/available-days-for-cargo",
MY_BOOKING_WINDOWS: "/api/train-scheduling/my-booking-windows",
CONTRACT_BOOKING_WINDOWS: (contractId: string) =>
`/api/train-scheduling/contracts/${contractId}/booking-windows`,
},
PAYMENTS: {

View File

@@ -1,7 +1,7 @@
import { Box, Group, Skeleton, Stack, Text } from "@mantine/core";
import { Box, Button, Group, Skeleton, Stack, Text } from "@mantine/core";
import { memo } from "react";
import { useNavigate } from "react-router-dom";
import { ArrowRight, CalendarClock } from "lucide-react";
import { ArrowRight, CalendarClock, PackagePlus } from "lucide-react";
import type { MyBookingWindow } from "@/services/bookings.service";
import { Card } from "./Card";
@@ -162,11 +162,7 @@ export const UpcomingWindowsSection = memo(function UpcomingWindowsSection({
borderRadius: 12,
border: `1px solid ${w.isOpenNow ? "#CDEBDD" : BORDER}`,
backgroundColor: w.isOpenNow ? "#F4FBF7" : undefined,
cursor: w.isOpenNow ? "pointer" : "default",
}}
onClick={
w.isOpenNow ? () => navigate("/contracts") : undefined
}
>
<Box style={{ minWidth: 0 }}>
<Group gap={6} wrap="nowrap">
@@ -189,6 +185,25 @@ export const UpcomingWindowsSection = memo(function UpcomingWindowsSection({
<Group gap={8} wrap="nowrap" style={{ flexShrink: 0 }}>
<DirectionBadge direction={w.direction} />
<StatusBadge window={w} />
{w.isOpenNow && (
<Button
size="xs"
radius="md"
color="edr-green"
leftSection={<PackagePlus size={14} />}
// Book straight against the row's contract when it carries
// one; otherwise fall back to the contract list to pick.
onClick={() =>
navigate(
w.contractId
? `/contracts/${w.contractId}/bookings/new`
: "/contracts",
)
}
>
Book now
</Button>
)}
</Group>
</Group>
))}

View File

@@ -61,6 +61,7 @@ import { ContractClearanceWorkflowBanner } from "./ContractClearanceWorkflowBann
import { ClearanceUploadedDocumentsPanel } from "@/components/contracts/ClearanceUploadedDocumentsPanel";
import { formatRateUnit } from "./new-contract-form/unit-rates";
import { getContractBookingAction } from "./contract-booking-action";
import { closedWindowMessage, hasOpenWindow } from "./booking-window";
import {
BORDER,
ContractStatusBadge,
@@ -198,6 +199,18 @@ export default function ContractDetailPage() {
(r) => r.status === "PENDING" || r.status === "ACCEPTED",
);
// Booking windows for this contract's routes — gates the direct "New shipment
// booking" entry so the customer only sees it while a window is open.
// Refetched every minute so "Open now" flips without a manual reload.
const { data: bookingWindows = [] } = useQuery({
...api.bookings.getContractBookingWindows.queryOptions({
input: { contractId: id! },
refetchInterval: 60_000,
}),
enabled: !!id,
});
const bookingWindowOpen = hasOpenWindow(bookingWindows);
const contractBookings = useMemo(
() =>
(bookingsPage?.items ?? []).filter(
@@ -370,17 +383,40 @@ export default function ContractDetailPage() {
Request shipment
</Button>
)}
{canBookShipment && (
<Button
color="edr-green"
radius="md"
size="md"
leftSection={<PackagePlus size={16} />}
onClick={() => navigate(`/contracts/${contract.id}/bookings/new`)}
>
New shipment booking
</Button>
)}
{canBookShipment &&
(bookingWindowOpen ? (
<Button
color="edr-green"
radius="md"
size="md"
leftSection={<PackagePlus size={16} />}
onClick={() =>
navigate(`/contracts/${contract.id}/bookings/new`)
}
>
New shipment booking
</Button>
) : (
<Paper
withBorder
radius="md"
px="md"
py={10}
maw={420}
style={{ borderColor: BORDER, background: "#F8FAFC" }}
>
<Group gap={10} align="flex-start" wrap="nowrap">
<CalendarClock
size={16}
color={MUTED}
style={{ flexShrink: 0, marginTop: 2 }}
/>
<Text fz={13} c="dimmed">
{closedWindowMessage(bookingWindows)}
</Text>
</Group>
</Paper>
))}
{glPreparingBooking && (
<Badge
size="lg"
@@ -1102,7 +1138,7 @@ export default function ContractDetailPage() {
>
<Group justify="space-between" align="center" mb="md">
<SectionLabel>Bookings under this contract</SectionLabel>
{canBookShipment && (
{canBookShipment && bookingWindowOpen && (
<Button
color="edr-green"
radius="md"
@@ -1116,6 +1152,18 @@ export default function ContractDetailPage() {
</Button>
)}
</Group>
{canBookShipment && !bookingWindowOpen && (
<Group gap={8} align="flex-start" wrap="nowrap" mb="md">
<CalendarClock
size={15}
color={MUTED}
style={{ flexShrink: 0, marginTop: 2 }}
/>
<Text fz={13} c="dimmed">
{closedWindowMessage(bookingWindows)}
</Text>
</Group>
)}
{contractBookings.length === 0 ? (
<Stack align="center" gap={10} py="xl">
<Inbox size={26} color={MUTED} style={{ opacity: 0.5 }} />

View File

@@ -52,6 +52,7 @@ import {
} from "./new-shipment-form/schema";
import { computeShipmentTotal } from "./new-shipment-form/total";
import { ContractCapacityNotice } from "./new-shipment-form/ContractCapacityNotice";
import { closedWindowMessage, hasOpenWindow } from "./booking-window";
type ShipmentForm = ReturnType<
typeof useForm<ShipmentFormInputValues, any, ShipmentFormValues>
@@ -65,7 +66,19 @@ export default function NewShipmentPage() {
api.contracts.get.queryOptions({ input: { id: id! }, enabled: !!id }),
);
if (isLoading) {
// Coarse booking-window gate: block the form entirely when no window is
// currently open for the contract's routes. The day-picker inside the form
// still narrows to bookable days; this is the outer "is booking open at all"
// check that mirrors the contract detail page.
const { data: bookingWindows = [], isLoading: windowsLoading } = useQuery({
...api.bookings.getContractBookingWindows.queryOptions({
input: { contractId: id! },
refetchInterval: 60_000,
}),
enabled: !!id,
});
if (isLoading || windowsLoading) {
return (
<Center mih={400} p="xl">
<Loader color="edr-green" />
@@ -113,6 +126,60 @@ export default function NewShipmentPage() {
);
}
// Coarse gate: if the customer deep-links here while no booking window is
// open, show the same closed-state notice as the contract page instead of the
// form. Still allowed the moment any window isOpenNow.
if (!hasOpenWindow(bookingWindows)) {
return (
<Box style={{ padding: "28px 0 0" }}>
<Group
justify="space-between"
px="24px"
align="flex-end"
wrap="wrap"
gap="md"
mb="lg"
>
<Box>
<Title
order={1}
fw={800}
fz={26}
style={{ letterSpacing: "-0.01em" }}
>
New Shipment Booking
</Title>
<Text size="sm" c="edr-muted" mt={4}>
Book a shipment against contract {contract.reference}.
</Text>
</Box>
<Button
variant="default"
radius="md"
leftSection={<ChevronLeft size={16} />}
onClick={() => navigate(`/contracts/${contract.id}`)}
>
Back to contract
</Button>
</Group>
<Box px="24px">
<Alert
color="yellow"
variant="light"
radius="md"
icon={<CalendarDays size={18} />}
title="Booking is not open right now"
>
<Text size="sm">{closedWindowMessage(bookingWindows)}</Text>
<Text size="sm" mt="xs">
Come back when the booking window opens to book your shipment.
</Text>
</Alert>
</Box>
</Box>
);
}
return <NewShipmentBookingForm contract={contract} contractId={id!} />;
}

View File

@@ -0,0 +1,63 @@
import type { MyBookingWindow } from "@/services/bookings.service";
/** All booking-window times are communicated in East Africa Time. */
const TZ = "Africa/Addis_Ababa";
/** "Thu, 10 Jul, 08:00 EAT" — a full opening date/time in Addis Ababa time. */
export function formatWindowOpensAt(iso: string): string {
const day = new Date(iso).toLocaleDateString("en-GB", {
weekday: "short",
day: "numeric",
month: "short",
timeZone: TZ,
});
const time = new Date(iso).toLocaleTimeString("en-GB", {
hour: "2-digit",
minute: "2-digit",
hour12: false,
timeZone: TZ,
});
return `${day}, ${time}`;
}
/** True when at least one of the contract's windows is bookable right now. */
export function hasOpenWindow(windows: MyBookingWindow[]): boolean {
return windows.some((w) => w.isOpenNow);
}
/**
* The soonest upcoming (not-yet-open) window with a known opening time, so the
* customer can be told when to come back. Returns `null` when nothing upcoming
* carries an opening time.
*/
export function soonestUpcomingWindow(
windows: MyBookingWindow[],
): MyBookingWindow | null {
const upcoming = windows
.filter((w) => !w.isOpenNow && w.windowOpensAt)
.sort(
(a, b) =>
new Date(a.windowOpensAt!).getTime() -
new Date(b.windowOpensAt!).getTime(),
);
return upcoming[0] ?? null;
}
/**
* The closed-state message shown when no booking window is open: the soonest
* upcoming window's opening time + lane, or a generic notice when nothing is
* scheduled.
*/
export function closedWindowMessage(windows: MyBookingWindow[]): string {
const next = soonestUpcomingWindow(windows);
if (!next || !next.windowOpensAt) {
return "No upcoming booking window scheduled.";
}
const lane =
next.origin && next.destination
? ` for ${next.origin}${next.destination}`
: "";
return `Booking is not open right now. Next window: ${formatWindowOpensAt(
next.windowOpensAt,
)} EAT${lane}.`;
}

View File

@@ -376,6 +376,12 @@ export const api = {
"myBookingWindows",
() => bookingsService.getMyBookingWindows(),
),
getContractBookingWindows: endpoint<{ contractId: string }, MyBookingWindow[]>(
"train-scheduling",
"contractBookingWindows",
({ contractId }) => bookingsService.getContractBookingWindows(contractId),
),
},
contracts: {

View File

@@ -53,6 +53,8 @@ export interface PriceLineItem {
*/
export interface MyBookingWindow {
scheduleId: string;
/** Contract whose route this window belongs to, when the row carries it. */
contractId: string | null;
direction: "IMPORT" | "EXPORT" | null;
windowPhase: string | null;
isOpenNow: boolean;
@@ -371,4 +373,18 @@ export const bookingsService = {
);
return data.data ?? data;
},
/**
* Booking windows for a single contract's routes (same row shape as
* `getMyBookingWindows`). Used to gate the direct "New shipment booking"
* entry on the contract detail page and the new-shipment form.
*/
getContractBookingWindows: async (
contractId: string,
): Promise<MyBookingWindow[]> => {
const { data } = await client.get(
URL_CONSTANTS.TRAIN_SCHEDULING.CONTRACT_BOOKING_WINDOWS(contractId),
);
return data.data ?? data;
},
};