ui design for schedule

This commit is contained in:
Marshal
2026-06-09 23:25:52 +00:00
parent 5774d7db9d
commit 257428be18
19 changed files with 2088 additions and 408 deletions

View File

@@ -1,19 +1,32 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Link, useParams } from "react-router-dom";
import { isAxiosError } from "axios";
import { ArrowLeft, Train } from "lucide-react";
import {
ArrowLeft,
CalendarClock,
CheckCircle2,
Container as ContainerIcon,
Eye,
LayoutGrid,
Package,
Route as RouteIcon,
Send,
Train,
Weight,
} from "lucide-react";
import {
Badge,
Box,
Button,
Card,
Checkbox,
Divider,
Group,
Loader,
Paper,
RingProgress,
SimpleGrid,
Stack,
Stepper,
Text,
ThemeIcon,
Title,
} from "@mantine/core";
@@ -30,15 +43,18 @@ import {
PreviewSummary,
ScheduleWarningsAlert,
} from "@/components/trainScheduling/ScheduleWarningsAlert";
import { FreightTypeBadge } from "@/components/trainScheduling/ScheduleStatusBadge";
import {
FreightTypeBadge,
ScheduleStatusBadge,
} from "@/components/trainScheduling/ScheduleStatusBadge";
RouteCorridor,
StatTile,
StatusPill,
scheduleBrand,
} from "@/components/trainScheduling/scheduleVisuals";
import { RescheduleTrainDialog } from "@/components/trainScheduling/RescheduleTrainDialog";
import { SchedulingWorkflowHeader } from "@/components/trainScheduling/SchedulingWorkflowHeader";
import { schedulingWorkflow } from "@/components/trainScheduling/schedulingWorkflow.styles";
import { WorkflowRail, WorkflowStep } from "@/components/trainScheduling/WorkflowStep";
import { shouldShowContainerPlacementStep } from "@/components/trainScheduling/schedulingContainerStep.util";
import { WagonPlanGrid } from "@/components/trainScheduling/WagonPlanGrid";
import { TrainCompositionDiagram } from "@/components/trainScheduling/TrainCompositionDiagram";
import {
useEligibleBookings,
useScheduleDetail,
@@ -133,8 +149,19 @@ export default function TrainScheduleV2DetailPage() {
);
const displayWagonPlan = useMemo(() => {
if (previewResult?.wagonPlan?.length) return previewResult.wagonPlan;
if (schedule?.trainSet?.wagons?.length) return schedule.trainSet.wagons;
const savedWagons = schedule?.trainSet?.wagons ?? [];
// Map each slot to its reserved physical wagon number (from the wagon table) so the
// plan shows real wagon ids (e.g. WGN-DEMO-001) instead of generic "Wagon #1".
const physicalBySeq = new Map(
savedWagons.map((w) => [w.sequenceNo, w.physicalWagonNumber ?? null]),
);
if (previewResult?.wagonPlan?.length) {
return previewResult.wagonPlan.map((slot) => ({
...slot,
physicalWagonNumber: physicalBySeq.get(slot.sequenceNo) ?? null,
}));
}
if (savedWagons.length) return savedWagons;
return [];
}, [previewResult?.wagonPlan, schedule?.trainSet?.wagons]);
@@ -322,12 +349,346 @@ export default function TrainScheduleV2DetailPage() {
}
};
const stepLabels = [
"Bookings",
"Wagon plan",
...(hasContainerStep ? ["Containers"] : []),
"Finalize",
const containerComplete =
hasContainerStep &&
containerUnits.length > 0 &&
validateLocalPlacements(containerUnits, containerPlacements).length === 0;
const finalizeComplete = ["SCHEDULED", "DISPATCHED", "ARRIVED"].includes(
schedule.status,
);
const stepsMeta = [
{
key: "bookings",
icon: Package,
title: "Bookings",
subtitle: "Select cargo & preview the plan",
complete: Boolean(previewResult) || assignedIds.length > 0,
},
{
key: "wagon",
icon: LayoutGrid,
title: "Wagon plan",
subtitle: "Review generated allocations",
complete: displayWagonPlan.length > 0,
},
...(hasContainerStep
? [
{
key: "container",
icon: ContainerIcon,
title: "Containers",
subtitle: "Map units to wagon slots",
complete: containerComplete,
},
]
: []),
{
key: "finalize",
icon: CheckCircle2,
title: "Finalize",
subtitle: "Lock the plan & dispatch",
complete: finalizeComplete,
},
];
const completedCount = stepsMeta.filter((s) => s.complete).length;
const progressPct = Math.round((completedCount / stepsMeta.length) * 100);
const toggleStep = (i: number) => setActiveStep((cur) => (cur === i ? -1 : i));
const renderStepRightSlot = (key: string) => {
if (key === "bookings") {
if (previewResult) {
return (
<Badge
variant="light"
color={previewResult.valid ? "green" : "red"}
radius="sm"
>
{previewResult.valid ? "Plan valid" : "Has issues"}
</Badge>
);
}
return allSelectedIds.length ? (
<Badge variant="light" color="green" radius="sm">
{allSelectedIds.length} selected
</Badge>
) : null;
}
if (key === "wagon" && displayWagonPlan.length) {
return (
<Badge variant="light" color="green" radius="sm">
{displayWagonPlan.length} wagons
</Badge>
);
}
if (key === "container" && containerUnits.length) {
return (
<Badge
variant="light"
color={containerComplete ? "green" : "yellow"}
radius="sm"
>
{containerUnits.length} units
</Badge>
);
}
if (key === "finalize") {
return <StatusPill status={schedule.status} />;
}
return null;
};
const renderStepBody = (key: string) => {
if (key === "bookings") {
return (
<Stack gap="md">
<ScheduleBookingsStep
assignedBookings={(schedule.bookings ?? []).map((b) => ({
id: b.id,
reference: b.reference ?? b.id.slice(0, 8),
weightTons: b.weightTons,
}))}
eligibleItems={eligibleQuery.data?.items ?? []}
eligibleLoading={eligibleQuery.isLoading}
selectedIds={allSelectedIds}
onSelectionChange={(ids) => {
const assigned = new Set(assignedIds);
setSelectedBookingIds(ids.filter((id) => !assigned.has(id)));
}}
assignedIds={assignedIds}
freightType={freightType}
canRemove={canModifyBookings}
onRemove={handleUnassign}
/>
{canEditBookings ? (
<Group
align="center"
justify="space-between"
wrap="wrap"
gap="md"
p="sm"
style={{
borderRadius: 12,
background: "var(--mantine-color-gray-0)",
border: "1px solid var(--mantine-color-gray-2)",
}}
>
<Checkbox
label="Force assign (bypass hold / overweight warnings)"
checked={forceAssign}
onChange={(e) => setForceAssign(e.currentTarget.checked)}
size="sm"
/>
<Button
color="green"
radius="md"
leftSection={<Eye size={16} />}
loading={preview.isPending}
onClick={() => void runPreview()}
>
Preview plan
</Button>
</Group>
) : null}
{previewResult ? (
<Stack gap="sm">
<ScheduleWarningsAlert
violations={previewResult.violations}
warnings={previewResult.warnings}
/>
<FleetAvailabilitySummary
fleetAvailability={previewResult.fleetAvailability}
deferredBookings={previewResult.deferredBookings}
/>
<PreviewSummary summary={previewResult.summary} />
</Stack>
) : null}
</Stack>
);
}
if (key === "wagon") {
return (
<Stack gap="md">
{!displayWagonPlan.length && !previewResult ? (
<Paper p="md" radius="lg" withBorder bg="gray.0">
<Text size="sm" c="dimmed">
Run a preview from the Bookings step to generate the wagon plan.
</Text>
</Paper>
) : null}
<FleetAvailabilitySummary
fleetAvailability={previewResult?.fleetAvailability}
deferredBookings={previewResult?.deferredBookings}
/>
<WagonPlanGrid wagonPlan={displayWagonPlan} freightType={freightType} />
{canEditBookings && (previewResult || displayWagonPlan.length) ? (
<Group>
{!hasContainerStep ? (
<Button
color="green"
radius="md"
loading={assign.isPending}
onClick={handleAssign}
>
{assignedIds.length ? "Save assignments" : "Assign bookings"}
</Button>
) : (
<Button
color="green"
radius="md"
rightSection={<ContainerIcon size={16} />}
onClick={() => setActiveStep(2)}
>
Continue to containers
</Button>
)}
<Button variant="default" radius="md" onClick={() => void runPreview()}>
Refresh preview
</Button>
</Group>
) : null}
</Stack>
);
}
if (key === "container") {
return (
<Stack gap="md">
{!containerUnits.length ? (
<Paper p="md" radius="lg" withBorder bg="gray.0">
<Text size="sm" c="dimmed">
Run preview from the Bookings step to load container units for numbering.
</Text>
</Paper>
) : (
<ContainerPlacementGrid
units={containerUnits}
containerSlots={containerSlots}
placements={containerPlacements}
onChange={setContainerPlacements}
/>
)}
{canEditBookings ? (
<Group>
<Button
color="green"
radius="md"
loading={assign.isPending}
onClick={handleAssign}
>
{assignedIds.length ? "Save assignments" : "Assign bookings"}
</Button>
<Button
variant="default"
radius="md"
onClick={() => setActiveStep(finalizeStep)}
>
Skip to finalize
</Button>
</Group>
) : null}
</Stack>
);
}
// finalize
return (
<Stack gap="md">
<TrainCompositionDiagram
locomotive={schedule.trainSet?.locomotive}
wagons={
schedule.trainSet?.wagons?.length
? schedule.trainSet.wagons
: displayWagonPlan
}
freightType={freightType}
trainNumber={schedule.trainNumber}
totalLengthMeters={schedule.trainSet?.totalLengthMeters}
/>
<Paper
p="lg"
radius="lg"
withBorder
style={{
background: scheduleBrand.softSurface,
borderColor: scheduleBrand.mutedBorder,
}}
>
<Group gap="md" align="flex-start" wrap="nowrap">
<ThemeIcon size={44} radius="md" variant="light" color="green">
<CheckCircle2 size={22} />
</ThemeIcon>
<Stack gap={2}>
<Text fw={600}>Ready to depart</Text>
<Text size="sm" c="dimmed">
Finalizing locks the plan and moves the schedule to{" "}
<Text span fw={600} c="green.7">
SCHEDULED
</Text>
. Dispatch then begins rail movement and notifies the yard.
</Text>
</Stack>
</Group>
</Paper>
<Group>
{canFinalize ? (
<Button
color="green"
size="md"
radius="md"
leftSection={<CheckCircle2 size={18} />}
loading={finalize.isPending}
onClick={async () => {
try {
await finalize.mutateAsync(scheduleId);
toast({ title: "Schedule finalized" });
} catch (err) {
toast({
title: "Finalize failed",
description: parseError(err, "Could not finalize"),
variant: "destructive",
});
}
}}
>
Finalize schedule
</Button>
) : null}
{canDispatch ? (
<Button
color="green"
size="md"
radius="md"
leftSection={<Send size={18} />}
loading={dispatch.isPending}
onClick={async () => {
try {
await dispatch.mutateAsync(scheduleId);
toast({ title: "Train dispatched" });
} catch (err) {
toast({
title: "Dispatch failed",
description: parseError(err, "Could not dispatch"),
variant: "destructive",
});
}
}}
>
Dispatch train
</Button>
) : null}
{!canFinalize && !canDispatch ? (
<Text size="sm" c="dimmed">
No actions available for this schedule status.
</Text>
) : null}
</Group>
</Stack>
);
};
return (
<Stack gap="lg">
@@ -343,282 +704,208 @@ export default function TrainScheduleV2DetailPage() {
Back to schedules
</Button>
<Card
radius={schedulingWorkflow.card.radius}
padding={schedulingWorkflow.card.padding}
withBorder
<Paper
radius="xl"
p="xl"
style={{
background: "linear-gradient(135deg, var(--mantine-color-teal-0) 0%, white 55%, var(--mantine-color-gray-0) 100%)",
position: "relative",
overflow: "hidden",
background: scheduleBrand.heroGradient,
boxShadow: scheduleBrand.shadow,
}}
>
<Group justify="space-between" align="flex-start" wrap="wrap">
<Group gap="md" align="flex-start">
<Paper p="sm" radius="xl" bg="teal.1">
<Train size={24} color="var(--mantine-color-teal-7)" />
</Paper>
<Stack gap={4}>
<Title order={3}>{schedule.route?.name ?? "Train schedule"}</Title>
<Text size="sm" c="dimmed">
{schedule.originStation?.label ?? schedule.originStation?.code} {" "}
{schedule.destinationStation?.label ?? schedule.destinationStation?.code}
</Text>
<Text size="xs" c="dimmed">
Departure {new Date(schedule.scheduledDepartureDate).toLocaleString()}
</Text>
</Stack>
</Group>
<Group gap="sm">
<Box
style={{
position: "absolute",
top: -90,
right: -50,
width: 280,
height: 280,
borderRadius: "50%",
background: "rgba(255,255,255,0.10)",
pointerEvents: "none",
}}
/>
<Stack gap="lg" style={{ position: "relative" }}>
<Group justify="space-between" align="flex-start" wrap="wrap">
<Group gap="md" align="flex-start" wrap="nowrap">
<ThemeIcon
size={56}
radius="lg"
variant="white"
style={{ color: "var(--mantine-color-green-7)" }}
>
<Train size={28} />
</ThemeIcon>
<Stack gap={6}>
<Group gap="sm" align="center" wrap="wrap">
<Title order={2} c="white" fw={700}>
{schedule.route?.name ?? "Train schedule"}
</Title>
{schedule.trainNumber ? (
<Badge
variant="white"
c="green.8"
radius="sm"
style={{ fontWeight: 600 }}
>
{schedule.trainNumber}
</Badge>
) : null}
</Group>
<Box maw={340}>
<RouteCorridor
onDark
origin={
schedule.originStation?.label ?? schedule.originStation?.code
}
destination={
schedule.destinationStation?.label ??
schedule.destinationStation?.code
}
/>
</Box>
<Group gap="sm" align="center">
<FreightTypeBadge freightType={schedule.freightType} />
<StatusPill status={schedule.status} />
</Group>
</Stack>
</Group>
{schedule.status !== "DISPATCHED" ? (
<Button variant="light" size="compact-sm" onClick={() => setMaintenanceOpen(true)}>
<Button
variant="white"
c="green.8"
radius="lg"
size="sm"
onClick={() => setMaintenanceOpen(true)}
>
Reschedule train
</Button>
) : null}
<FreightTypeBadge freightType={schedule.freightType} />
<ScheduleStatusBadge status={schedule.status} />
</Group>
</Group>
<Divider my="md" />
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="md">
<StatTile
onDark
icon={Train}
label="Locomotive"
value={schedule.trainSet?.locomotive?.code ?? "—"}
/>
<StatTile
onDark
icon={Package}
label="Bookings"
value={schedule.bookings?.length ?? 0}
/>
<StatTile
onDark
icon={Weight}
label="Wagons / load"
value={`${schedule.trainSet?.wagonCount ?? displayWagonPlan.length} · ${
schedule.trainSet?.totalWeightTons ?? 0
}T`}
/>
<StatTile
onDark
icon={CalendarClock}
label="Departure"
value={new Date(schedule.scheduledDepartureDate).toLocaleDateString(
"en",
{ month: "short", day: "2-digit" },
)}
hint={new Date(schedule.scheduledDepartureDate).toLocaleTimeString("en", {
hour: "2-digit",
minute: "2-digit",
})}
/>
</SimpleGrid>
<Group gap="xl">
<Stack gap={2}>
<Text size="xs" c="dimmed" tt="uppercase">
Locomotive
</Text>
<Text size="sm" fw={600}>
{schedule.trainSet?.locomotive?.code ?? "—"}
</Text>
</Stack>
<Stack gap={2}>
<Text size="xs" c="dimmed" tt="uppercase">
Bookings
</Text>
<Text size="sm" fw={600}>
{schedule.bookings?.length ?? 0}
</Text>
</Stack>
<Stack gap={2}>
<Text size="xs" c="dimmed" tt="uppercase">
Wagons
</Text>
<Text size="sm" fw={600}>
{schedule.trainSet?.wagonCount ?? displayWagonPlan.length} ·{" "}
{schedule.trainSet?.totalWeightTons ?? 0}T
</Text>
</Stack>
{previewResult ? (
<Badge variant="light" color={previewResult.valid ? "green" : "red"}>
<Badge
size="lg"
radius="sm"
variant="white"
c={previewResult.valid ? "green.8" : "red.7"}
leftSection={
<Box
w={8}
h={8}
style={{
borderRadius: 999,
background: previewResult.valid
? "var(--mantine-color-green-6)"
: "var(--mantine-color-red-6)",
}}
/>
}
>
Preview {previewResult.valid ? "valid" : "has issues"}
</Badge>
) : null}
</Group>
</Card>
<Card radius={schedulingWorkflow.card.radius} padding={schedulingWorkflow.card.padding} withBorder>
<Stack gap="lg">
<SchedulingWorkflowHeader
title="Scheduling workflow"
subtitle={`${schedule.route?.name ?? "Train schedule"} · ${schedule.originStation?.code ?? ""}${schedule.destinationStation?.code ?? ""}`}
activeStep={activeStep}
totalSteps={stepLabels.length}
stepLabel={stepLabels[activeStep] ?? ""}
stepDescription={
activeStep === 0
? "Select & preview"
: activeStep === 1
? "Allocations"
: hasContainerStep && activeStep === 2
? "Map units"
: "Depart"
}
stepIcon={
activeStep === 0
? "package"
: activeStep === 1
? "layout"
: hasContainerStep && activeStep === 2
? "container"
: "check"
}
/>
<Stepper
active={activeStep}
onStepClick={setActiveStep}
color={schedulingWorkflow.stepper.color}
iconSize={schedulingWorkflow.stepper.iconSize}
size={schedulingWorkflow.stepper.size}
>
<Stepper.Step label="Bookings" description="Select & preview">
<Stack gap="md" mt="lg">
<ScheduleBookingsStep
assignedBookings={(schedule.bookings ?? []).map((b) => ({
id: b.id,
reference: b.reference ?? b.id.slice(0, 8),
weightTons: b.weightTons,
}))}
eligibleItems={eligibleQuery.data?.items ?? []}
eligibleLoading={eligibleQuery.isLoading}
selectedIds={allSelectedIds}
onSelectionChange={(ids) => {
const assigned = new Set(assignedIds);
setSelectedBookingIds(ids.filter((id) => !assigned.has(id)));
}}
assignedIds={assignedIds}
freightType={freightType}
canRemove={canModifyBookings}
onRemove={handleUnassign}
/>
{canEditBookings ? (
<Group align="center" wrap="wrap">
<Button
variant="filled"
loading={preview.isPending}
onClick={() => void runPreview()}
>
Preview plan
</Button>
<Checkbox
label="Force assign (bypass hold/overweight warnings)"
checked={forceAssign}
onChange={(e) => setForceAssign(e.currentTarget.checked)}
/>
</Group>
) : null}
{previewResult ? (
<Stack gap="sm">
<ScheduleWarningsAlert
violations={previewResult.violations}
warnings={previewResult.warnings}
/>
<FleetAvailabilitySummary
fleetAvailability={previewResult.fleetAvailability}
deferredBookings={previewResult.deferredBookings}
/>
<PreviewSummary summary={previewResult.summary} />
</Stack>
) : null}
</Stack>
</Stepper.Step>
<Stepper.Step label="Wagon plan" description="Allocations">
<Stack gap="md" mt="lg">
{!displayWagonPlan.length && !previewResult ? (
<Text size="sm" c="dimmed">
Run a preview from the Bookings step to generate the wagon plan.
</Text>
) : null}
<FleetAvailabilitySummary
fleetAvailability={previewResult?.fleetAvailability}
deferredBookings={previewResult?.deferredBookings}
/>
<WagonPlanGrid wagonPlan={displayWagonPlan} freightType={freightType} />
{canEditBookings && (previewResult || displayWagonPlan.length) ? (
<Group>
{!hasContainerStep ? (
<Button color="green" loading={assign.isPending} onClick={handleAssign}>
{assignedIds.length ? "Save assignments" : "Assign bookings"}
</Button>
) : (
<Button variant="light" onClick={() => setActiveStep(2)}>
Continue to containers
</Button>
)}
<Button variant="default" onClick={() => void runPreview()}>
Refresh preview
</Button>
</Group>
) : null}
</Stack>
</Stepper.Step>
{hasContainerStep ? (
<Stepper.Step label="Containers" description="Map units">
<Stack gap="md" mt="lg">
{!containerUnits.length ? (
<Paper p="md" radius="xl" withBorder bg="gray.0">
<Text size="sm" c="dimmed">
Run preview from the Bookings step to load container units for numbering.
</Text>
</Paper>
) : (
<ContainerPlacementGrid
units={containerUnits}
containerSlots={containerSlots}
placements={containerPlacements}
onChange={setContainerPlacements}
/>
)}
{canEditBookings ? (
<Group>
<Button color="green" loading={assign.isPending} onClick={handleAssign}>
{assignedIds.length ? "Save assignments" : "Assign bookings"}
</Button>
<Button variant="light" onClick={() => setActiveStep(finalizeStep)}>
Skip to finalize
</Button>
</Group>
) : null}
</Stack>
</Stepper.Step>
) : null}
<Stepper.Step label="Finalize" description="Depart">
<Stack gap="md" mt="lg">
<Paper p="md" radius="lg" withBorder bg="gray.0">
<Text size="sm" c="dimmed">
Finalize moves the schedule to SCHEDULED. Dispatch begins rail movement.
</Text>
</Paper>
<Group>
{canFinalize ? (
<Button
color="green"
loading={finalize.isPending}
onClick={async () => {
try {
await finalize.mutateAsync(scheduleId);
toast({ title: "Schedule finalized" });
} catch (err) {
toast({
title: "Finalize failed",
description: parseError(err, "Could not finalize"),
variant: "destructive",
});
}
}}
>
Finalize schedule
</Button>
) : null}
{canDispatch ? (
<Button
color="blue"
loading={dispatch.isPending}
onClick={async () => {
try {
await dispatch.mutateAsync(scheduleId);
toast({ title: "Train dispatched" });
} catch (err) {
toast({
title: "Dispatch failed",
description: parseError(err, "Could not dispatch"),
variant: "destructive",
});
}
}}
>
Dispatch train
</Button>
) : null}
</Group>
</Stack>
</Stepper.Step>
</Stepper>
</Stack>
</Card>
</Paper>
<Paper radius="xl" p="lg" withBorder style={{ borderColor: "var(--mantine-color-gray-2)" }}>
<Stack gap="lg">
{/* Workflow header with ring progress */}
<Group justify="space-between" align="center" wrap="nowrap">
<Group gap="md" align="center" wrap="nowrap">
<ThemeIcon
size={44}
radius="md"
variant="gradient"
gradient={{ from: "green", to: "teal", deg: 135 }}
>
<RouteIcon size={22} />
</ThemeIcon>
<Stack gap={2}>
<Title order={4} fw={700}>
Scheduling workflow
</Title>
<Text size="sm" c="dimmed">
{completedCount} of {stepsMeta.length} steps complete · expand any
step to edit
</Text>
</Stack>
</Group>
<RingProgress
size={64}
thickness={6}
roundCaps
sections={[{ value: progressPct, color: "green" }]}
label={
<Text ta="center" size="xs" fw={700} c="green.7">
{progressPct}%
</Text>
}
/>
</Group>
<WorkflowRail>
{stepsMeta.map((step, index) => (
<WorkflowStep
key={step.key}
index={index}
icon={step.icon}
title={step.title}
subtitle={step.subtitle}
state={
activeStep === index
? "active"
: step.complete
? "complete"
: "upcoming"
}
open={activeStep === index}
onToggle={() => toggleStep(index)}
rightSlot={renderStepRightSlot(step.key)}
>
{renderStepBody(step.key)}
</WorkflowStep>
))}
</WorkflowRail>
</Stack>
</Paper>
{scheduleId ? (
<RescheduleTrainDialog

View File

@@ -17,14 +17,17 @@ import {
ThemeIcon,
Title,
} from "@mantine/core";
import { Train } from "lucide-react";
import { ArrowRight, CalendarClock, Send, Train, Weight } from "lucide-react";
import FleetToolbar from "@/components/fleet/FleetToolbar";
import { useFleetViewMode } from "@/components/fleet/useFleetViewMode";
import { FreightTypeBadge } from "@/components/trainScheduling/ScheduleStatusBadge";
import {
FreightTypeBadge,
ScheduleStatusBadge,
} from "@/components/trainScheduling/ScheduleStatusBadge";
RouteCorridor,
StatTile,
StatusPill,
scheduleBrand,
} from "@/components/trainScheduling/scheduleVisuals";
import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter";
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
import { useRoutes } from "@/hooks/useRoutes";
@@ -34,21 +37,24 @@ import {
useScheduleMutations,
} from "@/hooks/trainScheduling/useTrainScheduling";
import { useToast } from "@/hooks/use-toast";
import type { FreightType, TrainScheduleListItem } from "@/types/trainScheduling";
import type { TrainScheduleListItem } from "@/types/trainScheduling";
import { DataTable, DataTableFooter, usePagination } from "@edr/ui-common";
import { schedulingWorkflow } from "@/components/trainScheduling/schedulingWorkflow.styles";
const formatDate = (value?: string | null) => {
if (!value) return "—";
const splitDate = (value?: string | null) => {
if (!value) return { day: "—", time: "" };
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "—";
return new Intl.DateTimeFormat("en", {
year: "numeric",
month: "short",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
}).format(date);
if (Number.isNaN(date.getTime())) return { day: "—", time: "" };
return {
day: new Intl.DateTimeFormat("en", {
month: "short",
day: "2-digit",
year: "numeric",
}).format(date),
time: new Intl.DateTimeFormat("en", {
hour: "2-digit",
minute: "2-digit",
}).format(date),
};
};
const parseError = (error: unknown, fallback: string) => {
@@ -83,9 +89,28 @@ export default function TrainScheduleV2ListPage() {
[routesQuery.data],
);
const allSchedules = schedulesQuery.data ?? [];
const stats = useMemo(() => {
const base = {
total: allSchedules.length,
scheduled: 0,
dispatched: 0,
draft: 0,
weight: 0,
};
for (const s of allSchedules) {
if (s.status === "SCHEDULED") base.scheduled += 1;
if (s.status === "DISPATCHED") base.dispatched += 1;
if (s.status === "DRAFT") base.draft += 1;
base.weight += s.totalWeightTons ?? 0;
}
return base;
}, [allSchedules]);
const filtered = useMemo(() => {
const query = search.trim().toLowerCase();
return (schedulesQuery.data ?? []).filter((s) => {
return allSchedules.filter((s) => {
if (statusFilter !== "ALL" && s.status !== statusFilter) return false;
if (freightFilter !== "ALL" && s.freightType !== freightFilter) return false;
if (!query) return true;
@@ -103,7 +128,7 @@ export default function TrainScheduleV2ListPage() {
.toLowerCase();
return haystack.includes(query);
});
}, [schedulesQuery.data, search, statusFilter, freightFilter]);
}, [allSchedules, search, statusFilter, freightFilter]);
const pageCount = Math.max(1, Math.ceil(filtered.length / pagination.pageSize));
const paged = useMemo(() => {
@@ -119,19 +144,55 @@ export default function TrainScheduleV2ListPage() {
id: "date",
header: "Departure",
meta: { headerClassName, cellClassName },
cell: ({ row }) => formatDate(row.original.scheduleDate),
cell: ({ row }) => {
const { day, time } = splitDate(row.original.scheduleDate);
return (
<Group gap="sm" wrap="nowrap">
<Box
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: 34,
height: 34,
borderRadius: 9,
background: "var(--mantine-color-green-0)",
color: "var(--mantine-color-green-7)",
flexShrink: 0,
}}
>
<CalendarClock size={16} />
</Box>
<Stack gap={0}>
<Text size="sm" fw={600} lh={1.2}>
{day}
</Text>
<Text size="xs" c="dimmed" lh={1.2}>
{time || "—"}
</Text>
</Stack>
</Group>
);
},
},
{
id: "route",
header: "Route",
meta: { headerClassName, cellClassName },
cell: ({ row }) => row.original.routeName ?? "—",
},
{
id: "corridor",
header: "Corridor",
meta: { headerClassName, cellClassName },
cell: ({ row }) => `${row.original.origin ?? "—"}${row.original.destination ?? "—"}`,
cell: ({ row }) => (
<Stack gap={4}>
<Text size="sm" fw={600} lh={1.2}>
{row.original.routeName ?? "—"}
</Text>
<Box maw={220}>
<RouteCorridor
origin={row.original.origin}
destination={row.original.destination}
variant="compact"
/>
</Box>
</Stack>
),
},
{
id: "freight",
@@ -143,20 +204,37 @@ export default function TrainScheduleV2ListPage() {
id: "loco",
header: "Locomotive",
meta: { headerClassName, cellClassName },
cell: ({ row }) => row.original.locomotive?.code ?? "—",
cell: ({ row }) =>
row.original.locomotive?.code ? (
<Group gap={6} wrap="nowrap">
<Train size={14} color="var(--mantine-color-gray-5)" />
<Text size="sm" fw={500}>
{row.original.locomotive.code}
</Text>
</Group>
) : (
<Text size="sm" c="dimmed">
</Text>
),
},
{
id: "metrics",
header: "Bookings / Wagons",
header: "Load",
meta: { headerClassName, cellClassName },
cell: ({ row }) =>
`${row.original.bookingsCount} / ${row.original.wagonCount} · ${row.original.totalWeightTons}T`,
cell: ({ row }) => (
<Group gap={6} wrap="nowrap">
<MetricChip value={row.original.bookingsCount} label="bkg" />
<MetricChip value={row.original.wagonCount} label="wgn" />
<MetricChip value={`${row.original.totalWeightTons}T`} label="" subtle />
</Group>
),
},
{
id: "status",
header: "Status",
meta: { headerClassName, cellClassName },
cell: ({ row }) => <ScheduleStatusBadge status={row.original.status} />,
cell: ({ row }) => <StatusPill status={row.original.status} />,
},
{
id: "actions",
@@ -166,7 +244,9 @@ export default function TrainScheduleV2ListPage() {
<Group gap={6} justify="flex-end" wrap="nowrap">
<Button
variant="light"
color="green"
size="compact-sm"
rightSection={<ArrowRight size={14} />}
onClick={() =>
navigate(`/dashboard/operations/train-scheduling-v2/${row.original.id}`)
}
@@ -175,7 +255,7 @@ export default function TrainScheduleV2ListPage() {
</Button>
{["DRAFT", "SCHEDULED"].includes(row.original.status) ? (
<Button
variant="light"
variant="subtle"
color="red"
size="compact-sm"
loading={cancel.isPending}
@@ -202,7 +282,7 @@ export default function TrainScheduleV2ListPage() {
),
},
];
}, [navigate, cancel.isPending, toast]);
}, [navigate, cancel.isPending, cancel, toast]);
const handleCreate = async () => {
if (!routeId || !scheduleDate || !locomotiveId) {
@@ -232,27 +312,94 @@ export default function TrainScheduleV2ListPage() {
: "success";
return (
<Stack gap="md">
<Stack gap="lg">
{/* Hero banner */}
<Paper
p="lg"
radius={schedulingWorkflow.card.radius}
withBorder
radius="xl"
p="xl"
style={{
background:
"linear-gradient(135deg, var(--mantine-color-teal-0) 0%, white 55%, var(--mantine-color-gray-0) 100%)",
position: "relative",
overflow: "hidden",
background: scheduleBrand.heroGradient,
boxShadow: scheduleBrand.shadow,
}}
>
<Group gap="md" align="center">
<ThemeIcon size={48} radius="xl" variant="gradient" gradient={{ from: "teal", to: "green", deg: 135 }}>
<Train size={24} />
</ThemeIcon>
<Stack gap={2}>
<Title order={3}>Train Schedules</Title>
<Text size="sm" c="dimmed">
Plan departures, allocate bookings, and dispatch trains across corridors.
</Text>
</Stack>
</Group>
{/* decorative glow */}
<Box
style={{
position: "absolute",
top: -90,
right: -60,
width: 280,
height: 280,
borderRadius: "50%",
background: "rgba(255,255,255,0.12)",
filter: "blur(8px)",
pointerEvents: "none",
}}
/>
<Box
style={{
position: "absolute",
bottom: -120,
right: 120,
width: 220,
height: 220,
borderRadius: "50%",
background: "rgba(255,255,255,0.06)",
pointerEvents: "none",
}}
/>
<Stack gap="lg" style={{ position: "relative" }}>
<Group justify="space-between" align="flex-start" wrap="wrap">
<Group gap="md" align="center" wrap="nowrap">
<ThemeIcon
size={56}
radius="lg"
variant="white"
style={{ color: "var(--mantine-color-green-7)" }}
>
<Train size={28} />
</ThemeIcon>
<Stack gap={4}>
<Title order={2} c="white" fw={700}>
Train Schedules
</Title>
<Text size="sm" c="rgba(255,255,255,0.85)" maw={520}>
Plan departures, allocate bookings, and dispatch trains across
every corridor.
</Text>
</Stack>
</Group>
<Button
size="md"
radius="lg"
variant="white"
c="green.8"
leftSection={<Train size={18} />}
onClick={() => setCreateOpen(true)}
>
New schedule
</Button>
</Group>
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="md">
<StatTile onDark icon={Train} label="Total trains" value={stats.total} />
<StatTile
onDark
icon={CalendarClock}
label="Scheduled"
value={stats.scheduled}
/>
<StatTile onDark icon={Send} label="Dispatched" value={stats.dispatched} />
<StatTile
onDark
icon={Weight}
label="Planned load"
value={`${Math.round(stats.weight)}T`}
/>
</SimpleGrid>
</Stack>
</Paper>
<Card radius="lg" padding={0} withBorder style={{ borderColor: "var(--mantine-color-gray-2)" }}>
@@ -338,37 +485,15 @@ export default function TrainScheduleV2ListPage() {
) : (
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md" p="md">
{paged.map((schedule) => (
<Card key={schedule.id} radius="lg" padding="lg" withBorder>
<Stack gap="sm">
<Group justify="space-between">
<Text fw={600} size="sm">
{schedule.routeName ?? "Train schedule"}
</Text>
<ScheduleStatusBadge status={schedule.status} />
</Group>
<Text size="sm" c="dimmed">
{formatDate(schedule.scheduleDate)}
</Text>
<Text size="xs" c="dimmed">
{schedule.origin} {schedule.destination}
</Text>
<Group gap={6}>
<FreightTypeBadge freightType={schedule.freightType} />
<Text size="xs" c="dimmed">
{schedule.bookingsCount} bookings · {schedule.wagonCount} wagons
</Text>
</Group>
<Button
variant="light"
size="compact-sm"
onClick={() =>
navigate(`/dashboard/operations/train-scheduling-v2/${schedule.id}`)
}
>
Open
</Button>
</Stack>
</Card>
<ScheduleCard
key={schedule.id}
schedule={schedule}
onOpen={() =>
navigate(
`/dashboard/operations/train-scheduling-v2/${schedule.id}`,
)
}
/>
))}
</SimpleGrid>
)}
@@ -436,3 +561,127 @@ export default function TrainScheduleV2ListPage() {
</Stack>
);
}
function MetricChip({
value,
label,
subtle = false,
}: {
value: string | number;
label: string;
subtle?: boolean;
}) {
return (
<Group
gap={4}
wrap="nowrap"
style={{
padding: "2px 8px",
borderRadius: 8,
background: subtle
? "var(--mantine-color-gray-1)"
: "var(--mantine-color-green-0)",
border: `1px solid ${
subtle ? "var(--mantine-color-gray-2)" : "var(--mantine-color-green-1)"
}`,
}}
>
<Text size="sm" fw={700} c={subtle ? "gray.7" : "green.8"} lh={1.2}>
{value}
</Text>
{label ? (
<Text size="xs" c="dimmed" lh={1.2}>
{label}
</Text>
) : null}
</Group>
);
}
function ScheduleCard({
schedule,
onOpen,
}: {
schedule: TrainScheduleListItem;
onOpen: () => void;
}) {
const { day, time } = splitDate(schedule.scheduleDate);
return (
<Card
radius="lg"
padding={0}
withBorder
onClick={onOpen}
style={{
cursor: "pointer",
overflow: "hidden",
borderColor: "var(--mantine-color-gray-2)",
transition: "box-shadow 150ms ease, transform 150ms ease",
}}
onMouseEnter={(e) => {
e.currentTarget.style.boxShadow = scheduleBrand.shadowSm;
e.currentTarget.style.transform = "translateY(-2px)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.boxShadow = "";
e.currentTarget.style.transform = "";
}}
>
{/* accent strip */}
<Box style={{ height: 4, background: scheduleBrand.heroGradient }} />
<Stack gap="sm" p="md">
<Group justify="space-between" align="flex-start" wrap="nowrap">
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
<ThemeIcon size={38} radius="md" variant="light" color="green">
<Train size={18} />
</ThemeIcon>
<Stack gap={0} style={{ minWidth: 0 }}>
<Text fw={600} size="sm" lineClamp={1}>
{schedule.routeName ?? "Train schedule"}
</Text>
<Text size="xs" c="dimmed">
{day} · {time}
</Text>
</Stack>
</Group>
<StatusPill status={schedule.status} />
</Group>
<Box
p="xs"
style={{
borderRadius: 10,
background: "var(--mantine-color-gray-0)",
border: "1px solid var(--mantine-color-gray-1)",
}}
>
<RouteCorridor origin={schedule.origin} destination={schedule.destination} />
</Box>
<Group justify="space-between" align="center">
<FreightTypeBadge freightType={schedule.freightType} />
<Group gap={6} wrap="nowrap">
<MetricChip value={schedule.bookingsCount} label="bkg" />
<MetricChip value={schedule.wagonCount} label="wgn" />
<MetricChip value={`${schedule.totalWeightTons}T`} label="" subtle />
</Group>
</Group>
<Button
variant="light"
color="green"
size="sm"
radius="md"
fullWidth
rightSection={<ArrowRight size={15} />}
onClick={(e) => {
e.stopPropagation();
onOpen();
}}
>
Open schedule
</Button>
</Stack>
</Card>
);
}