mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 01:48:12 +00:00
1177 lines
37 KiB
TypeScript
1177 lines
37 KiB
TypeScript
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 (
|
||
<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}
|
||
{b.consolidationPartnerRef ? (
|
||
<Tooltip
|
||
label={`Consolidated — shares one wagon with ${b.consolidationPartnerRef}`}
|
||
withArrow
|
||
multiline
|
||
maw={260}
|
||
>
|
||
<Badge
|
||
size="xs"
|
||
variant="light"
|
||
color="edr-green"
|
||
radius="sm"
|
||
leftSection={<ArrowLeftRight size={10} />}
|
||
style={{ textTransform: "none" }}
|
||
>
|
||
shared wagon · {b.consolidationPartnerRef}
|
||
</Badge>
|
||
</Tooltip>
|
||
) : 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). */
|
||
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 (
|
||
<Group justify="space-between" gap="sm" wrap="nowrap">
|
||
<Text size="sm" fw={active ? 700 : 500} c={active ? "edr-green.7" : "dimmed"}>
|
||
{label}
|
||
</Text>
|
||
<Text size="sm" fw={active ? 700 : 500} c={active ? "dark" : "dimmed"}>
|
||
{iso ? `${timeFmt.format(new Date(iso))} EAT` : "—"}
|
||
</Text>
|
||
</Group>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 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 (
|
||
<Paper
|
||
withBorder
|
||
radius="lg"
|
||
p="md"
|
||
mt="md"
|
||
style={{
|
||
borderColor: open
|
||
? "var(--mantine-color-edr-green-2)"
|
||
: "var(--mantine-color-gray-2)",
|
||
background: open ? "var(--mantine-color-edr-green-0)" : undefined,
|
||
}}
|
||
>
|
||
<Group justify="space-between" wrap="nowrap" mb="sm">
|
||
<Group gap="sm" wrap="nowrap">
|
||
{phase ? (
|
||
<WindowPhasePill phase={phase} cycleNo={w.bookingCycleNo} />
|
||
) : null}
|
||
<WindowStatusPill status={w.bookingWindowStatus} />
|
||
</Group>
|
||
{openDay ? (
|
||
<Text size="xs" c="dimmed">
|
||
Booking day · {openDay}
|
||
</Text>
|
||
) : null}
|
||
</Group>
|
||
|
||
{cd ? (
|
||
<Box mb="sm">
|
||
<CountdownTimer
|
||
deadline={cd.deadline}
|
||
label={cd.label}
|
||
expiredText={cd.expiredText}
|
||
size="md"
|
||
/>
|
||
</Box>
|
||
) : null}
|
||
|
||
<Stack gap={6}>
|
||
<PhaseTimeRow label="Window opens" iso={w.windowOpensAt} active={phase === "PRE_WINDOW"} />
|
||
<PhaseTimeRow label="Window closes" iso={w.windowClosesAt} active={phase === "OPEN"} />
|
||
<PhaseTimeRow label="Document review ends" iso={w.docReviewEndsAt} active={phase === "DOC_REVIEW"} />
|
||
<PhaseTimeRow label="Payment window ends" iso={w.paymentPhaseEndsAt} active={phase === "PAYMENT"} />
|
||
</Stack>
|
||
</Paper>
|
||
);
|
||
}
|
||
|
||
/** 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<string, BatchBoardBookingDetail>();
|
||
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<string | null>("overview");
|
||
const [selectedBookingId, setSelectedBookingId] = useState<string | null>(
|
||
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 (
|
||
<PageContainer fluid>
|
||
<Group justify="center" py="xl">
|
||
<Loader color="edr-green" />
|
||
</Group>
|
||
</PageContainer>
|
||
);
|
||
}
|
||
|
||
const totalBookings = totalBookingCount(data.counts);
|
||
const countdown = phaseCountdown(data);
|
||
|
||
return (
|
||
<PageContainer fluid>
|
||
<Breadcrumbs
|
||
items={[
|
||
{ label: "Operations" },
|
||
{ label: "Batch board", href: "/dashboard/operations/batch-board" },
|
||
{
|
||
label:
|
||
data.scheduleReference ??
|
||
data.trainNumber ??
|
||
data.routeName ??
|
||
"Schedule",
|
||
},
|
||
]}
|
||
/>
|
||
|
||
<Tabs value={activeTab} onChange={setActiveTab}>
|
||
<Tabs.List>
|
||
<Tabs.Tab value="overview">Overview</Tabs.Tab>
|
||
<Tabs.Tab
|
||
value="priority"
|
||
leftSection={<Trophy size={14} />}
|
||
>
|
||
Priority Tracking{" "}
|
||
{allBookings.length > 0 && `(${allBookings.length})`}
|
||
</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>
|
||
{data.scheduleReference ? (
|
||
<HeroChip icon={<Hash size={12} />}>
|
||
{data.scheduleReference}
|
||
</HeroChip>
|
||
) : null}
|
||
<WindowStatusPill status={data.bookingWindowStatus} />
|
||
{data.windowPhase ? (
|
||
<WindowPhasePill
|
||
phase={data.windowPhase}
|
||
cycleNo={data.bookingCycleNo}
|
||
/>
|
||
) : null}
|
||
<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}
|
||
{data.windowPhase ? (
|
||
<HeroChip icon={<Clock size={12} />}>
|
||
Cycle {data.bookingCycleNo}
|
||
{countdown ? ` · ${countdown}` : ""}
|
||
</HeroChip>
|
||
) : null}
|
||
</Group>
|
||
</Stack>
|
||
|
||
<Group gap="sm">
|
||
<Button
|
||
variant="default"
|
||
radius="md"
|
||
leftSection={<RefreshCw size={16} />}
|
||
loading={isFetching}
|
||
onClick={() => void refetch()}
|
||
>
|
||
Refresh
|
||
</Button>
|
||
{data.windowPhase === "DOC_REVIEW" ? (
|
||
<Button
|
||
color="yellow"
|
||
radius="md"
|
||
leftSection={<ClipboardCheck size={16} />}
|
||
loading={completeDocReview.isPending}
|
||
onClick={handleCompleteDocReview}
|
||
>
|
||
Doc review complete — run batch
|
||
</Button>
|
||
) : null}
|
||
<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: "Train length",
|
||
value: data.capacity.maxLengthMeters
|
||
? `${fmtMeters(data.capacity.allocatedLengthMeters)} / ${fmtMeters(data.capacity.maxLengthMeters)}`
|
||
: fmtMeters(data.capacity.allocatedLengthMeters),
|
||
icon: Ruler,
|
||
},
|
||
{
|
||
label: "Gross weight",
|
||
value: data.capacity.maxWeightTons
|
||
? `${fmtTons(data.capacity.usedWeightTons)} / ${fmtTons(data.capacity.maxWeightTons)}`
|
||
: fmtTons(data.capacity.usedWeightTons),
|
||
hint: "wagon tare + cargo",
|
||
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}>Booking window (EAT)</Title>
|
||
<Text size="sm" c="dimmed">
|
||
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.
|
||
</Text>
|
||
</Box>
|
||
</Group>
|
||
|
||
<ScheduleWindowPanel window={data} />
|
||
|
||
{windowBookings.length ? (
|
||
<Box mt="lg">
|
||
<Group justify="space-between" align="center" mb="sm">
|
||
<Text fw={700} size="sm">
|
||
Bookings in this window
|
||
</Text>
|
||
<WindowCountChips counts={windowCounts} />
|
||
</Group>
|
||
<BookingTable bookings={windowBookings} />
|
||
</Box>
|
||
) : (
|
||
<Text size="sm" c="dimmed" ta="center" py="lg">
|
||
No bookings in this window yet.
|
||
</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={scheduleDetailQuery.data.freightType ?? null}
|
||
trainNumber={scheduleDetailQuery.data.trainNumber}
|
||
totalLengthMeters={
|
||
scheduleDetailQuery.data.trainSet?.totalLengthMeters
|
||
}
|
||
/>
|
||
</Box>
|
||
) : null}
|
||
</Stack>
|
||
</Tabs.Panel>
|
||
|
||
<Tabs.Panel value="priority" pt="lg">
|
||
<PriorityTrackingTab data={data} bookings={allBookings} />
|
||
</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={data.capacity.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>
|
||
);
|
||
}
|