mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 12:58:13 +00:00
1069 lines
34 KiB
TypeScript
1069 lines
34 KiB
TypeScript
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 (
|
||
<Badge variant="light" color={meta.color} radius="sm" leftSection={<Icon size={11} />}>
|
||
{meta.label}
|
||
</Badge>
|
||
);
|
||
}
|
||
|
||
function AllocationBadge({
|
||
status,
|
||
issue,
|
||
}: {
|
||
status: BookingAllocationStatus;
|
||
issue: string | null;
|
||
}) {
|
||
const meta = ALLOC_META[status];
|
||
const badge = (
|
||
<Badge variant="light" color={meta.color} radius="sm">
|
||
{meta.label}
|
||
</Badge>
|
||
);
|
||
if (!issue) return badge;
|
||
return (
|
||
<Tooltip label={issue} multiline maw={320} withArrow>
|
||
<Group gap={4} wrap="nowrap">
|
||
{badge}
|
||
<AlertTriangle size={14} color="var(--mantine-color-red-6)" />
|
||
</Group>
|
||
</Tooltip>
|
||
);
|
||
}
|
||
|
||
function BookingTable({ bookings }: { bookings: BatchBoardBookingDetail[] }) {
|
||
if (!bookings.length) {
|
||
return (
|
||
<Text size="sm" c="dimmed" py="sm" ta="center">
|
||
No bookings in this batch window.
|
||
</Text>
|
||
);
|
||
}
|
||
|
||
const th = (label: string) => (
|
||
<Table.Th
|
||
style={{
|
||
fontSize: 11,
|
||
fontWeight: 700,
|
||
textTransform: "uppercase",
|
||
letterSpacing: 0.5,
|
||
color: "var(--mantine-color-gray-6)",
|
||
background: "var(--mantine-color-gray-0)",
|
||
whiteSpace: "nowrap",
|
||
}}
|
||
>
|
||
{label}
|
||
</Table.Th>
|
||
);
|
||
|
||
return (
|
||
<Box
|
||
style={{
|
||
border: "1px solid var(--mantine-color-gray-2)",
|
||
borderRadius: 12,
|
||
overflow: "auto",
|
||
}}
|
||
>
|
||
<Table highlightOnHover verticalSpacing="sm" horizontalSpacing="md" miw={760}>
|
||
<Table.Thead>
|
||
<Table.Tr>
|
||
{th("Reference")}
|
||
{th("Customer")}
|
||
{th("Contract signed")}
|
||
{th("Selected for batch")}
|
||
{th("Capacity")}
|
||
{th("Batch state")}
|
||
{th("Wagon allocation")}
|
||
</Table.Tr>
|
||
</Table.Thead>
|
||
<Table.Tbody>
|
||
{bookings.map((b) => (
|
||
<Table.Tr key={b.id}>
|
||
<Table.Td>
|
||
<Group gap={6} wrap="nowrap">
|
||
<Text size="sm" fw={700} c="dark.5">
|
||
{b.reference}
|
||
</Text>
|
||
{b.isGovernment ? (
|
||
<Badge size="xs" variant="light" color="grape">
|
||
Gov
|
||
</Badge>
|
||
) : null}
|
||
</Group>
|
||
</Table.Td>
|
||
<Table.Td>
|
||
<Group gap={8} wrap="nowrap">
|
||
<Box
|
||
style={{
|
||
display: "flex",
|
||
alignItems: "center",
|
||
justifyContent: "center",
|
||
width: 26,
|
||
height: 26,
|
||
borderRadius: 8,
|
||
flexShrink: 0,
|
||
background: "#FEF1D5",
|
||
border: "1px solid #FBD171",
|
||
}}
|
||
>
|
||
<Text size="10px" fw={800} style={{ color: "#B26C09" }}>
|
||
{initials(b.company)}
|
||
</Text>
|
||
</Box>
|
||
<Text size="sm" c="gray.7" truncate>
|
||
{b.company}
|
||
</Text>
|
||
</Group>
|
||
</Table.Td>
|
||
<Table.Td>
|
||
<Text size="sm" style={{ whiteSpace: "nowrap" }}>
|
||
{fmtDateTime(b.fullyExecutedAt)} EAT
|
||
</Text>
|
||
</Table.Td>
|
||
<Table.Td>
|
||
{b.selectedForBatchAt ? (
|
||
<>
|
||
<Text size="sm" style={{ whiteSpace: "nowrap" }}>
|
||
{fmtDateTime(b.selectedForBatchAt)} EAT
|
||
</Text>
|
||
{b.paymentDeadline ? (
|
||
<Text size="xs" c="orange.7" fw={600} style={{ whiteSpace: "nowrap" }}>
|
||
Pay by {fmtDateTime(b.paymentDeadline)} EAT
|
||
</Text>
|
||
) : null}
|
||
</>
|
||
) : (
|
||
<Text size="sm" c="dimmed">
|
||
—
|
||
</Text>
|
||
)}
|
||
</Table.Td>
|
||
<Table.Td>
|
||
<Group gap={4} wrap="nowrap">
|
||
<Badge variant="default" radius="sm" size="sm">
|
||
{b.wagons}w
|
||
</Badge>
|
||
<Badge variant="default" radius="sm" size="sm">
|
||
{fmtTons(b.weightTons)}
|
||
</Badge>
|
||
</Group>
|
||
</Table.Td>
|
||
<Table.Td>
|
||
<StateBadge state={b.state} />
|
||
</Table.Td>
|
||
<Table.Td>
|
||
<AllocationBadge status={b.allocationStatus} issue={b.allocationIssue} />
|
||
</Table.Td>
|
||
</Table.Tr>
|
||
))}
|
||
</Table.Tbody>
|
||
</Table>
|
||
</Box>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<Group gap={5} wrap="nowrap">
|
||
{chips.map((c) => (
|
||
<Tooltip key={c.label} label={`${c.value} ${c.label}`} withArrow>
|
||
<Group
|
||
gap={4}
|
||
wrap="nowrap"
|
||
style={{
|
||
padding: "2px 8px",
|
||
borderRadius: 999,
|
||
background: `var(--mantine-color-${c.color}-0)`,
|
||
border: `1px solid var(--mantine-color-${c.color}-2)`,
|
||
}}
|
||
>
|
||
<Box
|
||
w={6}
|
||
h={6}
|
||
style={{
|
||
borderRadius: 999,
|
||
background: `var(--mantine-color-${c.color}-6)`,
|
||
}}
|
||
/>
|
||
<Text size="xs" fw={700} c={`${c.color}.8`}>
|
||
{c.value}
|
||
</Text>
|
||
</Group>
|
||
</Tooltip>
|
||
))}
|
||
</Group>
|
||
);
|
||
}
|
||
|
||
/** "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 (
|
||
<Accordion.Item value={window.key}>
|
||
<Accordion.Control>
|
||
<Group justify="space-between" wrap="nowrap" pr="md" gap="sm">
|
||
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
|
||
<Box
|
||
style={{
|
||
display: "flex",
|
||
alignItems: "center",
|
||
justifyContent: "center",
|
||
width: 34,
|
||
height: 34,
|
||
borderRadius: 10,
|
||
flexShrink: 0,
|
||
background: total ? "#FEF1D5" : "var(--mantine-color-gray-0)",
|
||
border: total ? "1px solid #FBD171" : "1px solid var(--mantine-color-gray-2)",
|
||
color: total ? "#B26C09" : "var(--mantine-color-gray-5)",
|
||
}}
|
||
>
|
||
<Clock size={16} />
|
||
</Box>
|
||
<Box style={{ minWidth: 0 }}>
|
||
<Text fw={700} size="sm" truncate>
|
||
{timeLabelOf(window.label)}
|
||
</Text>
|
||
<Text size="xs" c="dimmed">
|
||
{total ? `${total} booking${total === 1 ? "" : "s"}` : "Empty window"}
|
||
</Text>
|
||
</Box>
|
||
</Group>
|
||
<Group gap={6} wrap="nowrap">
|
||
{hasIssues ? (
|
||
<Badge
|
||
variant="light"
|
||
color="red"
|
||
size="sm"
|
||
leftSection={<AlertTriangle size={10} />}
|
||
>
|
||
Issues
|
||
</Badge>
|
||
) : null}
|
||
<WindowCountChips counts={window.counts} />
|
||
</Group>
|
||
</Group>
|
||
</Accordion.Control>
|
||
<Accordion.Panel>
|
||
<BookingTable bookings={window.bookings} />
|
||
</Accordion.Panel>
|
||
</Accordion.Item>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 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 (
|
||
<Paper
|
||
p="md"
|
||
radius="lg"
|
||
withBorder
|
||
style={{ borderColor: "var(--mantine-color-gray-2)", background: "white" }}
|
||
>
|
||
<Stack gap={8}>
|
||
<Group gap="sm" wrap="nowrap" align="center">
|
||
<ThemeIcon size={38} radius="md" variant="light" color="#F2A516">
|
||
<Icon size={19} />
|
||
</ThemeIcon>
|
||
<Stack gap={1} style={{ minWidth: 0, flex: 1 }}>
|
||
<Text fw={800} size="lg" lh={1.05} c="dark.5" style={{ whiteSpace: "nowrap" }}>
|
||
{value}
|
||
</Text>
|
||
<Text size="xs" fw={600} c="dimmed" truncate>
|
||
{label}
|
||
{sub ? ` · ${sub}` : ""}
|
||
</Text>
|
||
</Stack>
|
||
{pct != null ? (
|
||
<MiniRing pct={pct} accent={ringAccent} size={42} stroke={5}>
|
||
<Text size="9px" fw={800} c="dark.4">
|
||
{Math.round(pct)}%
|
||
</Text>
|
||
</MiniRing>
|
||
) : null}
|
||
</Group>
|
||
|
||
{pct == null ? (
|
||
<MiniSparkline variant={variant} accent="gold" baseline={0.5} seed={label} height={20} />
|
||
) : null}
|
||
</Stack>
|
||
</Paper>
|
||
);
|
||
}
|
||
|
||
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<string | null>(null);
|
||
const [activeTab, setActiveTab] = useState<string | null>("overview");
|
||
const [selectedBookingId, setSelectedBookingId] = useState<string | null>(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 (
|
||
<Container fluid py="lg" px="xl">
|
||
<Group justify="center" py="xl">
|
||
<Loader color="green" />
|
||
</Group>
|
||
</Container>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<Container fluid py="lg" px="xl">
|
||
<Breadcrumbs
|
||
items={[
|
||
{ label: "Operations" },
|
||
{ label: "Batch board", href: "/dashboard/operations/batch-board" },
|
||
{ label: data.trainNumber ?? data.routeName ?? "Schedule" },
|
||
]}
|
||
/>
|
||
|
||
<Tabs value={activeTab} onChange={setActiveTab} mt="md">
|
||
<Tabs.List>
|
||
<Tabs.Tab value="overview">Overview</Tabs.Tab>
|
||
<Tabs.Tab value="composition">
|
||
Train Composition {scheduleDetailQuery.data?.trainSet?.wagons && scheduleDetailQuery.data.trainSet.wagons.length > 0 && `(${scheduleDetailQuery.data.trainSet.wagons.length})`}
|
||
</Tabs.Tab>
|
||
</Tabs.List>
|
||
|
||
<Tabs.Panel value="overview" pt="lg">
|
||
<Stack gap="lg">
|
||
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
|
||
<Stack gap={9} style={{ minWidth: 0 }}>
|
||
<Group gap="sm" wrap="wrap" align="center">
|
||
<Button
|
||
variant="subtle"
|
||
color="gray"
|
||
radius="md"
|
||
px="sm"
|
||
leftSection={<ArrowLeft size={16} />}
|
||
onClick={() => navigate("/dashboard/operations/batch-board")}
|
||
>
|
||
Back
|
||
</Button>
|
||
<Title order={2} fw={800} style={{ color: "#0f172a", letterSpacing: 0.2 }}>
|
||
{data.trainNumber ?? data.routeName ?? "Schedule"}
|
||
</Title>
|
||
<WindowStatusPill status={data.bookingWindowStatus} />
|
||
<HeroChip>{data.status}</HeroChip>
|
||
</Group>
|
||
<RouteCorridor origin={data.origin} destination={data.destination} />
|
||
<Group gap={6} wrap="wrap">
|
||
<HeroChip icon={<CalendarDays size={12} />}>
|
||
{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"}
|
||
</HeroChip>
|
||
{data.locomotive ? (
|
||
<HeroChip icon={<TrainFront size={12} />}>
|
||
Loco {data.locomotive.code} · {fmtTons(data.locomotive.maxPullWeightTons)} ·{" "}
|
||
{data.locomotive.maxTrainLengthMeters} m
|
||
</HeroChip>
|
||
) : null}
|
||
</Group>
|
||
</Stack>
|
||
|
||
<Group gap="sm">
|
||
<Button
|
||
variant="default"
|
||
radius="md"
|
||
leftSection={<RefreshCw size={16} />}
|
||
loading={isFetching}
|
||
onClick={() => void refetch()}
|
||
>
|
||
Refresh
|
||
</Button>
|
||
<Button
|
||
color="green"
|
||
radius="md"
|
||
leftSection={<PlayCircle size={16} />}
|
||
loading={runAllocation.isPending}
|
||
onClick={handleRunAllocation}
|
||
>
|
||
Run allocation
|
||
</Button>
|
||
<Button
|
||
variant="light"
|
||
color="green"
|
||
radius="md"
|
||
leftSection={<Layers size={16} />}
|
||
onClick={() =>
|
||
navigate(`/dashboard/operations/train-scheduling-v2/${data.scheduleId}`)
|
||
}
|
||
>
|
||
Open schedule
|
||
</Button>
|
||
</Group>
|
||
</Group>
|
||
|
||
{!data.locomotive ? (
|
||
<Alert color="red" radius="md" icon={<AlertTriangle size={16} />}>
|
||
No locomotive assigned — wagon allocation cannot run.
|
||
</Alert>
|
||
) : null}
|
||
|
||
<SimpleGrid cols={{ base: 1, xs: 2, md: 4 }} spacing="md">
|
||
<StatCard
|
||
icon={Boxes}
|
||
label="Allocated wagons"
|
||
value={data.capacity.allocatedWagons}
|
||
sub="on this train"
|
||
/>
|
||
<StatCard
|
||
icon={Ruler}
|
||
label="Train length"
|
||
value={
|
||
data.capacity.maxLengthMeters
|
||
? `${fmtMeters(data.capacity.allocatedLengthMeters)} / ${fmtMeters(data.capacity.maxLengthMeters)}`
|
||
: fmtMeters(data.capacity.allocatedLengthMeters)
|
||
}
|
||
pct={lengthPct}
|
||
/>
|
||
<StatCard
|
||
icon={Weight}
|
||
label="Weight"
|
||
value={
|
||
data.capacity.maxWeightTons
|
||
? `${fmtTons(data.capacity.usedWeightTons)} / ${fmtTons(data.capacity.maxWeightTons)}`
|
||
: fmtTons(data.capacity.usedWeightTons)
|
||
}
|
||
pct={weightPct}
|
||
/>
|
||
<StatCard
|
||
icon={Package}
|
||
label="Bookings"
|
||
value={totalBookings}
|
||
sub={`${data.counts.allocated} allocated · ${data.counts.expired} expired`}
|
||
variant="line"
|
||
/>
|
||
</SimpleGrid>
|
||
|
||
{/* Booking pipeline */}
|
||
<Paper
|
||
radius="lg"
|
||
withBorder
|
||
p="lg"
|
||
mt="lg"
|
||
style={{ borderColor: "var(--mantine-color-gray-2)" }}
|
||
>
|
||
<Group justify="space-between" mb="sm">
|
||
<Group gap={8} wrap="nowrap">
|
||
<ThemeIcon size={32} radius="md" variant="light" color="#F2A516">
|
||
<Package size={16} />
|
||
</ThemeIcon>
|
||
<Text fw={700}>Booking pipeline</Text>
|
||
</Group>
|
||
<Text size="sm" fw={700} c="dark.4">
|
||
{totalBookings} booking{totalBookings === 1 ? "" : "s"}
|
||
</Text>
|
||
</Group>
|
||
<BookingPipeline counts={data.counts} size={14} />
|
||
</Paper>
|
||
|
||
{data.allocationViolations.length ? (
|
||
<Alert
|
||
color="red"
|
||
mt="lg"
|
||
radius="lg"
|
||
icon={<AlertTriangle size={16} />}
|
||
title="Allocation constraints"
|
||
>
|
||
<Stack gap={4}>
|
||
{data.allocationViolations.map((v) => (
|
||
<Text key={v} size="sm">
|
||
{v}
|
||
</Text>
|
||
))}
|
||
</Stack>
|
||
</Alert>
|
||
) : null}
|
||
|
||
{/* Batch windows */}
|
||
<Paper
|
||
radius="lg"
|
||
withBorder
|
||
p="lg"
|
||
mt="lg"
|
||
style={{ borderColor: "var(--mantine-color-gray-2)" }}
|
||
>
|
||
<Group gap="sm" mb={4} wrap="nowrap" align="flex-start">
|
||
<Box
|
||
style={{
|
||
display: "flex",
|
||
alignItems: "center",
|
||
justifyContent: "center",
|
||
width: 38,
|
||
height: 38,
|
||
borderRadius: 10,
|
||
background: "linear-gradient(135deg, #FBD171, #F2A516)",
|
||
color: "white",
|
||
flexShrink: 0,
|
||
}}
|
||
>
|
||
<Clock size={19} />
|
||
</Box>
|
||
<Box>
|
||
<Title order={4}>Batch windows (EAT)</Title>
|
||
<Text size="sm" c="dimmed">
|
||
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.
|
||
</Text>
|
||
</Box>
|
||
</Group>
|
||
|
||
{dayGroups.length && selectedDay ? (
|
||
<>
|
||
{/* Date stepper — page back/forward through each day in the range */}
|
||
<Group justify="center" align="center" wrap="nowrap" gap="md" mt="md">
|
||
<ActionIcon
|
||
variant="light"
|
||
color="#F2A516"
|
||
size="xl"
|
||
radius="xl"
|
||
aria-label="Previous day"
|
||
disabled={selectedIndex <= 0}
|
||
onClick={() => setSelectedDate(dayGroups[selectedIndex - 1]?.date ?? null)}
|
||
>
|
||
<ChevronLeft size={20} />
|
||
</ActionIcon>
|
||
|
||
<Paper
|
||
withBorder
|
||
radius="xl"
|
||
px="xl"
|
||
py="xs"
|
||
style={{
|
||
flex: 1,
|
||
maxWidth: 360,
|
||
textAlign: "center",
|
||
background: selectedDay.totalBookings ? "#FEF1D5" : "white",
|
||
borderColor: selectedDay.totalBookings
|
||
? "#FBD171"
|
||
: "var(--mantine-color-gray-2)",
|
||
}}
|
||
>
|
||
<Group justify="center" gap={8} wrap="nowrap">
|
||
<CalendarDays size={15} color="#B26C09" />
|
||
<Text
|
||
fw={800}
|
||
style={{ color: selectedDay.totalBookings ? "#8A5304" : "#0f172a" }}
|
||
>
|
||
{selectedDay.dateLabel}
|
||
</Text>
|
||
{selectedDay.date === todayEat ? (
|
||
<Badge size="xs" variant="light" color="#F2A516">
|
||
Today
|
||
</Badge>
|
||
) : null}
|
||
</Group>
|
||
<Text size="xs" c="dimmed" mt={2}>
|
||
{selectedDay.totalBookings
|
||
? `${selectedDay.totalBookings} booking${selectedDay.totalBookings === 1 ? "" : "s"} · ${selectedDay.windows.length} windows`
|
||
: `${selectedDay.windows.length} windows · no bookings`}
|
||
</Text>
|
||
</Paper>
|
||
|
||
<ActionIcon
|
||
variant="light"
|
||
color="#F2A516"
|
||
size="xl"
|
||
radius="xl"
|
||
aria-label="Next day"
|
||
disabled={selectedIndex >= dayGroups.length - 1}
|
||
onClick={() => setSelectedDate(dayGroups[selectedIndex + 1]?.date ?? null)}
|
||
>
|
||
<ChevronRight size={20} />
|
||
</ActionIcon>
|
||
</Group>
|
||
|
||
<Group justify="space-between" align="center" mt="sm">
|
||
<Text size="xs" c="dimmed">
|
||
Day {selectedIndex + 1} of {dayGroups.length}
|
||
</Text>
|
||
<Group gap={6} wrap="nowrap">
|
||
{selectedDay.hasIssues ? (
|
||
<Badge
|
||
variant="light"
|
||
color="red"
|
||
size="sm"
|
||
leftSection={<AlertTriangle size={10} />}
|
||
>
|
||
Issues
|
||
</Badge>
|
||
) : null}
|
||
<WindowCountChips counts={selectedDay.counts} />
|
||
</Group>
|
||
</Group>
|
||
|
||
<Accordion
|
||
key={selectedDay.date}
|
||
multiple
|
||
defaultValue={openWindowKeys}
|
||
variant="separated"
|
||
radius="md"
|
||
mt="md"
|
||
className="bb-window-accordion"
|
||
>
|
||
{selectedDay.windows.map((window) => (
|
||
<WindowAccordionItem key={window.key} window={window} />
|
||
))}
|
||
</Accordion>
|
||
</>
|
||
) : (
|
||
<Text size="sm" c="dimmed" ta="center" py="lg">
|
||
No batch windows for this schedule.
|
||
</Text>
|
||
)}
|
||
|
||
{data.pendingContract.bookings.length ? (
|
||
<Accordion
|
||
multiple
|
||
defaultValue={["pending-contract"]}
|
||
variant="separated"
|
||
radius="md"
|
||
mt="md"
|
||
className="bb-window-accordion"
|
||
>
|
||
<Accordion.Item value="pending-contract">
|
||
<Accordion.Control>
|
||
<Group justify="space-between" wrap="nowrap" pr="md" gap="sm">
|
||
<Group gap="sm" wrap="nowrap">
|
||
<Box
|
||
style={{
|
||
display: "flex",
|
||
alignItems: "center",
|
||
justifyContent: "center",
|
||
width: 34,
|
||
height: 34,
|
||
borderRadius: 10,
|
||
flexShrink: 0,
|
||
background: "var(--mantine-color-gray-0)",
|
||
border: "1px solid var(--mantine-color-gray-2)",
|
||
color: "var(--mantine-color-gray-6)",
|
||
}}
|
||
>
|
||
<FileSignature size={16} />
|
||
</Box>
|
||
<Box>
|
||
<Text fw={700} size="sm">
|
||
Pending contract
|
||
</Text>
|
||
<Text size="xs" c="dimmed">
|
||
Contract not signed yet — not in any window
|
||
</Text>
|
||
</Box>
|
||
</Group>
|
||
<Badge variant="outline" color="gray" size="sm">
|
||
{data.pendingContract.bookings.length} booking
|
||
{data.pendingContract.bookings.length === 1 ? "" : "s"}
|
||
</Badge>
|
||
</Group>
|
||
</Accordion.Control>
|
||
<Accordion.Panel>
|
||
<BookingTable bookings={data.pendingContract.bookings} />
|
||
</Accordion.Panel>
|
||
</Accordion.Item>
|
||
</Accordion>
|
||
) : null}
|
||
</Paper>
|
||
|
||
{/* Train composition diagram */}
|
||
{hasAssignedWagons && scheduleDetailQuery.data ? (
|
||
<Box mt="lg">
|
||
<TrainCompositionDiagram
|
||
locomotive={scheduleDetailQuery.data.trainSet?.locomotive}
|
||
wagons={scheduleDetailQuery.data.trainSet?.wagons ?? []}
|
||
freightType="CONTAINER"
|
||
trainNumber={scheduleDetailQuery.data.trainNumber}
|
||
totalLengthMeters={scheduleDetailQuery.data.trainSet?.totalLengthMeters}
|
||
/>
|
||
</Box>
|
||
) : null}
|
||
</Stack>
|
||
</Tabs.Panel>
|
||
|
||
<Tabs.Panel value="composition" pt="lg">
|
||
{scheduleDetailQuery.data && scheduleDetailQuery.data.trainSet ? (
|
||
<Group align="stretch" gap="md" wrap="nowrap">
|
||
<Box style={{ flex: 1, minWidth: 0 }}>
|
||
<TrainConsistView
|
||
scheduleDetail={scheduleDetailQuery.data}
|
||
scheduleId={scheduleId ?? ""}
|
||
maxWagons={53}
|
||
highlightBookingId={selectedBookingId}
|
||
/>
|
||
</Box>
|
||
<Box style={{ width: 380, flexShrink: 0, minHeight: 520 }}>
|
||
<CompositionBookingTabs
|
||
scheduleDetail={scheduleDetailQuery.data}
|
||
scheduleId={scheduleId ?? ""}
|
||
awaitingPayment={batchBookings.awaitingPayment}
|
||
expired={batchBookings.expired}
|
||
selectedBookingId={selectedBookingId}
|
||
onSelectBooking={setSelectedBookingId}
|
||
/>
|
||
</Box>
|
||
</Group>
|
||
) : (
|
||
<Paper
|
||
radius="lg"
|
||
withBorder
|
||
py={64}
|
||
style={{ borderColor: "var(--mantine-color-gray-2)" }}
|
||
>
|
||
<Group justify="center">
|
||
<Loader color="green" size="sm" />
|
||
<Text size="sm" c="dimmed">
|
||
Loading train composition…
|
||
</Text>
|
||
</Group>
|
||
</Paper>
|
||
)}
|
||
</Tabs.Panel>
|
||
</Tabs>
|
||
</Container>
|
||
);
|
||
}
|