From 7eae1a920ed169a7a2285360e8c99a95bbf18a19 Mon Sep 17 00:00:00 2001 From: Marshal Date: Fri, 3 Jul 2026 15:01:16 +0000 Subject: [PATCH] Add contract booking windows feature --- .../bookings/booking-transition.service.ts | 13 +- .../booking-window.service.ts | 77 +++++++- .../train-scheduling.controller.ts | 11 ++ .../train-scheduling.service.ts | 169 +++++++++++++++--- .../contracts/GlCreateBookingForm.tsx | 78 +++++++- .../backoffice/src/constants/URLS.ts | 2 + .../pages/customers/CustomerDetailPage.tsx | 8 +- .../TrainSchedulingGlobalRulesPage.tsx | 58 ++++-- .../backoffice/src/services/api.ts | 13 ++ .../src/services/trainScheduling.service.ts | 14 ++ .../backoffice/src/types/trainScheduling.ts | 19 ++ .../portal/src/constants/URLS.ts | 2 + .../components/UpcomingWindowsSection.tsx | 27 ++- .../pages/contracts/ContractDetailPage.tsx | 72 ++++++-- .../src/pages/contracts/NewShipmentPage.tsx | 69 ++++++- .../src/pages/contracts/booking-window.ts | 63 +++++++ .../portal/src/services/api.ts | 6 + .../portal/src/services/bookings.service.ts | 16 ++ 18 files changed, 637 insertions(+), 80 deletions(-) create mode 100644 apps/edr-freight-web/portal/src/pages/contracts/booking-window.ts diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts index 3e1ea3cd4..8423e9606 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts @@ -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); } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts index f2fe5db07..da235c562 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts @@ -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 { @@ -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 { + 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(); + const seenEmail = new Set(); + 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< diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts index 8887fb4c6..268b0a0b9 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts @@ -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)" }) diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index fbcb82142..3c4c614f8 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts @@ -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 = { 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 { + 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 = 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 = 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). diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx index f9bd7683d..ef9b2453f 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx @@ -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(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() { ) : null} + {!windowsLoading && !windowOpen ? ( + } + title="Booking window is closed" + mb="lg" + > + GL can create a booking only while a window is open.{" "} + {nextWindow?.windowOpensAt ? ( + <> + Next window: {fmtWindowOpensAt(nextWindow.windowOpensAt)} EAT{" "} + for{" "} + + {nextWindow.origin ?? "Origin"} → {nextWindow.destination ?? "Destination"} + + . + + ) : ( + <>No upcoming booking window scheduled. + )} + + ) : null} + + {windowsLoading || windowOpen ? ( + <> ) : null} + + ) : null} ); } diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts index 083dd0efc..5bb9e3f7d 100644 --- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts @@ -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) => diff --git a/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx index 7f62ea3f5..ad888b1f6 100644 --- a/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx @@ -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( diff --git a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainSchedulingGlobalRulesPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainSchedulingGlobalRulesPage.tsx index d5209886d..efdeb9c2a 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainSchedulingGlobalRulesPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainSchedulingGlobalRulesPage.tsx @@ -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> = {}; + 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} /> ({ ...current, maxTrainWeightTons: value })) } min={1} + clampBehavior="strict" disabled={loading} /> ({ ...current, maxWagonsPerTrain: value })) } min={1} + clampBehavior="strict" disabled={loading} /> @@ -135,6 +158,7 @@ export default function TrainSchedulingGlobalRulesPage() { setForm((current) => ({ ...current, importWindowLeadDays: value })) } min={0} + clampBehavior="strict" disabled={loading} /> ({ ...current, exportBookingLeadHours: value })) } min={1} + clampBehavior="strict" disabled={loading} /> ({ ...current, docReviewMinutes: value })) } min={0} + clampBehavior="strict" disabled={loading} /> ({ ...current, paymentWindowMinutes: value })) } min={1} + clampBehavior="strict" disabled={loading} /> ({ ...current, reopenDelayMinutes: value })) } min={1} + clampBehavior="strict" disabled={loading} /> diff --git a/apps/edr-freight-web/backoffice/src/services/api.ts b/apps/edr-freight-web/backoffice/src/services/api.ts index 0edb591ac..a49ec0617 100644 --- a/apps/edr-freight-web/backoffice/src/services/api.ts +++ b/apps/edr-freight-web/backoffice/src/services/api.ts @@ -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[] diff --git a/apps/edr-freight-web/backoffice/src/services/trainScheduling.service.ts b/apps/edr-freight-web/backoffice/src/services/trainScheduling.service.ts index 0d6ef6cc9..dd8784f25 100644 --- a/apps/edr-freight-web/backoffice/src/services/trainScheduling.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/trainScheduling.service.ts @@ -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 => { + const response = await client.get( + URL_CONSTANTS.TRAIN_SCHEDULING.CONTRACT_BOOKING_WINDOWS(contractId), + ); + return unwrap(response.data); + }, + getBookableSchedules: async ( originYardId?: string, destinationYardId?: string, diff --git a/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts b/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts index 2e8ec2b21..0cb30fe77 100644 --- a/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts +++ b/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts @@ -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 }>; diff --git a/apps/edr-freight-web/portal/src/constants/URLS.ts b/apps/edr-freight-web/portal/src/constants/URLS.ts index ce2c35c1a..a4e20b029 100644 --- a/apps/edr-freight-web/portal/src/constants/URLS.ts +++ b/apps/edr-freight-web/portal/src/constants/URLS.ts @@ -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: { diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/UpcomingWindowsSection.tsx b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/UpcomingWindowsSection.tsx index fbb704292..2aa9adbbb 100644 --- a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/UpcomingWindowsSection.tsx +++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/UpcomingWindowsSection.tsx @@ -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 - } > @@ -189,6 +185,25 @@ export const UpcomingWindowsSection = memo(function UpcomingWindowsSection({ + {w.isOpenNow && ( + + )} ))} diff --git a/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx index f15200d49..97b7afb8b 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx @@ -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 )} - {canBookShipment && ( - - )} + {canBookShipment && + (bookingWindowOpen ? ( + + ) : ( + + + + + {closedWindowMessage(bookingWindows)} + + + + ))} {glPreparingBooking && ( Bookings under this contract - {canBookShipment && ( + {canBookShipment && bookingWindowOpen && ( + + + } + title="Booking is not open right now" + > + {closedWindowMessage(bookingWindows)} + + Come back when the booking window opens to book your shipment. + + + + + ); + } + return ; } diff --git a/apps/edr-freight-web/portal/src/pages/contracts/booking-window.ts b/apps/edr-freight-web/portal/src/pages/contracts/booking-window.ts new file mode 100644 index 000000000..44b97c741 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/contracts/booking-window.ts @@ -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}.`; +} diff --git a/apps/edr-freight-web/portal/src/services/api.ts b/apps/edr-freight-web/portal/src/services/api.ts index ed359d998..81a2488a9 100644 --- a/apps/edr-freight-web/portal/src/services/api.ts +++ b/apps/edr-freight-web/portal/src/services/api.ts @@ -376,6 +376,12 @@ export const api = { "myBookingWindows", () => bookingsService.getMyBookingWindows(), ), + + getContractBookingWindows: endpoint<{ contractId: string }, MyBookingWindow[]>( + "train-scheduling", + "contractBookingWindows", + ({ contractId }) => bookingsService.getContractBookingWindows(contractId), + ), }, contracts: { diff --git a/apps/edr-freight-web/portal/src/services/bookings.service.ts b/apps/edr-freight-web/portal/src/services/bookings.service.ts index 225f14e15..f92e20cf4 100644 --- a/apps/edr-freight-web/portal/src/services/bookings.service.ts +++ b/apps/edr-freight-web/portal/src/services/bookings.service.ts @@ -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 => { + const { data } = await client.get( + URL_CONSTANTS.TRAIN_SCHEDULING.CONTRACT_BOOKING_WINDOWS(contractId), + ); + return data.data ?? data; + }, };