mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 19:58:11 +00:00
Merge pull request #1097 from Tria-plc/freight_feature/usermanagement
Freight feature/usermanagement
This commit is contained in:
@@ -2,6 +2,7 @@ import {
|
||||
ArrowLeftRight,
|
||||
Boxes,
|
||||
Building2,
|
||||
BarChart3,
|
||||
Container,
|
||||
FileSignature,
|
||||
FileText,
|
||||
@@ -71,6 +72,8 @@ import InvoiceDetailPage from "./pages/invoices/InvoiceDetailPage";
|
||||
import InvoicesPage from "./pages/invoices/InvoicesPage";
|
||||
import MyProfilePage from "./pages/dashboard/MyProfilePage";
|
||||
import OverviewPage from "./pages/dashboard/OverviewPage";
|
||||
import ReportsHubPage from "./pages/reports/ReportsHubPage";
|
||||
import ReportPage from "./pages/reports/ReportPage";
|
||||
import AiBookingMockTestPage from "./pages/ai/AiBookingMockTestPage";
|
||||
import PaymentsPage from "./pages/payments/PaymentsPage";
|
||||
//import EmployeesPage from "./pages/dashboard/user-management/EmployeesPage";
|
||||
@@ -154,6 +157,12 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
icon: <LayoutDashboard />,
|
||||
permission: FREIGHT_PERMS.overview.view,
|
||||
},
|
||||
{
|
||||
label: "Reports",
|
||||
href: "/dashboard/reports",
|
||||
icon: <BarChart3 />,
|
||||
permission: FREIGHT_PERMS.bookings.view,
|
||||
},
|
||||
{
|
||||
label: "Customers",
|
||||
href: "/dashboard/customers",
|
||||
@@ -823,6 +832,8 @@ const App = () => {
|
||||
/>
|
||||
<Route path="/dashboard" element={<DashboardShell />}>
|
||||
<Route path="overview" element={<OverviewPage />} />
|
||||
<Route path="reports" element={<ReportsHubPage />} />
|
||||
<Route path="reports/:reportKey" element={<ReportPage />} />
|
||||
{/* Dev/testing page for the mock AI booking assistant. */}
|
||||
<Route
|
||||
path="ai-booking-mock-test"
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { ArrowRight, FileText, Train, Users } from "lucide-react";
|
||||
import { Card, Group, SimpleGrid, Stack, Text, ThemeIcon } from "@mantine/core";
|
||||
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||
|
||||
const links = [
|
||||
{
|
||||
title: "Booking requests",
|
||||
description: "Review and action incoming freight bookings",
|
||||
href: "/dashboard/booking-requests",
|
||||
icon: FileText,
|
||||
permission: [FREIGHT_PERMS.bookings.view],
|
||||
},
|
||||
{
|
||||
title: "Train scheduling v2",
|
||||
description: "Full allocation workflow — assign, pin wagons, finalize",
|
||||
href: "/dashboard/operations/train-scheduling-v2",
|
||||
icon: Train,
|
||||
permission: [FREIGHT_PERMS.trainScheduling.view],
|
||||
},
|
||||
{
|
||||
title: "Trains",
|
||||
description: "Manage train master data and fleet status",
|
||||
href: "/dashboard/trains",
|
||||
icon: Train,
|
||||
permission: [FREIGHT_PERMS.fleet.view, FREIGHT_PERMS.trains.view],
|
||||
},
|
||||
{
|
||||
title: "User management",
|
||||
description: "Employees, roles, and permissions",
|
||||
href: "/user-management",
|
||||
icon: Users,
|
||||
permission: [
|
||||
FREIGHT_PERMS.admin,
|
||||
FREIGHT_PERMS.staff.roles.view,
|
||||
FREIGHT_PERMS.staff.employeeRegistration.view,
|
||||
FREIGHT_PERMS.staff.roleAssignment.view,
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export function OverviewQuickLinks() {
|
||||
const navigate = useNavigate();
|
||||
const { user } = useAuth();
|
||||
|
||||
const visible = links.filter((link) =>
|
||||
link.permission.some((key) => hasPermission(user, key)),
|
||||
);
|
||||
if (!visible.length) return null;
|
||||
|
||||
return (
|
||||
<Stack gap="md" h="100%">
|
||||
<Text fw={600}>Quick links</Text>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="sm">
|
||||
{visible.map((link) => {
|
||||
const Icon = link.icon;
|
||||
return (
|
||||
<Card
|
||||
key={link.href}
|
||||
p="md"
|
||||
radius="lg"
|
||||
withBorder
|
||||
style={{ cursor: "pointer" }}
|
||||
onClick={() => navigate(link.href)}
|
||||
>
|
||||
<Group justify="space-between" align="flex-start" wrap="nowrap">
|
||||
<Group align="flex-start" gap="sm" wrap="nowrap">
|
||||
<ThemeIcon variant="light" color="edr-green" size="lg" radius="md">
|
||||
<Icon size={18} />
|
||||
</ThemeIcon>
|
||||
<Stack gap={2}>
|
||||
<Text fw={600} size="sm">
|
||||
{link.title}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{link.description}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
<ArrowRight size={16} color="var(--mantine-color-gray-5)" />
|
||||
</Group>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import { OverviewBillingTabPanel } from "./tabs/OverviewBillingTabPanel";
|
||||
import { OverviewBookingsTabPanel } from "./tabs/OverviewBookingsTabPanel";
|
||||
import { OverviewContractsTabPanel } from "./tabs/OverviewContractsTabPanel";
|
||||
import { OverviewCustomersTabPanel } from "./tabs/OverviewCustomersTabPanel";
|
||||
import { OverviewFleetTabPanel } from "./tabs/OverviewFleetTabPanel";
|
||||
import { OverviewOperationsTabPanel } from "./tabs/OverviewOperationsTabPanel";
|
||||
import { OverviewStaffTabPanel } from "./tabs/OverviewStaffTabPanel";
|
||||
|
||||
@@ -36,7 +37,12 @@ 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");
|
||||
// Fleet reuses the operations dataset — same query key, so switching between
|
||||
// the two tabs costs one fetch.
|
||||
const operations = useOverviewOperationsTab(
|
||||
range,
|
||||
tab === "operations" || tab === "fleet",
|
||||
);
|
||||
const customers = useOverviewCustomersTab(range, tab === "customers");
|
||||
const staff = useOverviewStaffTab(range, tab === "staff");
|
||||
|
||||
@@ -47,7 +53,7 @@ export function OverviewTabContent({ tab, range }: OverviewTabContentProps) {
|
||||
? contracts
|
||||
: tab === "billing"
|
||||
? billing
|
||||
: tab === "operations"
|
||||
: tab === "operations" || tab === "fleet"
|
||||
? operations
|
||||
: tab === "customers"
|
||||
? customers
|
||||
@@ -99,6 +105,9 @@ export function OverviewTabContent({ tab, range }: OverviewTabContentProps) {
|
||||
{tab === "operations" && operations.data && (
|
||||
<OverviewOperationsTabPanel data={operations.data} />
|
||||
)}
|
||||
{tab === "fleet" && operations.data && (
|
||||
<OverviewFleetTabPanel data={operations.data} />
|
||||
)}
|
||||
{tab === "customers" && customers.data && (
|
||||
<OverviewCustomersTabPanel data={customers.data} />
|
||||
)}
|
||||
|
||||
@@ -24,6 +24,13 @@ export function OverviewBookingsTabPanel({ data }: OverviewBookingsTabPanelProps
|
||||
<Stack gap="lg">
|
||||
<OverviewKpiStrip
|
||||
items={[
|
||||
{
|
||||
label: "Total bookings",
|
||||
value: data.kpis.total,
|
||||
icon: FileText,
|
||||
accent: "gold",
|
||||
hint: "All time",
|
||||
},
|
||||
{
|
||||
label: "Active bookings",
|
||||
value: data.kpis.totalActive,
|
||||
@@ -71,7 +78,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 +88,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 +109,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>
|
||||
);
|
||||
|
||||
@@ -41,6 +41,13 @@ export function OverviewContractsTabPanel({
|
||||
<Stack gap="lg">
|
||||
<OverviewKpiStrip
|
||||
items={[
|
||||
{
|
||||
label: "Total contracts",
|
||||
value: data.kpis.total,
|
||||
icon: FileSignature,
|
||||
accent: "gold",
|
||||
hint: "All time",
|
||||
},
|
||||
{
|
||||
label: "Active contracts",
|
||||
value: data.kpis.totalActive,
|
||||
@@ -94,7 +101,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 +111,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 +120,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>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import { Train, Truck, Wrench } from "lucide-react";
|
||||
import { Grid, Stack } from "@mantine/core";
|
||||
|
||||
import type { IOverviewOperationsTab, IOverviewStatusCount } from "@/types/overview";
|
||||
import { OverviewDonutChart } from "../OverviewDonutChart";
|
||||
import { OverviewHorizontalBarChart } from "../OverviewHorizontalBarChart";
|
||||
import { OverviewKpiStrip } from "../OverviewKpiStrip";
|
||||
|
||||
function formatStatusLabel(status: string) {
|
||||
return status
|
||||
.replace(/_/g, " ")
|
||||
.toLowerCase()
|
||||
.replace(/\b\w/g, (char) => char.toUpperCase());
|
||||
}
|
||||
|
||||
function sumCounts(items: IOverviewStatusCount[]) {
|
||||
return items.reduce((sum, item) => sum + item.count, 0);
|
||||
}
|
||||
|
||||
function countByStatus(items: IOverviewStatusCount[], status: string) {
|
||||
return items.find((item) => item.status === status)?.count ?? 0;
|
||||
}
|
||||
|
||||
function toDonutData(items: IOverviewStatusCount[]) {
|
||||
return items.map((item) => ({
|
||||
name: formatStatusLabel(item.status),
|
||||
value: item.count,
|
||||
}));
|
||||
}
|
||||
|
||||
interface OverviewFleetTabPanelProps {
|
||||
data: IOverviewOperationsTab;
|
||||
}
|
||||
|
||||
export function OverviewFleetTabPanel({ data }: OverviewFleetTabPanelProps) {
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<OverviewKpiStrip
|
||||
items={[
|
||||
{
|
||||
label: "Total trains",
|
||||
value: sumCounts(data.trainStatusBreakdown),
|
||||
icon: Train,
|
||||
accent: "gold",
|
||||
hint: "All time",
|
||||
},
|
||||
{
|
||||
label: "Active trains",
|
||||
value: data.kpis.trainsActive,
|
||||
icon: Train,
|
||||
accent: "emerald",
|
||||
},
|
||||
{
|
||||
label: "Total wagons",
|
||||
value: sumCounts(data.wagonStatusBreakdown),
|
||||
icon: Truck,
|
||||
accent: "gold",
|
||||
hint: "All time",
|
||||
},
|
||||
{
|
||||
label: "Wagons available",
|
||||
value: data.kpis.wagonsAvailable,
|
||||
icon: Truck,
|
||||
accent: "emerald",
|
||||
},
|
||||
{
|
||||
label: "In maintenance",
|
||||
value: countByStatus(data.wagonStatusBreakdown, "MAINTENANCE"),
|
||||
icon: Wrench,
|
||||
accent: "amber",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<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 }}>
|
||||
<OverviewDonutChart
|
||||
title="Train status"
|
||||
data={toDonutData(data.trainStatusBreakdown)}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||
<OverviewDonutChart
|
||||
title="Wagon status"
|
||||
data={toDonutData(data.wagonStatusBreakdown)}
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
@@ -46,40 +87,58 @@ export function OverviewOperationsTabPanel({ data }: OverviewOperationsTabPanelP
|
||||
/>
|
||||
|
||||
<Grid gap="md">
|
||||
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||
<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="Train status"
|
||||
data={data.trainStatusBreakdown.map((item) => ({
|
||||
name: formatStatusLabel(item.status),
|
||||
value: item.count,
|
||||
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="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="Wagon status"
|
||||
data={data.wagonStatusBreakdown.map((item) => ({
|
||||
name: formatStatusLabel(item.status),
|
||||
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="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>
|
||||
|
||||
@@ -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 0–23, 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'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'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>
|
||||
);
|
||||
}
|
||||
@@ -184,7 +184,8 @@ export const QUERY_KEYS = {
|
||||
["overview", "contracts", range ?? "30d"] as const,
|
||||
billingTab: (range?: string) =>
|
||||
["overview", "billing", range ?? "30d"] as const,
|
||||
operationsTab: () => ["overview", "operations"] as const,
|
||||
operationsTab: (range?: string) =>
|
||||
["overview", "operations", range ?? "30d"] as const,
|
||||
customersTab: (range?: string) =>
|
||||
["overview", "customers", range ?? "30d"] as const,
|
||||
staffTab: (range?: string) =>
|
||||
|
||||
@@ -109,6 +109,10 @@ export const URL_CONSTANTS = {
|
||||
BY_USER_ID: (id: string) => `/api/customers/user/${id}`,
|
||||
},
|
||||
|
||||
REPORTS: {
|
||||
RUN: (key: string) => `/reports/${key}`,
|
||||
},
|
||||
|
||||
OVERVIEW: {
|
||||
BASE: "/overview",
|
||||
BOOKINGS: "/overview/bookings",
|
||||
@@ -343,6 +347,10 @@ export const URL_CONSTANTS = {
|
||||
`/train-scheduling/bookings/${bookingId}/expire`,
|
||||
MOVE_BOOKING_SCHEDULE: (bookingId: string) =>
|
||||
`/train-scheduling/bookings/${bookingId}/move-schedule`,
|
||||
ALLOCATION_CANDIDATES: (bookingId: string) =>
|
||||
`/train-scheduling/bookings/${bookingId}/allocation-candidates`,
|
||||
ALLOCATE_BOOKING: (bookingId: string) =>
|
||||
`/train-scheduling/bookings/${bookingId}/allocate`,
|
||||
GLOBAL_RULES: "/train-scheduling/global-rules",
|
||||
BOOKING_WINDOWS: "/train-scheduling/booking-windows",
|
||||
PREVIEW: "/train-scheduling/preview",
|
||||
|
||||
@@ -35,10 +35,10 @@ export function useOverviewBillingTab(range: OverviewRange, enabled: boolean) {
|
||||
});
|
||||
}
|
||||
|
||||
export function useOverviewOperationsTab(enabled: boolean) {
|
||||
export function useOverviewOperationsTab(range: OverviewRange, enabled: boolean) {
|
||||
return useQuery({
|
||||
queryKey: QUERY_KEYS.OVERVIEW.operationsTab(),
|
||||
queryFn: () => overviewService.getOperationsTab(),
|
||||
queryKey: QUERY_KEYS.OVERVIEW.operationsTab(range),
|
||||
queryFn: () => overviewService.getOperationsTab(range),
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -4,7 +4,9 @@ import {
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Checkbox,
|
||||
Group,
|
||||
Modal,
|
||||
MultiSelect,
|
||||
Select,
|
||||
Stack,
|
||||
@@ -50,7 +52,10 @@ import {
|
||||
import { ContractReferenceLink } from "@/components/bookings/ContractReferenceLink";
|
||||
import { api } from "@/services/api";
|
||||
import type { BookingListFilter } from "@/services/bookings.service";
|
||||
import { trainSchedulingService } from "@/services/trainScheduling.service";
|
||||
import type { AllocationCandidate } from "@/types/trainScheduling";
|
||||
import type { BookingListRow } from "@/types/booking";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import {
|
||||
Badge,
|
||||
DataTable,
|
||||
@@ -155,6 +160,15 @@ export default function BookingRequestsPage() {
|
||||
const [scheduledTo, setScheduledTo] = useState<Date | null>(null);
|
||||
const [allocateOpen, setAllocateOpen] = useState(false);
|
||||
const [allocateIds, setAllocateIds] = useState<string[]>([]);
|
||||
// Paid bookings with no train attached (staff removed them or a sweep
|
||||
// detached them) — the queue the per-row Allocate action works through.
|
||||
const [paidUnallocated, setPaidUnallocated] = useState(false);
|
||||
const [allocatingId, setAllocatingId] = useState<string | null>(null);
|
||||
const [otherDayModal, setOtherDayModal] = useState<{
|
||||
booking: BookingListRow;
|
||||
candidates: AllocationCandidate[];
|
||||
} | null>(null);
|
||||
const { toast } = useToast();
|
||||
const suppressRowClickRef = useRef(false);
|
||||
const suppressRowClick = useCallback(() => {
|
||||
suppressRowClickRef.current = true;
|
||||
@@ -187,6 +201,10 @@ export default function BookingRequestsPage() {
|
||||
...(directionFilter ? { tradeDirection: directionFilter } : {}),
|
||||
...(freightTypeFilter ? { freightType: freightTypeFilter } : {}),
|
||||
...(paymentStatusFilter ? { paymentStatus: paymentStatusFilter } : {}),
|
||||
// Wins over the payment-status select — the queue is by definition PAID.
|
||||
...(paidUnallocated
|
||||
? { paymentStatus: "PAID", assignedToSchedule: "false" as const }
|
||||
: {}),
|
||||
...(ownershipFilter
|
||||
? { isGovernment: ownershipFilter as "true" | "false" }
|
||||
: {}),
|
||||
@@ -208,6 +226,7 @@ export default function BookingRequestsPage() {
|
||||
directionFilter,
|
||||
freightTypeFilter,
|
||||
paymentStatusFilter,
|
||||
paidUnallocated,
|
||||
ownershipFilter,
|
||||
originYardFilter,
|
||||
destinationYardFilter,
|
||||
@@ -251,6 +270,7 @@ export default function BookingRequestsPage() {
|
||||
(directionFilter ? 1 : 0) +
|
||||
(freightTypeFilter ? 1 : 0) +
|
||||
(paymentStatusFilter ? 1 : 0) +
|
||||
(paidUnallocated ? 1 : 0) +
|
||||
(ownershipFilter ? 1 : 0) +
|
||||
(originYardFilter ? 1 : 0) +
|
||||
(destinationYardFilter ? 1 : 0) +
|
||||
@@ -263,6 +283,7 @@ export default function BookingRequestsPage() {
|
||||
setDirectionFilter(null);
|
||||
setFreightTypeFilter(null);
|
||||
setPaymentStatusFilter(null);
|
||||
setPaidUnallocated(false);
|
||||
setOwnershipFilter(null);
|
||||
setOriginYardFilter(null);
|
||||
setDestinationYardFilter(null);
|
||||
@@ -301,6 +322,67 @@ export default function BookingRequestsPage() {
|
||||
[navigate],
|
||||
);
|
||||
|
||||
// One click: same-day fit → allocate straight away. No same-day fit but a
|
||||
// train on another date fits → let staff pick it (customer is notified of
|
||||
// the date change by the API). Nothing fits → say so.
|
||||
const handleAllocatePaid = useCallback(
|
||||
async (row: BookingListRow) => {
|
||||
setAllocatingId(row.id);
|
||||
try {
|
||||
const candidates =
|
||||
await trainSchedulingService.getAllocationCandidates(row.id);
|
||||
if (candidates.sameDay.length > 0) {
|
||||
const target = candidates.sameDay[0];
|
||||
await trainSchedulingService.allocatePaidBooking(row.id, target.id);
|
||||
toast({
|
||||
title: `Allocated ${row.reference}`,
|
||||
description: `Placed on ${target.reference ?? "train"} departing ${formatDate(target.scheduledDepartureDate)}.`,
|
||||
});
|
||||
void refetch();
|
||||
} else if (candidates.otherDays.length > 0) {
|
||||
setOtherDayModal({ booking: row, candidates: candidates.otherDays });
|
||||
} else {
|
||||
toast({
|
||||
title: "No fitting train",
|
||||
description:
|
||||
"No open schedule covers this booking's route with enough capacity.",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
toast({ title: "Allocation failed", variant: "destructive" });
|
||||
} finally {
|
||||
setAllocatingId(null);
|
||||
}
|
||||
},
|
||||
[refetch, toast],
|
||||
);
|
||||
|
||||
const handleAllocateOtherDay = useCallback(
|
||||
async (candidate: AllocationCandidate) => {
|
||||
if (!otherDayModal) return;
|
||||
const { booking } = otherDayModal;
|
||||
setAllocatingId(booking.id);
|
||||
try {
|
||||
await trainSchedulingService.allocatePaidBooking(
|
||||
booking.id,
|
||||
candidate.id,
|
||||
);
|
||||
toast({
|
||||
title: `Allocated ${booking.reference}`,
|
||||
description: `Placed on ${candidate.reference ?? "train"} departing ${formatDate(candidate.scheduledDepartureDate)}. Customer notified of the date change.`,
|
||||
});
|
||||
setOtherDayModal(null);
|
||||
void refetch();
|
||||
} catch {
|
||||
toast({ title: "Allocation failed", variant: "destructive" });
|
||||
} finally {
|
||||
setAllocatingId(null);
|
||||
}
|
||||
},
|
||||
[otherDayModal, refetch, toast],
|
||||
);
|
||||
|
||||
const columns: ColumnDef<BookingListRow>[] = [
|
||||
{
|
||||
id: "booking",
|
||||
@@ -435,13 +517,33 @@ export default function BookingRequestsPage() {
|
||||
{
|
||||
id: "actions",
|
||||
size: 140,
|
||||
cell: ({ row }) => (
|
||||
<BookingActionsMenu
|
||||
row={row.original}
|
||||
variant="table"
|
||||
onSuppressRowClick={suppressRowClick}
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const b = row.original;
|
||||
const needsAllocation = b.paymentStatus === "PAID" && !b.trainScheduleId;
|
||||
return (
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
{needsAllocation ? (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
color="edr-green"
|
||||
loading={allocatingId === b.id}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
suppressRowClick();
|
||||
void handleAllocatePaid(b);
|
||||
}}
|
||||
>
|
||||
Allocate
|
||||
</Button>
|
||||
) : null}
|
||||
<BookingActionsMenu
|
||||
row={b}
|
||||
variant="table"
|
||||
onSuppressRowClick={suppressRowClick}
|
||||
/>
|
||||
</Group>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -640,6 +742,16 @@ export default function BookingRequestsPage() {
|
||||
radius="lg"
|
||||
style={{ minWidth: 180 }}
|
||||
/>
|
||||
<Checkbox
|
||||
label="Paid, not allocated"
|
||||
checked={paidUnallocated}
|
||||
onChange={(e) => {
|
||||
setPaidUnallocated(e.currentTarget.checked);
|
||||
resetPage();
|
||||
}}
|
||||
radius="sm"
|
||||
style={{ alignSelf: "center" }}
|
||||
/>
|
||||
<Select
|
||||
placeholder="Gov / Private"
|
||||
data={OWNERSHIP_OPTIONS}
|
||||
@@ -751,6 +863,42 @@ export default function BookingRequestsPage() {
|
||||
</Card>
|
||||
</Stack>
|
||||
|
||||
<Modal
|
||||
opened={otherDayModal !== null}
|
||||
onClose={() => setOtherDayModal(null)}
|
||||
title="Allocate to another date"
|
||||
centered
|
||||
>
|
||||
<Stack gap="sm">
|
||||
<Text size="sm" c="dimmed">
|
||||
No train on {otherDayModal ? formatDate(otherDayModal.booking.scheduledDate) : "the booking's day"}{" "}
|
||||
fits booking {otherDayModal?.booking.reference}. These trains on
|
||||
other dates do — the customer will be notified of the date change.
|
||||
</Text>
|
||||
{otherDayModal?.candidates.map((c) => (
|
||||
<Group key={c.id} justify="space-between" wrap="nowrap">
|
||||
<div>
|
||||
<Text size="sm" fw={500}>
|
||||
{c.reference ?? "Train"}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
Departs {formatDate(c.scheduledDepartureDate)}
|
||||
{c.direction ? ` · ${c.direction}` : ""}
|
||||
</Text>
|
||||
</div>
|
||||
<Button
|
||||
size="compact-sm"
|
||||
color="edr-green"
|
||||
loading={allocatingId === otherDayModal.booking.id}
|
||||
onClick={() => void handleAllocateOtherDay(c)}
|
||||
>
|
||||
Allocate
|
||||
</Button>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
{allocateBooking ? (
|
||||
<AllocateBookingWizard
|
||||
booking={allocateBooking}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
FileSignature,
|
||||
FileText,
|
||||
Train,
|
||||
TrainFront,
|
||||
UserCheck,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
@@ -13,7 +14,6 @@ import {
|
||||
Badge,
|
||||
Button,
|
||||
Container,
|
||||
Paper,
|
||||
Skeleton,
|
||||
Stack,
|
||||
Tabs,
|
||||
@@ -22,7 +22,6 @@ import { useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { OverviewPageHeader } from "@/components/overview/OverviewPageHeader";
|
||||
import { OverviewQuickLinks } from "@/components/overview/OverviewQuickLinks";
|
||||
import { OverviewTabContent } from "@/components/overview/OverviewTabContent";
|
||||
import "@/components/overview/overview.css";
|
||||
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
|
||||
@@ -82,6 +81,18 @@ const TAB_ITEMS: Array<{
|
||||
FREIGHT_PERMS.lastMile.view,
|
||||
],
|
||||
},
|
||||
{
|
||||
value: "fleet",
|
||||
label: "Fleet",
|
||||
icon: TrainFront,
|
||||
kpiKey: "operations",
|
||||
metricKey: "wagonsAvailable",
|
||||
permission: [
|
||||
FREIGHT_PERMS.fleet.view,
|
||||
FREIGHT_PERMS.wagons.view,
|
||||
FREIGHT_PERMS.trainScheduling.view,
|
||||
],
|
||||
},
|
||||
{
|
||||
value: "customers",
|
||||
label: "Customers",
|
||||
@@ -174,6 +185,12 @@ const OverviewPage = () => {
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{visibleTabs.length === 0 && !isLoading && !isError && (
|
||||
<Alert color="gray" variant="light" title="No dashboard sections available">
|
||||
Your role has no access to any overview section.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{visibleTabs.length > 0 && (
|
||||
<Tabs
|
||||
value={currentTab}
|
||||
@@ -220,10 +237,6 @@ const OverviewPage = () => {
|
||||
))}
|
||||
</Tabs>
|
||||
)}
|
||||
|
||||
<Paper p="lg" radius="lg" withBorder>
|
||||
<OverviewQuickLinks />
|
||||
</Paper>
|
||||
</Stack>
|
||||
</Container>
|
||||
);
|
||||
|
||||
445
apps/edr-freight-web/backoffice/src/pages/reports/ReportPage.tsx
Normal file
445
apps/edr-freight-web/backoffice/src/pages/reports/ReportPage.tsx
Normal file
@@ -0,0 +1,445 @@
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
MultiSelect,
|
||||
Select,
|
||||
Text,
|
||||
} from "@mantine/core";
|
||||
import { DateInput } from "@mantine/dates";
|
||||
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
DataTable,
|
||||
DataTableFooter,
|
||||
usePagination,
|
||||
type ColumnDef,
|
||||
} from "@edr/ui-common";
|
||||
import { Download, FileSpreadsheet, Printer, RotateCcw } from "lucide-react";
|
||||
import { useMemo } from "react";
|
||||
import { useParams, useSearchParams, Link } from "react-router-dom";
|
||||
import {
|
||||
Area,
|
||||
AreaChart,
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
Legend,
|
||||
Line,
|
||||
LineChart,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
import * as XLSX from "xlsx";
|
||||
import { ALL_TRADE_DIRECTIONS, TRADE_DIRECTION_LABELS } from "@edr/types";
|
||||
|
||||
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
|
||||
import { overviewChartColors } from "@/components/overview/overview.styles";
|
||||
import { api } from "@/services/api";
|
||||
import type { ReportQueryInput, ReportRow } from "@/types/reports";
|
||||
import {
|
||||
REPORT_CONFIG_BY_KEY,
|
||||
type ReportColumn,
|
||||
type ReportConfig,
|
||||
} from "./reportConfigs";
|
||||
|
||||
const compact = new Intl.NumberFormat("en", { notation: "compact" });
|
||||
|
||||
const UNIT_SUFFIX = { ETB: " ETB", t: " t", "%": "%", min: " min" } as const;
|
||||
|
||||
function formatCell(value: unknown, col: ReportColumn): string {
|
||||
if (value === null || value === undefined || value === "") return "—";
|
||||
if (col.unit || col.numeric) {
|
||||
const n = Number(value);
|
||||
if (!Number.isNaN(n)) {
|
||||
return `${n.toLocaleString()}${col.unit ? UNIT_SUFFIX[col.unit] : ""}`;
|
||||
}
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
const toDate = (s: string | null): Date | null => (s ? new Date(s) : null);
|
||||
// Mantine DateInput onChange emits a date string (or null).
|
||||
const toParam = (d: Date | string | null): string | null => {
|
||||
if (!d) return null;
|
||||
return typeof d === "string" ? d.slice(0, 10) : d.toISOString().slice(0, 10);
|
||||
};
|
||||
|
||||
function downloadBlob(content: BlobPart, type: string, filename: string) {
|
||||
const url = URL.createObjectURL(new Blob([content], { type }));
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function exportCsv(config: ReportConfig, rows: ReportRow[]) {
|
||||
const esc = (v: unknown) => `"${String(v ?? "").replace(/"/g, '""')}"`;
|
||||
const lines = [
|
||||
config.columns.map((c) => esc(c.label)).join(","),
|
||||
...rows.map((r) => config.columns.map((c) => esc(r[c.key])).join(",")),
|
||||
];
|
||||
downloadBlob(lines.join("\n"), "text/csv;charset=utf-8", `${config.key}.csv`);
|
||||
}
|
||||
|
||||
function exportXlsx(config: ReportConfig, rows: ReportRow[]) {
|
||||
const sheetRows = rows.map((r) =>
|
||||
Object.fromEntries(config.columns.map((c) => [c.label, r[c.key] ?? ""])),
|
||||
);
|
||||
const wb = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(
|
||||
wb,
|
||||
XLSX.utils.json_to_sheet(sheetRows),
|
||||
config.title.slice(0, 31),
|
||||
);
|
||||
XLSX.writeFile(wb, `${config.key}.xlsx`);
|
||||
}
|
||||
|
||||
function ReportChartView({
|
||||
config,
|
||||
rows,
|
||||
}: {
|
||||
config: ReportConfig;
|
||||
rows: ReportRow[];
|
||||
}) {
|
||||
const chart = config.chart;
|
||||
const data = useMemo(() => {
|
||||
if (!chart) return [];
|
||||
const sliced = chart.topN ? rows.slice(0, chart.topN) : rows;
|
||||
// xKey "a+b" concatenates columns (e.g. origin+destination → "A → B").
|
||||
const keys = chart.xKey.split("+");
|
||||
return sliced.map((r) => ({
|
||||
...r,
|
||||
__x:
|
||||
keys.length > 1
|
||||
? keys.map((k) => String(r[k] ?? "")).join(" → ")
|
||||
: String(r[chart.xKey] ?? ""),
|
||||
}));
|
||||
}, [chart, rows]);
|
||||
|
||||
if (!chart) return null;
|
||||
if (data.length === 0) {
|
||||
return (
|
||||
<Card withBorder shadow="sm">
|
||||
<Text c="dimmed" ta="center" py="xl">
|
||||
No data for the selected filters
|
||||
</Text>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const ChartComponent =
|
||||
chart.type === "bar" ? BarChart : chart.type === "line" ? LineChart : AreaChart;
|
||||
|
||||
return (
|
||||
<Card withBorder shadow="sm">
|
||||
<ResponsiveContainer width="100%" height={280}>
|
||||
<ChartComponent data={data} margin={{ top: 8, right: 8, left: 8, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
|
||||
<XAxis dataKey="__x" tick={{ fontSize: 12 }} interval="preserveStartEnd" />
|
||||
<YAxis
|
||||
tick={{ fontSize: 12 }}
|
||||
tickFormatter={(v: number) => compact.format(v)}
|
||||
width={56}
|
||||
/>
|
||||
<Tooltip formatter={(value) => Number(value ?? 0).toLocaleString()} />
|
||||
{chart.series.length > 1 ? <Legend /> : null}
|
||||
{chart.series.map((s, i) => {
|
||||
const color =
|
||||
overviewChartColors.pipeline[i % overviewChartColors.pipeline.length];
|
||||
if (chart.type === "bar") {
|
||||
return (
|
||||
<Bar key={s.key} dataKey={s.key} name={s.label} fill={color} radius={[4, 4, 0, 0]} />
|
||||
);
|
||||
}
|
||||
if (chart.type === "line") {
|
||||
return (
|
||||
<Line
|
||||
key={s.key}
|
||||
type="monotone"
|
||||
dataKey={s.key}
|
||||
name={s.label}
|
||||
stroke={color}
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Area
|
||||
key={s.key}
|
||||
type="monotone"
|
||||
dataKey={s.key}
|
||||
name={s.label}
|
||||
stroke={color}
|
||||
fill={color}
|
||||
fillOpacity={0.15}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</ChartComponent>
|
||||
</ResponsiveContainer>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ReportPage() {
|
||||
const { reportKey = "" } = useParams<{ reportKey: string }>();
|
||||
const config = REPORT_CONFIG_BY_KEY.get(reportKey);
|
||||
const [params, setParams] = useSearchParams();
|
||||
const { pagination, setPagination } = usePagination({ pageSize: 20 });
|
||||
|
||||
const setParam = (name: string, value: string | null) => {
|
||||
setParams(
|
||||
(prev) => {
|
||||
if (value) prev.set(name, value);
|
||||
else prev.delete(name);
|
||||
return prev;
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
setPagination((p) => ({ ...p, pageIndex: 0 }));
|
||||
};
|
||||
|
||||
const input: ReportQueryInput = {
|
||||
key: reportKey,
|
||||
dateFrom: params.get("dateFrom") ?? undefined,
|
||||
dateTo: params.get("dateTo") ?? undefined,
|
||||
granularity:
|
||||
(params.get("granularity") as ReportQueryInput["granularity"]) ?? undefined,
|
||||
yardIds: params.get("yardIds") ?? undefined,
|
||||
statuses: params.get("statuses") ?? undefined,
|
||||
direction: params.get("direction") ?? undefined,
|
||||
freightType: params.get("freightType") ?? undefined,
|
||||
};
|
||||
|
||||
const reportQuery = useQuery(
|
||||
api.reports.run.queryOptions({
|
||||
input,
|
||||
placeholderData: keepPreviousData,
|
||||
staleTime: 30_000,
|
||||
enabled: Boolean(config),
|
||||
}),
|
||||
);
|
||||
|
||||
const yardsQuery = useQuery(
|
||||
api.routes.yards.queryOptions({
|
||||
staleTime: 5 * 60_000,
|
||||
enabled: Boolean(config?.filters.includes("yards")),
|
||||
}),
|
||||
);
|
||||
|
||||
if (!config) {
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader title="Unknown report" backTo="/dashboard/reports" />
|
||||
<Text>
|
||||
This report does not exist. <Link to="/dashboard/reports">Back to reports</Link>
|
||||
</Text>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
const rows = reportQuery.data?.rows ?? [];
|
||||
const kpis = reportQuery.data?.kpis ?? [];
|
||||
const pageCount = Math.max(1, Math.ceil(rows.length / pagination.pageSize));
|
||||
|
||||
const columns: ColumnDef<ReportRow, unknown>[] = config.columns.map((col) => ({
|
||||
accessorKey: col.key,
|
||||
header: col.label,
|
||||
cell: (info) => formatCell(info.getValue(), col),
|
||||
}));
|
||||
|
||||
const tableStatus = reportQuery.isLoading
|
||||
? "loading"
|
||||
: reportQuery.isError
|
||||
? "error"
|
||||
: "success";
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
title={config.title}
|
||||
subtitle={config.description}
|
||||
backTo="/dashboard/reports"
|
||||
action={
|
||||
<Group gap="xs">
|
||||
<Button
|
||||
variant="default"
|
||||
size="xs"
|
||||
leftSection={<Download size={14} />}
|
||||
onClick={() => exportCsv(config, rows)}
|
||||
disabled={rows.length === 0}
|
||||
>
|
||||
CSV
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
size="xs"
|
||||
leftSection={<FileSpreadsheet size={14} />}
|
||||
onClick={() => exportXlsx(config, rows)}
|
||||
disabled={rows.length === 0}
|
||||
>
|
||||
Excel
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
size="xs"
|
||||
leftSection={<Printer size={14} />}
|
||||
onClick={() => window.print()}
|
||||
>
|
||||
Print
|
||||
</Button>
|
||||
</Group>
|
||||
}
|
||||
/>
|
||||
|
||||
<Card withBorder shadow="sm">
|
||||
<Group gap="sm" align="flex-end" wrap="wrap">
|
||||
<DateInput
|
||||
label="From"
|
||||
size="xs"
|
||||
clearable
|
||||
value={toDate(params.get("dateFrom"))}
|
||||
maxDate={toDate(params.get("dateTo")) ?? undefined}
|
||||
onChange={(d) => setParam("dateFrom", toParam(d))}
|
||||
placeholder="All time"
|
||||
/>
|
||||
<DateInput
|
||||
label="To"
|
||||
size="xs"
|
||||
clearable
|
||||
value={toDate(params.get("dateTo"))}
|
||||
minDate={toDate(params.get("dateFrom")) ?? undefined}
|
||||
onChange={(d) => setParam("dateTo", toParam(d))}
|
||||
placeholder="All time"
|
||||
/>
|
||||
{config.filters.includes("granularity") ? (
|
||||
<Select
|
||||
label="Group by"
|
||||
size="xs"
|
||||
data={[
|
||||
{ value: "day", label: "Day" },
|
||||
{ value: "week", label: "Week" },
|
||||
{ value: "month", label: "Month" },
|
||||
]}
|
||||
value={params.get("granularity") ?? "day"}
|
||||
onChange={(v) => setParam("granularity", v)}
|
||||
allowDeselect={false}
|
||||
/>
|
||||
) : null}
|
||||
{config.filters.includes("yards") ? (
|
||||
<MultiSelect
|
||||
label="Yards"
|
||||
size="xs"
|
||||
searchable
|
||||
clearable
|
||||
w={220}
|
||||
data={(yardsQuery.data ?? []).map((y) => ({
|
||||
value: y.id,
|
||||
label: y.label,
|
||||
}))}
|
||||
value={params.get("yardIds")?.split(",").filter(Boolean) ?? []}
|
||||
onChange={(v) => setParam("yardIds", v.length ? v.join(",") : null)}
|
||||
placeholder="All yards"
|
||||
/>
|
||||
) : null}
|
||||
{config.filters.includes("direction") ? (
|
||||
<Select
|
||||
label="Direction"
|
||||
size="xs"
|
||||
clearable
|
||||
data={ALL_TRADE_DIRECTIONS.map((d) => ({
|
||||
value: d,
|
||||
label: TRADE_DIRECTION_LABELS[d],
|
||||
}))}
|
||||
value={params.get("direction")}
|
||||
onChange={(v) => setParam("direction", v)}
|
||||
placeholder="All"
|
||||
/>
|
||||
) : null}
|
||||
{config.filters.includes("freightType") ? (
|
||||
<Select
|
||||
label="Freight type"
|
||||
size="xs"
|
||||
clearable
|
||||
data={["CONTAINER", "BULK"]}
|
||||
value={params.get("freightType")}
|
||||
onChange={(v) => setParam("freightType", v)}
|
||||
placeholder="All"
|
||||
/>
|
||||
) : null}
|
||||
{config.filters.includes("statuses") && config.statusOptions ? (
|
||||
<MultiSelect
|
||||
label="Status"
|
||||
size="xs"
|
||||
searchable
|
||||
clearable
|
||||
w={220}
|
||||
data={config.statusOptions}
|
||||
value={params.get("statuses")?.split(",").filter(Boolean) ?? []}
|
||||
onChange={(v) => setParam("statuses", v.length ? v.join(",") : null)}
|
||||
placeholder="Default (active)"
|
||||
/>
|
||||
) : null}
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="xs"
|
||||
leftSection={<RotateCcw size={14} />}
|
||||
onClick={() => setParams({}, { replace: true })}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
</Group>
|
||||
</Card>
|
||||
|
||||
<KpiStrip
|
||||
loading={reportQuery.isLoading}
|
||||
items={kpis.map((k) => ({
|
||||
label: k.label,
|
||||
value: k.value.toLocaleString(),
|
||||
hint: k.unit,
|
||||
}))}
|
||||
/>
|
||||
|
||||
<ReportChartView config={config} rows={rows} />
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={rows}
|
||||
status={tableStatus}
|
||||
emptyMessage="No data for the selected filters"
|
||||
error={
|
||||
reportQuery.isError
|
||||
? {
|
||||
message: "Failed to load report",
|
||||
onRetry: () => void reportQuery.refetch(),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
pagination={{
|
||||
pageIndex: pagination.pageIndex,
|
||||
pageSize: pagination.pageSize,
|
||||
pageCount,
|
||||
totalCount: rows.length,
|
||||
}}
|
||||
tableOptions={{
|
||||
manualPagination: false,
|
||||
state: { pagination },
|
||||
onPaginationChange: setPagination,
|
||||
autoResetPageIndex: false,
|
||||
}}
|
||||
footer={({ table, pagination: p }) => (
|
||||
<DataTableFooter
|
||||
table={table}
|
||||
pagination={p}
|
||||
options={{ labels: { items: "rows" } }}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Card,
|
||||
Group,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { Search, Star } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { PageContainer, PageHeader } from "@/components/page";
|
||||
import {
|
||||
REPORT_CONFIGS,
|
||||
REPORT_DOMAINS,
|
||||
type ReportConfig,
|
||||
} from "./reportConfigs";
|
||||
|
||||
const FAVORITES_KEY = "reports.favorites";
|
||||
|
||||
const loadFavorites = (): string[] => {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(FAVORITES_KEY) ?? "[]");
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
function ReportCard({
|
||||
config,
|
||||
favorite,
|
||||
onToggleFavorite,
|
||||
}: {
|
||||
config: ReportConfig;
|
||||
favorite: boolean;
|
||||
onToggleFavorite: () => void;
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
return (
|
||||
<Card
|
||||
withBorder
|
||||
shadow="sm"
|
||||
className="cursor-pointer transition-colors hover:bg-gray-50"
|
||||
onClick={() => navigate(`/dashboard/reports/${config.key}`)}
|
||||
>
|
||||
<Group justify="space-between" align="flex-start" wrap="nowrap">
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<Text fw={600} truncate>
|
||||
{config.title}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed" lineClamp={2}>
|
||||
{config.description}
|
||||
</Text>
|
||||
</div>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color={favorite ? "yellow" : "gray"}
|
||||
aria-label={favorite ? "Remove from favorites" : "Add to favorites"}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggleFavorite();
|
||||
}}
|
||||
>
|
||||
<Star size={16} fill={favorite ? "currentColor" : "none"} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
<Badge mt="sm" size="sm" variant="light">
|
||||
{config.domain}
|
||||
</Badge>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ReportsHubPage() {
|
||||
const [search, setSearch] = useState("");
|
||||
const [favorites, setFavorites] = useState<string[]>(loadFavorites);
|
||||
|
||||
const toggleFavorite = (key: string) => {
|
||||
setFavorites((prev) => {
|
||||
const next = prev.includes(key)
|
||||
? prev.filter((k) => k !== key)
|
||||
: [...prev, key];
|
||||
localStorage.setItem(FAVORITES_KEY, JSON.stringify(next));
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const visible = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
if (!q) return REPORT_CONFIGS;
|
||||
return REPORT_CONFIGS.filter(
|
||||
(c) =>
|
||||
c.title.toLowerCase().includes(q) ||
|
||||
c.description.toLowerCase().includes(q),
|
||||
);
|
||||
}, [search]);
|
||||
|
||||
const pinned = visible.filter((c) => favorites.includes(c.key));
|
||||
|
||||
const renderGrid = (configs: ReportConfig[]) => (
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
{configs.map((c) => (
|
||||
<ReportCard
|
||||
key={c.key}
|
||||
config={c}
|
||||
favorite={favorites.includes(c.key)}
|
||||
onToggleFavorite={() => toggleFavorite(c.key)}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
);
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
title="Reports"
|
||||
subtitle="Operational, commercial and financial reporting"
|
||||
action={
|
||||
<TextInput
|
||||
size="xs"
|
||||
w={240}
|
||||
leftSection={<Search size={14} />}
|
||||
placeholder="Search reports…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
{pinned.length ? (
|
||||
<Stack gap="sm">
|
||||
<Title order={4}>Favorites</Title>
|
||||
{renderGrid(pinned)}
|
||||
</Stack>
|
||||
) : null}
|
||||
|
||||
{REPORT_DOMAINS.map((domain) => {
|
||||
const configs = visible.filter((c) => c.domain === domain);
|
||||
if (!configs.length) return null;
|
||||
return (
|
||||
<Stack key={domain} gap="sm">
|
||||
<Title order={4}>{domain}</Title>
|
||||
{renderGrid(configs)}
|
||||
</Stack>
|
||||
);
|
||||
})}
|
||||
|
||||
{visible.length === 0 ? (
|
||||
<Text c="dimmed" ta="center" py="xl">
|
||||
No reports match “{search}”
|
||||
</Text>
|
||||
) : null}
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,444 @@
|
||||
import { BookingStatus } from "@edr/types";
|
||||
|
||||
export type ReportDomain = "Commercial" | "Operations" | "Finance" | "Data";
|
||||
|
||||
export type ReportColumnUnit = "ETB" | "t" | "%" | "min";
|
||||
|
||||
export interface ReportColumn {
|
||||
key: string;
|
||||
label: string;
|
||||
/** Numeric unit — formats the cell (thousands separators, suffix). */
|
||||
unit?: ReportColumnUnit;
|
||||
numeric?: boolean;
|
||||
}
|
||||
|
||||
export interface ReportChart {
|
||||
type: "area" | "line" | "bar";
|
||||
xKey: string;
|
||||
series: { key: string; label: string }[];
|
||||
/** Chart only the first N rows (rows arrive sorted by the backend). */
|
||||
topN?: number;
|
||||
}
|
||||
|
||||
export type ReportFilterKey =
|
||||
| "granularity"
|
||||
| "yards"
|
||||
| "direction"
|
||||
| "freightType"
|
||||
| "statuses";
|
||||
|
||||
export interface ReportConfig {
|
||||
key: string;
|
||||
title: string;
|
||||
description: string;
|
||||
domain: ReportDomain;
|
||||
filters: ReportFilterKey[];
|
||||
/** Options for the `statuses` filter, when enabled. */
|
||||
statusOptions?: string[];
|
||||
chart?: ReportChart;
|
||||
columns: ReportColumn[];
|
||||
}
|
||||
|
||||
// Full enum from @edr/types; Set dedupes the deprecated AwaitingPayment alias.
|
||||
const BOOKING_STATUSES = [...new Set(Object.values(BookingStatus))];
|
||||
|
||||
// Full list mirroring CONTRACT_STATUSES in contract.entity.ts (no shared enum
|
||||
// in @edr/types yet).
|
||||
const CONTRACT_STATUSES = [
|
||||
"DRAFT",
|
||||
"SUBMITTED",
|
||||
"PRICE_CHANGED_PENDING_CONFIRM",
|
||||
"CHANGES_REQUESTED",
|
||||
"PENDING_APPROVAL",
|
||||
"APPROVED",
|
||||
"APPROVED_PENDING_SIGNATURE",
|
||||
"CONTRACT_READY",
|
||||
"SIGNED_CUSTOMER",
|
||||
"FULLY_EXECUTED",
|
||||
"CONTRACT_ACTIVE",
|
||||
"AWAITING_CLEARANCE_DOCUMENTS",
|
||||
"CLEARANCE_UNDER_REVIEW",
|
||||
"CLEARANCE_READY_FOR_BOOKING",
|
||||
"ACTIVE_SHIPMENT_IN_PROGRESS",
|
||||
"SUSPENDED",
|
||||
"CONTRACT_CLOSED",
|
||||
"EXPIRED",
|
||||
"REJECTED",
|
||||
"CANCELLED",
|
||||
"RENEWAL_DRAFT",
|
||||
"RENEWAL_SUBMITTED",
|
||||
"RENEWAL_PENDING_APPROVAL",
|
||||
"AMENDMENTS_PROPOSED",
|
||||
"ARCHIVED",
|
||||
];
|
||||
|
||||
const INVOICE_STATUSES = [
|
||||
"ISSUED",
|
||||
"PENDING",
|
||||
"PARTIALLY_PAID",
|
||||
"PAID",
|
||||
"OVERDUE",
|
||||
"REFUNDED",
|
||||
];
|
||||
|
||||
export const REPORT_CONFIGS: ReportConfig[] = [
|
||||
{
|
||||
key: "bookings-trend",
|
||||
title: "Bookings Trend",
|
||||
description: "Booking volume, tonnage and revenue over time",
|
||||
domain: "Commercial",
|
||||
filters: ["granularity", "yards", "direction", "freightType", "statuses"],
|
||||
statusOptions: BOOKING_STATUSES,
|
||||
chart: {
|
||||
type: "area",
|
||||
xKey: "period",
|
||||
series: [{ key: "revenue", label: "Revenue (ETB)" }],
|
||||
},
|
||||
columns: [
|
||||
{ key: "period", label: "Period" },
|
||||
{ key: "bookings", label: "Bookings", numeric: true },
|
||||
{ key: "tons", label: "Tonnage", unit: "t" },
|
||||
{ key: "revenue", label: "Revenue", unit: "ETB" },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "revenue-by-customer",
|
||||
title: "Revenue by Customer",
|
||||
description: "Ranked customers by booking revenue",
|
||||
domain: "Commercial",
|
||||
filters: ["yards", "direction", "freightType", "statuses"],
|
||||
statusOptions: BOOKING_STATUSES,
|
||||
chart: {
|
||||
type: "bar",
|
||||
xKey: "customer",
|
||||
series: [{ key: "revenue", label: "Revenue (ETB)" }],
|
||||
topN: 10,
|
||||
},
|
||||
columns: [
|
||||
{ key: "customer", label: "Customer" },
|
||||
{ key: "bookings", label: "Bookings", numeric: true },
|
||||
{ key: "tons", label: "Tonnage", unit: "t" },
|
||||
{ key: "revenue", label: "Revenue", unit: "ETB" },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "revenue-by-lane",
|
||||
title: "Revenue by Lane",
|
||||
description: "Origin → destination lanes by tonnage and revenue",
|
||||
domain: "Commercial",
|
||||
filters: ["direction", "freightType", "statuses"],
|
||||
statusOptions: BOOKING_STATUSES,
|
||||
chart: {
|
||||
type: "bar",
|
||||
xKey: "origin+destination",
|
||||
series: [{ key: "revenue", label: "Revenue (ETB)" }],
|
||||
topN: 10,
|
||||
},
|
||||
columns: [
|
||||
{ key: "origin", label: "Origin" },
|
||||
{ key: "destination", label: "Destination" },
|
||||
{ key: "bookings", label: "Bookings", numeric: true },
|
||||
{ key: "tons", label: "Tonnage", unit: "t" },
|
||||
{ key: "revenue", label: "Revenue", unit: "ETB" },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "contract-utilization",
|
||||
title: "Contract Utilization",
|
||||
description: "Committed scope caps vs booked tonnage per contract",
|
||||
domain: "Commercial",
|
||||
filters: ["direction", "statuses"],
|
||||
statusOptions: CONTRACT_STATUSES,
|
||||
columns: [
|
||||
{ key: "reference", label: "Contract" },
|
||||
{ key: "customer", label: "Customer" },
|
||||
{ key: "status", label: "Status" },
|
||||
{ key: "kind", label: "Kind" },
|
||||
{ key: "valid_from", label: "Valid from" },
|
||||
{ key: "valid_until", label: "Valid until" },
|
||||
{ key: "committed", label: "Committed", unit: "t" },
|
||||
{ key: "booked_tons", label: "Booked", unit: "t" },
|
||||
{ key: "bookings", label: "Bookings", numeric: true },
|
||||
{ key: "utilization_pct", label: "Utilization", unit: "%" },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "train-on-time",
|
||||
title: "Train On-Time Performance",
|
||||
description: "Departure punctuality and delays by lane (60-min grace)",
|
||||
domain: "Operations",
|
||||
filters: ["yards", "direction"],
|
||||
chart: {
|
||||
type: "bar",
|
||||
xKey: "origin+destination",
|
||||
series: [{ key: "on_time_pct", label: "On-time %" }],
|
||||
topN: 15,
|
||||
},
|
||||
columns: [
|
||||
{ key: "origin", label: "Origin" },
|
||||
{ key: "destination", label: "Destination" },
|
||||
{ key: "trips", label: "Trips", numeric: true },
|
||||
{ key: "departed", label: "Departed", numeric: true },
|
||||
{ key: "avg_dep_delay_min", label: "Avg dep. delay", unit: "min" },
|
||||
{ key: "avg_arr_delay_min", label: "Avg arr. delay", unit: "min" },
|
||||
{ key: "on_time_pct", label: "On-time", unit: "%" },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "schedule-fill-rate",
|
||||
title: "Schedule Fill Rate",
|
||||
description: "Booked tonnage vs wagon capacity per train schedule",
|
||||
domain: "Operations",
|
||||
filters: ["yards", "direction"],
|
||||
chart: {
|
||||
type: "line",
|
||||
xKey: "departure",
|
||||
series: [{ key: "fill_pct", label: "Fill %" }],
|
||||
},
|
||||
columns: [
|
||||
{ key: "train_number", label: "Train" },
|
||||
{ key: "departure", label: "Departure" },
|
||||
{ key: "origin", label: "Origin" },
|
||||
{ key: "destination", label: "Destination" },
|
||||
{ key: "direction", label: "Direction" },
|
||||
{ key: "status", label: "Status" },
|
||||
{ key: "wagon_count", label: "Wagons", numeric: true },
|
||||
{ key: "capacity_tons", label: "Capacity", unit: "t" },
|
||||
{ key: "booked_tons", label: "Booked", unit: "t" },
|
||||
{ key: "fill_pct", label: "Fill", unit: "%" },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "trips-per-route",
|
||||
title: "Trips per Route",
|
||||
description: "Completed trips and tonnage hauled per lane",
|
||||
domain: "Operations",
|
||||
filters: ["yards", "direction"],
|
||||
chart: {
|
||||
type: "bar",
|
||||
xKey: "origin+destination",
|
||||
series: [{ key: "trips", label: "Trips" }],
|
||||
topN: 15,
|
||||
},
|
||||
columns: [
|
||||
{ key: "origin", label: "Origin" },
|
||||
{ key: "destination", label: "Destination" },
|
||||
{ key: "direction", label: "Direction" },
|
||||
{ key: "trips", label: "Trips", numeric: true },
|
||||
{ key: "tons_hauled", label: "Tonnage hauled", unit: "t" },
|
||||
{ key: "avg_tons_per_trip", label: "Avg per trip", unit: "t" },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "invoiced-vs-collected",
|
||||
title: "Invoiced vs Collected",
|
||||
description: "Billing issued vs payments received over time",
|
||||
domain: "Finance",
|
||||
filters: ["granularity", "direction"],
|
||||
chart: {
|
||||
type: "line",
|
||||
xKey: "period",
|
||||
series: [
|
||||
{ key: "invoiced", label: "Invoiced (ETB)" },
|
||||
{ key: "collected", label: "Collected (ETB)" },
|
||||
],
|
||||
},
|
||||
columns: [
|
||||
{ key: "period", label: "Period" },
|
||||
{ key: "invoices", label: "Invoices", numeric: true },
|
||||
{ key: "invoiced", label: "Invoiced", unit: "ETB" },
|
||||
{ key: "collected", label: "Collected", unit: "ETB" },
|
||||
{ key: "outstanding", label: "Outstanding", unit: "ETB" },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "aging-receivables",
|
||||
title: "Aging Receivables",
|
||||
description: "Outstanding invoice balances by age bucket per customer",
|
||||
domain: "Finance",
|
||||
filters: ["direction", "statuses"],
|
||||
statusOptions: INVOICE_STATUSES,
|
||||
chart: {
|
||||
type: "bar",
|
||||
xKey: "customer",
|
||||
series: [{ key: "outstanding", label: "Outstanding (ETB)" }],
|
||||
topN: 10,
|
||||
},
|
||||
columns: [
|
||||
{ key: "customer", label: "Customer" },
|
||||
{ key: "invoices", label: "Invoices", numeric: true },
|
||||
{ key: "outstanding", label: "Outstanding", unit: "ETB" },
|
||||
{ key: "current", label: "Current", unit: "ETB" },
|
||||
{ key: "overdue_0_30", label: "0–30d", unit: "ETB" },
|
||||
{ key: "overdue_31_60", label: "31–60d", unit: "ETB" },
|
||||
{ key: "overdue_61_90", label: "61–90d", unit: "ETB" },
|
||||
{ key: "overdue_90_plus", label: "90d+", unit: "ETB" },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "revenue-by-payment-method",
|
||||
title: "Revenue by Payment Method",
|
||||
description: "Successful payments broken down by method",
|
||||
domain: "Finance",
|
||||
filters: ["direction"],
|
||||
chart: {
|
||||
type: "bar",
|
||||
xKey: "method",
|
||||
series: [{ key: "amount", label: "Amount (ETB)" }],
|
||||
},
|
||||
columns: [
|
||||
{ key: "method", label: "Method" },
|
||||
{ key: "payments", label: "Payments", numeric: true },
|
||||
{ key: "amount", label: "Amount", unit: "ETB" },
|
||||
],
|
||||
},
|
||||
// --- Record-level list exports (Data domain) — filtered or full dumps ---
|
||||
{
|
||||
key: "bookings-list",
|
||||
title: "Bookings Export",
|
||||
description: "Booking records with customer, lane, cargo, amounts",
|
||||
domain: "Data",
|
||||
filters: ["yards", "direction", "freightType", "statuses"],
|
||||
statusOptions: BOOKING_STATUSES,
|
||||
columns: [
|
||||
{ key: "reference", label: "Reference" },
|
||||
{ key: "created", label: "Created" },
|
||||
{ key: "customer", label: "Customer" },
|
||||
{ key: "status", label: "Status" },
|
||||
{ key: "freight_type", label: "Freight" },
|
||||
{ key: "direction", label: "Direction" },
|
||||
{ key: "origin", label: "Origin" },
|
||||
{ key: "destination", label: "Destination" },
|
||||
{ key: "cargo", label: "Cargo" },
|
||||
{ key: "tons", label: "Tonnage", unit: "t" },
|
||||
{ key: "amount", label: "Amount", unit: "ETB" },
|
||||
{ key: "payment_status", label: "Payment" },
|
||||
{ key: "scheduling_status", label: "Scheduling" },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "contracts-list",
|
||||
title: "Contracts Export",
|
||||
description: "Contract records with validity, status, customer",
|
||||
domain: "Data",
|
||||
filters: ["direction", "statuses"],
|
||||
statusOptions: CONTRACT_STATUSES,
|
||||
columns: [
|
||||
{ key: "reference", label: "Reference" },
|
||||
{ key: "customer", label: "Customer" },
|
||||
{ key: "kind", label: "Kind" },
|
||||
{ key: "status", label: "Status" },
|
||||
{ key: "direction", label: "Direction" },
|
||||
{ key: "freight_type", label: "Freight" },
|
||||
{ key: "valid_from", label: "Valid from" },
|
||||
{ key: "valid_until", label: "Valid until" },
|
||||
{ key: "created", label: "Created" },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "schedules-list",
|
||||
title: "Train Schedules Export",
|
||||
description: "Schedule records with planned vs actual times",
|
||||
domain: "Data",
|
||||
filters: ["yards", "direction", "statuses"],
|
||||
statusOptions: ["DRAFT", "SCHEDULED", "DISPATCHED", "ARRIVED", "CANCELLED"],
|
||||
columns: [
|
||||
{ key: "train_number", label: "Train" },
|
||||
{ key: "reference", label: "Reference" },
|
||||
{ key: "direction", label: "Direction" },
|
||||
{ key: "status", label: "Status" },
|
||||
{ key: "origin", label: "Origin" },
|
||||
{ key: "destination", label: "Destination" },
|
||||
{ key: "scheduled_departure", label: "Sched. departure" },
|
||||
{ key: "actual_departure", label: "Actual departure" },
|
||||
{ key: "scheduled_arrival", label: "Sched. arrival" },
|
||||
{ key: "actual_arrival", label: "Actual arrival" },
|
||||
{ key: "max_wagons", label: "Max wagons", numeric: true },
|
||||
{ key: "wagon_count", label: "Wagons", numeric: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "fleet-wagons",
|
||||
title: "Wagons Export",
|
||||
description: "Wagon fleet with type, capacity, status, location",
|
||||
domain: "Data",
|
||||
filters: ["yards", "statuses"],
|
||||
statusOptions: ["AVAILABLE", "ASSIGNED", "MAINTENANCE"],
|
||||
columns: [
|
||||
{ key: "wagon_number", label: "Wagon" },
|
||||
{ key: "type", label: "Type" },
|
||||
{ key: "capacity_tons", label: "Capacity", unit: "t" },
|
||||
{ key: "status", label: "Status" },
|
||||
{ key: "current_yard", label: "Current yard" },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "fleet-locomotives",
|
||||
title: "Locomotives Export",
|
||||
description: "Locomotive fleet with type, pull capacity, status",
|
||||
domain: "Data",
|
||||
filters: ["yards", "statuses"],
|
||||
statusOptions: ["AVAILABLE", "OUT_OF_SERVICE"],
|
||||
columns: [
|
||||
{ key: "code", label: "Code" },
|
||||
{ key: "name", label: "Name" },
|
||||
{ key: "locomotive_type", label: "Type" },
|
||||
{ key: "max_pull_tons", label: "Max pull", unit: "t" },
|
||||
{ key: "status", label: "Status" },
|
||||
{ key: "current_yard", label: "Current yard" },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "customers-list",
|
||||
title: "Customers Export",
|
||||
description: "Company records with type, status, TIN",
|
||||
domain: "Data",
|
||||
filters: ["statuses"],
|
||||
statusOptions: ["pending", "active"],
|
||||
columns: [
|
||||
{ key: "name", label: "Name" },
|
||||
{ key: "type", label: "Type" },
|
||||
{ key: "kind", label: "Kind" },
|
||||
{ key: "status", label: "Status" },
|
||||
{ key: "tin", label: "TIN" },
|
||||
{ key: "approved", label: "Approved" },
|
||||
{ key: "created", label: "Created" },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "payments-list",
|
||||
title: "Payments Export",
|
||||
description: "Payment transactions with method, status, references",
|
||||
domain: "Data",
|
||||
filters: ["direction", "statuses"],
|
||||
statusOptions: [
|
||||
"action-required",
|
||||
"processing",
|
||||
"success",
|
||||
"failed",
|
||||
"canceled",
|
||||
"refunded",
|
||||
],
|
||||
columns: [
|
||||
{ key: "created", label: "Created" },
|
||||
{ key: "method", label: "Method" },
|
||||
{ key: "status", label: "Status" },
|
||||
{ key: "currency", label: "Currency" },
|
||||
{ key: "amount", label: "Amount", unit: "ETB" },
|
||||
{ key: "transaction_id", label: "Transaction" },
|
||||
{ key: "merchant_order_id", label: "Merchant order" },
|
||||
{ key: "paid", label: "Paid" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const REPORT_CONFIG_BY_KEY = new Map(
|
||||
REPORT_CONFIGS.map((c) => [c.key, c]),
|
||||
);
|
||||
|
||||
export const REPORT_DOMAINS: ReportDomain[] = [
|
||||
"Commercial",
|
||||
"Operations",
|
||||
"Finance",
|
||||
"Data",
|
||||
];
|
||||
@@ -6,12 +6,14 @@ import {
|
||||
Button,
|
||||
Card,
|
||||
Checkbox,
|
||||
Divider,
|
||||
Group,
|
||||
Menu,
|
||||
Modal,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Switch,
|
||||
Text,
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
@@ -46,6 +48,10 @@ import {
|
||||
directionRowStyle,
|
||||
} from "@/components/trainBuilder/trainStatus";
|
||||
import BookingWindowSettingsModal from "@/components/trainScheduling/BookingWindowSettingsModal";
|
||||
import CreateScheduleWindowFields, {
|
||||
buildWindowRulePayload,
|
||||
type WindowFormState,
|
||||
} from "@/components/trainScheduling/CreateScheduleWindowFields";
|
||||
import EditScheduleDateModal from "@/components/trainScheduling/EditScheduleDateModal";
|
||||
import { showScheduleWarnings } from "@/components/trainScheduling/locomotiveOptions";
|
||||
import {
|
||||
@@ -59,6 +65,7 @@ import { useToast } from "@/hooks/use-toast";
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { canCreateSchedule, canUpdateSchedule } from "@/lib/permissions";
|
||||
import type {
|
||||
CreateScheduleWindowRulePayload,
|
||||
FreightType,
|
||||
TrainScheduleListFilters,
|
||||
TrainScheduleListItem,
|
||||
@@ -133,6 +140,10 @@ export default function TrainScheduleV2ListPage() {
|
||||
const [scheduleDate, setScheduleDate] = useState("");
|
||||
const [trainId, setTrainId] = useState("");
|
||||
const [reverseWagonOrder, setReverseWagonOrder] = useState(false);
|
||||
// Booking window for the schedule being created: off = inherit the live global
|
||||
// rules (the default), on = the values in `windowForm` are frozen onto it.
|
||||
const [configureWindow, setConfigureWindow] = useState(false);
|
||||
const [windowForm, setWindowForm] = useState<WindowFormState | null>(null);
|
||||
// Recomputed each time the create modal opens so a long-lived tab can't keep
|
||||
// offering a stale "now" as the earliest selectable departure.
|
||||
const minScheduleDate = useMemo(
|
||||
@@ -535,6 +546,25 @@ export default function TrainScheduleV2ListPage() {
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Only build the window override when the toggle is on — off means "inherit
|
||||
// the global rules", which the API expresses as an absent windowRule.
|
||||
let windowRule: CreateScheduleWindowRulePayload | undefined;
|
||||
if (configureWindow) {
|
||||
if (!windowForm) {
|
||||
toast({ title: "Booking window settings are still loading", variant: "destructive" });
|
||||
return;
|
||||
}
|
||||
const built = buildWindowRulePayload(
|
||||
windowForm,
|
||||
selectedRoute?.direction === "EXPORT",
|
||||
);
|
||||
if ("error" in built) {
|
||||
toast({ title: built.error, variant: "destructive" });
|
||||
return;
|
||||
}
|
||||
windowRule = built.payload;
|
||||
}
|
||||
|
||||
try {
|
||||
const created = await create.mutateAsync({
|
||||
payload: {
|
||||
@@ -542,11 +572,14 @@ export default function TrainScheduleV2ListPage() {
|
||||
scheduleDate: new Date(scheduleDate).toISOString(),
|
||||
trainId,
|
||||
reverseWagonOrder,
|
||||
...(windowRule ? { windowRule } : {}),
|
||||
},
|
||||
});
|
||||
toast({ title: "Train schedule created" });
|
||||
showScheduleWarnings(created.warnings);
|
||||
setReverseWagonOrder(false);
|
||||
setConfigureWindow(false);
|
||||
setWindowForm(null);
|
||||
setCreateOpen(false);
|
||||
navigate(`/dashboard/operations/train-scheduling-v2/${created.id}`);
|
||||
} catch (err) {
|
||||
@@ -843,6 +876,22 @@ export default function TrainScheduleV2ListPage() {
|
||||
checked={reverseWagonOrder}
|
||||
onChange={(e) => setReverseWagonOrder(e.currentTarget.checked)}
|
||||
/>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Switch
|
||||
label="Configure booking window for this schedule"
|
||||
description="Off, this train follows the global booking rules. On, the settings below are frozen onto it and a later global-rules change won't move them."
|
||||
checked={configureWindow}
|
||||
onChange={(e) => setConfigureWindow(e.currentTarget.checked)}
|
||||
/>
|
||||
{configureWindow ? (
|
||||
<CreateScheduleWindowFields
|
||||
isExport={selectedRoute?.direction === "EXPORT"}
|
||||
form={windowForm}
|
||||
onChange={setWindowForm}
|
||||
/>
|
||||
) : null}
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={() => setCreateOpen(false)}>
|
||||
Cancel
|
||||
|
||||
@@ -163,6 +163,8 @@ import {
|
||||
type SaveLocomotivePayload,
|
||||
} from "./locomotives.service";
|
||||
import { overviewService } from "./overview.service";
|
||||
import { reportsService } from "./reports.service";
|
||||
import type { ReportQueryInput, ReportResult } from "@/types/reports";
|
||||
import {
|
||||
paymentsService,
|
||||
type PaginatedPayments,
|
||||
@@ -2880,4 +2882,13 @@ export const api = {
|
||||
({ range }) => overviewService.getDashboard(range),
|
||||
),
|
||||
},
|
||||
|
||||
reports: {
|
||||
run: endpoint<ReportQueryInput, ReportResult>(
|
||||
"reports",
|
||||
"run",
|
||||
(input) => reportsService.run(input),
|
||||
(input) => ["reports", input.key, input],
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -43,8 +43,12 @@ export const overviewService = {
|
||||
return unwrap(response);
|
||||
},
|
||||
|
||||
getOperationsTab: async (): Promise<IOverviewOperationsTab> => {
|
||||
const response = await client.get<IOverviewOperationsTab>(O.OPERATIONS);
|
||||
getOperationsTab: async (
|
||||
range?: OverviewRange,
|
||||
): Promise<IOverviewOperationsTab> => {
|
||||
const response = await client.get<IOverviewOperationsTab>(O.OPERATIONS, {
|
||||
params: range ? { range } : undefined,
|
||||
});
|
||||
return unwrap(response);
|
||||
},
|
||||
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { api as client } from "../auth/http";
|
||||
import { unwrap } from "@/utils/endpoint";
|
||||
import { URL_CONSTANTS } from "@/constants/URLS";
|
||||
import type { ReportQueryInput, ReportResult } from "@/types/reports";
|
||||
|
||||
export const reportsService = {
|
||||
run: async ({ key, ...params }: ReportQueryInput): Promise<ReportResult> => {
|
||||
const response = await client.get<ReportResult>(
|
||||
URL_CONSTANTS.REPORTS.RUN(key),
|
||||
{ params },
|
||||
);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
};
|
||||
@@ -3,6 +3,7 @@ import { api as client } from "../auth/http";
|
||||
import { unwrap } from "@/utils/endpoint";
|
||||
import { URL_CONSTANTS } from "@/constants/URLS";
|
||||
import type {
|
||||
AllocationCandidates,
|
||||
BatchBoardFilters,
|
||||
BatchBoardListResponse,
|
||||
BatchBoardScheduleDetail,
|
||||
@@ -319,6 +320,25 @@ export const trainSchedulingService = {
|
||||
);
|
||||
},
|
||||
|
||||
getAllocationCandidates: async (
|
||||
bookingId: string,
|
||||
): Promise<AllocationCandidates> => {
|
||||
const response = await client.get<AllocationCandidates>(
|
||||
URL_CONSTANTS.TRAIN_SCHEDULING.ALLOCATION_CANDIDATES(bookingId),
|
||||
);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
allocatePaidBooking: async (
|
||||
bookingId: string,
|
||||
trainScheduleId: string,
|
||||
): Promise<void> => {
|
||||
await client.post(
|
||||
URL_CONSTANTS.TRAIN_SCHEDULING.ALLOCATE_BOOKING(bookingId),
|
||||
{ trainScheduleId },
|
||||
);
|
||||
},
|
||||
|
||||
getScheduleById: async (
|
||||
id: string,
|
||||
freightType?: FreightType,
|
||||
|
||||
@@ -8,6 +8,8 @@ export type {
|
||||
IOverviewBillingKpis,
|
||||
IOverviewStaffKpis,
|
||||
IOverviewTrendPoint,
|
||||
IOverviewDirectionTrendPoint,
|
||||
IOverviewTonnagePoint,
|
||||
IOverviewStatusCount,
|
||||
IOverviewPipelineCount,
|
||||
IOverviewPaymentTrendPoint,
|
||||
|
||||
27
apps/edr-freight-web/backoffice/src/types/reports.ts
Normal file
27
apps/edr-freight-web/backoffice/src/types/reports.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
export interface ReportKpi {
|
||||
label: string;
|
||||
value: number;
|
||||
unit?: string;
|
||||
}
|
||||
|
||||
export type ReportRow = Record<string, unknown>;
|
||||
|
||||
export interface ReportResult {
|
||||
kpis: ReportKpi[];
|
||||
rows: ReportRow[];
|
||||
}
|
||||
|
||||
/** Query params for GET /reports/:key. List filters are comma-separated. */
|
||||
export interface ReportQueryInput {
|
||||
key: string;
|
||||
dateFrom?: string;
|
||||
dateTo?: string;
|
||||
granularity?: "day" | "week" | "month";
|
||||
companyIds?: string;
|
||||
routeIds?: string;
|
||||
yardIds?: string;
|
||||
cargoTypeIds?: string;
|
||||
statuses?: string;
|
||||
direction?: string;
|
||||
freightType?: string;
|
||||
}
|
||||
@@ -103,6 +103,21 @@ export interface BookingWagonShortage {
|
||||
wagonsShort: number;
|
||||
}
|
||||
|
||||
/** A train a paid-unallocated booking can board (route + capacity verified). */
|
||||
export interface AllocationCandidate {
|
||||
id: string;
|
||||
reference: string | null;
|
||||
direction: string | null;
|
||||
scheduledDepartureDate: string;
|
||||
}
|
||||
|
||||
export interface AllocationCandidates {
|
||||
/** Trains departing on the booking's own scheduled day. */
|
||||
sameDay: AllocationCandidate[];
|
||||
/** Fitting trains on other days — allocating to one notifies the customer. */
|
||||
otherDays: AllocationCandidate[];
|
||||
}
|
||||
|
||||
export interface DeferredBookingRow {
|
||||
id: string;
|
||||
reference: string;
|
||||
@@ -891,6 +906,23 @@ export interface CreateTrainSchedulePayload {
|
||||
maxWagonsPerTrain?: number;
|
||||
/** Reverse the wagon order on this train: physically-last wagon becomes position 1. */
|
||||
reverseWagonOrder?: boolean;
|
||||
/**
|
||||
* Configure the booking window for THIS schedule instead of inheriting the
|
||||
* live global rules. Omit to follow the global rules (the default).
|
||||
*/
|
||||
windowRule?: CreateScheduleWindowRulePayload;
|
||||
}
|
||||
|
||||
/**
|
||||
* Booking-window rule chosen at creation. Mirrors the per-schedule override plus
|
||||
* the booking-close offset; omitted fields fall back to the global rule.
|
||||
*/
|
||||
export interface CreateScheduleWindowRulePayload
|
||||
extends UpdateScheduleWindowRulePayload {
|
||||
/** Minutes before departure an IMPORT/DOMESTIC window closes; null = at departure. */
|
||||
importCloseOffsetMinutes?: number | null;
|
||||
/** Minutes before departure an EXPORT window closes; null = at departure. */
|
||||
exportCloseOffsetMinutes?: number | null;
|
||||
}
|
||||
|
||||
export interface AssignBookingsPayload {
|
||||
|
||||
Reference in New Issue
Block a user