mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 04:08:11 +00:00
Merge branch 'dev' of github.com:Tria-plc/edr-platform into freight/feature/first_mile_invoice
This commit is contained in:
@@ -61,13 +61,14 @@ export class TrainSchedulingController {
|
|||||||
@Get("my-booking-windows")
|
@Get("my-booking-windows")
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary:
|
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) {
|
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(
|
const companyId = await this.billingService.resolveCompanyId(
|
||||||
resolveAuthUserId(user),
|
resolveAuthUserId(user),
|
||||||
);
|
);
|
||||||
if (!companyId) return [];
|
|
||||||
return this.trainSchedulingService.getBookingWindowsForCompany(companyId);
|
return this.trainSchedulingService.getBookingWindowsForCompany(companyId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3121,14 +3121,20 @@ export class TrainSchedulingService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Upcoming/open booking windows for a customer's active-contract lanes —
|
* Upcoming/open booking windows announced on the portal home "booking
|
||||||
* powers the portal home "booking windows" section. Only window-engine
|
* windows" section. ALL window-engine schedules (IMPORT cycle / EXPORT lead)
|
||||||
* schedules (IMPORT cycle / EXPORT lead) are listed; DOMESTIC trains are
|
* are listed so every customer sees what is opening — not just those on their
|
||||||
* always open and need no announcement.
|
* 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(
|
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,
|
cr.contract_id AS contract_id,
|
||||||
c.contract_kind AS contract_kind,
|
c.contract_kind AS contract_kind,
|
||||||
ts.direction,
|
ts.direction,
|
||||||
@@ -3143,11 +3149,11 @@ export class TrainSchedulingService {
|
|||||||
oy.label AS origin_label, oy.code AS origin_code,
|
oy.label AS origin_label, oy.code AS origin_code,
|
||||||
dy.label AS destination_label, dy.code AS destination_code
|
dy.label AS destination_label, dy.code AS destination_code
|
||||||
FROM freight.train_schedules ts
|
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
|
ON cr.origin_yard_id = ts.origin_station_id
|
||||||
AND cr.destination_yard_id = ts.destination_station_id
|
AND cr.destination_yard_id = ts.destination_station_id
|
||||||
AND cr.deleted_at IS NULL
|
AND cr.deleted_at IS NULL
|
||||||
JOIN freight.contracts c
|
LEFT JOIN freight.contracts c
|
||||||
ON c.id = cr.contract_id
|
ON c.id = cr.contract_id
|
||||||
AND c.company_id = $1
|
AND c.company_id = $1
|
||||||
AND c.status IN ('CONTRACT_ACTIVE', 'FULLY_EXECUTED')
|
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 IS NOT NULL
|
||||||
AND ts.window_phase NOT IN ('DONE', 'CLOSED_FOR_DAY')
|
AND ts.window_phase NOT IN ('DONE', 'CLOSED_FOR_DAY')
|
||||||
AND ts.scheduled_departure_date >= now()
|
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],
|
[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,
|
useEtClearanceQueue,
|
||||||
} from "@/hooks/contracts/useContracts";
|
} from "@/hooks/contracts/useContracts";
|
||||||
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||||
|
import { GlUpcomingWindowsSection } from "@/components/contracts/GlUpcomingWindowsSection";
|
||||||
|
|
||||||
type ViewMode = "table" | "cards";
|
type ViewMode = "table" | "cards";
|
||||||
type QueueTab = "all" | "et";
|
type QueueTab = "all" | "et";
|
||||||
@@ -446,6 +447,8 @@ export default function ContractClearanceListPage() {
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<GlUpcomingWindowsSection />
|
||||||
|
|
||||||
<Card p={0} withBorder shadow="sm" radius="lg">
|
<Card p={0} withBorder shadow="sm" radius="lg">
|
||||||
<Stack gap={0}>
|
<Stack gap={0}>
|
||||||
{queueTabOptions.length > 1 ? (
|
{queueTabOptions.length > 1 ? (
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
Accordion,
|
Accordion,
|
||||||
ActionIcon,
|
|
||||||
Alert,
|
Alert,
|
||||||
Badge,
|
Badge,
|
||||||
Box,
|
Box,
|
||||||
@@ -24,9 +23,7 @@ import {
|
|||||||
Boxes,
|
Boxes,
|
||||||
CalendarDays,
|
CalendarDays,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
ChevronLeft,
|
|
||||||
ClipboardCheck,
|
ClipboardCheck,
|
||||||
ChevronRight,
|
|
||||||
Clock,
|
Clock,
|
||||||
FileSignature,
|
FileSignature,
|
||||||
Hourglass,
|
Hourglass,
|
||||||
@@ -40,7 +37,7 @@ import {
|
|||||||
XCircle,
|
XCircle,
|
||||||
} from "lucide-react";
|
} 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 { KpiStrip, PageContainer } from "@/components/page";
|
||||||
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
|
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
|
||||||
@@ -431,101 +428,148 @@ function WindowCountChips({ counts }: { counts: BatchWindowGroup["counts"] }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** "05 Jun 2026 · 06:00 – 09:00 EAT" → "06:00 – 09:00 EAT" (date lives in the day header). */
|
/** "05 Jun 2026 · 06:00 – 09:00 EAT" → "06:00 – 09:00 EAT" (date lives in the day header). */
|
||||||
function timeLabelOf(label: string): string {
|
|
||||||
const idx = label.indexOf("·");
|
|
||||||
return idx >= 0 ? label.slice(idx + 1).trim() : label;
|
|
||||||
}
|
|
||||||
|
|
||||||
const EAT_TZ = "Africa/Addis_Ababa";
|
const EAT_TZ = "Africa/Addis_Ababa";
|
||||||
const dateKeyFmt = new Intl.DateTimeFormat("en-CA", {
|
|
||||||
timeZone: EAT_TZ,
|
|
||||||
year: "numeric",
|
|
||||||
month: "2-digit",
|
|
||||||
day: "2-digit",
|
|
||||||
});
|
|
||||||
const dateLabelFmt = new Intl.DateTimeFormat("en-GB", {
|
const dateLabelFmt = new Intl.DateTimeFormat("en-GB", {
|
||||||
timeZone: EAT_TZ,
|
timeZone: EAT_TZ,
|
||||||
weekday: "short",
|
weekday: "short",
|
||||||
day: "2-digit",
|
day: "2-digit",
|
||||||
month: "short",
|
month: "short",
|
||||||
});
|
});
|
||||||
|
const timeFmt = new Intl.DateTimeFormat("en-GB", {
|
||||||
|
timeZone: EAT_TZ,
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
hour12: false,
|
||||||
|
});
|
||||||
|
|
||||||
/** EAT calendar date key for a window — prefers the API field, falls back to `start`. */
|
interface ScheduleWindow {
|
||||||
function windowDateKey(w: BatchWindowGroup): string {
|
windowPhase: BatchBoardScheduleDetail["windowPhase"];
|
||||||
if (w.date) return w.date;
|
bookingWindowStatus: string;
|
||||||
if (w.start) return dateKeyFmt.format(new Date(w.start));
|
windowOpensAt: string | null;
|
||||||
return "undated";
|
windowClosesAt: string | null;
|
||||||
|
docReviewEndsAt: string | null;
|
||||||
|
paymentPhaseEndsAt: string | null;
|
||||||
|
bookingCycleNo?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Human day label for a window — prefers the API field, falls back to `start`. */
|
/**
|
||||||
function windowDateLabel(w: BatchWindowGroup): string {
|
* Deadline + label for the phase the schedule's booking window is currently in —
|
||||||
if (w.dateLabel) return w.dateLabel;
|
* the SAME phases the customer sees on the portal: pre-window (opens) → open
|
||||||
if (w.start) return dateLabelFmt.format(new Date(w.start));
|
* (closes) → document review → payment. `expiredText` names the next step so a
|
||||||
return "Undated";
|
* lapsed deadline reads as a handover, not a bare "Expired".
|
||||||
|
*/
|
||||||
|
function windowPhaseCountdown(
|
||||||
|
w: ScheduleWindow,
|
||||||
|
): { 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 WindowAccordionItem({ window }: { window: BatchWindowGroup }) {
|
/** One phase row: label + its clock time (or "—" when unset). */
|
||||||
const total = window.bookings.length;
|
function PhaseTimeRow({
|
||||||
const hasIssues = window.bookings.some(
|
label,
|
||||||
(b) => b.allocationStatus === "FAILED" || b.allocationStatus === "DEFERRED",
|
iso,
|
||||||
|
active,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
iso: string | null;
|
||||||
|
active: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Group justify="space-between" gap="sm" wrap="nowrap">
|
||||||
|
<Text size="sm" fw={active ? 700 : 500} c={active ? "edr-green.7" : "dimmed"}>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
<Text size="sm" fw={active ? 700 : 500} c={active ? "dark" : "dimmed"}>
|
||||||
|
{iso ? `${timeFmt.format(new Date(iso))} EAT` : "—"}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The schedule's REAL booking window — the exact same window the customer sees on
|
||||||
|
* the portal (frozen open/close from the schedule's own snapshot + the post-close
|
||||||
|
* document-review and payment phases), with a live countdown to the current phase.
|
||||||
|
* Replaces the old theoretical "3-hour windows across every day" projection.
|
||||||
|
*/
|
||||||
|
function ScheduleWindowPanel({ window: w }: { window: ScheduleWindow }) {
|
||||||
|
const phase = w.windowPhase;
|
||||||
|
const cd = windowPhaseCountdown(w);
|
||||||
|
const open = phase === "OPEN" && w.bookingWindowStatus === "OPEN";
|
||||||
|
|
||||||
|
const openDay = w.windowOpensAt
|
||||||
|
? dateLabelFmt.format(new Date(w.windowOpensAt))
|
||||||
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Accordion.Item value={window.key}>
|
<Paper
|
||||||
<Accordion.Control>
|
withBorder
|
||||||
<Group justify="space-between" wrap="nowrap" pr="md" gap="sm">
|
radius="lg"
|
||||||
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
|
p="md"
|
||||||
<Box
|
mt="md"
|
||||||
style={{
|
style={{
|
||||||
display: "flex",
|
borderColor: open
|
||||||
alignItems: "center",
|
? "var(--mantine-color-edr-green-2)"
|
||||||
justifyContent: "center",
|
: "var(--mantine-color-gray-2)",
|
||||||
width: 34,
|
background: open ? "var(--mantine-color-edr-green-0)" : undefined,
|
||||||
height: 34,
|
}}
|
||||||
borderRadius: 10,
|
>
|
||||||
flexShrink: 0,
|
<Group justify="space-between" wrap="nowrap" mb="sm">
|
||||||
background: total ? "#FEF1D5" : "var(--mantine-color-gray-0)",
|
<Group gap="sm" wrap="nowrap">
|
||||||
border: total
|
{phase ? (
|
||||||
? "1px solid #FBD171"
|
<WindowPhasePill phase={phase} cycleNo={w.bookingCycleNo} />
|
||||||
: "1px solid var(--mantine-color-gray-2)",
|
) : null}
|
||||||
color: total ? "#B26C09" : "var(--mantine-color-gray-5)",
|
<WindowStatusPill status={w.bookingWindowStatus} />
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Clock size={16} />
|
|
||||||
</Box>
|
|
||||||
<Box style={{ minWidth: 0 }}>
|
|
||||||
<Text fw={700} size="sm" truncate>
|
|
||||||
{timeLabelOf(window.label)}
|
|
||||||
</Text>
|
|
||||||
<Text size="xs" c="dimmed">
|
|
||||||
{total
|
|
||||||
? `${total} booking${total === 1 ? "" : "s"}`
|
|
||||||
: "Empty window"}
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
</Group>
|
|
||||||
<Group gap={6} wrap="nowrap">
|
|
||||||
{hasIssues ? (
|
|
||||||
<Badge
|
|
||||||
variant="light"
|
|
||||||
color="red"
|
|
||||||
size="sm"
|
|
||||||
leftSection={<AlertTriangle size={10} />}
|
|
||||||
>
|
|
||||||
Issues
|
|
||||||
</Badge>
|
|
||||||
) : null}
|
|
||||||
<WindowCountChips counts={window.counts} />
|
|
||||||
</Group>
|
|
||||||
</Group>
|
</Group>
|
||||||
</Accordion.Control>
|
{openDay ? (
|
||||||
<Accordion.Panel>
|
<Text size="xs" c="dimmed">
|
||||||
<BookingTable bookings={window.bookings} />
|
Booking day · {openDay}
|
||||||
</Accordion.Panel>
|
</Text>
|
||||||
</Accordion.Item>
|
) : null}
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{cd ? (
|
||||||
|
<Box mb="sm">
|
||||||
|
<CountdownTimer
|
||||||
|
deadline={cd.deadline}
|
||||||
|
label={cd.label}
|
||||||
|
expiredText={cd.expiredText}
|
||||||
|
size="md"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Stack gap={6}>
|
||||||
|
<PhaseTimeRow label="Window opens" iso={w.windowOpensAt} active={phase === "PRE_WINDOW"} />
|
||||||
|
<PhaseTimeRow label="Window closes" iso={w.windowClosesAt} active={phase === "OPEN"} />
|
||||||
|
<PhaseTimeRow label="Document review ends" iso={w.docReviewEndsAt} active={phase === "DOC_REVIEW"} />
|
||||||
|
<PhaseTimeRow label="Payment window ends" iso={w.paymentPhaseEndsAt} active={phase === "PAYMENT"} />
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** EAT calendar date key for a window — prefers the API field, falls back to `start`. */
|
||||||
|
|
||||||
export default function BatchScheduleDetailPage() {
|
export default function BatchScheduleDetailPage() {
|
||||||
const { scheduleId } = useParams<{ scheduleId: string }>();
|
const { scheduleId } = useParams<{ scheduleId: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -577,6 +621,34 @@ export default function BatchScheduleDetailPage() {
|
|||||||
return [...byId.values()];
|
return [...byId.values()];
|
||||||
}, [data]);
|
}, [data]);
|
||||||
|
|
||||||
|
// All bookings that fall inside the schedule's booking window (every window
|
||||||
|
// cycle, flattened) — the window is one booking day, so these belong to the
|
||||||
|
// single window panel above.
|
||||||
|
const windowBookings = useMemo(
|
||||||
|
() => (data?.windows ?? []).flatMap((w) => w.bookings),
|
||||||
|
[data?.windows],
|
||||||
|
);
|
||||||
|
|
||||||
|
const windowCounts = useMemo(() => {
|
||||||
|
const counts = {
|
||||||
|
allocated: 0,
|
||||||
|
selectedForBatch: 0,
|
||||||
|
ready: 0,
|
||||||
|
waiting: 0,
|
||||||
|
expired: 0,
|
||||||
|
pendingContract: 0,
|
||||||
|
};
|
||||||
|
for (const b of windowBookings) {
|
||||||
|
if (b.state === "ALLOCATED") counts.allocated += 1;
|
||||||
|
else if (b.state === "SELECTED_FOR_BATCH") counts.selectedForBatch += 1;
|
||||||
|
else if (b.state === "READY") counts.ready += 1;
|
||||||
|
else if (b.state === "WAITING") counts.waiting += 1;
|
||||||
|
else if (b.state === "EXPIRED") counts.expired += 1;
|
||||||
|
else counts.pendingContract += 1;
|
||||||
|
}
|
||||||
|
return counts;
|
||||||
|
}, [windowBookings]);
|
||||||
|
|
||||||
// Batch bookings by state for the composition side panel (payment / expired lists).
|
// Batch bookings by state for the composition side panel (payment / expired lists).
|
||||||
const batchBookings = useMemo(() => {
|
const batchBookings = useMemo(() => {
|
||||||
const all = allBookings;
|
const all = allBookings;
|
||||||
@@ -591,102 +663,10 @@ export default function BatchScheduleDetailPage() {
|
|||||||
[data?.status],
|
[data?.status],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Group the flat window list into per-day sections (one per EAT calendar date).
|
|
||||||
const dayGroups = useMemo(() => {
|
|
||||||
if (!data) return [];
|
|
||||||
const byDate = new Map<
|
|
||||||
string,
|
|
||||||
{
|
|
||||||
date: string;
|
|
||||||
dateLabel: string;
|
|
||||||
windows: BatchWindowGroup[];
|
|
||||||
totalBookings: number;
|
|
||||||
counts: BatchWindowGroup["counts"];
|
|
||||||
hasIssues: boolean;
|
|
||||||
}
|
|
||||||
>();
|
|
||||||
for (const w of data.windows) {
|
|
||||||
const dateKey = windowDateKey(w);
|
|
||||||
let group = byDate.get(dateKey);
|
|
||||||
if (!group) {
|
|
||||||
group = {
|
|
||||||
date: dateKey,
|
|
||||||
dateLabel: windowDateLabel(w),
|
|
||||||
windows: [],
|
|
||||||
totalBookings: 0,
|
|
||||||
counts: {
|
|
||||||
allocated: 0,
|
|
||||||
selectedForBatch: 0,
|
|
||||||
ready: 0,
|
|
||||||
waiting: 0,
|
|
||||||
expired: 0,
|
|
||||||
pendingContract: 0,
|
|
||||||
},
|
|
||||||
hasIssues: false,
|
|
||||||
};
|
|
||||||
byDate.set(dateKey, group);
|
|
||||||
}
|
|
||||||
group.windows.push(w);
|
|
||||||
group.totalBookings += w.bookings.length;
|
|
||||||
group.counts.allocated += w.counts.allocated;
|
|
||||||
group.counts.selectedForBatch += w.counts.selectedForBatch;
|
|
||||||
group.counts.ready += w.counts.ready;
|
|
||||||
group.counts.waiting += w.counts.waiting;
|
|
||||||
group.counts.expired += w.counts.expired;
|
|
||||||
group.counts.pendingContract += w.counts.pendingContract;
|
|
||||||
group.hasIssues =
|
|
||||||
group.hasIssues ||
|
|
||||||
w.bookings.some(
|
|
||||||
(b) =>
|
|
||||||
b.allocationStatus === "FAILED" ||
|
|
||||||
b.allocationStatus === "DEFERRED",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return [...byDate.values()];
|
|
||||||
}, [data]);
|
|
||||||
|
|
||||||
// Windows with bookings open by default (inside an expanded day).
|
|
||||||
const openWindowKeys = useMemo(
|
|
||||||
() =>
|
|
||||||
data
|
|
||||||
? data.windows.filter((w) => w.bookings.length > 0).map((w) => w.key)
|
|
||||||
: [],
|
|
||||||
[data],
|
|
||||||
);
|
|
||||||
|
|
||||||
const todayEat = useMemo(
|
|
||||||
() =>
|
|
||||||
new Intl.DateTimeFormat("en-CA", {
|
|
||||||
timeZone: "Africa/Addis_Ababa",
|
|
||||||
year: "numeric",
|
|
||||||
month: "2-digit",
|
|
||||||
day: "2-digit",
|
|
||||||
}).format(new Date()),
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Date-stepper: which day is currently shown. Default to today, else the first
|
|
||||||
// day with bookings, else the first day. Keep the selection if still valid.
|
|
||||||
const [selectedDate, setSelectedDate] = useState<string | null>(null);
|
|
||||||
const [activeTab, setActiveTab] = useState<string | null>("overview");
|
const [activeTab, setActiveTab] = useState<string | null>("overview");
|
||||||
const [selectedBookingId, setSelectedBookingId] = useState<string | null>(
|
const [selectedBookingId, setSelectedBookingId] = useState<string | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
useEffect(() => {
|
|
||||||
if (!dayGroups.length) return;
|
|
||||||
if (selectedDate && dayGroups.some((d) => d.date === selectedDate)) return;
|
|
||||||
const preferred =
|
|
||||||
dayGroups.find((d) => d.date === todayEat) ??
|
|
||||||
dayGroups.find((d) => d.totalBookings > 0) ??
|
|
||||||
dayGroups[0];
|
|
||||||
setSelectedDate(preferred.date);
|
|
||||||
}, [dayGroups, selectedDate, todayEat]);
|
|
||||||
|
|
||||||
const selectedIndex = Math.max(
|
|
||||||
0,
|
|
||||||
dayGroups.findIndex((d) => d.date === selectedDate),
|
|
||||||
);
|
|
||||||
const selectedDay = dayGroups[selectedIndex];
|
|
||||||
|
|
||||||
const handleCompleteDocReview = () => {
|
const handleCompleteDocReview = () => {
|
||||||
completeDocReview
|
completeDocReview
|
||||||
@@ -1012,137 +992,30 @@ export default function BatchScheduleDetailPage() {
|
|||||||
<Clock size={19} />
|
<Clock size={19} />
|
||||||
</ThemeIcon>
|
</ThemeIcon>
|
||||||
<Box>
|
<Box>
|
||||||
<Title order={4}>Batch windows (EAT)</Title>
|
<Title order={4}>Booking window (EAT)</Title>
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">
|
||||||
3-hour windows for every day from when the booking window
|
The schedule's real booking window — the same window and
|
||||||
opened through the departure date. Bookings appear under the
|
phase timings the customer sees on the portal. Bookings in
|
||||||
date their contract was signed — open a day to see its
|
the window are listed below.
|
||||||
windows.
|
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
{dayGroups.length && selectedDay ? (
|
<ScheduleWindowPanel window={data} />
|
||||||
<>
|
|
||||||
{/* Date stepper — page back/forward through each day in the range */}
|
|
||||||
<Group
|
|
||||||
justify="center"
|
|
||||||
align="center"
|
|
||||||
wrap="nowrap"
|
|
||||||
gap="md"
|
|
||||||
mt="md"
|
|
||||||
>
|
|
||||||
<ActionIcon
|
|
||||||
variant="light"
|
|
||||||
color="#F2A516"
|
|
||||||
size="xl"
|
|
||||||
radius="xl"
|
|
||||||
aria-label="Previous day"
|
|
||||||
disabled={selectedIndex <= 0}
|
|
||||||
onClick={() =>
|
|
||||||
setSelectedDate(
|
|
||||||
dayGroups[selectedIndex - 1]?.date ?? null,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<ChevronLeft size={20} />
|
|
||||||
</ActionIcon>
|
|
||||||
|
|
||||||
<Paper
|
{windowBookings.length ? (
|
||||||
withBorder
|
<Box mt="lg">
|
||||||
radius="xl"
|
<Group justify="space-between" align="center" mb="sm">
|
||||||
px="xl"
|
<Text fw={700} size="sm">
|
||||||
py="xs"
|
Bookings in this window
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
maxWidth: 360,
|
|
||||||
textAlign: "center",
|
|
||||||
background: selectedDay.totalBookings
|
|
||||||
? "#FEF1D5"
|
|
||||||
: "white",
|
|
||||||
borderColor: selectedDay.totalBookings
|
|
||||||
? "#FBD171"
|
|
||||||
: "var(--mantine-color-gray-2)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Group justify="center" gap={8} wrap="nowrap">
|
|
||||||
<CalendarDays size={15} color="#B26C09" />
|
|
||||||
<Text
|
|
||||||
fw={800}
|
|
||||||
style={{
|
|
||||||
color: selectedDay.totalBookings
|
|
||||||
? "#8A5304"
|
|
||||||
: "#0f172a",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{selectedDay.dateLabel}
|
|
||||||
</Text>
|
|
||||||
{selectedDay.date === todayEat ? (
|
|
||||||
<Badge size="xs" variant="light" color="#F2A516">
|
|
||||||
Today
|
|
||||||
</Badge>
|
|
||||||
) : null}
|
|
||||||
</Group>
|
|
||||||
<Text size="xs" c="dimmed" mt={2}>
|
|
||||||
{selectedDay.totalBookings
|
|
||||||
? `${selectedDay.totalBookings} booking${selectedDay.totalBookings === 1 ? "" : "s"} · ${selectedDay.windows.length} windows`
|
|
||||||
: `${selectedDay.windows.length} windows · no bookings`}
|
|
||||||
</Text>
|
|
||||||
</Paper>
|
|
||||||
|
|
||||||
<ActionIcon
|
|
||||||
variant="light"
|
|
||||||
color="#F2A516"
|
|
||||||
size="xl"
|
|
||||||
radius="xl"
|
|
||||||
aria-label="Next day"
|
|
||||||
disabled={selectedIndex >= dayGroups.length - 1}
|
|
||||||
onClick={() =>
|
|
||||||
setSelectedDate(
|
|
||||||
dayGroups[selectedIndex + 1]?.date ?? null,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<ChevronRight size={20} />
|
|
||||||
</ActionIcon>
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
<Group justify="space-between" align="center" mt="sm">
|
|
||||||
<Text size="xs" c="dimmed">
|
|
||||||
Day {selectedIndex + 1} of {dayGroups.length}
|
|
||||||
</Text>
|
</Text>
|
||||||
<Group gap={6} wrap="nowrap">
|
<WindowCountChips counts={windowCounts} />
|
||||||
{selectedDay.hasIssues ? (
|
|
||||||
<Badge
|
|
||||||
variant="light"
|
|
||||||
color="red"
|
|
||||||
size="sm"
|
|
||||||
leftSection={<AlertTriangle size={10} />}
|
|
||||||
>
|
|
||||||
Issues
|
|
||||||
</Badge>
|
|
||||||
) : null}
|
|
||||||
<WindowCountChips counts={selectedDay.counts} />
|
|
||||||
</Group>
|
|
||||||
</Group>
|
</Group>
|
||||||
|
<BookingTable bookings={windowBookings} />
|
||||||
<Accordion
|
</Box>
|
||||||
key={selectedDay.date}
|
|
||||||
multiple
|
|
||||||
defaultValue={openWindowKeys}
|
|
||||||
variant="separated"
|
|
||||||
radius="md"
|
|
||||||
mt="md"
|
|
||||||
className="bb-window-accordion"
|
|
||||||
>
|
|
||||||
{selectedDay.windows.map((window) => (
|
|
||||||
<WindowAccordionItem key={window.key} window={window} />
|
|
||||||
))}
|
|
||||||
</Accordion>
|
|
||||||
</>
|
|
||||||
) : (
|
) : (
|
||||||
<Text size="sm" c="dimmed" ta="center" py="lg">
|
<Text size="sm" c="dimmed" ta="center" py="lg">
|
||||||
No batch windows for this schedule.
|
No bookings in this window yet.
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -45,22 +45,50 @@ function windowLabel(w: MyBookingWindow): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The deadline + label for whichever phase the window is currently in. Phases
|
* The countdown for whichever phase the window is currently in. Phases run:
|
||||||
* run: window open (closes at windowClosesAt) → document review (docReviewEndsAt)
|
* pre-window (opens at windowOpensAt) → open (closes at windowClosesAt) →
|
||||||
* → payment (paymentPhaseEndsAt). Returns null when no phase is timing down.
|
* document review (docReviewEndsAt) → payment (paymentPhaseEndsAt).
|
||||||
|
*
|
||||||
|
* `label` describes the deadline being counted down to; `expiredText` names the
|
||||||
|
* NEXT step so that when a deadline lapses between the 60s refetches the row
|
||||||
|
* announces what comes next ("Booking opening now…", "Review starting…") rather
|
||||||
|
* than the bare word "Expired". Returns null when no phase is timing down.
|
||||||
*/
|
*/
|
||||||
function phaseCountdown(
|
function phaseCountdown(
|
||||||
w: MyBookingWindow,
|
w: MyBookingWindow,
|
||||||
): { label: string; deadline: string } | null {
|
): { label: string; deadline: string; expiredText: string } | null {
|
||||||
switch (w.windowPhase) {
|
switch (w.windowPhase) {
|
||||||
|
case "PRE_WINDOW":
|
||||||
|
if (w.windowOpensAt)
|
||||||
|
return {
|
||||||
|
label: "Booking opens in",
|
||||||
|
deadline: w.windowOpensAt,
|
||||||
|
expiredText: "Booking opening now…",
|
||||||
|
};
|
||||||
|
return null;
|
||||||
case "OPEN":
|
case "OPEN":
|
||||||
if (w.windowClosesAt) return { label: "Window closes in", deadline: w.windowClosesAt };
|
if (w.windowClosesAt)
|
||||||
|
return {
|
||||||
|
label: "Window closes in",
|
||||||
|
deadline: w.windowClosesAt,
|
||||||
|
expiredText: "Document review starting…",
|
||||||
|
};
|
||||||
return null;
|
return null;
|
||||||
case "DOC_REVIEW":
|
case "DOC_REVIEW":
|
||||||
if (w.docReviewEndsAt) return { label: "Document review ends in", deadline: w.docReviewEndsAt };
|
if (w.docReviewEndsAt)
|
||||||
|
return {
|
||||||
|
label: "Document review ends in",
|
||||||
|
deadline: w.docReviewEndsAt,
|
||||||
|
expiredText: "Payment starting…",
|
||||||
|
};
|
||||||
return null;
|
return null;
|
||||||
case "PAYMENT":
|
case "PAYMENT":
|
||||||
if (w.paymentPhaseEndsAt) return { label: "Payment due in", deadline: w.paymentPhaseEndsAt };
|
if (w.paymentPhaseEndsAt)
|
||||||
|
return {
|
||||||
|
label: "Payment due in",
|
||||||
|
deadline: w.paymentPhaseEndsAt,
|
||||||
|
expiredText: "Payment window closing…",
|
||||||
|
};
|
||||||
return null;
|
return null;
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
@@ -141,9 +169,11 @@ interface UpcomingWindowsSectionProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The customer's upcoming/open booking windows on their active-contract
|
* All announced upcoming/open booking windows, shown to every customer
|
||||||
* lanes. Import trains open a window on one booking day; export trains open
|
* regardless of whether they hold a contract on the lane. Import trains open a
|
||||||
* 24h before departure. Hidden entirely when there is nothing to show.
|
* window on one booking day; export trains open 24h before departure. Rows on a
|
||||||
|
* lane the customer has an active contract for carry a "Book now" action;
|
||||||
|
* others route to the contract list. Hidden entirely when nothing is announced.
|
||||||
*/
|
*/
|
||||||
export const UpcomingWindowsSection = memo(function UpcomingWindowsSection({
|
export const UpcomingWindowsSection = memo(function UpcomingWindowsSection({
|
||||||
windows,
|
windows,
|
||||||
@@ -162,7 +192,7 @@ export const UpcomingWindowsSection = memo(function UpcomingWindowsSection({
|
|||||||
Booking Windows
|
Booking Windows
|
||||||
</Text>
|
</Text>
|
||||||
<Text fz={13} c="edr-muted">
|
<Text fz={13} c="edr-muted">
|
||||||
Upcoming and open booking windows on your contract lanes
|
Upcoming and open booking windows across all lanes
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
</Group>
|
</Group>
|
||||||
@@ -211,6 +241,7 @@ export const UpcomingWindowsSection = memo(function UpcomingWindowsSection({
|
|||||||
<CountdownTimer
|
<CountdownTimer
|
||||||
deadline={cd.deadline}
|
deadline={cd.deadline}
|
||||||
label={cd.label}
|
label={cd.label}
|
||||||
|
expiredText={cd.expiredText}
|
||||||
size="xs"
|
size="xs"
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -47,13 +47,16 @@ export interface PriceLineItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* An upcoming/open booking window on one of the signed-in customer's
|
* An announced upcoming/open booking window, shown to every signed-in customer
|
||||||
* active-contract lanes. Import trains open a window on one booking day;
|
* regardless of contract. Import trains open a window on one booking day;
|
||||||
* export trains open 24h before departure (first come, first served).
|
* export trains open 24h before departure (first come, first served).
|
||||||
*/
|
*/
|
||||||
export interface MyBookingWindow {
|
export interface MyBookingWindow {
|
||||||
scheduleId: string;
|
scheduleId: string;
|
||||||
/** Contract whose route this window belongs to, when the row carries it. */
|
/**
|
||||||
|
* The customer's active contract on this lane, when they hold one — enables
|
||||||
|
* "Book now" to target it. Null for lanes they have no contract on.
|
||||||
|
*/
|
||||||
contractId: string | null;
|
contractId: string | null;
|
||||||
/** ONE_TIME contracts can't draw down against a window — button is hidden. */
|
/** ONE_TIME contracts can't draw down against a window — button is hidden. */
|
||||||
contractKind: "ONE_TIME" | "GENERAL" | null;
|
contractKind: "ONE_TIME" | "GENERAL" | null;
|
||||||
|
|||||||
Reference in New Issue
Block a user