import { useEffect, useMemo, useState } from "react";
import type { ReactNode } from "react";
import { useNavigate, useParams } from "react-router-dom";
import {
Accordion,
ActionIcon,
Alert,
Badge,
Box,
Button,
Container,
Group,
Loader,
Paper,
SimpleGrid,
Stack,
Table,
Tabs,
Text,
ThemeIcon,
Title,
Tooltip,
} from "@mantine/core";
import {
AlertTriangle,
ArrowLeft,
Boxes,
CalendarDays,
CheckCircle2,
ChevronLeft,
ChevronRight,
Clock,
FileSignature,
Hourglass,
Layers,
Package,
PlayCircle,
RefreshCw,
Ruler,
TrainFront,
Weight,
XCircle,
} from "lucide-react";
import type { LucideIcon } from "lucide-react";
import Breadcrumbs from "@/components/ui/Breadcrumbs";
import { TrainCompositionDiagram } from "@/components/trainScheduling/TrainCompositionDiagram";
import { TrainConsistView, CompositionBookingTabs } from "@/components/trainScheduling/compositionEditor";
import {
BookingPipeline,
HeroChip,
totalBookingCount,
WindowStatusPill,
} from "@/components/trainScheduling/batchVisuals";
import { MiniRing, MiniSparkline } from "@/components/common/MiniGraph";
import type { OverviewAccent } from "@/components/overview/overview.styles";
import { RouteCorridor } from "@/components/trainScheduling/scheduleVisuals";
import {
useBatchBoardDetail,
useRunAllocation,
useScheduleDetail,
} from "@/hooks/trainScheduling/useTrainScheduling";
import { useToast } from "@/hooks/use-toast";
import type {
BatchBoardBookingDetail,
BatchBoardBookingState,
BatchWindowGroup,
BookingAllocationStatus,
} from "@/types/trainScheduling";
const STATE_META: Record<
BatchBoardBookingState,
{ label: string; color: string; icon: typeof CheckCircle2 }
> = {
ALLOCATED: { label: "Allocated", color: "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: "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 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}
);
}
function BookingTable({ bookings }: { bookings: BatchBoardBookingDetail[] }) {
if (!bookings.length) {
return (
No bookings in this batch window.
);
}
const th = (label: string) => (
{label}
);
return (
{th("Reference")}
{th("Customer")}
{th("Contract signed")}
{th("Selected for batch")}
{th("Capacity")}
{th("Batch state")}
{th("Wagon allocation")}
{bookings.map((b) => (
{b.reference}
{b.isGovernment ? (
Gov
) : null}
{initials(b.company)}
{b.company}
{fmtDateTime(b.fullyExecutedAt)} EAT
{b.selectedForBatchAt ? (
<>
{fmtDateTime(b.selectedForBatchAt)} EAT
{b.paymentDeadline ? (
Pay by {fmtDateTime(b.paymentDeadline)} EAT
) : null}
>
) : (
—
)}
{b.wagons}w
{fmtTons(b.weightTons)}
))}
);
}
function WindowCountChips({ counts }: { counts: BatchWindowGroup["counts"] }) {
const chips: Array<{ value: number; color: string; label: string }> = [
{ value: counts.allocated, color: "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}
);
}
/**
* Light stat card matching the overview look — keeps the explicit numbers.
* Capacity cards (pct set) show a ring; count cards show an area/line trend.
*/
function StatCard({
icon: Icon,
label,
value,
sub,
pct,
variant = "area",
}: {
icon: LucideIcon;
label: string;
value: ReactNode;
sub?: string;
pct?: number | null;
variant?: "area" | "line";
}) {
const ringAccent: OverviewAccent =
pct == null ? "gold" : pct >= 100 ? "rose" : pct >= 85 ? "orange" : "gold";
return (
{value}
{label}
{sub ? ` · ${sub}` : ""}
{pct != null ? (
{Math.round(pct)}%
) : null}
{pct == null ? (
) : null}
);
}
export default function BatchScheduleDetailPage() {
const { scheduleId } = useParams<{ scheduleId: string }>();
const navigate = useNavigate();
const { toast } = useToast();
const { data, isLoading, isFetching, refetch } = useBatchBoardDetail(scheduleId);
const runAllocation = useRunAllocation(scheduleId ?? "");
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 = useScheduleDetail(scheduleId, "CONTAINER");
// Batch bookings by state for the composition side panel (payment / expired lists).
const batchBookings = useMemo(() => {
if (!data) return { awaitingPayment: [], expired: [] };
const all = [
...data.windows.flatMap((w) => w.bookings),
...data.pendingContract.bookings,
];
return {
awaitingPayment: all.filter((b) => b.state === "SELECTED_FOR_BATCH"),
expired: all.filter((b) => b.state === "EXPIRED"),
};
}, [data]);
// 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 handleRunAllocation = () => {
runAllocation
.mutateAsync()
.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 lengthPct =
data.capacity.maxLengthMeters && data.capacity.maxLengthMeters > 0
? (data.capacity.allocatedLengthMeters / data.capacity.maxLengthMeters) * 100
: null;
const weightPct =
data.capacity.maxWeightTons && data.capacity.maxWeightTons > 0
? (data.capacity.usedWeightTons / data.capacity.maxWeightTons) * 100
: null;
const totalBookings = totalBookingCount(data.counts);
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.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}
}
loading={isFetching}
onClick={() => void refetch()}
>
Refresh
}
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}
{/* 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…
)}
);
}