mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 00:38:11 +00:00
1144 lines
36 KiB
TypeScript
1144 lines
36 KiB
TypeScript
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,
|
||
Boxes,
|
||
CalendarDays,
|
||
CheckCircle2,
|
||
ChevronLeft,
|
||
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,
|
||
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,
|
||
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 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>
|
||
);
|
||
}
|
||
|
||
const bookingCellMeta = {
|
||
headerClassName: ruleEngineTable.headerCell,
|
||
cellClassName: ruleEngineTable.bodyCell,
|
||
};
|
||
|
||
const BOOKING_COLUMNS: ColumnDef<BatchBoardBookingDetail>[] = [
|
||
{
|
||
id: "reference",
|
||
header: "Reference",
|
||
meta: bookingCellMeta,
|
||
cell: ({ row }) => {
|
||
const b = row.original;
|
||
return (
|
||
<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>
|
||
);
|
||
},
|
||
},
|
||
{
|
||
id: "customer",
|
||
header: "Customer",
|
||
meta: bookingCellMeta,
|
||
cell: ({ row }) => {
|
||
const b = row.original;
|
||
return (
|
||
<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>
|
||
);
|
||
},
|
||
},
|
||
{
|
||
id: "contractSigned",
|
||
header: "Contract signed",
|
||
meta: bookingCellMeta,
|
||
cell: ({ row }) => (
|
||
<Text size="sm" style={{ whiteSpace: "nowrap" }}>
|
||
{fmtDateTime(row.original.fullyExecutedAt)} EAT
|
||
</Text>
|
||
),
|
||
},
|
||
{
|
||
id: "selectedForBatch",
|
||
header: "Selected for batch",
|
||
meta: bookingCellMeta,
|
||
cell: ({ row }) => {
|
||
const b = row.original;
|
||
if (!b.selectedForBatchAt) {
|
||
return (
|
||
<Text size="sm" c="dimmed">
|
||
—
|
||
</Text>
|
||
);
|
||
}
|
||
return (
|
||
<>
|
||
<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}
|
||
</>
|
||
);
|
||
},
|
||
},
|
||
{
|
||
id: "capacity",
|
||
header: "Capacity",
|
||
meta: bookingCellMeta,
|
||
cell: ({ row }) => {
|
||
const b = row.original;
|
||
return (
|
||
<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>
|
||
);
|
||
},
|
||
},
|
||
{
|
||
id: "state",
|
||
header: "Batch state",
|
||
meta: bookingCellMeta,
|
||
cell: ({ row }) => <StateBadge state={row.original.state} />,
|
||
},
|
||
{
|
||
id: "allocation",
|
||
header: "Wagon allocation",
|
||
meta: bookingCellMeta,
|
||
cell: ({ row }) => (
|
||
<AllocationBadge
|
||
status={row.original.allocationStatus}
|
||
issue={row.original.allocationIssue}
|
||
/>
|
||
),
|
||
},
|
||
];
|
||
|
||
function BookingTable({ bookings }: { bookings: BatchBoardBookingDetail[] }) {
|
||
return (
|
||
<DataTable
|
||
columns={BOOKING_COLUMNS}
|
||
data={bookings}
|
||
status="success"
|
||
emptyMessage="No bookings in this batch window."
|
||
containerClassName="overflow-x-auto rounded-lg border border-edr-border"
|
||
/>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<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>
|
||
);
|
||
}
|
||
|
||
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 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<string, BatchBoardBookingDetail>();
|
||
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<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({ 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 (
|
||
<PageContainer fluid>
|
||
<Group justify="center" py="xl">
|
||
<Loader color="edr-green" />
|
||
</Group>
|
||
</PageContainer>
|
||
);
|
||
}
|
||
|
||
const totalBookings = totalBookingCount(data.counts);
|
||
|
||
return (
|
||
<PageContainer fluid>
|
||
<Breadcrumbs
|
||
items={[
|
||
{ label: "Operations" },
|
||
{ label: "Batch board", href: "/dashboard/operations/batch-board" },
|
||
{ label: data.trainNumber ?? data.routeName ?? "Schedule" },
|
||
]}
|
||
/>
|
||
|
||
<Tabs value={activeTab} onChange={setActiveTab}>
|
||
<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}>
|
||
{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="edr-green"
|
||
radius="md"
|
||
leftSection={<PlayCircle size={16} />}
|
||
loading={runAllocation.isPending}
|
||
onClick={handleRunAllocation}
|
||
>
|
||
Run allocation
|
||
</Button>
|
||
<Button
|
||
variant="light"
|
||
color="edr-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}
|
||
|
||
<KpiStrip
|
||
items={[
|
||
{
|
||
label: "Allocated wagons",
|
||
value: data.capacity.allocatedWagons,
|
||
hint: "on this train",
|
||
icon: Boxes,
|
||
},
|
||
{
|
||
label: "Train length",
|
||
value: data.capacity.maxLengthMeters
|
||
? `${fmtMeters(data.capacity.allocatedLengthMeters)} / ${fmtMeters(data.capacity.maxLengthMeters)}`
|
||
: fmtMeters(data.capacity.allocatedLengthMeters),
|
||
icon: Ruler,
|
||
},
|
||
{
|
||
label: "Weight",
|
||
value: data.capacity.maxWeightTons
|
||
? `${fmtTons(data.capacity.usedWeightTons)} / ${fmtTons(data.capacity.maxWeightTons)}`
|
||
: fmtTons(data.capacity.usedWeightTons),
|
||
icon: Weight,
|
||
},
|
||
{
|
||
label: "Bookings",
|
||
value: totalBookings,
|
||
hint: `${data.counts.allocated} allocated · ${data.counts.expired} expired`,
|
||
icon: Package,
|
||
},
|
||
]}
|
||
/>
|
||
|
||
{/* 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}
|
||
|
||
{/* Manage bookings — search, filter, remove / re-assign (bulk too) */}
|
||
<Paper
|
||
radius="lg"
|
||
withBorder
|
||
p="lg"
|
||
mt="lg"
|
||
style={{ borderColor: "var(--mantine-color-gray-2)" }}
|
||
>
|
||
<Group gap="sm" mb="md" wrap="nowrap" align="flex-start">
|
||
<ThemeIcon
|
||
size={38}
|
||
radius="md"
|
||
variant="light"
|
||
color="#F2A516"
|
||
>
|
||
<Package size={19} />
|
||
</ThemeIcon>
|
||
<Box>
|
||
<Title order={4}>Manage bookings</Title>
|
||
<Text size="sm" c="dimmed">
|
||
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.
|
||
</Text>
|
||
</Box>
|
||
</Group>
|
||
<BookingsManager
|
||
scheduleId={scheduleId ?? ""}
|
||
bookings={allBookings}
|
||
onChanged={() => void refetch()}
|
||
readOnly={bookingsReadOnly}
|
||
/>
|
||
</Paper>
|
||
|
||
{/* 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">
|
||
<ThemeIcon
|
||
size={38}
|
||
radius="md"
|
||
variant="light"
|
||
color="#F2A516"
|
||
>
|
||
<Clock size={19} />
|
||
</ThemeIcon>
|
||
<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="edr-green" size="sm" />
|
||
<Text size="sm" c="dimmed">
|
||
Loading train composition…
|
||
</Text>
|
||
</Group>
|
||
</Paper>
|
||
)}
|
||
</Tabs.Panel>
|
||
</Tabs>
|
||
</PageContainer>
|
||
);
|
||
}
|