import { useEffect, useMemo, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import {
Accordion,
ActionIcon,
Alert,
Badge,
Box,
Button,
Group,
Loader,
Paper,
Stack,
Tabs,
Text,
ThemeIcon,
Title,
Tooltip,
} from "@mantine/core";
import {
AlertTriangle,
ArrowLeft,
ArrowLeftRight,
Boxes,
CalendarDays,
CheckCircle2,
ChevronLeft,
ClipboardCheck,
ChevronRight,
Clock,
FileSignature,
Hourglass,
Layers,
Package,
PlayCircle,
RefreshCw,
Ruler,
TrainFront,
Weight,
XCircle,
} from "lucide-react";
import { 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 { 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). */
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 dateKeyFmt = new Intl.DateTimeFormat("en-CA", {
timeZone: EAT_TZ,
year: "numeric",
month: "2-digit",
day: "2-digit",
});
const dateLabelFmt = new Intl.DateTimeFormat("en-GB", {
timeZone: EAT_TZ,
weekday: "short",
day: "2-digit",
month: "short",
});
/** EAT calendar date key for a window — prefers the API field, falls back to `start`. */
function windowDateKey(w: BatchWindowGroup): string {
if (w.date) return w.date;
if (w.start) return dateKeyFmt.format(new Date(w.start));
return "undated";
}
/** Human day label for a window — prefers the API field, falls back to `start`. */
function windowDateLabel(w: BatchWindowGroup): string {
if (w.dateLabel) return w.dateLabel;
if (w.start) return dateLabelFmt.format(new Date(w.start));
return "Undated";
}
function WindowAccordionItem({ window }: { window: BatchWindowGroup }) {
const total = window.bookings.length;
const hasIssues = window.bookings.some(
(b) => b.allocationStatus === "FAILED" || b.allocationStatus === "DEFERRED",
);
return (
{timeLabelOf(window.label)}
{total
? `${total} booking${total === 1 ? "" : "s"}`
: "Empty window"}
{hasIssues ? (
}
>
Issues
) : null}
);
}
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),
refetchInterval: 30_000,
}),
);
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),
}),
);
// 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]);
// 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],
);
// 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(null);
const [activeTab, setActiveTab] = useState("overview");
const [selectedBookingId, setSelectedBookingId] = useState(
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 = () => {
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
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.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 */}
Batch windows (EAT)
3-hour windows for every day from when the booking window
opened through the departure date. Bookings appear under the
date their contract was signed — open a day to see its
windows.
{dayGroups.length && selectedDay ? (
<>
{/* Date stepper — page back/forward through each day in the range */}
setSelectedDate(
dayGroups[selectedIndex - 1]?.date ?? null,
)
}
>
{selectedDay.dateLabel}
{selectedDay.date === todayEat ? (
Today
) : null}
{selectedDay.totalBookings
? `${selectedDay.totalBookings} booking${selectedDay.totalBookings === 1 ? "" : "s"} · ${selectedDay.windows.length} windows`
: `${selectedDay.windows.length} windows · no bookings`}
= dayGroups.length - 1}
onClick={() =>
setSelectedDate(
dayGroups[selectedIndex + 1]?.date ?? null,
)
}
>
Day {selectedIndex + 1} of {dayGroups.length}
{selectedDay.hasIssues ? (
}
>
Issues
) : null}
{selectedDay.windows.map((window) => (
))}
>
) : (
No batch windows for this schedule.
)}
{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…
)}
);
}