mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
add booking window to gl
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user