mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 04:15:43 +00:00
Implement clearance-first booking flow and completion process for customs contracts
This commit is contained in:
@@ -198,18 +198,17 @@ export default function GlCreateBookingForm() {
|
||||
);
|
||||
|
||||
// Next future window across all routes, used for the "next window" notice —
|
||||
// the train dispatching soonest among those not yet open, matching the
|
||||
// departure-date ordering of the window cards.
|
||||
// the next moment booking OPENS (chronological), which may belong to a
|
||||
// later-departing train. Departure-first ordering here named the soonest
|
||||
// train's later opening as "next" while another lane opened earlier.
|
||||
const nextWindow = useMemo(() => {
|
||||
const now = Date.now();
|
||||
return (bookingWindows ?? [])
|
||||
.filter((w) => w.windowOpensAt && new Date(w.windowOpensAt).getTime() > now)
|
||||
.sort((a, b) => {
|
||||
const da = a.departureDate ? new Date(a.departureDate).getTime() : Infinity;
|
||||
const db = b.departureDate ? new Date(b.departureDate).getTime() : Infinity;
|
||||
if (da !== db) return da - db;
|
||||
return new Date(a.windowOpensAt!).getTime() - new Date(b.windowOpensAt!).getTime();
|
||||
})[0];
|
||||
.sort(
|
||||
(a, b) =>
|
||||
new Date(a.windowOpensAt!).getTime() - new Date(b.windowOpensAt!).getTime(),
|
||||
)[0];
|
||||
}, [bookingWindows]);
|
||||
|
||||
const [scheduledDate, setScheduledDate] = useState("");
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import { Button, Group, type ButtonProps } from "@mantine/core";
|
||||
import {
|
||||
Button,
|
||||
Group,
|
||||
Modal,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
type ButtonProps,
|
||||
} from "@mantine/core";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { useState, type ReactNode } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
@@ -57,7 +64,9 @@ export function ContractCustomerAction({
|
||||
}
|
||||
|
||||
if (action.type === "pay") {
|
||||
return <PayNowButton booking={action.booking} label={action.label} size={size} />;
|
||||
return (
|
||||
<PayNowButton booking={action.booking} label={action.label} size={size} />
|
||||
);
|
||||
}
|
||||
|
||||
if (action.type === "initiate") {
|
||||
@@ -118,6 +127,7 @@ export function InitiateBookingButton({
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () =>
|
||||
@@ -137,6 +147,7 @@ export function InitiateBookingButton({
|
||||
toast.success(
|
||||
"Booking initiated — upload your clearance documents to start the review.",
|
||||
);
|
||||
setConfirmOpen(false);
|
||||
navigate(`/bookings/${booking.id}`);
|
||||
},
|
||||
onError: (e: Error) =>
|
||||
@@ -144,37 +155,87 @@ export function InitiateBookingButton({
|
||||
});
|
||||
|
||||
return (
|
||||
<Button
|
||||
size={size}
|
||||
radius="md"
|
||||
h={listStyle ? 34 : undefined}
|
||||
variant="filled"
|
||||
color="edr-green"
|
||||
fullWidth={fullWidth}
|
||||
leftSection={<Icon size={15} />}
|
||||
loading={mutation.isPending}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
mutation.mutate();
|
||||
}}
|
||||
styles={
|
||||
listStyle
|
||||
? {
|
||||
root: {
|
||||
fontWeight: 600,
|
||||
fontSize: 13,
|
||||
paddingInline: 14,
|
||||
whiteSpace: "nowrap" as const,
|
||||
boxShadow: "0 1px 2px rgba(14,163,113,0.25)",
|
||||
},
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
fw={listStyle ? undefined : 700}
|
||||
fz={listStyle ? undefined : 13}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
<>
|
||||
<Modal
|
||||
opened={confirmOpen}
|
||||
onClose={() => {
|
||||
if (!mutation.isPending) setConfirmOpen(false);
|
||||
}}
|
||||
centered
|
||||
radius="lg"
|
||||
size="md"
|
||||
closeOnClickOutside={!mutation.isPending}
|
||||
closeOnEscape={!mutation.isPending}
|
||||
withCloseButton={!mutation.isPending}
|
||||
title={
|
||||
<Group gap={10} wrap="nowrap">
|
||||
<ThemeIcon variant="light" color="edr-green" radius="md" size={34}>
|
||||
<Icon size={18} />
|
||||
</ThemeIcon>
|
||||
<Text fw={700}>Initiate a new booking?</Text>
|
||||
</Group>
|
||||
}
|
||||
>
|
||||
<Text size="sm" c="dimmed">
|
||||
This creates a new shipment booking under contract{" "}
|
||||
<Text span fw={700} c="#10202F">
|
||||
{contract.reference}
|
||||
</Text>
|
||||
. You'll upload the clearance documents next, and the shipment
|
||||
quantity is drawn down from your contract's reserved capacity.
|
||||
</Text>
|
||||
<Group justify="flex-end" gap="sm" mt="lg">
|
||||
<Button
|
||||
variant="default"
|
||||
radius="md"
|
||||
onClick={() => setConfirmOpen(false)}
|
||||
disabled={mutation.isPending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
leftSection={<Icon size={16} />}
|
||||
loading={mutation.isPending}
|
||||
onClick={() => mutation.mutate()}
|
||||
>
|
||||
Yes, initiate booking
|
||||
</Button>
|
||||
</Group>
|
||||
</Modal>
|
||||
<Button
|
||||
size={size}
|
||||
radius="md"
|
||||
h={listStyle ? 34 : undefined}
|
||||
variant="filled"
|
||||
color="edr-green"
|
||||
fullWidth={fullWidth}
|
||||
leftSection={<Icon size={15} />}
|
||||
loading={mutation.isPending}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setConfirmOpen(true);
|
||||
}}
|
||||
styles={
|
||||
listStyle
|
||||
? {
|
||||
root: {
|
||||
fontWeight: 600,
|
||||
fontSize: 13,
|
||||
paddingInline: 14,
|
||||
whiteSpace: "nowrap" as const,
|
||||
boxShadow: "0 1px 2px rgba(14,163,113,0.25)",
|
||||
},
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
fw={listStyle ? undefined : 700}
|
||||
fz={listStyle ? undefined : 13}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -191,7 +252,12 @@ export function ContractCustomerActionCell({
|
||||
return (
|
||||
<Group gap={8} wrap="nowrap" justify="flex-end">
|
||||
{docButton}
|
||||
<ContractCustomerAction contract={contract} bookings={bookings} size="sm" listStyle />
|
||||
<ContractCustomerAction
|
||||
contract={contract}
|
||||
bookings={bookings}
|
||||
size="sm"
|
||||
listStyle
|
||||
/>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { useState } from "react";
|
||||
import { Alert, Button, Group, Text } from "@mantine/core";
|
||||
import { CheckCircle2, ClipboardList, Clock, Upload } from "lucide-react";
|
||||
import {
|
||||
CheckCircle2,
|
||||
ClipboardList,
|
||||
Clock,
|
||||
PackagePlus,
|
||||
Upload,
|
||||
} from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
@@ -12,15 +19,19 @@ import { CardTitle, SectionCard } from "./layout";
|
||||
|
||||
/**
|
||||
* Customer-facing clearance section on the booking detail page: a compact
|
||||
* status summary with a single action button. The document grid, re-uploads,
|
||||
* and the shipment-day picker all live in the shared {@link BookingActionModal}
|
||||
* (the same modal the My Shipments list uses), so the flow behaves identically
|
||||
* from both entry points.
|
||||
* status summary with a single action button. The document grid and re-uploads
|
||||
* live in the shared {@link BookingActionModal} (the same modal the My
|
||||
* Shipments list uses); a finished bare instance instead shows a "Book" button
|
||||
* that navigates to the booking form.
|
||||
*/
|
||||
export function ClearanceCard({ booking }: { booking: Freight.IBooking }) {
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const status = booking.status as string;
|
||||
const action = getBookingNextAction(booking);
|
||||
// BOOK: clearance finished on a bare instance — go straight to the booking
|
||||
// form (cargo + shipment day + window check) instead of opening the modal.
|
||||
const isBookAction = action?.kind === "BOOK" && Boolean(action.to);
|
||||
|
||||
if (status === "OPERATION_REQUESTED") {
|
||||
return (
|
||||
@@ -36,7 +47,9 @@ export function ClearanceCard({ booking }: { booking: Freight.IBooking }) {
|
||||
const summary =
|
||||
status === "CLEARANCE_READY" ? (
|
||||
<Alert color="teal" radius="md" icon={<CheckCircle2 size={18} />}>
|
||||
Clearance is complete. Pick a shipment day and proceed to operation.
|
||||
{isBookAction
|
||||
? "Clearance is complete. Book your shipment — enter the cargo details and pick a shipment day inside an open booking window."
|
||||
: "Clearance is complete. Pick a shipment day and proceed to operation."}
|
||||
</Alert>
|
||||
) : status === "DOCUMENTS_UNDER_REVIEW" ? (
|
||||
<Alert color="blue" radius="md" icon={<Clock size={18} />}>
|
||||
@@ -59,8 +72,16 @@ export function ClearanceCard({ booking }: { booking: Freight.IBooking }) {
|
||||
<Button
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
leftSection={<ClipboardList size={16} />}
|
||||
onClick={() => setModalOpen(true)}
|
||||
leftSection={
|
||||
isBookAction ? (
|
||||
<PackagePlus size={16} />
|
||||
) : (
|
||||
<ClipboardList size={16} />
|
||||
)
|
||||
}
|
||||
onClick={() =>
|
||||
isBookAction ? navigate(action!.to!) : setModalOpen(true)
|
||||
}
|
||||
>
|
||||
{action.label}
|
||||
</Button>
|
||||
@@ -70,15 +91,18 @@ export function ClearanceCard({ booking }: { booking: Freight.IBooking }) {
|
||||
{summary}
|
||||
|
||||
<Text fz="12.5px" c="dimmed" mt="sm">
|
||||
Use “{action?.label ?? "the action button"}” to manage your clearance
|
||||
documents.
|
||||
{isBookAction
|
||||
? "Use “Book” to enter the cargo details and schedule your shipment."
|
||||
: `Use “${action?.label ?? "the action button"}” to manage your clearance documents.`}
|
||||
</Text>
|
||||
|
||||
<BookingActionModal
|
||||
booking={booking}
|
||||
opened={modalOpen}
|
||||
onClose={() => setModalOpen(false)}
|
||||
/>
|
||||
{!isBookAction && (
|
||||
<BookingActionModal
|
||||
booking={booking}
|
||||
opened={modalOpen}
|
||||
onClose={() => setModalOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -66,6 +66,10 @@ export function StatusHero({
|
||||
}) {
|
||||
const status = booking.status;
|
||||
const stage = resolveStage(booking);
|
||||
// Contract-drawdown instance in the clearance gate: it was INITIATED with one
|
||||
// click (no cargo/date yet), not submitted through the wizard.
|
||||
const isInitiatedInstance =
|
||||
status === "AWAITING_DOCUMENTS" && Boolean(booking.contractId);
|
||||
// Legacy bookings never reach the ARRIVED status — they light up the Arrival
|
||||
// stage from the train's ARRIVED state while staying IN_TRANSIT, so the
|
||||
// headline is overridden here. Bookings with a per-booking journey carry the
|
||||
@@ -78,7 +82,14 @@ export function StatusHero({
|
||||
"Your shipment reached its destination yard and is being unloaded and prepared for release.",
|
||||
stage,
|
||||
}
|
||||
: (STATUS_MAP[status] ?? STATUS_MAP.DRAFT);
|
||||
: isInitiatedInstance
|
||||
? {
|
||||
title: "Booking initiated — clearance documents needed",
|
||||
description:
|
||||
"Upload the required clearance documents to start the review. Once the review is finalized you can book your shipment.",
|
||||
stage,
|
||||
}
|
||||
: (STATUS_MAP[status] ?? STATUS_MAP.DRAFT);
|
||||
const negative = isNegative(status);
|
||||
const draft = isDraftLike(status);
|
||||
|
||||
@@ -123,6 +134,11 @@ export function StatusHero({
|
||||
current={stage}
|
||||
tone={draft ? "ink" : "green"}
|
||||
negative={negative}
|
||||
// Contract drawdowns are initiated with one click, not submitted
|
||||
// through the wizard — relabel the stage for them.
|
||||
labelOverrides={
|
||||
booking.contractId ? { 1: "Initiated" } : undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</SectionCard>
|
||||
@@ -132,10 +148,13 @@ export function StatusHero({
|
||||
function ProgressTracker({
|
||||
current,
|
||||
tone = "green",
|
||||
labelOverrides,
|
||||
}: {
|
||||
current: number;
|
||||
tone?: "green" | "ink";
|
||||
negative?: boolean;
|
||||
/** Per-stage-index label replacements (e.g. "Submitted" → "Initiated"). */
|
||||
labelOverrides?: Record<number, string>;
|
||||
}) {
|
||||
const last = PROGRESS_STAGES.length - 1;
|
||||
const activeFill = tone === "ink" ? "#0C1A2B" : "#0EA371";
|
||||
@@ -227,7 +246,7 @@ function ProgressTracker({
|
||||
ta="center"
|
||||
c={state === "idle" ? "#9AA8B5" : "#10202F"}
|
||||
>
|
||||
{stage.label}
|
||||
{labelOverrides?.[idx] ?? stage.label}
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { Box, Button } from "@mantine/core";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import { AlertCircle, ArrowRight, PencilLine, Upload } from "lucide-react";
|
||||
import {
|
||||
AlertCircle,
|
||||
ArrowRight,
|
||||
PackagePlus,
|
||||
PencilLine,
|
||||
Upload,
|
||||
} from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
@@ -19,6 +26,7 @@ const ICON_BY_KIND: Record<
|
||||
UPLOAD_DOCUMENTS: Upload,
|
||||
FIX_DOCUMENTS: AlertCircle,
|
||||
SCHEDULE_OPERATION: ArrowRight,
|
||||
BOOK: PackagePlus,
|
||||
};
|
||||
|
||||
interface BookingActionButtonProps {
|
||||
@@ -39,6 +47,7 @@ export function BookingActionButton({
|
||||
size = "sm",
|
||||
}: BookingActionButtonProps) {
|
||||
const [opened, { open, close }] = useDisclosure(false);
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Staff returned the booking for changes — let the customer update the docs
|
||||
// they submitted and resubmit, in place.
|
||||
@@ -49,6 +58,9 @@ export function BookingActionButton({
|
||||
|
||||
const Icon = action ? ICON_BY_KIND[action.kind] : PencilLine;
|
||||
const label = action ? action.label : "Update & resubmit";
|
||||
// BOOK navigates to the booking form (cargo + day + window check) — the
|
||||
// same page a one-time booking uses — instead of opening the modal.
|
||||
const navigateTo = action?.kind === "BOOK" ? action.to : undefined;
|
||||
|
||||
return (
|
||||
// Mantine modals portal to <body>, but React events still bubble through
|
||||
@@ -65,7 +77,8 @@ export function BookingActionButton({
|
||||
leftSection={<Icon size={14} />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
open();
|
||||
if (navigateTo) navigate(navigateTo);
|
||||
else open();
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
@@ -77,7 +90,7 @@ export function BookingActionButton({
|
||||
opened={opened}
|
||||
onClose={close}
|
||||
/>
|
||||
) : (
|
||||
) : navigateTo ? null : (
|
||||
<BookingActionModal booking={booking} opened={opened} onClose={close} />
|
||||
)}
|
||||
</Box>
|
||||
|
||||
@@ -48,24 +48,25 @@ function BookingActionModalBody({
|
||||
const handleProceed = () => flow.proceedToOperation({ onSuccess: onClose });
|
||||
|
||||
return (
|
||||
// Sized and styled to match the contract clearance modal
|
||||
// (ContractClearanceAction) so both flows read as the same surface.
|
||||
<Modal
|
||||
opened
|
||||
onClose={onClose}
|
||||
centered
|
||||
size={560}
|
||||
radius={16}
|
||||
padding={24}
|
||||
size="xl"
|
||||
radius="md"
|
||||
title={
|
||||
<Box>
|
||||
<Text fz={16} fw={800} c="#10202F">
|
||||
{action?.title ?? "Booking"}
|
||||
<Text fw={700} fz={16}>
|
||||
{action?.title ?? "Clearance documents"}
|
||||
</Text>
|
||||
<Text fz={12} c="dimmed" ff="monospace">
|
||||
{reference}
|
||||
</Text>
|
||||
</Box>
|
||||
}
|
||||
overlayProps={{ backgroundOpacity: 0.5, blur: 4 }}
|
||||
overlayProps={{ blur: 2, backgroundOpacity: 0.55 }}
|
||||
styles={{ body: { paddingTop: 8 } }}
|
||||
>
|
||||
{flow.isLoading || !flow.clearance ? (
|
||||
|
||||
@@ -9,7 +9,8 @@ import type { Freight } from "@edr/types";
|
||||
export type BookingActionKind =
|
||||
| "UPLOAD_DOCUMENTS" // AWAITING_DOCUMENTS — upload the required clearance docs
|
||||
| "FIX_DOCUMENTS" // DOCUMENTS_UNDER_REVIEW — some docs queried, re-upload them
|
||||
| "SCHEDULE_OPERATION"; // CLEARANCE_READY — pick a day and proceed to operation
|
||||
| "SCHEDULE_OPERATION" // CLEARANCE_READY (legacy with cargo) — pick a day and proceed
|
||||
| "BOOK"; // CLEARANCE_READY bare instance — navigate to the booking form
|
||||
|
||||
export interface BookingNextAction {
|
||||
kind: BookingActionKind;
|
||||
@@ -17,6 +18,8 @@ export interface BookingNextAction {
|
||||
label: string;
|
||||
/** Modal title. */
|
||||
title: string;
|
||||
/** Set for navigation actions (BOOK) — the button navigates instead of opening the modal. */
|
||||
to?: string;
|
||||
}
|
||||
|
||||
const ACTION_BY_STATUS: Record<string, BookingNextAction> = {
|
||||
@@ -37,6 +40,18 @@ const ACTION_BY_STATUS: Record<string, BookingNextAction> = {
|
||||
},
|
||||
};
|
||||
|
||||
type ActionBooking = Pick<
|
||||
Freight.IBooking,
|
||||
"id" | "status" | "contractId" | "totalAmount" | "customsClearingEnabled"
|
||||
>;
|
||||
|
||||
/** Initiated instance still carrying no cargo/price (clearance-first flow). */
|
||||
function isBareInstance(booking: ActionBooking): boolean {
|
||||
return (
|
||||
Boolean(booking.contractId) && !(Number(booking.totalAmount ?? 0) > 0)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the customer's next clearance/operation action for a booking, or
|
||||
* `null` when there's nothing for them to do at this stage. Pure + cheap so it
|
||||
@@ -47,8 +62,27 @@ const ACTION_BY_STATUS: Record<string, BookingNextAction> = {
|
||||
* "under review" state when nothing is actually queried.
|
||||
*/
|
||||
export function getBookingNextAction(
|
||||
booking: Pick<Freight.IBooking, "status">,
|
||||
booking: ActionBooking,
|
||||
): BookingNextAction | null {
|
||||
if (booking.status === "CLEARANCE_READY" && isBareInstance(booking)) {
|
||||
// Customs (Path B): GL completes the booking — the customer can only view
|
||||
// the finished clearance in the modal.
|
||||
if (booking.customsClearingEnabled) {
|
||||
return {
|
||||
kind: "SCHEDULE_OPERATION",
|
||||
label: "View clearance",
|
||||
title: "Clearance complete",
|
||||
};
|
||||
}
|
||||
// Non-customs (Path A): straight to the booking form — cargo + shipment
|
||||
// day + window check, the same page a one-time booking uses.
|
||||
return {
|
||||
kind: "BOOK",
|
||||
label: "Book",
|
||||
title: "Book your shipment",
|
||||
to: `/contracts/${booking.contractId}/bookings/${booking.id}/complete`,
|
||||
};
|
||||
}
|
||||
return ACTION_BY_STATUS[booking.status as string] ?? null;
|
||||
}
|
||||
|
||||
@@ -58,9 +92,7 @@ export function getBookingNextAction(
|
||||
* booking that needs documents updated and resubmitting. Used to decide whether
|
||||
* to render {@link BookingActionButton}.
|
||||
*/
|
||||
export function bookingHasInlineAction(
|
||||
booking: Pick<Freight.IBooking, "status">,
|
||||
): boolean {
|
||||
export function bookingHasInlineAction(booking: ActionBooking): boolean {
|
||||
return (
|
||||
booking.status === "CHANGES_REQUESTED" ||
|
||||
getBookingNextAction(booking) !== null
|
||||
|
||||
@@ -27,21 +27,30 @@ export function hasOpenWindow(windows: MyBookingWindow[]): boolean {
|
||||
|
||||
/**
|
||||
* The next upcoming (not-yet-open) window the customer should come back for —
|
||||
* the one whose train dispatches soonest, so it lines up with the departure-date
|
||||
* ordering of the cards. Returns `null` when nothing upcoming carries an opening
|
||||
* time. (`windowOpensAt` is still required so the banner can name a come-back time.)
|
||||
* the one that OPENS soonest from now. Two guards matter here:
|
||||
* - only openings strictly in the future qualify. A train mid-cycle
|
||||
* (doc-review/payment) still reports the window that already opened and
|
||||
* closed; showing that past time as "next" told customers to come back for
|
||||
* a window that was over.
|
||||
* - ordered by opening time, not departure date — "next window" is the next
|
||||
* moment booking opens, which may belong to a later-departing train.
|
||||
* Returns `null` when nothing upcoming carries a future opening time.
|
||||
*/
|
||||
export function soonestUpcomingWindow(
|
||||
windows: MyBookingWindow[],
|
||||
): MyBookingWindow | null {
|
||||
const now = Date.now();
|
||||
const upcoming = windows
|
||||
.filter((w) => !w.isOpenNow && w.windowOpensAt)
|
||||
.sort((a, b) => {
|
||||
const da = a.departureDate ? new Date(a.departureDate).getTime() : Infinity;
|
||||
const db = b.departureDate ? new Date(b.departureDate).getTime() : Infinity;
|
||||
if (da !== db) return da - db;
|
||||
return new Date(a.windowOpensAt!).getTime() - new Date(b.windowOpensAt!).getTime();
|
||||
});
|
||||
.filter(
|
||||
(w) =>
|
||||
!w.isOpenNow &&
|
||||
w.windowOpensAt &&
|
||||
new Date(w.windowOpensAt).getTime() > now,
|
||||
)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
new Date(a.windowOpensAt!).getTime() - new Date(b.windowOpensAt!).getTime(),
|
||||
);
|
||||
return upcoming[0] ?? null;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user