Enhance overview and train scheduling features

- Updated OverviewContractsTabPanel to include a new donut chart for freight type distribution.
- Modified OverviewOperationsTabPanel to improve data visualization with additional charts and refactored data handling.
- Introduced CreateScheduleWindowFields component for configuring booking windows in train scheduling.
- Added new API endpoints for allocation candidates and booking allocation in trainScheduling.service.
- Enhanced BookingRequestsPage to support allocation of paid bookings with a modal for selecting alternative dates.
- Updated QUERY_KEYS and URLS constants to accommodate new operations and features.
- Improved type definitions for overview and train scheduling to support new functionalities.
This commit is contained in:
Marshal
2026-08-03 21:06:59 +00:00
parent 488c2465be
commit e68bdb7a1a
30 changed files with 1580 additions and 82 deletions

View File

@@ -0,0 +1,82 @@
import {
Bar,
BarChart,
CartesianGrid,
Legend,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import { Paper, Stack, Text } from "@mantine/core";
export interface StackedBarSeries {
/** Key into each data row holding this series' value. */
key: string;
label: string;
color: string;
}
interface OverviewStackedBarChartProps<T extends object> {
title: string;
data: T[];
/** Fixed order + fixed color per series — colors follow the entity, not the rank. */
series: StackedBarSeries[];
xKey?: string;
emptyMessage?: string;
formatXLabel?: (value: string) => string;
}
export function OverviewStackedBarChart<T extends object>({
title,
data,
series,
xKey = "date",
emptyMessage = "No data available",
formatXLabel,
}: OverviewStackedBarChartProps<T>) {
const hasData = data.some((row) =>
series.some((s) => Number((row as Record<string, unknown>)[s.key]) > 0),
);
return (
<Paper p="md" radius="lg" withBorder h="100%" style={{ minHeight: 260 }}>
<Stack gap="sm" h="100%">
<Text fw={600}>{title}</Text>
{!hasData ? (
<Text size="sm" c="dimmed" ta="center" py="xl">
{emptyMessage}
</Text>
) : (
<ResponsiveContainer width="100%" height={230}>
<BarChart data={data} margin={{ top: 8, right: 8, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" vertical={false} />
<XAxis
dataKey={xKey}
tickFormatter={formatXLabel}
tick={{ fontSize: 11 }}
stroke="#94a3b8"
/>
<YAxis allowDecimals={false} tick={{ fontSize: 12 }} stroke="#94a3b8" />
<Tooltip labelFormatter={formatXLabel && ((v) => formatXLabel(String(v)))} />
<Legend iconType="circle" iconSize={9} />
{series.map((s, index) => (
<Bar
key={s.key}
dataKey={s.key}
name={s.label}
stackId="stack"
fill={s.color}
stroke="#ffffff"
strokeWidth={1}
barSize={18}
radius={index === series.length - 1 ? [4, 4, 0, 0] : undefined}
/>
))}
</BarChart>
</ResponsiveContainer>
)}
</Stack>
</Paper>
);
}

View File

@@ -36,7 +36,7 @@ export function OverviewTabContent({ tab, range }: OverviewTabContentProps) {
const bookings = useOverviewBookingsTab(range, tab === "bookings");
const contracts = useOverviewContractsTab(range, tab === "contracts");
const billing = useOverviewBillingTab(range, tab === "billing");
const operations = useOverviewOperationsTab(tab === "operations");
const operations = useOverviewOperationsTab(range, tab === "operations");
const customers = useOverviewCustomersTab(range, tab === "customers");
const staff = useOverviewStaffTab(range, tab === "staff");

View File

@@ -71,7 +71,7 @@ export function OverviewBookingsTabPanel({ data }: OverviewBookingsTabPanelProps
</Grid>
<Grid gap="md">
<Grid.Col span={{ base: 12, md: 6 }}>
<Grid.Col span={{ base: 12, md: 4 }}>
<OverviewDonutChart
title="By status"
data={data.bookingsByStatus.map((item) => ({
@@ -81,10 +81,20 @@ export function OverviewBookingsTabPanel({ data }: OverviewBookingsTabPanelProps
emptyMessage="No bookings yet"
/>
</Grid.Col>
<Grid.Col span={{ base: 12, md: 6 }}>
<OverviewDonutChart
<Grid.Col span={{ base: 12, md: 4 }}>
<OverviewHorizontalBarChart
title="By freight type"
data={data.bookingsByFreightType.map((item) => ({
label: item.label,
value: item.count,
}))}
valueLabel="Bookings"
/>
</Grid.Col>
<Grid.Col span={{ base: 12, md: 4 }}>
<OverviewDonutChart
title="By payment currency"
data={data.bookingsByCurrency.map((item) => ({
name: item.label,
value: item.count,
}))}
@@ -92,14 +102,6 @@ export function OverviewBookingsTabPanel({ data }: OverviewBookingsTabPanelProps
</Grid.Col>
</Grid>
<OverviewHorizontalBarChart
title="By payment currency"
data={data.bookingsByCurrency.map((item) => ({
label: item.label,
value: item.count,
}))}
/>
<OverviewRecentBookingsTable bookings={data.recentBookings} />
</Stack>
);

View File

@@ -94,7 +94,7 @@ export function OverviewContractsTabPanel({
</Grid>
<Grid gap="md">
<Grid.Col span={{ base: 12, md: 6 }}>
<Grid.Col span={{ base: 12, md: 4 }}>
<OverviewDonutChart
title="By status"
data={data.contractsByStatus.map((item) => ({
@@ -104,7 +104,7 @@ export function OverviewContractsTabPanel({
emptyMessage="No contracts yet"
/>
</Grid.Col>
<Grid.Col span={{ base: 12, md: 6 }}>
<Grid.Col span={{ base: 12, md: 4 }}>
<OverviewDonutChart
title="By kind"
data={data.contractsByKind.map((item) => ({
@@ -113,16 +113,17 @@ export function OverviewContractsTabPanel({
}))}
/>
</Grid.Col>
<Grid.Col span={{ base: 12, md: 4 }}>
<OverviewDonutChart
title="By freight type"
data={data.contractsByFreightType.map((item) => ({
name: item.label === "CONTAINER" ? "Container" : "Bulk",
value: item.count,
}))}
/>
</Grid.Col>
</Grid>
<OverviewHorizontalBarChart
title="By freight type"
data={data.contractsByFreightType.map((item) => ({
label: item.label === "CONTAINER" ? "Container" : "Bulk",
value: item.count,
}))}
/>
<OverviewRecentContractsTable contracts={data.recentContracts} />
</Stack>
);

View File

@@ -1,13 +1,25 @@
import { Box, Container as ContainerIcon, Train, Truck } from "lucide-react";
import {
Box,
CalendarClock,
Container as ContainerIcon,
Send,
Train,
Truck,
} from "lucide-react";
import { Grid, Stack } from "@mantine/core";
import type { IOverviewOperationsTab } from "@/types/overview";
import { OverviewDonutChart } from "../OverviewDonutChart";
import { OverviewHorizontalBarChart } from "../OverviewHorizontalBarChart";
import { OverviewKpiStrip } from "../OverviewKpiStrip";
import { OverviewStackedBarChart } from "../OverviewStackedBarChart";
interface OverviewOperationsTabPanelProps {
data: IOverviewOperationsTab;
}
/** Fixed direction colors (CVD-validated pair + violet): color follows the entity. */
const DIRECTION_SERIES = [
{ key: "exportCount", label: "Export", color: "#D98A0B" },
{ key: "importCount", label: "Import", color: "#0369a1" },
{ key: "domesticCount", label: "Domestic", color: "#7c3aed" },
];
function formatStatusLabel(status: string) {
return status
@@ -16,6 +28,22 @@ function formatStatusLabel(status: string) {
.replace(/\b\w/g, (char) => char.toUpperCase());
}
function formatDateLabel(date: string) {
const parsed = new Date(`${date}T00:00:00`);
return parsed.toLocaleDateString(undefined, { month: "short", day: "numeric" });
}
function toDonutData(items: { status: string; count: number }[]) {
return items.map((item) => ({
name: formatStatusLabel(item.status),
value: item.count,
}));
}
interface OverviewOperationsTabPanelProps {
data: IOverviewOperationsTab;
}
export function OverviewOperationsTabPanel({ data }: OverviewOperationsTabPanelProps) {
return (
<Stack gap="lg">
@@ -27,6 +55,19 @@ export function OverviewOperationsTabPanel({ data }: OverviewOperationsTabPanelP
icon: Train,
accent: "emerald",
},
{
label: "Upcoming departures",
value: data.kpis.schedulesUpcoming,
icon: CalendarClock,
accent: "sky",
hint: "Scheduled, not yet departed",
},
{
label: "Dispatched today",
value: data.kpis.dispatchedToday,
icon: Send,
accent: "amber",
},
{
label: "Wagons available",
value: data.kpis.wagonsAvailable,
@@ -45,41 +86,95 @@ export function OverviewOperationsTabPanel({ data }: OverviewOperationsTabPanelP
]}
/>
<Grid gap="md">
<Grid.Col span={{ base: 12, lg: 8 }}>
<OverviewStackedBarChart
title="Train departures by direction"
data={data.departureTrend}
series={DIRECTION_SERIES}
formatXLabel={formatDateLabel}
emptyMessage="No scheduled departures in this period"
/>
</Grid.Col>
<Grid.Col span={{ base: 12, lg: 4 }}>
<OverviewDonutChart
title="Schedule status"
data={toDonutData(data.scheduleStatusBreakdown)}
emptyMessage="No train schedules yet"
/>
</Grid.Col>
</Grid>
<Grid gap="md">
<Grid.Col span={{ base: 12, md: 6 }}>
<OverviewHorizontalBarChart
title="Wagon fleet by type"
data={data.wagonsByType.map((item) => ({
label: item.label,
value: item.count,
}))}
valueLabel="Wagons"
/>
</Grid.Col>
<Grid.Col span={{ base: 12, md: 6 }}>
<OverviewHorizontalBarChart
title="Wagons by yard"
data={data.wagonsByYard.map((item) => ({
label: item.label,
value: item.count,
}))}
valueLabel="Wagons"
emptyMessage="No wagons assigned to yards"
/>
</Grid.Col>
</Grid>
<Grid gap="md">
<Grid.Col span={{ base: 12, md: 6 }}>
<OverviewHorizontalBarChart
title="Cargo tonnage by type"
data={data.cargoTonnageByType.map((item) => ({
label: item.label,
value: item.tons,
}))}
valueLabel="Tons"
emptyMessage="No cargo recorded"
/>
</Grid.Col>
<Grid.Col span={{ base: 12, md: 6 }}>
<OverviewDonutChart
title="Containers by size"
data={data.containersBySize.map((item) => ({
name: item.label,
value: item.count,
}))}
/>
</Grid.Col>
</Grid>
<Grid gap="md">
<Grid.Col span={{ base: 12, md: 6 }}>
<OverviewDonutChart
title="Train status"
data={data.trainStatusBreakdown.map((item) => ({
name: formatStatusLabel(item.status),
value: item.count,
}))}
data={toDonutData(data.trainStatusBreakdown)}
/>
</Grid.Col>
<Grid.Col span={{ base: 12, md: 6 }}>
<OverviewDonutChart
title="Wagon status"
data={data.wagonStatusBreakdown.map((item) => ({
name: formatStatusLabel(item.status),
value: item.count,
}))}
data={toDonutData(data.wagonStatusBreakdown)}
/>
</Grid.Col>
<Grid.Col span={{ base: 12, md: 6 }}>
<OverviewDonutChart
title="Container status"
data={data.containerStatusBreakdown.map((item) => ({
name: formatStatusLabel(item.status),
value: item.count,
}))}
data={toDonutData(data.containerStatusBreakdown)}
/>
</Grid.Col>
<Grid.Col span={{ base: 12, md: 6 }}>
<OverviewDonutChart
title="Cargo status"
data={data.cargoStatusBreakdown.map((item) => ({
name: formatStatusLabel(item.status),
value: item.count,
}))}
data={toDonutData(data.cargoStatusBreakdown)}
/>
</Grid.Col>
</Grid>

View File

@@ -0,0 +1,366 @@
import { useEffect, useMemo, useState } from "react";
import {
Alert,
Badge,
Box,
Divider,
Group,
Loader,
NumberInput,
Select,
Stack,
Switch,
Text,
} from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
import { Info, Moon, Sun } from "lucide-react";
import DurationField from "@/components/trainScheduling/DurationField";
import { trainSchedulingService } from "@/services/trainScheduling.service";
import type { CreateScheduleWindowRulePayload } from "@/types/trainScheduling";
/** Fallbacks matching the API's global-rules defaults (used if the fetch fails). */
const DEFAULTS = {
windowOpenHour: 8,
windowCloseHour: 17,
windowDurationHours: 3,
docReviewMinutes: 30,
paymentWindowMinutes: 60,
importWindowLeadDays: 3,
exportBookingLeadHours: 24,
};
/** 12-hour label for an EAT hour 023, e.g. 8 → "8:00 AM", 17 → "5:00 PM". */
function hourLabel(hour: number): string {
const period = hour < 12 ? "AM" : "PM";
const h12 = hour % 12 === 0 ? 12 : hour % 12;
return `${h12}:00 ${period}`;
}
const HOUR_OPTIONS = Array.from({ length: 24 }, (_, h) => ({
value: String(h),
label: `${hourLabel(h)} · ${String(h).padStart(2, "0")}:00`,
}));
export interface WindowFormState {
windowOpenHour: number;
windowCloseHour: number;
windowDurationHours: number | "";
docReviewMinutes: number | "";
paymentWindowMinutes: number | "";
importWindowLeadDays: number | "";
exportBookingLeadHours: number | "";
/** Blank = close exactly at departure. */
closeOffsetMinutes: number | "";
}
/**
* Builds the create payload from form state, or returns an error message when a
* required field was left blank. The close offset is direction-scoped: only the
* offset matching this schedule's direction is sent, since the other is never read.
*/
export function buildWindowRulePayload(
form: WindowFormState,
isExport: boolean,
): { payload: CreateScheduleWindowRulePayload } | { error: string } {
const duration = Number(form.windowDurationHours);
const doc = Number(form.docReviewMinutes);
const pay = Number(form.paymentWindowMinutes);
const lead = Number(form.importWindowLeadDays);
const exportLead = Number(form.exportBookingLeadHours);
const leadInvalid = isExport
? form.exportBookingLeadHours === "" || !Number.isFinite(exportLead) || exportLead < 1
: form.importWindowLeadDays === "" || !Number.isFinite(lead);
if (
form.windowDurationHours === "" ||
form.docReviewMinutes === "" ||
form.paymentWindowMinutes === "" ||
!Number.isFinite(duration) ||
!Number.isFinite(doc) ||
!Number.isFinite(pay) ||
leadInvalid
) {
return { error: "Fill every booking-window field, or turn the toggle off" };
}
// Blank offset = close at departure. Sent as null (not omitted) so it wins
// over a non-null global offset.
const offset = form.closeOffsetMinutes === "" ? null : Number(form.closeOffsetMinutes);
return {
payload: {
windowOpenHour: form.windowOpenHour,
windowCloseHour: form.windowCloseHour,
windowDurationHours: duration,
docReviewMinutes: doc,
paymentWindowMinutes: pay,
...(isExport
? { exportBookingLeadHours: exportLead, exportCloseOffsetMinutes: offset }
: { importWindowLeadDays: lead, importCloseOffsetMinutes: offset }),
},
};
}
export interface CreateScheduleWindowFieldsProps {
/** Direction of the selected route — picks lead/offset semantics. */
isExport: boolean;
form: WindowFormState | null;
onChange: (next: WindowFormState) => void;
}
/**
* Booking-window settings for a schedule being created. Prefills from the live
* global rules (so the fields show what the schedule WOULD inherit), then lets
* staff tune them for this one train. Mirrors BookingWindowSettingsModal, plus
* the booking-close offset.
*/
export default function CreateScheduleWindowFields({
isExport,
form,
onChange,
}: CreateScheduleWindowFieldsProps) {
const rulesQuery = useQuery({
queryKey: ["train-scheduling", "global-rules"],
queryFn: () => trainSchedulingService.getGlobalRules(),
staleTime: 5 * 60_000,
});
// Seed once from the global rules, so the toggle opens on the values this
// schedule would otherwise inherit rather than on hardcoded guesses.
const [seeded, setSeeded] = useState(false);
useEffect(() => {
if (seeded || form != null) return;
const r = rulesQuery.data;
if (!r && rulesQuery.isLoading) return;
const num = (v: unknown, fallback: number) => {
const n = v == null || v === "" ? NaN : Number(v);
return Number.isFinite(n) ? n : fallback;
};
const offset = isExport
? (r as { exportCloseOffsetMinutes?: number | null } | undefined)
?.exportCloseOffsetMinutes
: (r as { importCloseOffsetMinutes?: number | null } | undefined)
?.importCloseOffsetMinutes;
onChange({
windowOpenHour: num(r?.windowOpenHour, DEFAULTS.windowOpenHour),
windowCloseHour: num(r?.windowCloseHour, DEFAULTS.windowCloseHour),
windowDurationHours: num(r?.windowDurationHours, DEFAULTS.windowDurationHours),
docReviewMinutes: num(r?.docReviewMinutes, DEFAULTS.docReviewMinutes),
paymentWindowMinutes: num(
isExport
? (r as { exportPaymentWindowMinutes?: number } | undefined)
?.exportPaymentWindowMinutes
: r?.paymentWindowMinutes,
DEFAULTS.paymentWindowMinutes,
),
importWindowLeadDays: num(r?.importWindowLeadDays, DEFAULTS.importWindowLeadDays),
exportBookingLeadHours: num(
r?.exportBookingLeadHours,
DEFAULTS.exportBookingLeadHours,
),
closeOffsetMinutes: offset == null || offset === 0 ? "" : Number(offset),
});
setSeeded(true);
}, [seeded, form, rulesQuery.data, rulesQuery.isLoading, isExport, onChange]);
const set = (patch: Partial<WindowFormState>) => {
if (form) onChange({ ...form, ...patch });
};
const is24h = form != null && form.windowOpenHour === form.windowCloseHour;
// Close < open is a valid OVERNIGHT desk (e.g. 08:00 → 07:00 next morning).
const isOvernight = form != null && form.windowCloseHour < form.windowOpenHour;
const reopenSummary = useMemo(() => {
if (!form) return "";
const total = (Number(form.docReviewMinutes) || 0) + (Number(form.paymentWindowMinutes) || 0);
const h = Math.floor(total / 60);
const m = total % 60;
const parts = [h ? `${h}h` : "", m ? `${m}m` : ""].filter(Boolean);
return parts.length ? parts.join(" ") : "0m";
}, [form]);
if (!form) {
return (
<Group justify="center" py="md">
<Loader size="sm" />
</Group>
);
}
return (
<Stack gap="lg">
{isExport ? (
<Alert variant="light" color="blue" icon={<Info size={16} />}>
Export schedules use a single first-come-first-served window: it opens the
export lead time before departure shifted to the next desk opening if that
lands outside desk hours and stays open until it closes. Cycle timing below
doesn&apos;t apply.
</Alert>
) : (
<Alert variant="light" color="orange" icon={<Info size={16} />}>
These settings apply to this train only, and can be set only for the FIRST
train on a route and departure day. Later trains that day join its booking
group and share the same window.
</Alert>
)}
{/* ── Daily desk hours ─────────────────────────────────────────── */}
<Box>
<Group justify="space-between" align="center" mb={6}>
<Text size="sm" fw={600}>
Daily desk hours (EAT)
</Text>
{is24h ? (
<Badge variant="light" color="grape" leftSection={<Moon size={12} />}>
24-hour desk
</Badge>
) : (
<Badge variant="light" color="edr-green" leftSection={<Sun size={12} />}>
{hourLabel(form.windowOpenHour)} {hourLabel(form.windowCloseHour)}
</Badge>
)}
</Group>
<Group grow align="flex-start">
<Select
label="Opens"
data={HOUR_OPTIONS}
value={String(form.windowOpenHour)}
onChange={(v) => v != null && set({ windowOpenHour: Number(v) })}
allowDeselect={false}
comboboxProps={{ withinPortal: true }}
/>
<Select
label="Closes"
data={HOUR_OPTIONS}
value={String(form.windowCloseHour)}
onChange={(v) => v != null && set({ windowCloseHour: Number(v) })}
allowDeselect={false}
comboboxProps={{ withinPortal: true }}
/>
</Group>
{isOvernight && !is24h ? (
<Text size="xs" c="dimmed" mt={4}>
Overnight desk opens {form.windowOpenHour}:00 and runs past midnight,
closing {form.windowCloseHour}:00 the next morning.
</Text>
) : null}
<Switch
mt="sm"
size="sm"
color="grape"
label="Run 24 hours a day (never pause overnight)"
checked={is24h}
onChange={(e) =>
set({
// On → close == open (24h desk). Off → restore a normal ~9h day.
windowCloseHour: e.currentTarget.checked
? form.windowOpenHour
: Math.min(23, form.windowOpenHour + 9),
})
}
/>
</Box>
<Divider />
{/* ── Cycle timing ─────────────────────────────────────────────── */}
<Box>
<Text size="sm" fw={600} mb={6}>
Cycle timing
</Text>
<Stack gap="sm">
<DurationField
label="Window duration"
description="How long each booking cycle stays open before it closes for review"
value={form.windowDurationHours}
nativeUnit="hours"
onChange={(v) => set({ windowDurationHours: v })}
min={0.0166}
disabled={isExport}
/>
<Group grow align="flex-start">
<DurationField
label="Document review"
description="Staff time to accept documents after the window closes"
value={form.docReviewMinutes}
nativeUnit="minutes"
onChange={(v) => set({ docReviewMinutes: v })}
min={0}
disabled={isExport}
/>
<DurationField
label="Payment window"
description="Time a selected customer has to pay"
value={form.paymentWindowMinutes}
nativeUnit="minutes"
onChange={(v) => set({ paymentWindowMinutes: v })}
min={1}
/>
</Group>
{!isExport ? (
<Text size="xs" c="dimmed">
Reopen gap after each cycle = document review + payment ={" "}
<b>{reopenSummary}</b>.
</Text>
) : null}
</Stack>
</Box>
<Divider />
{/* ── Lead time ────────────────────────────────────────────────── */}
{isExport ? (
<NumberInput
label="Export booking lead (hours)"
description="How many hours before departure the export booking window opens"
value={form.exportBookingLeadHours}
onChange={(v) => set({ exportBookingLeadHours: v === "" ? "" : Number(v) })}
min={1}
clampBehavior="none"
allowNegative={false}
allowDecimal={false}
/>
) : (
<NumberInput
label="Window lead (days)"
description="How many days before departure the booking window starts"
value={form.importWindowLeadDays}
onChange={(v) => set({ importWindowLeadDays: v === "" ? "" : Number(v) })}
min={0}
clampBehavior="none"
allowNegative={false}
allowDecimal={false}
/>
)}
<Divider />
{/* ── Booking close offset ─────────────────────────────────────── */}
<Box>
<Text size="sm" fw={600} mb={2}>
Booking close offset
</Text>
<Text size="xs" c="dimmed" mb={8}>
How long before departure this schedule stops accepting bookings. e.g. a
3-hour import offset closes a 17:00 departure&apos;s window at 14:00; a 1-day
export offset closes a Jul-10 16:00 departure at Jul-9 16:00. Leave blank to
close exactly at departure.
</Text>
<DurationField
label={isExport ? "Export close offset" : "Import close offset"}
description={
isExport
? "This export booking window closes this long before departure (blank = at departure)"
: "This import booking window closes this long before departure (blank = at departure)"
}
value={form.closeOffsetMinutes}
nativeUnit="minutes"
onChange={(v) => set({ closeOffsetMinutes: v })}
min={0}
/>
</Box>
</Stack>
);
}