add booking window to gl

This commit is contained in:
Marshal
2026-07-03 23:30:49 +00:00
parent c2649dae3c
commit 69818ab3f9
5 changed files with 279 additions and 13 deletions

View File

@@ -61,13 +61,14 @@ export class TrainSchedulingController {
@Get("my-booking-windows")
@ApiOperation({
summary:
"Upcoming/open booking windows on the signed-in customer's active contract lanes",
"Upcoming/open booking windows announced to the signed-in customer (all window-engine schedules; their own contract lanes carry a Book-now target)",
})
async getMyBookingWindows(@CurrentUser() user: AuthUserPayload) {
// Every customer sees announced windows; companyId (when resolvable) just
// enriches lanes they hold a contract on so "Book now" can target it.
const companyId = await this.billingService.resolveCompanyId(
resolveAuthUserId(user),
);
if (!companyId) return [];
return this.trainSchedulingService.getBookingWindowsForCompany(companyId);
}

View File

@@ -3121,14 +3121,20 @@ export class TrainSchedulingService {
}
/**
* Upcoming/open booking windows for a customer's active-contract lanes —
* powers the portal home "booking windows" section. Only window-engine
* schedules (IMPORT cycle / EXPORT lead) are listed; DOMESTIC trains are
* always open and need no announcement.
* Upcoming/open booking windows announced on the portal home "booking
* windows" section. ALL window-engine schedules (IMPORT cycle / EXPORT lead)
* are listed so every customer sees what is opening — not just those on their
* contract lanes; DOMESTIC trains are always open and need no announcement.
*
* When `companyId` is given, a matching active contract on the lane is
* LEFT-JOINed in so the row carries `contractId`/`contractKind` (enabling
* "Book now"); customers with no covering contract still see the window with a
* null contract, and the portal routes them to the contract list to get one.
*/
async getBookingWindowsForCompany(companyId: string) {
async getBookingWindowsForCompany(companyId: string | null) {
const rows: Array<BookingWindowRow> = await this.dataSource.query(
`SELECT DISTINCT ts.id AS schedule_id,
`SELECT DISTINCT ON (ts.id)
ts.id AS schedule_id,
cr.contract_id AS contract_id,
c.contract_kind AS contract_kind,
ts.direction,
@@ -3143,11 +3149,11 @@ export class TrainSchedulingService {
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
LEFT 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.deleted_at IS NULL
JOIN freight.contracts c
LEFT JOIN freight.contracts c
ON c.id = cr.contract_id
AND c.company_id = $1
AND c.status IN ('CONTRACT_ACTIVE', 'FULLY_EXECUTED')
@@ -3159,10 +3165,16 @@ export class TrainSchedulingService {
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`,
ORDER BY ts.id, c.id NULLS LAST, ts.window_opens_at ASC NULLS LAST`,
[companyId],
);
return rows.map((r) => this.mapBookingWindowRow(r));
return rows
.map((r) => this.mapBookingWindowRow(r))
.sort((a, b) => {
const ta = a.windowOpensAt ? new Date(a.windowOpensAt).getTime() : Infinity;
const tb = b.windowOpensAt ? new Date(b.windowOpensAt).getTime() : Infinity;
return ta - tb;
});
}
/**

View File

@@ -0,0 +1,250 @@
import { useMemo } from "react";
import { Badge, Box, Card, Group, ScrollArea, Skeleton, Stack, Text } from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
import { ArrowRight, CalendarClock } from "lucide-react";
import { CountdownTimer } from "@edr/ui-common";
import { api } from "@/services/api";
import type { BatchBoardSchedule } from "@/types/trainScheduling";
/** All window times are communicated in East Africa Time. */
const TZ = "Africa/Addis_Ababa";
function fmtDay(iso: string): string {
return new Date(iso).toLocaleDateString("en-GB", {
weekday: "short",
day: "numeric",
month: "short",
timeZone: TZ,
});
}
function fmtTime(iso: string): string {
return new Date(iso).toLocaleTimeString("en-GB", {
hour: "2-digit",
minute: "2-digit",
hour12: false,
timeZone: TZ,
});
}
function windowLabel(w: BatchBoardSchedule): string {
if (w.windowOpensAt && w.windowClosesAt) {
return `${fmtDay(w.windowOpensAt)} · ${fmtTime(w.windowOpensAt)} ${fmtTime(
w.windowClosesAt,
)} EAT`;
}
if (w.windowOpensAt) {
return `Opens ${fmtDay(w.windowOpensAt)} · ${fmtTime(w.windowOpensAt)} EAT`;
}
return (w.windowPhase ?? w.bookingWindowStatus).replace(/_/g, " ");
}
/**
* The countdown for whichever phase the window is currently in, mirroring the
* customer portal. Phases run pre-window (opens at windowOpensAt) → open (closes
* at windowClosesAt) → document review (docReviewEndsAt) → payment
* (paymentPhaseEndsAt). `expiredText` names the NEXT step so a deadline that
* lapses between the 60s refetches announces what comes next rather than the
* bare word "Expired". Returns null when no phase is timing down.
*/
function phaseCountdown(
w: BatchBoardSchedule,
): { label: string; deadline: string; expiredText: string } | null {
switch (w.windowPhase) {
case "PRE_WINDOW":
return w.windowOpensAt
? {
label: "Booking opens in",
deadline: w.windowOpensAt,
expiredText: "Booking opening now…",
}
: null;
case "OPEN":
return w.windowClosesAt
? {
label: "Window closes in",
deadline: w.windowClosesAt,
expiredText: "Document review starting…",
}
: null;
case "DOC_REVIEW":
return w.docReviewEndsAt
? {
label: "Document review ends in",
deadline: w.docReviewEndsAt,
expiredText: "Payment starting…",
}
: null;
case "PAYMENT":
return w.paymentPhaseEndsAt
? {
label: "Payment window ends in",
deadline: w.paymentPhaseEndsAt,
expiredText: "Payment window closing…",
}
: null;
default:
return null;
}
}
function isOpenNow(w: BatchBoardSchedule): boolean {
return w.windowPhase === "OPEN" && w.bookingWindowStatus === "OPEN";
}
/** Drop windows whose booking window (or the train itself) has already passed. */
function isPast(w: BatchBoardSchedule): boolean {
const now = Date.now();
const closes = w.windowClosesAt ? new Date(w.windowClosesAt).getTime() : null;
const departs = w.scheduleDate ? new Date(w.scheduleDate).getTime() : null;
// Still live while in a post-close staff phase (doc review / payment).
if (w.windowPhase === "DOC_REVIEW" || w.windowPhase === "PAYMENT") return false;
if (departs != null && departs <= now) return true;
if (closes != null && closes <= now) return true;
return false;
}
/**
* Upcoming / open import booking windows across all train schedules, shown to GL
* ET on the clearance queue so they can see which lanes are accepting bookings
* (mirrors the customer's portal "Booking Windows" card). Hidden when nothing is
* pending. Windows already past close/departure are dropped.
*/
export function GlUpcomingWindowsSection() {
const { data, isLoading } = useQuery(
api.trainScheduling.batchBoard.queryOptions({ refetchInterval: 60_000 }),
);
const windows = useMemo(() => {
const rows = (data ?? []).filter(
(w) => w.windowPhase != null && w.windowPhase !== "DONE" && !isPast(w),
);
// Open lanes first, then by opening time.
return rows.sort((a, b) => {
const openDiff = Number(isOpenNow(b)) - Number(isOpenNow(a));
if (openDiff !== 0) return openDiff;
const at = a.windowOpensAt ? new Date(a.windowOpensAt).getTime() : Infinity;
const bt = b.windowOpensAt ? new Date(b.windowOpensAt).getTime() : Infinity;
return at - bt;
});
}, [data]);
if (!isLoading && windows.length === 0) return null;
return (
<Card withBorder shadow="sm" radius="lg" p="lg">
<Group gap={8} mb="md" wrap="nowrap">
<CalendarClock size={18} />
<Box>
<Text fw={700} fz={16}>
Booking windows
</Text>
<Text fz={13} c="dimmed">
Upcoming and open import booking windows across all lanes (EAT)
</Text>
</Box>
</Group>
{isLoading ? (
<Stack gap={8}>
{[1, 2].map((i) => (
<Skeleton key={i} height={58} radius="md" />
))}
</Stack>
) : (
<ScrollArea.Autosize mah={340} type="hover">
<Stack gap={10} pr={4}>
{windows.map((w) => {
const open = isOpenNow(w);
const cd = phaseCountdown(w);
return (
<Group
key={`${w.scheduleId}-${w.bookingCycleNo}`}
justify="space-between"
wrap="nowrap"
gap={12}
p="sm"
style={{
borderRadius: 12,
border: `1px solid ${
open
? "var(--mantine-color-edr-green-2)"
: "var(--mantine-color-gray-2)"
}`,
backgroundColor: open
? "var(--mantine-color-edr-green-0)"
: undefined,
}}
>
<Box style={{ minWidth: 0 }}>
<Group gap={6} wrap="nowrap">
<Text fz={14} fw={700} truncate>
{w.origin ?? "—"}
</Text>
<ArrowRight size={13} style={{ flexShrink: 0 }} />
<Text fz={14} fw={700} truncate>
{w.destination ?? "—"}
</Text>
{w.trainNumber ? (
<Text fz={12} c="dimmed" truncate>
· {w.trainNumber}
</Text>
) : null}
</Group>
<Text fz={12} c="dimmed" truncate mt={2}>
{windowLabel(w)}
{w.scheduleDate ? ` · Departs ${fmtDay(w.scheduleDate)}` : ""}
</Text>
{cd ? (
<Box mt={4}>
<CountdownTimer
deadline={cd.deadline}
label={cd.label}
expiredText={cd.expiredText}
size="xs"
/>
</Box>
) : null}
</Box>
<Group gap={8} wrap="nowrap" style={{ flexShrink: 0 }}>
{w.direction ? (
<Badge
variant="light"
color={w.direction === "IMPORT" ? "blue" : "teal"}
radius="sm"
>
{w.direction === "IMPORT" ? "Import" : "Export"}
</Badge>
) : null}
<Badge
variant={open ? "filled" : "light"}
color={
open
? "edr-green"
: w.windowPhase === "PRE_WINDOW"
? "yellow"
: "gray"
}
radius="sm"
>
{open
? "Open now"
: w.windowPhase === "PRE_WINDOW" && w.windowOpensAt
? `Opens ${fmtTime(w.windowOpensAt)} EAT`
: (w.windowPhase ?? w.bookingWindowStatus).replace(
/_/g,
" ",
)}
</Badge>
</Group>
</Group>
);
})}
</Stack>
</ScrollArea.Autosize>
)}
</Card>
);
}

View File

@@ -50,6 +50,7 @@ import {
useEtClearanceQueue,
} from "@/hooks/contracts/useContracts";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import { GlUpcomingWindowsSection } from "@/components/contracts/GlUpcomingWindowsSection";
type ViewMode = "table" | "cards";
type QueueTab = "all" | "et";
@@ -446,6 +447,8 @@ export default function ContractClearanceListPage() {
]}
/>
<GlUpcomingWindowsSection />
<Card p={0} withBorder shadow="sm" radius="lg">
<Stack gap={0}>
{queueTabOptions.length > 1 ? (

View File

@@ -40,7 +40,7 @@ import {
XCircle,
} from "lucide-react";
import { DataTable, type ColumnDef } from "@edr/ui-common";
import { CountdownTimer, DataTable, type ColumnDef } from "@edr/ui-common";
import { KpiStrip, PageContainer } from "@/components/page";
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";