mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
Add contract booking windows feature
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<
|
||||
|
||||
@@ -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)" })
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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[]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }>;
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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 }} />
|
||||
|
||||
@@ -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!} />;
|
||||
}
|
||||
|
||||
|
||||
@@ -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}.`;
|
||||
}
|
||||
@@ -376,6 +376,12 @@ export const api = {
|
||||
"myBookingWindows",
|
||||
() => bookingsService.getMyBookingWindows(),
|
||||
),
|
||||
|
||||
getContractBookingWindows: endpoint<{ contractId: string }, MyBookingWindow[]>(
|
||||
"train-scheduling",
|
||||
"contractBookingWindows",
|
||||
({ contractId }) => bookingsService.getContractBookingWindows(contractId),
|
||||
),
|
||||
},
|
||||
|
||||
contracts: {
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user