mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 08:48:11 +00:00
Merge branch 'contrat-backup2' of github.com:Tria-plc/edr-platform into contrat-backup2
This commit is contained in:
@@ -419,10 +419,10 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
label: "Contract validity",
|
||||
href: "/dashboard/configuration/contract-validity-periods",
|
||||
},
|
||||
// {
|
||||
// label: "Train scheduling rules",
|
||||
// href: "/dashboard/configuration/train-scheduling-rules",
|
||||
// },
|
||||
{
|
||||
label: "Train scheduling rules",
|
||||
href: "/dashboard/configuration/train-scheduling-rules",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -48,6 +48,7 @@ import type {
|
||||
} from "@/types/trainScheduling";
|
||||
|
||||
import { ContainerPlacementGrid } from "./ContainerPlacementGrid";
|
||||
import { locomotiveOption, showScheduleWarnings } from "./locomotiveOptions";
|
||||
import {
|
||||
autoFillPlacements,
|
||||
mergePlacementsWithSaved,
|
||||
@@ -274,6 +275,7 @@ export function AllocateBookingWizard({
|
||||
const created = await create.mutateAsync({
|
||||
payload: { routeId, scheduleDate, locomotiveIds },
|
||||
});
|
||||
showScheduleWarnings(created.warnings);
|
||||
setSelectedScheduleId(created.id);
|
||||
return created.id;
|
||||
};
|
||||
@@ -536,10 +538,9 @@ export function AllocateBookingWizard({
|
||||
placeholder={
|
||||
routeId ? "Select at least two locomotives" : "Select a route first"
|
||||
}
|
||||
data={(locomotivesQuery.data ?? []).map((l) => ({
|
||||
value: l.id,
|
||||
label: `${l.code}${l.name ? ` · ${l.name}` : ""}`,
|
||||
}))}
|
||||
data={(locomotivesQuery.data ?? []).map((l) =>
|
||||
locomotiveOption(l, " · "),
|
||||
)}
|
||||
value={locomotiveIds}
|
||||
onChange={setLocomotiveIds}
|
||||
searchable
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Box, Group, Progress, Text, Tooltip } from "@mantine/core";
|
||||
|
||||
import type { BookingWindowPhase } from "@/types/trainScheduling";
|
||||
|
||||
import "./batchVisuals.css";
|
||||
|
||||
/**
|
||||
@@ -213,3 +215,69 @@ export function HeroChip({
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
const PHASE_META: Record<
|
||||
BookingWindowPhase,
|
||||
{ color: string; label: string; pulse: boolean }
|
||||
> = {
|
||||
PRE_WINDOW: { color: "gray", label: "Pre-window", pulse: false },
|
||||
OPEN: { color: "edr-green", label: "Booking open", pulse: true },
|
||||
DOC_REVIEW: { color: "yellow", label: "Doc review", pulse: true },
|
||||
PAYMENT: { color: "blue", label: "Payment", pulse: true },
|
||||
CLOSED_FOR_DAY: { color: "dark", label: "Closed for day", pulse: false },
|
||||
DONE: { color: "dark", label: "Done", pulse: false },
|
||||
};
|
||||
|
||||
/**
|
||||
* Import booking-cycle phase pill (OPEN → DOC_REVIEW → PAYMENT → …) with an
|
||||
* optional cycle number. Same visual language as `WindowStatusPill`.
|
||||
*/
|
||||
export function WindowPhasePill({
|
||||
phase,
|
||||
cycleNo,
|
||||
size = "md",
|
||||
}: {
|
||||
phase: BookingWindowPhase;
|
||||
cycleNo?: number;
|
||||
size?: "sm" | "md";
|
||||
}) {
|
||||
const meta = PHASE_META[phase] ?? {
|
||||
color: "gray",
|
||||
label: phase,
|
||||
pulse: false,
|
||||
};
|
||||
const compact = size === "sm";
|
||||
return (
|
||||
<Group
|
||||
gap={6}
|
||||
wrap="nowrap"
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
padding: compact ? "2px 8px" : "4px 11px",
|
||||
borderRadius: 999,
|
||||
background: `var(--mantine-color-${meta.color}-0)`,
|
||||
border: `1px solid var(--mantine-color-${meta.color}-2)`,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
w={compact ? 6 : 7}
|
||||
h={compact ? 6 : 7}
|
||||
className={meta.pulse ? "bb-pulse-dot" : undefined}
|
||||
style={{
|
||||
borderRadius: 999,
|
||||
flexShrink: 0,
|
||||
background: `var(--mantine-color-${meta.color}-6)`,
|
||||
}}
|
||||
/>
|
||||
<Text
|
||||
size="xs"
|
||||
fw={700}
|
||||
c={`${meta.color}.8`}
|
||||
style={{ letterSpacing: 0.3, lineHeight: 1, whiteSpace: "nowrap" }}
|
||||
>
|
||||
{meta.label}
|
||||
{cycleNo && cycleNo > 1 ? ` · cycle ${cycleNo}` : ""}
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import hotToast from "react-hot-toast";
|
||||
|
||||
import type { LocomotiveRecord } from "@/types/trainScheduling";
|
||||
|
||||
/**
|
||||
* Locomotives can now be scheduled in advance: not-at-origin-yard or
|
||||
* already-on-future-schedules is allowed with a warning (only OUT_OF_SERVICE
|
||||
* is blocked server-side). This returns the hint to surface in the picker,
|
||||
* or null when the locomotive is ready at the origin yard.
|
||||
*/
|
||||
export function locomotiveWarning(loco: LocomotiveRecord): string | null {
|
||||
const hints: string[] = [];
|
||||
if (loco.atOriginYard === false) hints.push("not at origin yard");
|
||||
const futureCount = loco.futureScheduleCount ?? 0;
|
||||
if (futureCount > 0) {
|
||||
hints.push(`on ${futureCount} future schedule${futureCount === 1 ? "" : "s"}`);
|
||||
}
|
||||
return hints.length ? hints.join(" · ") : null;
|
||||
}
|
||||
|
||||
/** MultiSelect option for the schedule-creation locomotive picker. */
|
||||
export function locomotiveOption(
|
||||
loco: LocomotiveRecord,
|
||||
nameSeparator = " — ",
|
||||
): { value: string; label: string } {
|
||||
const base = `${loco.code}${loco.name ? `${nameSeparator}${loco.name}` : ""}`;
|
||||
const warning = locomotiveWarning(loco);
|
||||
return {
|
||||
value: loco.id,
|
||||
label: warning ? `${base} · ⚠ ${warning}` : base,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Yellow toast listing create-schedule warnings (e.g. locomotive not at the
|
||||
* origin yard yet). The shared `useToast` hook only knows success/error, so
|
||||
* this styles a react-hot-toast directly.
|
||||
*/
|
||||
export function showScheduleWarnings(warnings?: string[] | null): void {
|
||||
if (!warnings?.length) return;
|
||||
hotToast(warnings.join("\n"), {
|
||||
icon: "⚠️",
|
||||
duration: 8000,
|
||||
style: {
|
||||
background: "var(--mantine-color-yellow-0)",
|
||||
color: "var(--mantine-color-yellow-9)",
|
||||
border: "1px solid var(--mantine-color-yellow-4)",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -261,6 +261,8 @@ export const URL_CONSTANTS = {
|
||||
BATCH_BOARD_DETAIL: (scheduleId: string) =>
|
||||
`/train-scheduling/batch-board/${scheduleId}`,
|
||||
RUN_BATCH: (id: string) => `/train-scheduling/schedules/${id}/run-batch`,
|
||||
DOC_REVIEW_COMPLETE: (id: string) =>
|
||||
`/train-scheduling/schedules/${id}/doc-review-complete`,
|
||||
RUN_ALLOCATION: (id: string) => `/train-scheduling/schedules/${id}/run-allocation`,
|
||||
ASSIGN_UNASSIGNED_BOOKING: (id: string) =>
|
||||
`/train-scheduling/schedules/${id}/assign-unassigned-booking`,
|
||||
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
BookingPipeline,
|
||||
HeroChip,
|
||||
totalBookingCount,
|
||||
WindowPhasePill,
|
||||
WindowStatusPill,
|
||||
} from "@/components/trainScheduling/batchVisuals";
|
||||
import { RouteCorridor } from "@/components/trainScheduling/scheduleVisuals";
|
||||
@@ -233,7 +234,16 @@ function ScheduleCard({ schedule }: { schedule: BatchBoardSchedule }) {
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
<WindowStatusPill status={schedule.bookingWindowStatus} />
|
||||
<Stack gap={4} align="flex-end">
|
||||
<WindowStatusPill status={schedule.bookingWindowStatus} />
|
||||
{schedule.windowPhase ? (
|
||||
<WindowPhasePill
|
||||
phase={schedule.windowPhase}
|
||||
cycleNo={schedule.bookingCycleNo}
|
||||
size="sm"
|
||||
/>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Group>
|
||||
|
||||
<RouteCorridor
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
CalendarDays,
|
||||
CheckCircle2,
|
||||
ChevronLeft,
|
||||
ClipboardCheck,
|
||||
ChevronRight,
|
||||
Clock,
|
||||
FileSignature,
|
||||
@@ -52,6 +53,7 @@ import {
|
||||
BookingPipeline,
|
||||
HeroChip,
|
||||
totalBookingCount,
|
||||
WindowPhasePill,
|
||||
WindowStatusPill,
|
||||
} from "@/components/trainScheduling/batchVisuals";
|
||||
import { RouteCorridor } from "@/components/trainScheduling/scheduleVisuals";
|
||||
@@ -62,6 +64,7 @@ import { useToast } from "@/hooks/use-toast";
|
||||
import type {
|
||||
BatchBoardBookingDetail,
|
||||
BatchBoardBookingState,
|
||||
BatchBoardScheduleDetail,
|
||||
BatchWindowGroup,
|
||||
BookingAllocationStatus,
|
||||
} from "@/types/trainScheduling";
|
||||
@@ -114,6 +117,61 @@ const fmtDateTime = (iso: string | null) =>
|
||||
}).format(new Date(iso))
|
||||
: "—";
|
||||
|
||||
const eatDayFmt = new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone: "Africa/Addis_Ababa",
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
});
|
||||
|
||||
/** "11:00 EAT" if the timestamp falls on today (EAT), else "05 Jun, 11:00 EAT". */
|
||||
const fmtPhaseTime = (iso: string) => {
|
||||
const date = new Date(iso);
|
||||
const time = new Intl.DateTimeFormat("en-GB", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
timeZone: "Africa/Addis_Ababa",
|
||||
}).format(date);
|
||||
if (eatDayFmt.format(date) === eatDayFmt.format(new Date())) {
|
||||
return `${time} EAT`;
|
||||
}
|
||||
const day = new Intl.DateTimeFormat("en-GB", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
timeZone: "Africa/Addis_Ababa",
|
||||
}).format(date);
|
||||
return `${day}, ${time} EAT`;
|
||||
};
|
||||
|
||||
/** Countdown label for the current booking-cycle phase, e.g. "Closes 11:00 EAT". */
|
||||
function phaseCountdown(data: BatchBoardScheduleDetail): string | null {
|
||||
switch (data.windowPhase) {
|
||||
case "PRE_WINDOW":
|
||||
return data.windowOpensAt
|
||||
? `Opens ${fmtPhaseTime(data.windowOpensAt)}`
|
||||
: null;
|
||||
case "OPEN":
|
||||
return data.windowClosesAt
|
||||
? `Closes ${fmtPhaseTime(data.windowClosesAt)}`
|
||||
: null;
|
||||
case "DOC_REVIEW":
|
||||
return data.docReviewEndsAt
|
||||
? `Doc review ends ${fmtPhaseTime(data.docReviewEndsAt)}`
|
||||
: null;
|
||||
case "PAYMENT":
|
||||
return data.paymentPhaseEndsAt
|
||||
? `Payment ends ${fmtPhaseTime(data.paymentPhaseEndsAt)}`
|
||||
: null;
|
||||
case "CLOSED_FOR_DAY":
|
||||
return data.windowOpensAt
|
||||
? `Reopens ${fmtPhaseTime(data.windowOpensAt)}`
|
||||
: null;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const initials = (name: string) =>
|
||||
name
|
||||
.split(/\s+/)
|
||||
@@ -462,6 +520,9 @@ export default function BatchScheduleDetailPage() {
|
||||
const runAllocation = useMutation(
|
||||
api.trainScheduling.runAllocation.mutationOptions(),
|
||||
);
|
||||
const completeDocReview = useMutation(
|
||||
api.trainScheduling.completeDocReview.mutationOptions(),
|
||||
);
|
||||
|
||||
const hasAssignedWagons = useMemo(
|
||||
() =>
|
||||
@@ -607,6 +668,24 @@ export default function BatchScheduleDetailPage() {
|
||||
);
|
||||
const selectedDay = dayGroups[selectedIndex];
|
||||
|
||||
const handleCompleteDocReview = () => {
|
||||
completeDocReview
|
||||
.mutateAsync(scheduleId ?? "")
|
||||
.then(() => {
|
||||
toast({
|
||||
title: "Document review complete",
|
||||
description: "Batch is running for this route-day group",
|
||||
});
|
||||
void refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast({
|
||||
title: "Could not complete document review",
|
||||
variant: "destructive",
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const handleRunAllocation = () => {
|
||||
runAllocation
|
||||
.mutateAsync({ scheduleId: scheduleId ?? "" })
|
||||
@@ -641,6 +720,7 @@ export default function BatchScheduleDetailPage() {
|
||||
}
|
||||
|
||||
const totalBookings = totalBookingCount(data.counts);
|
||||
const countdown = phaseCountdown(data);
|
||||
|
||||
return (
|
||||
<PageContainer fluid>
|
||||
@@ -689,6 +769,12 @@ export default function BatchScheduleDetailPage() {
|
||||
{data.trainNumber ?? data.routeName ?? "Schedule"}
|
||||
</Title>
|
||||
<WindowStatusPill status={data.bookingWindowStatus} />
|
||||
{data.windowPhase ? (
|
||||
<WindowPhasePill
|
||||
phase={data.windowPhase}
|
||||
cycleNo={data.bookingCycleNo}
|
||||
/>
|
||||
) : null}
|
||||
<HeroChip>{data.status}</HeroChip>
|
||||
</Group>
|
||||
<RouteCorridor
|
||||
@@ -717,6 +803,12 @@ export default function BatchScheduleDetailPage() {
|
||||
{data.locomotive.maxTrainLengthMeters} m
|
||||
</HeroChip>
|
||||
) : null}
|
||||
{data.windowPhase ? (
|
||||
<HeroChip icon={<Clock size={12} />}>
|
||||
Cycle {data.bookingCycleNo}
|
||||
{countdown ? ` · ${countdown}` : ""}
|
||||
</HeroChip>
|
||||
) : null}
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
@@ -730,6 +822,17 @@ export default function BatchScheduleDetailPage() {
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
{data.windowPhase === "DOC_REVIEW" ? (
|
||||
<Button
|
||||
color="yellow"
|
||||
radius="md"
|
||||
leftSection={<ClipboardCheck size={16} />}
|
||||
loading={completeDocReview.isPending}
|
||||
onClick={handleCompleteDocReview}
|
||||
>
|
||||
Doc review complete — run batch
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
|
||||
@@ -36,6 +36,10 @@ import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
|
||||
import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter";
|
||||
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
|
||||
import { FreightTypeBadge } from "@/components/trainScheduling/ScheduleStatusBadge";
|
||||
import {
|
||||
locomotiveOption,
|
||||
showScheduleWarnings,
|
||||
} from "@/components/trainScheduling/locomotiveOptions";
|
||||
import {
|
||||
RouteCorridor,
|
||||
StatusPill,
|
||||
@@ -110,7 +114,7 @@ export default function TrainScheduleV2ListPage() {
|
||||
selectedRoute.originYard?.label ??
|
||||
selectedRoute.originYard?.code ??
|
||||
"the route origin yard";
|
||||
return `Only locomotives currently at ${originLabel} are shown`;
|
||||
return `All in-service locomotives are shown — those not yet at ${originLabel} or already on future schedules are flagged`;
|
||||
}, [selectedRoute]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -356,6 +360,7 @@ export default function TrainScheduleV2ListPage() {
|
||||
payload: { routeId, scheduleDate, locomotiveIds },
|
||||
});
|
||||
toast({ title: "Train schedule created" });
|
||||
showScheduleWarnings(created.warnings);
|
||||
setCreateOpen(false);
|
||||
navigate(`/dashboard/operations/train-scheduling-v2/${created.id}`);
|
||||
} catch (err) {
|
||||
@@ -554,10 +559,7 @@ export default function TrainScheduleV2ListPage() {
|
||||
placeholder={
|
||||
routeId ? "Select at least two locomotives" : "Select a route first"
|
||||
}
|
||||
data={(locomotivesQuery.data ?? []).map((l) => ({
|
||||
value: l.id,
|
||||
label: `${l.code}${l.name ? ` — ${l.name}` : ""}`,
|
||||
}))}
|
||||
data={(locomotivesQuery.data ?? []).map((l) => locomotiveOption(l))}
|
||||
value={locomotiveIds}
|
||||
onChange={setLocomotiveIds}
|
||||
searchable
|
||||
|
||||
@@ -34,6 +34,13 @@ export default function TrainSchedulingGlobalRulesPage() {
|
||||
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),
|
||||
});
|
||||
setForm(updated);
|
||||
toast({ title: "Train scheduling rules saved" });
|
||||
@@ -108,6 +115,87 @@ export default function TrainSchedulingGlobalRulesPage() {
|
||||
min={0}
|
||||
disabled={loading}
|
||||
/>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
<Card maw={720} mt="md">
|
||||
<Stack gap="md">
|
||||
<PageHeader
|
||||
title="Booking windows"
|
||||
subtitle="Import booking-day cycle and export lead time. All times in Addis Ababa (EAT)."
|
||||
/>
|
||||
<NumberInput
|
||||
label="Import window lead (days)"
|
||||
description="The single booking day opens this many days before departure"
|
||||
value={form.importWindowLeadDays ?? ""}
|
||||
onChange={(value) =>
|
||||
setForm((current) => ({ ...current, importWindowLeadDays: Number(value) }))
|
||||
}
|
||||
min={0}
|
||||
disabled={loading}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Export booking lead (hours)"
|
||||
description="Export bookings are accepted first-come-first-serve starting this many hours before departure"
|
||||
value={form.exportBookingLeadHours ?? ""}
|
||||
onChange={(value) =>
|
||||
setForm((current) => ({ ...current, exportBookingLeadHours: Number(value) }))
|
||||
}
|
||||
min={1}
|
||||
disabled={loading}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Window open hour (EAT)"
|
||||
description="Local hour the import window opens on its booking day (e.g. 8 = 08:00)"
|
||||
value={form.windowOpenHour ?? ""}
|
||||
onChange={(value) =>
|
||||
setForm((current) => ({ ...current, windowOpenHour: Number(value) }))
|
||||
}
|
||||
min={0}
|
||||
max={23}
|
||||
disabled={loading}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Window duration (hours)"
|
||||
value={form.windowDurationHours ?? ""}
|
||||
onChange={(value) =>
|
||||
setForm((current) => ({ ...current, windowDurationHours: Number(value) }))
|
||||
}
|
||||
min={0.25}
|
||||
max={12}
|
||||
step={0.25}
|
||||
disabled={loading}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Document review (minutes)"
|
||||
description="Max staff time to accept booking documents after the window closes"
|
||||
value={form.docReviewMinutes ?? ""}
|
||||
onChange={(value) =>
|
||||
setForm((current) => ({ ...current, docReviewMinutes: Number(value) }))
|
||||
}
|
||||
min={0}
|
||||
disabled={loading}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Payment window (minutes)"
|
||||
description="Time a selected customer has to pay before the slot expires"
|
||||
value={form.paymentWindowMinutes ?? ""}
|
||||
onChange={(value) =>
|
||||
setForm((current) => ({ ...current, paymentWindowMinutes: Number(value) }))
|
||||
}
|
||||
min={1}
|
||||
disabled={loading}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Reopen delay (minutes)"
|
||||
description="Delay after window close before reopening when the train is not full (90 = 11:00 close → 12:30 reopen)"
|
||||
value={form.reopenDelayMinutes ?? ""}
|
||||
onChange={(value) =>
|
||||
setForm((current) => ({ ...current, reopenDelayMinutes: Number(value) }))
|
||||
}
|
||||
min={1}
|
||||
disabled={loading}
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<Button loading={saving} disabled={loading} onClick={() => void handleSave()}>
|
||||
Save rules
|
||||
|
||||
@@ -360,6 +360,14 @@ export const api = {
|
||||
() => TRAIN_SCHEDULING_INVALIDATIONS,
|
||||
),
|
||||
|
||||
completeDocReview: endpoint<string, BatchBoardScheduleDetail>(
|
||||
"train-scheduling",
|
||||
"doc-review-complete",
|
||||
(id) => trainSchedulingService.completeDocReview(id),
|
||||
undefined,
|
||||
() => TRAIN_SCHEDULING_INVALIDATIONS,
|
||||
),
|
||||
|
||||
setBookingWindow: endpoint<
|
||||
{ id: string; status: "OPEN" | "CLOSED" },
|
||||
TrainScheduleDetail
|
||||
|
||||
@@ -158,6 +158,20 @@ export const trainSchedulingService = {
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
/**
|
||||
* Staff finished reviewing documents early — runs the batch immediately
|
||||
* for the schedule's whole route-day group.
|
||||
*/
|
||||
completeDocReview: async (
|
||||
scheduleId: string,
|
||||
): Promise<BatchBoardScheduleDetail> => {
|
||||
const response = await client.post<BatchBoardScheduleDetail>(
|
||||
URL_CONSTANTS.TRAIN_SCHEDULING.DOC_REVIEW_COMPLETE(scheduleId),
|
||||
{},
|
||||
);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
runAllocation: async (
|
||||
scheduleId: string,
|
||||
): Promise<WagonAllocationAttemptResult> => {
|
||||
@@ -494,16 +508,7 @@ export const trainSchedulingService = {
|
||||
},
|
||||
|
||||
updateGlobalRules: async (
|
||||
payload: Partial<
|
||||
Pick<
|
||||
TrainSchedulingGlobalRules,
|
||||
| "maxTrainLengthMeters"
|
||||
| "maxTrainWeightTons"
|
||||
| "maxWagonsPerTrain"
|
||||
| "max20ftContainerWeightTons"
|
||||
| "max20ftPairWeightDiffTons"
|
||||
>
|
||||
>,
|
||||
payload: Partial<Omit<TrainSchedulingGlobalRules, "id">>,
|
||||
): Promise<TrainSchedulingGlobalRules> => {
|
||||
const response = await client.patch<TrainSchedulingGlobalRules>(
|
||||
URL_CONSTANTS.TRAIN_SCHEDULING.GLOBAL_RULES,
|
||||
|
||||
@@ -105,6 +105,13 @@ export interface TrainSchedulingGlobalRules {
|
||||
maxWagonsPerTrain: number;
|
||||
max20ftContainerWeightTons: number;
|
||||
max20ftPairWeightDiffTons: number;
|
||||
importWindowLeadDays: number;
|
||||
exportBookingLeadHours: number;
|
||||
windowOpenHour: number;
|
||||
windowDurationHours: number;
|
||||
docReviewMinutes: number;
|
||||
paymentWindowMinutes: number;
|
||||
reopenDelayMinutes: number;
|
||||
}
|
||||
|
||||
export interface TrainSchedulePreviewResponse {
|
||||
@@ -136,6 +143,10 @@ export interface LocomotiveRecord {
|
||||
status: "AVAILABLE" | "ASSIGNED" | "MAINTENANCE" | "OUT_OF_SERVICE";
|
||||
currentYardId?: string | null;
|
||||
locomotiveType?: "DIESEL" | "ELECTRIC";
|
||||
/** Whether the locomotive is currently at the route's origin yard. */
|
||||
atOriginYard?: boolean;
|
||||
/** Number of upcoming schedules this locomotive is already assigned to. */
|
||||
futureScheduleCount?: number;
|
||||
}
|
||||
|
||||
export interface TrainScheduleListItem {
|
||||
@@ -183,6 +194,18 @@ export interface BookableSchedule {
|
||||
locomotive: { id: string; code: string; name?: string | null } | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Import booking-cycle phase for a schedule's booking window (null for
|
||||
* legacy/DOMESTIC schedules that don't run the one-day cycle).
|
||||
*/
|
||||
export type BookingWindowPhase =
|
||||
| "PRE_WINDOW"
|
||||
| "OPEN"
|
||||
| "DOC_REVIEW"
|
||||
| "PAYMENT"
|
||||
| "CLOSED_FOR_DAY"
|
||||
| "DONE";
|
||||
|
||||
export type BatchBoardBookingState =
|
||||
| "ALLOCATED"
|
||||
| "SELECTED_FOR_BATCH"
|
||||
@@ -212,6 +235,13 @@ export interface BatchBoardSchedule {
|
||||
scheduleDate: string | null;
|
||||
status: string;
|
||||
bookingWindowStatus: string;
|
||||
direction: string | null;
|
||||
windowPhase: BookingWindowPhase | null;
|
||||
windowOpensAt: string | null;
|
||||
windowClosesAt: string | null;
|
||||
docReviewEndsAt: string | null;
|
||||
paymentPhaseEndsAt: string | null;
|
||||
bookingCycleNo: number;
|
||||
locomotive: {
|
||||
code: string;
|
||||
name: string | null;
|
||||
@@ -278,6 +308,13 @@ export interface BatchBoardScheduleDetail {
|
||||
scheduleDate: string | null;
|
||||
status: string;
|
||||
bookingWindowStatus: string;
|
||||
direction: string | null;
|
||||
windowPhase: BookingWindowPhase | null;
|
||||
windowOpensAt: string | null;
|
||||
windowClosesAt: string | null;
|
||||
docReviewEndsAt: string | null;
|
||||
paymentPhaseEndsAt: string | null;
|
||||
bookingCycleNo: number;
|
||||
locomotive: BatchBoardSchedule["locomotive"];
|
||||
capacity: BatchBoardSchedule["capacity"];
|
||||
counts: BatchBoardSchedule["counts"];
|
||||
|
||||
@@ -145,6 +145,7 @@ export const URL_CONSTANTS = {
|
||||
BOOKABLE_SCHEDULES: "/api/train-scheduling/bookable-schedules",
|
||||
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",
|
||||
},
|
||||
|
||||
PAYMENTS: {
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
RecentContractsSection,
|
||||
ShipmentsSection,
|
||||
StatsSection,
|
||||
UpcomingWindowsSection,
|
||||
} from "./components";
|
||||
import { useMyPortalData } from "./hooks";
|
||||
|
||||
@@ -37,6 +38,8 @@ export default function MyPortalPage() {
|
||||
dashboard,
|
||||
volumePoints,
|
||||
maxVolume,
|
||||
bookingWindowsQuery,
|
||||
bookingWindows,
|
||||
} = useMyPortalData(selectedProfileId ?? undefined);
|
||||
|
||||
const serviceOptions = companyProfiles.map((p) => ({
|
||||
@@ -95,6 +98,13 @@ export default function MyPortalPage() {
|
||||
contracts={allContracts}
|
||||
/> */}
|
||||
|
||||
{/* Upcoming/open booking windows on the customer's contract lanes —
|
||||
hidden when there is nothing coming up. */}
|
||||
<UpcomingWindowsSection
|
||||
windows={bookingWindows}
|
||||
isLoading={bookingWindowsQuery.isPending}
|
||||
/>
|
||||
|
||||
{/* Contracts + shipments side by side — the two primary tables. */}
|
||||
<Grid align="stretch">
|
||||
<Grid.Col span={{ base: 12, lg: 6 }}>
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
import { Box, Group, Skeleton, Stack, Text } from "@mantine/core";
|
||||
import { memo } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { ArrowRight, CalendarClock } from "lucide-react";
|
||||
import type { MyBookingWindow } from "@/services/bookings.service";
|
||||
import { Card } from "./Card";
|
||||
|
||||
const INK = "#10202F";
|
||||
const MUTED = "#6B7C8E";
|
||||
const BORDER = "#E6ECF2";
|
||||
|
||||
/** 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,
|
||||
});
|
||||
}
|
||||
|
||||
/** "Thu, 10 Jul · 08:00 – 11:00 EAT" (or a phase label when times are unset). */
|
||||
function windowLabel(w: MyBookingWindow): 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, " ");
|
||||
}
|
||||
|
||||
function Pill({
|
||||
children,
|
||||
bg,
|
||||
color,
|
||||
border,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
bg: string;
|
||||
color: string;
|
||||
border?: string;
|
||||
}) {
|
||||
return (
|
||||
<Box
|
||||
component="span"
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 4,
|
||||
borderRadius: 999,
|
||||
padding: "4px 10px",
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
whiteSpace: "nowrap",
|
||||
backgroundColor: bg,
|
||||
color,
|
||||
border: border ? `1px solid ${border}` : undefined,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function DirectionBadge({ direction }: { direction: MyBookingWindow["direction"] }) {
|
||||
if (!direction) return null;
|
||||
const isImport = direction === "IMPORT";
|
||||
return (
|
||||
<Pill
|
||||
bg={isImport ? "#EAF1FB" : "#ECF6F1"}
|
||||
color={isImport ? "#2E5B96" : "#0A6F4D"}
|
||||
>
|
||||
{isImport ? "Import" : "Export"}
|
||||
</Pill>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({ window: w }: { window: MyBookingWindow }) {
|
||||
if (w.isOpenNow) {
|
||||
return (
|
||||
<Pill bg="#ECF6F1" color="#0A6F4D" border="#CDEBDD">
|
||||
Open now
|
||||
</Pill>
|
||||
);
|
||||
}
|
||||
if (w.windowPhase === "PRE_WINDOW" && w.windowOpensAt) {
|
||||
return (
|
||||
<Pill bg="#FEF6E6" color="#B07D14">
|
||||
Opens at {fmtTime(w.windowOpensAt)} EAT
|
||||
</Pill>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Pill bg="#F1F5F9" color={MUTED}>
|
||||
Upcoming
|
||||
</Pill>
|
||||
);
|
||||
}
|
||||
|
||||
interface UpcomingWindowsSectionProps {
|
||||
windows: MyBookingWindow[];
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The customer's upcoming/open booking windows on their active-contract
|
||||
* lanes. Import trains open a window on one booking day; export trains open
|
||||
* 24h before departure. Hidden entirely when there is nothing to show.
|
||||
*/
|
||||
export const UpcomingWindowsSection = memo(function UpcomingWindowsSection({
|
||||
windows,
|
||||
isLoading,
|
||||
}: UpcomingWindowsSectionProps) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Nothing upcoming — keep the dashboard uncluttered.
|
||||
if (!isLoading && windows.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Card padding={28}>
|
||||
<Group justify="space-between" align="center" mb={18} wrap="nowrap">
|
||||
<Box>
|
||||
<Text fz={19} fw={800} c="edr-text">
|
||||
Booking Windows
|
||||
</Text>
|
||||
<Text fz={13} c="edr-muted">
|
||||
Upcoming and open booking windows on your contract lanes
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
|
||||
{isLoading ? (
|
||||
<Stack gap={6}>
|
||||
{[1, 2].map((i) => (
|
||||
<Skeleton key={i} height={60} radius="md" />
|
||||
))}
|
||||
</Stack>
|
||||
) : (
|
||||
<Stack gap={10}>
|
||||
{windows.map((w) => (
|
||||
<Group
|
||||
key={`${w.scheduleId}-${w.bookingCycleNo}`}
|
||||
justify="space-between"
|
||||
wrap="nowrap"
|
||||
gap={12}
|
||||
p="sm"
|
||||
style={{
|
||||
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
|
||||
}
|
||||
>
|
||||
<Box style={{ minWidth: 0 }}>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Text fz={14} fw={700} style={{ color: INK }} truncate>
|
||||
{w.origin ?? "—"}
|
||||
</Text>
|
||||
<ArrowRight size={13} color={MUTED} style={{ flexShrink: 0 }} />
|
||||
<Text fz={14} fw={700} style={{ color: INK }} truncate>
|
||||
{w.destination ?? "—"}
|
||||
</Text>
|
||||
</Group>
|
||||
<Group gap={5} wrap="nowrap" mt={2}>
|
||||
<CalendarClock size={12} color={MUTED} style={{ flexShrink: 0 }} />
|
||||
<Text fz={12} style={{ color: MUTED }} truncate>
|
||||
{windowLabel(w)} · Departs {fmtDay(w.departureDate)}
|
||||
</Text>
|
||||
</Group>
|
||||
</Box>
|
||||
|
||||
<Group gap={8} wrap="nowrap" style={{ flexShrink: 0 }}>
|
||||
<DirectionBadge direction={w.direction} />
|
||||
<StatusBadge window={w} />
|
||||
</Group>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
});
|
||||
@@ -12,4 +12,5 @@ export { ShipmentsSection } from "./ShipmentsSection";
|
||||
export { StatKpi } from "./StatKpi";
|
||||
export { StatsSection } from "./StatsSection";
|
||||
export { Stepper } from "./Stepper";
|
||||
export { UpcomingWindowsSection } from "./UpcomingWindowsSection";
|
||||
|
||||
|
||||
@@ -26,6 +26,15 @@ export function useMyPortalData(selectedProfileId?: string) {
|
||||
api.companies.getDashboard.queryOptions({ input: selectedProfileId }),
|
||||
);
|
||||
|
||||
// Upcoming/open booking windows on the customer's active-contract lanes.
|
||||
// Refetched every minute so "Open now" flips without a manual reload.
|
||||
const bookingWindowsQuery = useQuery(
|
||||
api.bookings.getMyBookingWindows.queryOptions({
|
||||
refetchInterval: 60_000,
|
||||
}),
|
||||
);
|
||||
const bookingWindows = bookingWindowsQuery.data ?? [];
|
||||
|
||||
const contractsQuery = useQuery(
|
||||
api.contracts.list.queryOptions({
|
||||
input: {
|
||||
@@ -95,6 +104,8 @@ export function useMyPortalData(selectedProfileId?: string) {
|
||||
dashboardQuery,
|
||||
contractsQuery,
|
||||
invoicesQuery,
|
||||
bookingWindowsQuery,
|
||||
bookingWindows,
|
||||
allContracts,
|
||||
recentContracts,
|
||||
activeContractsCount,
|
||||
|
||||
@@ -125,6 +125,46 @@ function Countdown({
|
||||
);
|
||||
}
|
||||
|
||||
// ── Partial-capacity batch offer ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Present on the booking (status SELECTED_FOR_BATCH) when only part of it fit
|
||||
* the train. Paying accepts the split; not paying keeps the booking whole and
|
||||
* it expires for this train. Local extension — not yet in @edr/types.
|
||||
*/
|
||||
interface ActiveBatchOffer {
|
||||
offeredWagons: number;
|
||||
totalWagons: number;
|
||||
offeredAmount: number;
|
||||
paymentDeadline: string;
|
||||
}
|
||||
|
||||
function PartialOfferNotice({ offer }: { offer: ActiveBatchOffer }) {
|
||||
const remaining = offer.totalWagons - offer.offeredWagons;
|
||||
return (
|
||||
<Box
|
||||
mt={14}
|
||||
p={14}
|
||||
style={{
|
||||
borderRadius: 10,
|
||||
backgroundColor: "#FEF6E6",
|
||||
border: "1px solid #F3E2B8",
|
||||
}}
|
||||
>
|
||||
<Text fz="13px" fw={800} c="#9A5B00">
|
||||
Partial allocation offer
|
||||
</Text>
|
||||
<Text mt={4} fz="12.5px" c="#7A5A1E" lh={1.55}>
|
||||
{offer.offeredWagons} of {offer.totalWagons} wagons fit this train.
|
||||
Paying accepts the split — the remaining {remaining} wagon
|
||||
{remaining === 1 ? "" : "s"} return to your contract to book in a later
|
||||
window. If you don't pay before the deadline, your booking stays
|
||||
whole and can be rebooked next window.
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Merged payment panel ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -140,7 +180,7 @@ export function BookingPaymentPanel({
|
||||
paying,
|
||||
showCountdown,
|
||||
}: {
|
||||
booking: Freight.IBooking;
|
||||
booking: Freight.IBooking & { activeBatchOffer?: ActiveBatchOffer | null };
|
||||
pricing: Pricing;
|
||||
onPay?: () => void;
|
||||
paying?: boolean;
|
||||
@@ -217,6 +257,10 @@ export function BookingPaymentPanel({
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{!paid && booking.activeBatchOffer && (
|
||||
<PartialOfferNotice offer={booking.activeBatchOffer} />
|
||||
)}
|
||||
|
||||
{showCountdown && booking.paymentDeadline && (
|
||||
<Box mt={16}>
|
||||
<Countdown
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { Controller, type UseFormReturn } from "react-hook-form";
|
||||
import { ContractFormInputValues, type ContractFormValues } from "./schema";
|
||||
import { filterBookableServices } from "./helpers";
|
||||
import { filterBookableServices, operationToTradeDirection } from "./helpers";
|
||||
import { fieldStyles, StepLabel } from "./shared";
|
||||
import { PaymentCurrencyField } from "./payment-currency-field";
|
||||
import { LocationPicker } from "@/pages/bookings/new-booking-form/LocationPicker";
|
||||
@@ -241,8 +241,18 @@ export function Step2ServiceType({
|
||||
(s) => s.id === serviceTypeId,
|
||||
);
|
||||
|
||||
const { includesCustoms, includesFirstMile, includesLastMile } =
|
||||
serviceType ?? {};
|
||||
const { includesCustoms } = serviceType ?? {};
|
||||
|
||||
// Import contracts never truck the first mile (goods arrive at the port);
|
||||
// export contracts never truck the last mile. Hide the irrelevant toggle by
|
||||
// trade direction, regardless of what the service bundles.
|
||||
const tradeDirection = operationType
|
||||
? operationToTradeDirection(operationType)
|
||||
: null;
|
||||
const includesFirstMile =
|
||||
(serviceType?.includesFirstMile ?? false) && tradeDirection !== "IMPORT";
|
||||
const includesLastMile =
|
||||
(serviceType?.includesLastMile ?? false) && tradeDirection !== "EXPORT";
|
||||
const firstMileEnabled = form.watch("firstMile.enabled");
|
||||
const lastMileEnabled = form.watch("lastMile.enabled");
|
||||
|
||||
@@ -290,6 +300,26 @@ export function Step2ServiceType({
|
||||
}
|
||||
}, [serviceType, includesFirstMile, includesLastMile, includesCustoms, form]);
|
||||
|
||||
// A hidden mile must not leak a stale enabled=true into the payload. The
|
||||
// effect above only fires on service change; switching operation type (import
|
||||
// ⇄ export) hides a mile without touching the service, so clear it here too.
|
||||
useEffect(() => {
|
||||
if (!includesFirstMile && form.getValues("firstMile.enabled")) {
|
||||
form.setValue(
|
||||
"firstMile",
|
||||
{ enabled: false, pickUpAddress: "", exactLocation: "", lat: null, lng: null },
|
||||
{ shouldValidate: true },
|
||||
);
|
||||
}
|
||||
if (!includesLastMile && form.getValues("lastMile.enabled")) {
|
||||
form.setValue(
|
||||
"lastMile",
|
||||
{ enabled: false, deliveryAddress: "", exactLocation: "", lat: null, lng: null },
|
||||
{ shouldValidate: true },
|
||||
);
|
||||
}
|
||||
}, [includesFirstMile, includesLastMile, form]);
|
||||
|
||||
const showServiceSections =
|
||||
serviceType != null || includesFirstMile || includesLastMile;
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
CreateBookingPayload,
|
||||
type CustomerTruckAssignmentPayload,
|
||||
GeneratePriceResponse,
|
||||
type MyBookingWindow,
|
||||
SubmitBookingResponse,
|
||||
} from "./bookings.service";
|
||||
import {
|
||||
@@ -358,6 +359,12 @@ export const api = {
|
||||
"availableDaysForCargo",
|
||||
(input) => bookingsService.getAvailableDaysForCargo(input),
|
||||
),
|
||||
|
||||
getMyBookingWindows: endpoint<void, MyBookingWindow[]>(
|
||||
"train-scheduling",
|
||||
"myBookingWindows",
|
||||
() => bookingsService.getMyBookingWindows(),
|
||||
),
|
||||
},
|
||||
|
||||
contracts: {
|
||||
|
||||
@@ -46,6 +46,25 @@ export interface PriceLineItem {
|
||||
currency: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* An upcoming/open booking window on one of the signed-in customer's
|
||||
* active-contract lanes. Import trains open a window on one booking day;
|
||||
* export trains open 24h before departure (first come, first served).
|
||||
*/
|
||||
export interface MyBookingWindow {
|
||||
scheduleId: string;
|
||||
direction: "IMPORT" | "EXPORT" | null;
|
||||
windowPhase: string | null;
|
||||
isOpenNow: boolean;
|
||||
windowOpensAt: string | null;
|
||||
windowClosesAt: string | null;
|
||||
bookingWindowStatus: string;
|
||||
bookingCycleNo: number;
|
||||
departureDate: string;
|
||||
origin: string | null;
|
||||
destination: string | null;
|
||||
}
|
||||
|
||||
export interface GeneratePriceResponse {
|
||||
bookingId: string;
|
||||
totalAmount: number;
|
||||
@@ -341,4 +360,15 @@ export const bookingsService = {
|
||||
);
|
||||
return (data.data as Freight.AvailableDaysResponse).days;
|
||||
},
|
||||
|
||||
/**
|
||||
* Upcoming/open booking windows on the signed-in customer's active-contract
|
||||
* lanes (import booking-day windows + export 24h pre-departure windows).
|
||||
*/
|
||||
getMyBookingWindows: async (): Promise<MyBookingWindow[]> => {
|
||||
const { data } = await client.get(
|
||||
URL_CONSTANTS.TRAIN_SCHEDULING.MY_BOOKING_WINDOWS,
|
||||
);
|
||||
return data.data ?? data;
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user