Merge pull request #1097 from Tria-plc/freight_feature/usermanagement

Freight feature/usermanagement
This commit is contained in:
marshal
2026-08-04 01:11:09 +03:00
committed by GitHub
48 changed files with 3687 additions and 181 deletions

View File

@@ -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}

View File

@@ -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>
);

View 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>
);
}

View File

@@ -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>
);
}

View File

@@ -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: "030d", unit: "ETB" },
{ key: "overdue_31_60", label: "3160d", unit: "ETB" },
{ key: "overdue_61_90", label: "6190d", 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",
];

View File

@@ -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