import { useMemo, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import {
Accordion,
Alert,
Badge,
Box,
Button,
Group,
Loader,
Paper,
Stack,
Tabs,
Text,
ThemeIcon,
Title,
Tooltip,
} from "@mantine/core";
import {
AlertTriangle,
ArrowLeft,
ArrowLeftRight,
CalendarDays,
CheckCircle2,
ClipboardCheck,
Clock,
FileSignature,
Hash,
Hourglass,
Layers,
Package,
PlayCircle,
RefreshCw,
Ruler,
TrainFront,
Trophy,
Weight,
XCircle,
} from "lucide-react";
import { CountdownTimer, DataTable, type ColumnDef } from "@edr/ui-common";
import { KpiStrip, PageContainer } from "@/components/page";
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
import Breadcrumbs from "@/components/ui/Breadcrumbs";
import { TrainCompositionDiagram } from "@/components/trainScheduling/TrainCompositionDiagram";
import {
TrainConsistView,
CompositionBookingTabs,
} from "@/components/trainScheduling/compositionEditor";
import {
BookingPipeline,
HeroChip,
totalBookingCount,
WindowPhasePill,
WindowStatusPill,
} from "@/components/trainScheduling/batchVisuals";
import { RouteCorridor } from "@/components/trainScheduling/scheduleVisuals";
import { PriorityTrackingTab } from "@/components/trainScheduling/PriorityTrackingTab";
import { useBookingWindowSocket } from "@/features/bookingWindows/useBookingWindowSocket";
import { BookingsManager } from "./BookingsManager";
import { useMutation, useQuery } from "@tanstack/react-query";
import { api } from "@/services/api";
import { useToast } from "@/hooks/use-toast";
import type {
BatchBoardBookingDetail,
BatchBoardBookingState,
BatchBoardScheduleDetail,
BatchWindowGroup,
BookingAllocationStatus,
} from "@/types/trainScheduling";
const STATE_META: Record<
BatchBoardBookingState,
{ label: string; color: string; icon: typeof CheckCircle2 }
> = {
ALLOCATED: { label: "Allocated", color: "edr-green", icon: CheckCircle2 },
SELECTED_FOR_BATCH: {
label: "Selected for batch",
color: "orange",
icon: Clock,
},
READY: { label: "Ready for batch", color: "teal", icon: Hourglass },
WAITING: { label: "Paid · waiting", color: "blue", icon: Hourglass },
PENDING_CONTRACT: {
label: "Pending contract",
color: "gray",
icon: Hourglass,
},
EXPIRED: { label: "Expired", color: "red", icon: XCircle },
};
const ALLOC_META: Record<
BookingAllocationStatus,
{ label: string; color: string }
> = {
ASSIGNED: { label: "Wagons assigned", color: "edr-green" },
NOT_ATTEMPTED: { label: "Not allocated", color: "gray" },
DEFERRED: { label: "Deferred", color: "orange" },
FAILED: { label: "Allocation failed", color: "red" },
};
const fmtTons = (n: number) =>
`${n.toLocaleString(undefined, { maximumFractionDigits: 1 })} t`;
const fmtMeters = (n: number) =>
`${n.toLocaleString(undefined, { maximumFractionDigits: 1 })} m`;
const fmtDateTime = (iso: string | null) =>
iso
? new Intl.DateTimeFormat("en-GB", {
day: "2-digit",
month: "short",
hour: "2-digit",
minute: "2-digit",
hour12: false,
timeZone: "Africa/Addis_Ababa",
}).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+/)
.filter(Boolean)
.slice(0, 2)
.map((w) => w[0])
.join("")
.toUpperCase() || "?";
function StateBadge({ state }: { state: BatchBoardBookingState }) {
const meta = STATE_META[state];
const Icon = meta.icon;
return (
}
>
{meta.label}
);
}
function AllocationBadge({
status,
issue,
}: {
status: BookingAllocationStatus;
issue: string | null;
}) {
const meta = ALLOC_META[status];
const badge = (
{meta.label}
);
if (!issue) return badge;
return (
{badge}
);
}
const bookingCellMeta = {
headerClassName: ruleEngineTable.headerCell,
cellClassName: ruleEngineTable.bodyCell,
};
const BOOKING_COLUMNS: ColumnDef[] = [
{
id: "reference",
header: "Reference",
meta: bookingCellMeta,
cell: ({ row }) => {
const b = row.original;
return (
{b.reference}
{b.isGovernment ? (
Gov
) : null}
{b.consolidationPartnerRef ? (
}
style={{ textTransform: "none" }}
>
shared wagon · {b.consolidationPartnerRef}
) : null}
);
},
},
{
id: "customer",
header: "Customer",
meta: bookingCellMeta,
cell: ({ row }) => {
const b = row.original;
return (
{initials(b.company)}
{b.company}
);
},
},
{
id: "contractSigned",
header: "Contract signed",
meta: bookingCellMeta,
cell: ({ row }) => (
{fmtDateTime(row.original.fullyExecutedAt)} EAT
),
},
{
id: "selectedForBatch",
header: "Selected for batch",
meta: bookingCellMeta,
cell: ({ row }) => {
const b = row.original;
if (!b.selectedForBatchAt) {
return (
—
);
}
return (
<>
{fmtDateTime(b.selectedForBatchAt)} EAT
{b.paymentDeadline ? (
Pay by {fmtDateTime(b.paymentDeadline)} EAT
) : null}
>
);
},
},
{
id: "capacity",
header: "Capacity",
meta: bookingCellMeta,
cell: ({ row }) => {
const b = row.original;
return (
{b.wagons}w
{fmtTons(b.weightTons)}
);
},
},
{
id: "state",
header: "Batch state",
meta: bookingCellMeta,
cell: ({ row }) => ,
},
{
id: "allocation",
header: "Wagon allocation",
meta: bookingCellMeta,
cell: ({ row }) => (
),
},
];
function BookingTable({ bookings }: { bookings: BatchBoardBookingDetail[] }) {
return (
);
}
function WindowCountChips({ counts }: { counts: BatchWindowGroup["counts"] }) {
const chips: Array<{ value: number; color: string; label: string }> = [
{ value: counts.allocated, color: "edr-green", label: "allocated" },
{ value: counts.selectedForBatch, color: "orange", label: "selected" },
{ value: counts.ready, color: "teal", label: "ready" },
{ value: counts.waiting, color: "blue", label: "waiting" },
{ value: counts.expired, color: "red", label: "expired" },
].filter((c) => c.value > 0);
return (
{chips.map((c) => (
{c.value}
))}
);
}
/** "05 Jun 2026 · 06:00 – 09:00 EAT" → "06:00 – 09:00 EAT" (date lives in the day header). */
const EAT_TZ = "Africa/Addis_Ababa";
const dateLabelFmt = new Intl.DateTimeFormat("en-GB", {
timeZone: EAT_TZ,
weekday: "short",
day: "2-digit",
month: "short",
});
const timeFmt = new Intl.DateTimeFormat("en-GB", {
timeZone: EAT_TZ,
hour: "2-digit",
minute: "2-digit",
hour12: false,
});
interface ScheduleWindow {
windowPhase: BatchBoardScheduleDetail["windowPhase"];
bookingWindowStatus: string;
windowOpensAt: string | null;
windowClosesAt: string | null;
docReviewEndsAt: string | null;
paymentPhaseEndsAt: string | null;
bookingCycleNo?: number;
}
/**
* Deadline + label for the phase the schedule's booking window is currently in —
* the SAME phases the customer sees on the portal: pre-window (opens) → open
* (closes) → document review → payment. `expiredText` names the next step so a
* 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;
}
}
/** One phase row: label + its clock time (or "—" when unset). */
function PhaseTimeRow({
label,
iso,
active,
}: {
label: string;
iso: string | null;
active: boolean;
}) {
return (
{label}
{iso ? `${timeFmt.format(new Date(iso))} EAT` : "—"}
);
}
/**
* 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 (
{phase ? (
) : null}
{openDay ? (
Booking day · {openDay}
) : null}
{cd ? (
) : null}
);
}
/** EAT calendar date key for a window — prefers the API field, falls back to `start`. */
export default function BatchScheduleDetailPage() {
const { scheduleId } = useParams<{ scheduleId: string }>();
const navigate = useNavigate();
const { toast } = useToast();
const { data, isLoading, isFetching, refetch } = useQuery(
api.trainScheduling.batchBoardDetail.queryOptions({
input: { scheduleId: scheduleId ?? "" },
enabled: Boolean(scheduleId),
// Poll fast while a window cycle is actively moving (open / doc-review /
// payment) so the priority ranking + pay countdowns stay live; back off to
// 30s once the cycle is idle (pre-window / closed / done).
refetchInterval: (query) => {
const phase = (query.state.data as BatchBoardScheduleDetail | undefined)
?.windowPhase;
return phase === "OPEN" ||
phase === "DOC_REVIEW" ||
phase === "PAYMENT"
? 5_000
: 30_000;
},
}),
);
// Keep the board in sync with server-pushed window-phase transitions too
// (invalidates the batch-board list + patches window carousels).
useBookingWindowSocket(Boolean(scheduleId));
const runAllocation = useMutation(
api.trainScheduling.runAllocation.mutationOptions(),
);
const completeDocReview = useMutation(
api.trainScheduling.completeDocReview.mutationOptions(),
);
const hasAssignedWagons = useMemo(
() =>
Boolean(
data?.windows.some((w) =>
w.bookings.some((b) => b.allocationStatus === "ASSIGNED"),
) ||
data?.pendingContract.bookings.some(
(b) => b.allocationStatus === "ASSIGNED",
),
),
[data],
);
const scheduleDetailQuery = useQuery(
api.trainScheduling.scheduleDetail.queryOptions({
input: { id: scheduleId ?? "", freightType: "CONTAINER" },
enabled: Boolean(scheduleId),
// Composition data only changes through mutations, which invalidate the
// whole train-scheduling root — no need to refetch on remounts in between.
staleTime: 5 * 60_000,
}),
);
// Every booking on this schedule, flattened across windows + pending-contract,
// de-duplicated (a booking only appears once). Feeds the management table.
const allBookings = useMemo(() => {
if (!data) return [] as BatchBoardBookingDetail[];
const merged = [
...data.windows.flatMap((w) => w.bookings),
...data.pendingContract.bookings,
];
const byId = new Map();
for (const b of merged) if (!byId.has(b.id)) byId.set(b.id, b);
return [...byId.values()];
}, [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).
const batchBookings = useMemo(() => {
const all = allBookings;
return {
awaitingPayment: all.filter((b) => b.state === "SELECTED_FOR_BATCH"),
expired: all.filter((b) => b.state === "EXPIRED"),
};
}, [allBookings]);
const bookingsReadOnly = useMemo(
() => ["DISPATCHED", "ARRIVED"].includes(data?.status ?? ""),
[data?.status],
);
const [activeTab, setActiveTab] = useState("overview");
const [selectedBookingId, setSelectedBookingId] = useState(
null,
);
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 ?? "" })
.then((result) => {
const failed = result.issues.filter(
(i) => i.status === "FAILED",
).length;
const deferred = result.deferred.length;
toast({
title: "Allocation run complete",
description:
failed || deferred
? `${result.assignedBookingIds.length} assigned · ${deferred} deferred · ${failed} failed`
: `${result.assignedBookingIds.length} booking(s) assigned to wagons`,
variant: failed ? "destructive" : "default",
});
void refetch();
})
.catch(() => {
toast({ title: "Allocation failed", variant: "destructive" });
});
};
if (isLoading || !data) {
return (
);
}
const totalBookings = totalBookingCount(data.counts);
const countdown = phaseCountdown(data);
return (
Overview
}
>
Priority Tracking{" "}
{allBookings.length > 0 && `(${allBookings.length})`}
Train Composition{" "}
{scheduleDetailQuery.data?.trainSet?.wagons &&
scheduleDetailQuery.data.trainSet.wagons.length > 0 &&
`(${scheduleDetailQuery.data.trainSet.wagons.length})`}
}
onClick={() =>
navigate("/dashboard/operations/batch-board")
}
>
Back
{data.trainNumber ?? data.routeName ?? "Schedule"}
{data.scheduleReference ? (
}>
{data.scheduleReference}
) : null}
{data.windowPhase ? (
) : null}
{data.status}
}>
{data.scheduleDate
? new Intl.DateTimeFormat("en-GB", {
weekday: "short",
day: "2-digit",
month: "short",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
hour12: false,
timeZone: "Africa/Addis_Ababa",
}).format(new Date(data.scheduleDate)) + " EAT"
: "No date"}
{data.locomotive ? (
}>
Loco {data.locomotive.code} ·{" "}
{fmtTons(data.locomotive.maxPullWeightTons)} ·{" "}
{data.locomotive.maxTrainLengthMeters} m
) : null}
{data.windowPhase ? (
}>
Cycle {data.bookingCycleNo}
{countdown ? ` · ${countdown}` : ""}
) : null}
}
loading={isFetching}
onClick={() => void refetch()}
>
Refresh
{data.windowPhase === "DOC_REVIEW" ? (
}
loading={completeDocReview.isPending}
onClick={handleCompleteDocReview}
>
Doc review complete — run batch
) : null}
}
loading={runAllocation.isPending}
onClick={handleRunAllocation}
>
Run allocation
}
onClick={() =>
navigate(
`/dashboard/operations/train-scheduling-v2/${data.scheduleId}`,
)
}
>
Open schedule
{!data.locomotive ? (
}>
No locomotive assigned — wagon allocation cannot run.
) : null}
{/* Booking pipeline */}
Booking pipeline
{totalBookings} booking{totalBookings === 1 ? "" : "s"}
{data.allocationViolations.length ? (
}
title="Allocation constraints"
>
{data.allocationViolations.map((v) => (
{v}
))}
) : null}
{/* Manage bookings — search, filter, remove / re-assign (bulk too) */}
Manage bookings
Search and filter every booking on this train. Remove an
allocated booking to free its wagons, or re-assign one that
is not yet allocated — individually or in bulk.
void refetch()}
readOnly={bookingsReadOnly}
/>
{/* Batch windows */}
Booking window (EAT)
The schedule's real booking window — the same window and
phase timings the customer sees on the portal. Bookings in
the window are listed below.
{windowBookings.length ? (
Bookings in this window
) : (
No bookings in this window yet.
)}
{data.pendingContract.bookings.length ? (
Pending contract
Contract not signed yet — not in any window
{data.pendingContract.bookings.length} booking
{data.pendingContract.bookings.length === 1
? ""
: "s"}
) : null}
{/* Train composition diagram */}
{hasAssignedWagons && scheduleDetailQuery.data ? (
) : null}
{scheduleDetailQuery.data && scheduleDetailQuery.data.trainSet ? (
) : (
Loading train composition…
)}
);
}